Class: Shadcn::Calendar::Component

Inherits:
ApplicationViewComponent show all
Defined in:
app/components/shadcn/calendar/component.rb

Overview

Port of registry/new-york-v4/ui/calendar.tsx.

Upstream is 220 lines that hand every class to react-day-picker and let it render — so the class map is fully specified there, and the markup is the package's. That markup is a plain <table role="grid">, which is why this port has no dependency: what the package mostly is — 3,479 lines of locale data, 2,046 of Ethiopic, Hebrew, Hijri, Persian and Jalali calendars, and 576 wrapping date-fns — a Rails app already has in Date and I18n. See features/calendar.md.

The grid is computed in Calendar::Month and rendered here, so a calendar is readable with no JavaScript at all — the one family in this gem where that is true of the whole component rather than of its resting state.

Constant Summary collapse

RTL_CHEVRONS =

Upstream writes these two with String.raw, so the backslash is part of the class. Single-quoted here for the same reason: in a double-quoted Ruby string \_ is just _, and the token would stop matching the element it names.

'rtl:**:[.rdp-button\_next>svg]:rotate-180 rtl:**:[.rdp-button\_previous>svg]:rotate-180'
PREVIOUS_CLASS =

The two rdp-* classes this port does render, and the only ones. They are not styling — they are what the two RTL_CHEVRONS selectors above point at, and upstream's own rule would match nothing without them. Every other rdp-* name comes from getDefaultClassNames(), a function call rather than a string in the vendored source, and nothing here styles them.

"rdp-button_previous"
NEXT_CLASS =
"rdp-button_next"
MODES =
%i[single multiple range].freeze
CAPTION_LAYOUTS =
%i[label dropdown dropdown_months dropdown_years].freeze
MONTHS_CLASS =
"relative flex flex-col gap-4 md:flex-row"
MONTH_CLASS =
"flex w-full flex-col gap-4"
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1"
CAPTION_CLASS =
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)"
CAPTION_LABEL_CLASS =
"font-medium select-none"
CAPTION_LABEL_LABEL_CLASS =
"text-sm"
CAPTION_LABEL_DROPDOWN_CLASS =
"flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm " \
"[&>svg]:size-3.5 [&>svg]:text-muted-foreground"
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium"
"relative rounded-md border border-input shadow-xs has-focus:border-ring " \
"has-focus:ring-[3px] has-focus:ring-ring/50"
"absolute inset-0 bg-popover opacity-0"
STATUS_STYLE =

What a screen reader is left with once the caption is two dropdowns: the month has to be announced when it changes, and the dropdowns announce themselves as controls rather than as the answer. Upstream's own inline style, which is sr-only written out.

"border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;" \
"position:absolute;width:1px;white-space:nowrap;word-wrap:normal"
GRID_CLASS =
"w-full border-collapse"
WEEKDAYS_CLASS =
"flex"
WEEKDAY_CLASS =
"flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none"
WEEK_CLASS =
"mt-2 flex w-full"
WEEK_NUMBER_HEADER_CLASS =
"w-(--cell-size) select-none"
WEEK_NUMBER_CLASS =
"text-[0.8rem] text-muted-foreground select-none"
WEEK_NUMBER_INNER_CLASS =
"flex size-(--cell-size) items-center justify-center text-center"
DAY_CLASS =
"group/day relative aspect-square h-full w-full p-0 text-center select-none " \
"[&:last-child[data-selected=true]_button]:rounded-r-md"
DAY_FIRST_CLASS =
"[&:first-child[data-selected=true]_button]:rounded-l-md"
DAY_FIRST_WITH_WEEK_NUMBER_CLASS =
"[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
TODAY_CLASS =
"rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none"
OUTSIDE_CLASS =
"text-muted-foreground aria-selected:text-muted-foreground"
DISABLED_CLASS =
"text-muted-foreground opacity-50"
HIDDEN_CLASS =
"invisible"
RANGE_START_CLASS =
"rounded-l-md bg-accent"
RANGE_MIDDLE_CLASS =
"rounded-none"
RANGE_END_CLASS =
"rounded-r-md bg-accent"
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50"
DAY_BUTTON_CLASS =
"flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none " \
"font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 " \
"group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] " \
"group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md " \
"data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary " \
"data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none " \
"data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground " \
"data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md " \
"data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground " \
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground " \
"dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70"
CHEVRON_CLASS =
"size-4"

Constants inherited from ApplicationViewComponent

ApplicationViewComponent::CONTENTS_STYLE, ApplicationViewComponent::VOID_TAGS

Instance Attribute Summary collapse

Attributes inherited from ApplicationViewComponent

#attributes

Instance Method Summary collapse

Methods inherited from ApplicationViewComponent

#css_classes, default_tag, #merged_style, #render_element, #shadcn_t, slot_name, #style_variants, #tag_name

Constructor Details

#initialize(month: nil, mode: :single, selected: nil, disabled: nil, name: nil, required: false, caption_layout: :label, button_variant: :ghost, show_outside_days: true, show_week_number: false, fixed_weeks: false, number_of_months: 1, start_month: nil, end_month: nil, week_starts_on: nil, **attributes) ⇒ Component

month: is which month is shown, selected: what is chosen in it, and they are separate on purpose — upstream's month and selected are two props for the same reason: a range can end in a month nobody is looking at.

disabled: takes anything that answers === — a Date, a Range, a lambda — or an array of those. That is Ruby's own matcher protocol, and it covers what upstream's Matcher union covers without inventing a shape for it.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'app/components/shadcn/calendar/component.rb', line 112

def initialize(month: nil, mode: :single, selected: nil, disabled: nil, name: nil, required: false,
               caption_layout: :label, button_variant: :ghost, show_outside_days: true,
               show_week_number: false, fixed_weeks: false, number_of_months: 1,
               start_month: nil, end_month: nil, week_starts_on: nil,
               **attributes)
  @mode = MODES.include?(mode&.to_sym) ? mode.to_sym : :single
  @selected = Array(selected.is_a?(Range) ? [ selected.begin, selected.end ] : selected).compact.map(&:to_date)
  # A range arrives either as a `Range` or as the two ends of one, because
  # that is what comes back from the controller and from a form. Both mean
  # the same thing here, and the modifier classes read this rather than
  # the argument's shape.
  @range = if selected.is_a?(Range) then selected.begin.to_date..selected.end.to_date
  elsif @mode == :range && @selected.size == 2 then @selected.first..@selected.last
  end
  @disabled = disabled
  @name = name
  @required = required
  @caption_layout = CAPTION_LAYOUTS.include?(caption_layout&.to_sym) ? caption_layout.to_sym : :label
  @button_variant = button_variant&.to_sym || :ghost
  @show_outside_days = show_outside_days
  @show_week_number = show_week_number
  @fixed_weeks = fixed_weeks
  @number_of_months = [ number_of_months.to_i, 1 ].max
  @start_month = start_month&.to_date
  @end_month = end_month&.to_date
  @week_starts_on = week_starts_on
  @month = Month.new(month || @selected.first || Date.current,
                     week_starts_on:, fixed_weeks:)
  # Upstream's `numberOfMonths`: one nav for the lot, and a caption and a
  # grid each. The months run forward from the one that was asked for.
  @months = Array.new(@number_of_months) { |offset| offset.zero? ? @month : @month.plus_months(offset) }
  super(**attributes)
end

Instance Attribute Details

#button_variantObject (readonly)

Returns the value of attribute button_variant.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def button_variant
  @button_variant
end

#caption_layoutObject (readonly)

Returns the value of attribute caption_layout.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def caption_layout
  @caption_layout
end

#disabledObject (readonly)

Returns the value of attribute disabled.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def disabled
  @disabled
end

#end_monthObject (readonly)

Returns the value of attribute end_month.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def end_month
  @end_month
end

#fixed_weeksObject (readonly)

Returns the value of attribute fixed_weeks.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def fixed_weeks
  @fixed_weeks
end

#modeObject (readonly)

Returns the value of attribute mode.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def mode
  @mode
end

#monthObject (readonly)

Returns the value of attribute month.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def month
  @month
end

#monthsObject (readonly)

Returns the value of attribute months.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def months
  @months
end

#nameObject (readonly)

Returns the value of attribute name.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def name
  @name
end

#number_of_monthsObject (readonly)

Returns the value of attribute number_of_months.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def number_of_months
  @number_of_months
end

#requiredObject (readonly)

Returns the value of attribute required.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def required
  @required
end

#selectedObject (readonly)

Returns the value of attribute selected.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def selected
  @selected
end

#show_outside_daysObject (readonly)

Returns the value of attribute show_outside_days.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def show_outside_days
  @show_outside_days
end

#show_week_numberObject (readonly)

Returns the value of attribute show_week_number.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def show_week_number
  @show_week_number
end

#start_monthObject (readonly)

Returns the value of attribute start_month.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def start_month
  @start_month
end

#week_starts_onObject (readonly)

Returns the value of attribute week_starts_on.



98
99
100
# File 'app/components/shadcn/calendar/component.rb', line 98

def week_starts_on
  @week_starts_on
end

Instance Method Details

#callObject



189
190
191
192
193
194
195
196
197
198
# File 'app/components/shadcn/calendar/component.rb', line 189

def call
  # Block content lands after the months, which is where upstream's
  # `footer` prop renders — and inside the root, which is what lets a
  # preset button's `data-action` reach this controller at all.
  render_element(body: safe_join([
    tag.div(safe_join([ nav, *months.map { |shown| month_body(shown) } ]), class: MONTHS_CLASS),
    inputs,
    content
  ].compact))
end

#element_attributes(**defaults) ⇒ Object

Everything the controller needs to draw a month the server never rendered, and nothing else. The names, the formats and the modifier classes all cross from here rather than being written in JavaScript: I18n stays the only thing that decides how a date reads, and Tailwind only ever sees a class in Ruby source.



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'app/components/shadcn/calendar/component.rb', line 151

def element_attributes(**defaults)
  super(**{
    "data-controller" => "shadcn--calendar",
    "data-shadcn--calendar-month-value" => month.first.iso8601,
    "data-shadcn--calendar-today-value" => Date.current.iso8601,
    "data-shadcn--calendar-week-starts-on-value" => month.week_starts_on,
    "data-shadcn--calendar-fixed-weeks-value" => fixed_weeks,
    "data-shadcn--calendar-show-outside-days-value" => show_outside_days,
    "data-shadcn--calendar-show-week-number-value" => show_week_number,
    "data-shadcn--calendar-start-month-value" => start_month&.beginning_of_month&.iso8601,
    "data-shadcn--calendar-end-month-value" => end_month&.end_of_month&.iso8601,
    "data-shadcn--calendar-mode-value" => mode,
    "data-shadcn--calendar-required-value" => required,
    "data-shadcn--calendar-input-names-value" => input_names.to_json,
    "data-shadcn--calendar-selected-value" => selected_dates.to_json,
    "data-shadcn--calendar-disabled-value" => portable_matchers.to_json,
    "data-shadcn--calendar-day-names-value" => day_names.to_json,
    "data-shadcn--calendar-short-day-names-value" => short_day_names.to_json,
    "data-shadcn--calendar-month-names-value" => month_names.to_json,
    "data-shadcn--calendar-short-month-names-value" => short_month_names.to_json,
    "data-shadcn--calendar-date-format-value" => day_label_format,
    "data-shadcn--calendar-month-format-value" => MONTH_FORMAT,
    # Fetched with the placeholder as its own argument, so what crosses is
    # the template rather than a rendered sentence — `I18n` raises on a
    # missing interpolation and the controller needs the `%{date}` intact.
    "data-shadcn--calendar-today-label-value" => shadcn_t("calendar.today_label", date: "%{date}"),
    "data-shadcn--calendar-selected-label-value" => shadcn_t("calendar.selected_label", date: "%{date}"),
    "data-shadcn--calendar-week-number-label-value" => shadcn_t("calendar.week_number", number: "%{number}"),
    "data-shadcn--calendar-today-class-value" => TODAY_CLASS,
    "data-shadcn--calendar-outside-class-value" => OUTSIDE_CLASS,
    "data-shadcn--calendar-disabled-class-value" => DISABLED_CLASS,
    "data-shadcn--calendar-hidden-class-value" => HIDDEN_CLASS,
    "data-shadcn--calendar-range-start-class-value" => RANGE_START_CLASS,
    "data-shadcn--calendar-range-middle-class-value" => RANGE_MIDDLE_CLASS,
    "data-shadcn--calendar-range-end-class-value" => RANGE_END_CLASS
  }.compact.merge(defaults))
end