ajo-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. package/src/virtual.ts +385 -0
@@ -0,0 +1,1441 @@
1
+ import type { Children, IntrinsicElements, Stateful, Stateless } from 'ajo'
2
+ import { controlled, dom, grid, remember, statefulRootAttrs as rootAttrs, type GridMove } from 'ajo-cloves'
3
+ import { calendarDate, compile, type Availability, type AvailabilityMatcher, type CalendarMatcher } from './availability'
4
+ import { DirectionContext } from './direction'
5
+ import type { FixedArgs, OmitArg } from './utils'
6
+
7
+ export type { AvailabilityMatcher, CalendarMatcher, TimeWindow } from './availability'
8
+
9
+ /** Selection model supported by a Calendar root. */
10
+ export type CalendarMode =
11
+ | 'multiple'
12
+ | 'range'
13
+ | 'single'
14
+
15
+ /** Calendar scale used for day navigation and whole-month/year picking. */
16
+ export type CalendarView =
17
+ | 'day'
18
+ | 'month'
19
+ | 'year'
20
+
21
+ /** Available navigation controls for a Calendar caption. */
22
+ export type CalendarCaptionLayout =
23
+ | 'button'
24
+ | 'dropdown'
25
+ | 'dropdown-months'
26
+ | 'dropdown-years'
27
+ | 'label'
28
+
29
+ /** Inclusive date range selected by a range Calendar. */
30
+ export type CalendarDateRange = {
31
+ from?: Date
32
+ to?: Date
33
+ }
34
+
35
+ /** Built-in state flags resolved for one Calendar day. */
36
+ export type CalendarModifiers = {
37
+ disabled: boolean
38
+ outside: boolean
39
+ range_end: boolean
40
+ range_middle: boolean
41
+ range_start: boolean
42
+ selected: boolean
43
+ today: boolean
44
+ unavailable: boolean
45
+ }
46
+
47
+ /** Structural Calendar part names accepted by `classNames`. */
48
+ export type CalendarClassName =
49
+ | 'caption'
50
+ | 'day'
51
+ | 'day_button'
52
+ | 'dropdowns'
53
+ | 'grid'
54
+ | 'head'
55
+ | 'month'
56
+ | 'month_cell'
57
+ | 'month_view'
58
+ | 'months'
59
+ | 'outside'
60
+ | 'range_end'
61
+ | 'range_middle'
62
+ | 'range_start'
63
+ | 'selected'
64
+ | 'today'
65
+ | 'week'
66
+ | 'week_number'
67
+ | 'weekday'
68
+ | 'year_cell'
69
+ | 'year_view'
70
+
71
+ /** Locale-aware formatting hooks used by Calendar views. */
72
+ export type CalendarFormatters = {
73
+ day: (date: Date, locale: string | undefined, timeZone?: string) => Children
74
+ fullDate: (date: Date, locale: string | undefined, timeZone?: string) => string
75
+ monthCaption: (date: Date, locale: string | undefined, timeZone?: string) => string
76
+ monthDropdown: (date: Date, locale: string | undefined, timeZone?: string) => string
77
+ weekNumber: (week: number, locale: string | undefined) => Children
78
+ weekday: (date: Date, locale: string | undefined, timeZone?: string) => Children
79
+ }
80
+
81
+ /** One value and label rendered by a Calendar caption dropdown. */
82
+ export type CalendarDropdownOption = {
83
+ label: Children
84
+ value: string
85
+ }
86
+
87
+ /** Arguments supplied to a custom Calendar caption dropdown. */
88
+ export type CalendarDropdownArgs = {
89
+ label: string
90
+ onValueChange: (value: string, event?: Event) => void
91
+ options: CalendarDropdownOption[]
92
+ value: string
93
+ }
94
+
95
+ /** Date and resolved state supplied to day styling callbacks. */
96
+ export type CalendarDayState = {
97
+ date: Date
98
+ hidden: boolean
99
+ modifierNames: string[]
100
+ modifiers: CalendarModifiers
101
+ range: boolean
102
+ }
103
+
104
+ /** Arguments shared by every Calendar selection mode. */
105
+ export type CalendarCommonArgs = OmitArg<IntrinsicElements['div'], 'children' | 'defaultValue' | 'onSelect'> & {
106
+ /** Allow range selection to span unavailable days without selecting or painting them. */
107
+ allowNonContiguous?: boolean
108
+ /** Caption layout. `dropdown` shows month and year selectors. */
109
+ captionLayout?: CalendarCaptionLayout
110
+ /** Class names for structural calendar parts. */
111
+ classNames?: Partial<Record<CalendarClassName, string>>
112
+ /** Class for the visible caption label. */
113
+ captionLabelClass?: string
114
+ /** Initial visible month for uncontrolled usage. */
115
+ defaultMonth?: Date
116
+ /** Initial uncontrolled view. Defaults to `minView`. */
117
+ defaultView?: CalendarView
118
+ /** Disable dates by date, range, weekday, list, or predicate. */
119
+ disabled?: CalendarMatcher | CalendarMatcher[]
120
+ /** Last navigable month. */
121
+ endMonth?: Date
122
+ /** Custom formatters for labels and visible date text. */
123
+ formatters?: Partial<CalendarFormatters>
124
+ /** Always render six weeks per month. */
125
+ fixedWeeks?: boolean
126
+ /** First navigable year. */
127
+ fromYear?: number
128
+ /** Class for hidden outside-day placeholders. */
129
+ hiddenDayClass?: string
130
+ /** Locale code or DayPicker-like locale object with `code`. */
131
+ locale?: string | { code?: string }
132
+ /** Controlled visible month. */
133
+ month?: Date
134
+ /** Lowest view that commits a value instead of drilling down. */
135
+ minView?: CalendarView
136
+ /** Month dropdown component. */
137
+ monthDropdown?: Stateless<CalendarDropdownArgs>
138
+ /** Class for the month label when month dropdown is hidden. */
139
+ monthLabelClass?: string
140
+ /** Extra date matchers exposed as `data-modifier-*` on day cells. */
141
+ modifiers?: Record<string, CalendarMatcher | CalendarMatcher[]>
142
+ /** Class for previous and next buttons. */
143
+ navButtonClass?: string
144
+ /** Class for caption spacers standing in for the nav buttons on middle months. */
145
+ navSpacerClass?: string
146
+ /** Number of visible months. */
147
+ numberOfMonths?: number
148
+ /** Called when the visible month changes. */
149
+ onMonthChange?: (month: Date, event?: Event) => void
150
+ /** Called when the calendar view changes. */
151
+ onViewChange?: (view: CalendarView, event?: Event) => void
152
+ /** Icon rendered in the next-month button. */
153
+ nextIcon?: Children
154
+ /** Icon class for the next-month button icon span. */
155
+ nextIconClass?: string
156
+ /** Accessible label for the next-month button. */
157
+ nextMonthLabel?: string
158
+ /** Accessible label for the previous-month button. */
159
+ previousMonthLabel?: string
160
+ /** Accessible label for the month dropdown. */
161
+ monthSelectLabel?: string
162
+ /** Accessible label for the year dropdown. */
163
+ yearSelectLabel?: string
164
+ /** Icon rendered in the previous-month button. */
165
+ previousIcon?: Children
166
+ /** Icon class for the previous-month button icon span. */
167
+ previousIconClass?: string
168
+ /** Render custom day content. */
169
+ renderDay?: (date: Date, modifiers: CalendarModifiers) => Children
170
+ /** Keep at least one selection. */
171
+ required?: boolean
172
+ /** Returns the class for a day grid cell from its state. */
173
+ dayClassName?: (state: CalendarDayState) => string | undefined
174
+ /** Class for each day button. */
175
+ dayButtonClass?: string
176
+ /** Show days from adjacent months. */
177
+ showOutsideDays?: boolean
178
+ /** Show ISO week numbers. */
179
+ showWeekNumber?: boolean
180
+ /** First navigable month. */
181
+ startMonth?: Date
182
+ /** IANA time zone used to derive, format, and emit calendar dates. */
183
+ timeZone?: string
184
+ /** Dates that remain selectable but carry unavailable state. */
185
+ unavailable?: AvailabilityMatcher | AvailabilityMatcher[]
186
+ /** Controlled calendar view. Values below `minView` clamp to it. */
187
+ view?: CalendarView
188
+ /** Last navigable year. */
189
+ toYear?: number
190
+ /** First day of week. 0 is Sunday. */
191
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
192
+ /** Year dropdown component. */
193
+ yearDropdown?: Stateless<CalendarDropdownArgs>
194
+ /** Class for the year label when year dropdown is hidden. */
195
+ yearLabelClass?: string
196
+ /** Additional classes for the calendar root. */
197
+ class?: string
198
+ } & FixedArgs<'children' | 'defaultValue'>
199
+
200
+ /** Arguments for a Calendar that selects one date or period. */
201
+ export type CalendarSingleArgs = CalendarCommonArgs & {
202
+ defaultSelected?: Date
203
+ mode?: 'single'
204
+ onSelect?: (date: Date | null, event: Event) => void
205
+ selected?: Date | null
206
+ }
207
+
208
+ /** Arguments for a Calendar that selects multiple dates or periods. */
209
+ export type CalendarMultipleArgs = CalendarCommonArgs & {
210
+ defaultSelected?: Date[]
211
+ mode: 'multiple'
212
+ onSelect?: (dates: Date[], event: Event) => void
213
+ selected?: Date[]
214
+ }
215
+
216
+ /** Arguments for a Calendar that selects an inclusive range. */
217
+ export type CalendarRangeArgs = CalendarCommonArgs & {
218
+ defaultSelected?: CalendarDateRange
219
+ mode: 'range'
220
+ onSelect?: (range: CalendarDateRange | null, event: Event) => void
221
+ selected?: CalendarDateRange | null
222
+ }
223
+
224
+ /** Public discriminated arguments accepted by the Calendar root. */
225
+ export type CalendarArgs =
226
+ | CalendarMultipleArgs
227
+ | CalendarRangeArgs
228
+ | CalendarSingleArgs
229
+
230
+ /** Arguments supplied to the Calendar day-button renderer. */
231
+ export type CalendarDayButtonArgs = OmitArg<IntrinsicElements['button'], 'children'> & {
232
+ date: Date
233
+ day: Children
234
+ modifiers: CalendarModifiers
235
+ /** IANA time zone used to derive the button's `data-day`. */
236
+ timeZone?: string
237
+ /** Additional classes. */
238
+ class?: string
239
+ } & FixedArgs<'children'>
240
+
241
+ type PlainDate = {
242
+ day: number
243
+ month: number
244
+ year: number
245
+ }
246
+
247
+ type MonthData = {
248
+ month: PlainDate
249
+ weeks: PlainDate[][]
250
+ }
251
+
252
+ const localeCode = (locale: CalendarCommonArgs['locale']) =>
253
+ typeof locale === 'string' ? locale : locale?.code
254
+
255
+ const pad = (value: number) => String(value).padStart(2, '0')
256
+
257
+ const iso = ({ day, month, year }: PlainDate) =>
258
+ `${year}-${pad(month)}-${pad(day)}`
259
+
260
+ const monthIso = ({ month, year }: PlainDate) =>
261
+ `${year}-${pad(month)}`
262
+
263
+ const parseIso = (value: string): PlainDate | undefined => {
264
+ const [year, month, day] = value.split('-').map(Number)
265
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return undefined
266
+ return normalize(year!, month!, day!)
267
+ }
268
+
269
+ const parseMonthIso = (value: string): PlainDate | undefined => {
270
+ const [year, month] = value.split('-').map(Number)
271
+ if (!Number.isFinite(year) || !Number.isFinite(month) || month! < 1 || month! > 12) return undefined
272
+ return { day: 1, month: month!, year: year! }
273
+ }
274
+
275
+ const utcDate = (year: number, month: number, day: number) => {
276
+ const date = new Date(0)
277
+ date.setUTCFullYear(year, month - 1, day)
278
+ date.setUTCHours(0, 0, 0, 0)
279
+ return date
280
+ }
281
+
282
+ const normalize = (year: number, month: number, day: number): PlainDate => {
283
+ const date = utcDate(year, month, day)
284
+ return {
285
+ day: date.getUTCDate(),
286
+ month: date.getUTCMonth() + 1,
287
+ year: date.getUTCFullYear(),
288
+ }
289
+ }
290
+
291
+ const addDays = (date: PlainDate, days: number) =>
292
+ normalize(date.year, date.month, date.day + days)
293
+
294
+ const addMonths = (date: PlainDate, monthCount: number) =>
295
+ normalize(date.year, date.month + monthCount, Math.min(date.day, daysInMonth(normalize(date.year, date.month + monthCount, 1))))
296
+
297
+ const monthStart = (date: PlainDate) =>
298
+ ({ year: date.year, month: date.month, day: 1 })
299
+
300
+ const daysInMonth = (date: PlainDate) =>
301
+ utcDate(date.year, date.month + 1, 0).getUTCDate()
302
+
303
+ const comparePlain = (first: PlainDate, second: PlainDate) =>
304
+ iso(first).localeCompare(iso(second))
305
+
306
+ const samePlain = (first: PlainDate, second: PlainDate) =>
307
+ first.year === second.year && first.month === second.month && first.day === second.day
308
+
309
+ const viewRank: Record<CalendarView, number> = { day: 0, month: 1, year: 2 }
310
+
311
+ const minimumView = (view: CalendarView | undefined) => view ?? 'day'
312
+
313
+ const clampView = (view: CalendarView, minView: CalendarView | undefined) =>
314
+ viewRank[view] < viewRank[minimumView(minView)] ? minimumView(minView) : view
315
+
316
+ const periodStart = (date: PlainDate, view: CalendarView): PlainDate => {
317
+ if (view === 'year') return { day: 1, month: 1, year: date.year }
318
+ if (view === 'month') return monthStart(date)
319
+ return date
320
+ }
321
+
322
+ const periodEnd = (date: PlainDate, view: CalendarView): PlainDate => {
323
+ if (view === 'year') return { day: 31, month: 12, year: date.year }
324
+ if (view === 'month') {
325
+ const start = monthStart(date)
326
+ return { ...start, day: daysInMonth(start) }
327
+ }
328
+ return date
329
+ }
330
+
331
+ const samePeriod = (first: PlainDate, second: PlainDate, view: CalendarView) => {
332
+ if (view === 'year') return first.year === second.year
333
+ if (view === 'month') return first.year === second.year && first.month === second.month
334
+ return samePlain(first, second)
335
+ }
336
+
337
+ const periodIntersects = (
338
+ range: { from?: PlainDate; to?: PlainDate },
339
+ date: PlainDate,
340
+ view: CalendarView,
341
+ ) => Boolean(range.from && range.to
342
+ && comparePlain(range.from, periodEnd(date, view)) <= 0
343
+ && comparePlain(periodStart(date, view), range.to) <= 0)
344
+
345
+ const weekday = (date: PlainDate) =>
346
+ utcDate(date.year, date.month, date.day).getUTCDay()
347
+
348
+ const plainToDate = (date: PlainDate, timeZone?: string) =>
349
+ calendarDate({ ...date, hour: 12, minute: 0, second: 0 }, timeZone)
350
+
351
+ const zonedDateFormatters = new Map<string, Intl.DateTimeFormat>()
352
+
353
+ const zonedDateFormatter = (timeZone: string) => {
354
+ let format = zonedDateFormatters.get(timeZone)
355
+ if (!format) {
356
+ format = new Intl.DateTimeFormat('en-CA', {
357
+ day: '2-digit',
358
+ month: '2-digit',
359
+ timeZone,
360
+ year: 'numeric',
361
+ })
362
+ remember(zonedDateFormatters, timeZone, format)
363
+ }
364
+ return format
365
+ }
366
+
367
+ const dateToPlain = (date: Date, timeZone?: string): PlainDate => {
368
+ if (timeZone) {
369
+ const parts = zonedDateFormatter(timeZone).formatToParts(date)
370
+ const value = (type: string) => Number(parts.find(part => part.type === type)?.value)
371
+ return normalize(value('year'), value('month'), value('day'))
372
+ }
373
+
374
+ return normalize(date.getFullYear(), date.getMonth() + 1, date.getDate())
375
+ }
376
+
377
+ const today = (timeZone?: string) =>
378
+ dateToPlain(new Date(), timeZone)
379
+
380
+ const startOfWeek = (date: PlainDate, weekStartsOn: number) =>
381
+ addDays(date, -((weekday(date) - weekStartsOn + 7) % 7))
382
+
383
+ const startOfGrid = (month: PlainDate, weekStartsOn: number) => {
384
+ return startOfWeek(month, weekStartsOn)
385
+ }
386
+
387
+ const weeksForMonth = (
388
+ month: PlainDate,
389
+ fixedWeeks: boolean,
390
+ weekStartsOn: number,
391
+ ) => {
392
+ const start = startOfGrid(month, weekStartsOn)
393
+ const length = fixedWeeks ? 42 : Math.ceil(((weekday(month) - weekStartsOn + 7) % 7 + daysInMonth(month)) / 7) * 7
394
+ const weeks: PlainDate[][] = []
395
+
396
+ for (let index = 0; index < length; index += 7) {
397
+ weeks.push(Array.from({ length: 7 }, (_, day) => addDays(start, index + day)))
398
+ }
399
+
400
+ return weeks
401
+ }
402
+
403
+ const months = (start: PlainDate, count: number, fixedWeeks: boolean, weekStartsOn: number): MonthData[] =>
404
+ Array.from({ length: Math.max(1, count) }, (_, index) => {
405
+ const month = monthStart(addMonths(start, index))
406
+ return { month, weeks: weeksForMonth(month, fixedWeeks, weekStartsOn) }
407
+ })
408
+
409
+ const DATE_FORMAT_OPTIONS = {
410
+ day: { day: 'numeric' },
411
+ fullDate: { dateStyle: 'full' },
412
+ monthCaption: { month: 'long', year: 'numeric' },
413
+ monthDropdown: { month: 'short' },
414
+ monthLabel: { month: 'long' },
415
+ weekday: { weekday: 'short' },
416
+ } satisfies Record<string, Intl.DateTimeFormatOptions>
417
+
418
+ type DateFormat = keyof typeof DATE_FORMAT_OPTIONS
419
+ const dateFormatters = new Map<string, Partial<Record<DateFormat, Intl.DateTimeFormat>>>()
420
+
421
+ const formatter = (
422
+ locale: string | undefined,
423
+ name: DateFormat,
424
+ timeZone?: string,
425
+ ) => {
426
+ const key = `${locale ?? ''}\0${timeZone ?? ''}`
427
+ let formats = dateFormatters.get(key)
428
+ if (!formats) {
429
+ formats = {}
430
+ remember(dateFormatters, key, formats)
431
+ }
432
+ return formats[name] ??= new Intl.DateTimeFormat(locale, { ...DATE_FORMAT_OPTIONS[name], timeZone })
433
+ }
434
+
435
+ const defaultFormatters: CalendarFormatters = {
436
+ day: (date, locale, timeZone) => formatter(locale, 'day', timeZone).format(date),
437
+ fullDate: (date, locale, timeZone) => formatter(locale, 'fullDate', timeZone).format(date),
438
+ monthCaption: (date, locale, timeZone) => formatter(locale, 'monthCaption', timeZone).format(date),
439
+ monthDropdown: (date, locale, timeZone) => formatter(locale, 'monthDropdown', timeZone).format(date),
440
+ weekNumber: week => pad(week),
441
+ weekday: (date, locale, timeZone) => formatter(locale, 'weekday', timeZone).format(date),
442
+ }
443
+
444
+ const toPlainArray = (dates: Date[] | undefined, timeZone?: string) =>
445
+ (dates ?? []).map(date => dateToPlain(date, timeZone))
446
+
447
+ const toRangePlain = (range: CalendarDateRange | undefined, timeZone?: string) => ({
448
+ from: range?.from ? dateToPlain(range.from, timeZone) : undefined,
449
+ to: range?.to ? dateToPlain(range.to, timeZone) : undefined,
450
+ })
451
+
452
+ const plainIn = (date: PlainDate, dates: PlainDate[]) =>
453
+ dates.some(item => samePlain(item, date))
454
+
455
+ const rangeContains = (range: { from?: PlainDate; to?: PlainDate }, date: PlainDate) =>
456
+ Boolean(range.from && range.to && comparePlain(range.from, date) <= 0 && comparePlain(date, range.to) <= 0)
457
+
458
+ const rangeMiddle = (range: { from?: PlainDate; to?: PlainDate }, date: PlainDate) =>
459
+ Boolean(range.from && range.to && comparePlain(range.from, date) < 0 && comparePlain(date, range.to) < 0)
460
+
461
+ const weekNumber = (date: PlainDate) => {
462
+ const target = utcDate(date.year, date.month, date.day)
463
+ const day = target.getUTCDay() || 7
464
+ target.setUTCDate(target.getUTCDate() + 4 - day)
465
+ const yearStart = utcDate(target.getUTCFullYear(), 1, 1)
466
+ return Math.ceil((((target.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
467
+ }
468
+
469
+ // Day columns grow with the calendar width (a wide caption widens the month)
470
+ // while never dropping below the cell size.
471
+ const gridTemplate = (showWeekNumber: boolean) =>
472
+ showWeekNumber
473
+ ? 'grid-template-columns:var(--cell-size) repeat(7,minmax(var(--cell-size),1fr))'
474
+ : 'grid-template-columns:repeat(7,minmax(var(--cell-size),1fr))'
475
+
476
+ const monthOptions = (year: number) =>
477
+ Array.from({ length: 12 }, (_, month) => ({ day: 1, month: month + 1, year }))
478
+
479
+ const yearBounds = (current: PlainDate, args: CalendarArgs, now: PlainDate) => {
480
+ const first = args.startMonth ? dateToPlain(args.startMonth, args.timeZone).year : args.fromYear ?? now.year - 100
481
+ const last = args.endMonth ? dateToPlain(args.endMonth, args.timeZone).year : args.toYear ?? Math.max(now.year, current.year)
482
+ return { first, last: Math.max(first, last) }
483
+ }
484
+
485
+ const yearRange = (current: PlainDate, args: CalendarArgs, now: PlainDate) => {
486
+ const { first, last } = yearBounds(current, args, now)
487
+ return Array.from({ length: last - first + 1 }, (_, index) => first + index)
488
+ }
489
+
490
+ const yearPage = (current: PlainDate, args: CalendarArgs, now: PlainDate) => {
491
+ const allowed = yearRange(current, args, now)
492
+ const first = allowed[0]!
493
+ const last = allowed[allowed.length - 1]!
494
+ const anchor = Math.min(last, Math.max(first, current.year))
495
+ const start = first + Math.floor((anchor - first) / 12) * 12
496
+ return {
497
+ allowed,
498
+ start,
499
+ years: Array.from({ length: 12 }, (_, index) => start + index),
500
+ }
501
+ }
502
+
503
+ const canNavigateYear = (year: number, current: PlainDate, args: CalendarArgs, now: PlainDate) => {
504
+ const { first, last } = yearBounds(current, args, now)
505
+ return first <= year && year <= last
506
+ }
507
+
508
+ const canNavigateTo = (month: PlainDate, args: CalendarArgs) => {
509
+ const start = args.startMonth ? monthStart(dateToPlain(args.startMonth, args.timeZone)) : args.fromYear ? { day: 1, month: 1, year: args.fromYear } : undefined
510
+ const end = args.endMonth ? monthStart(dateToPlain(args.endMonth, args.timeZone)) : args.toYear ? { day: 1, month: 12, year: args.toYear } : undefined
511
+ if (start && comparePlain(monthStart(month), start) < 0) return false
512
+ if (end && comparePlain(monthStart(month), end) > 0) return false
513
+ return true
514
+ }
515
+
516
+ const modifierNames = (
517
+ modifiers: Map<string, Availability>,
518
+ date: Date,
519
+ ) => Array.from(modifiers)
520
+ .filter(([, availability]) => availability.day(date))
521
+ .map(([name]) => name)
522
+
523
+ const modifierAttributes = (names: string[]) =>
524
+ Object.fromEntries(names.map(name => [`data-modifier-${name}`, 'true']))
525
+
526
+ const defaultSelectedRange = (value: CalendarArgs['defaultSelected']) =>
527
+ value && !Array.isArray(value) && !(value instanceof Date) ? value : undefined
528
+
529
+ const initialMonthDate = (
530
+ month: Date | undefined,
531
+ defaultMonth: Date | undefined,
532
+ defaultSelected: CalendarArgs['defaultSelected'],
533
+ ) => {
534
+ if (month) return month
535
+ if (defaultMonth) return defaultMonth
536
+ if (defaultSelected instanceof Date) return defaultSelected
537
+ if (Array.isArray(defaultSelected)) return defaultSelected[0] ?? new Date()
538
+ return defaultSelectedRange(defaultSelected)?.from ?? new Date()
539
+ }
540
+
541
+ /** Unstyled native month or year picker used when no custom dropdown is supplied. */
542
+ const CalendarDropdown: Stateless<CalendarDropdownArgs> = ({
543
+ label,
544
+ onValueChange,
545
+ options,
546
+ value,
547
+ }) => (
548
+ <select
549
+ aria-label={label}
550
+ data-slot="calendar-dropdown"
551
+ set:onchange={(event: Event) => onValueChange((event.currentTarget as HTMLSelectElement).value, event)}
552
+ set:value={value}
553
+ >
554
+ {options.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
555
+ </select>
556
+ )
557
+
558
+ const CalendarRoot: Stateful<CalendarArgs> = function* ({
559
+ defaultMonth,
560
+ defaultSelected,
561
+ defaultView,
562
+ minView,
563
+ month,
564
+ timeZone,
565
+ view,
566
+ }) {
567
+ const initial = initialMonthDate(
568
+ month as Date | undefined,
569
+ defaultMonth as Date | undefined,
570
+ defaultSelected as CalendarArgs['defaultSelected'],
571
+ )
572
+
573
+ const initialMonth = monthStart(dateToPlain(initial, timeZone))
574
+ const initialView = clampView(view ?? defaultView ?? minimumView(minView), minView)
575
+ const initialSingle = defaultSelected instanceof Date ? dateToPlain(defaultSelected, timeZone) : null
576
+ const initialMultiple = Array.isArray(defaultSelected) ? toPlainArray(defaultSelected, timeZone) : []
577
+ const initialRange = !Array.isArray(defaultSelected) && !(defaultSelected instanceof Date)
578
+ ? toRangePlain(defaultSelected as CalendarDateRange | undefined, timeZone)
579
+ : {}
580
+ let currentArgs = {} as CalendarArgs
581
+ let currentView = initialView
582
+ let renderedView = initialView
583
+ let visible = initialMonth
584
+ const domReady = dom(this)
585
+ let disabledSource: CalendarCommonArgs['disabled']
586
+ let disabledTimeZone: string | undefined
587
+ let disabledAvailability: Availability | undefined
588
+ let modifiersSource: CalendarCommonArgs['modifiers']
589
+ let modifiersTimeZone: string | undefined
590
+ let modifierAvailability = new Map<string, Availability>()
591
+ let unavailableSource: CalendarCommonArgs['unavailable']
592
+ let unavailableTimeZone: string | undefined
593
+ let unavailableAvailability: Availability | undefined
594
+
595
+ const syncAvailability = (args: CalendarArgs) => {
596
+ if (args.disabled !== disabledSource || args.timeZone !== disabledTimeZone) {
597
+ disabledSource = args.disabled
598
+ disabledTimeZone = args.timeZone
599
+ disabledAvailability = compile(args.disabled, { timeZone: args.timeZone })
600
+ }
601
+ if (args.modifiers !== modifiersSource || args.timeZone !== modifiersTimeZone) {
602
+ modifiersSource = args.modifiers
603
+ modifiersTimeZone = args.timeZone
604
+ modifierAvailability = new Map(Object.entries(args.modifiers ?? {}).flatMap(([name, matcher]) => {
605
+ const availability = compile(matcher, { timeZone: args.timeZone })
606
+ return availability ? [[name, availability]] : []
607
+ }))
608
+ }
609
+ if (args.unavailable !== unavailableSource || args.timeZone !== unavailableTimeZone) {
610
+ unavailableSource = args.unavailable
611
+ unavailableTimeZone = args.timeZone
612
+ unavailableAvailability = compile(args.unavailable, { timeZone: args.timeZone })
613
+ }
614
+ }
615
+
616
+ const monthState = controlled<PlainDate>(this, {
617
+ fallback: initialMonth,
618
+ onChange: (next, event) => currentArgs.onMonthChange?.(plainToDate(next, currentArgs.timeZone), event),
619
+ })
620
+ const viewState = controlled<CalendarView>(this, {
621
+ fallback: initialView,
622
+ onChange: (next, event) => currentArgs.onViewChange?.(next, event),
623
+ })
624
+ // Empty emissions: single and range emit null, multiple emits [] (never null).
625
+ const singleState = controlled<PlainDate | null>(this, {
626
+ fallback: initialSingle,
627
+ onChange: (next, event) => {
628
+ if (currentArgs.mode === 'multiple' || currentArgs.mode === 'range' || !event) return
629
+ currentArgs.onSelect?.(next && plainToDate(next, currentArgs.timeZone), event)
630
+ },
631
+ })
632
+ const multipleState = controlled<PlainDate[]>(this, {
633
+ fallback: initialMultiple,
634
+ onChange: (next, event) => {
635
+ if (currentArgs.mode !== 'multiple' || !event) return
636
+ currentArgs.onSelect?.(next.map(item => plainToDate(item, currentArgs.timeZone)), event)
637
+ },
638
+ })
639
+ const rangeState = controlled<{ from?: PlainDate; to?: PlainDate }>(this, {
640
+ fallback: initialRange,
641
+ onChange: (next, event) => {
642
+ if (currentArgs.mode !== 'range' || !event) return
643
+ currentArgs.onSelect?.(next.from || next.to
644
+ ? {
645
+ from: next.from ? plainToDate(next.from, currentArgs.timeZone) : undefined,
646
+ to: next.to ? plainToDate(next.to, currentArgs.timeZone) : undefined,
647
+ }
648
+ : null, event)
649
+ },
650
+ })
651
+
652
+ const focusDay = (date: PlainDate, args: CalendarArgs) => {
653
+ queueMicrotask(() => {
654
+ const find = () =>
655
+ this.querySelector<HTMLButtonElement>(`button[data-day="${iso(date)}"]:not(:disabled)`)
656
+ const target = find()
657
+
658
+ if (target) {
659
+ target.focus()
660
+ return
661
+ }
662
+
663
+ const visible = args.month ? monthStart(dateToPlain(args.month, args.timeZone)) : monthState.value
664
+ const count = Math.max(1, args.numberOfMonths ?? 1)
665
+ const start = monthStart(date)
666
+
667
+ if (comparePlain(start, visible) < 0) moveMonth(addMonths(visible, -1), args)
668
+ else if (comparePlain(start, addMonths(visible, count - 1)) > 0) moveMonth(addMonths(visible, 1), args)
669
+ else return
670
+
671
+ queueMicrotask(() => find()?.focus())
672
+ })
673
+ }
674
+
675
+ const focusMonthCell = (month: PlainDate, args: CalendarArgs, event?: Event) => {
676
+ const target = monthStart(month)
677
+ queueMicrotask(() => {
678
+ const find = () => this.querySelector<HTMLButtonElement>(`button[data-month="${monthIso(target)}"]:not(:disabled)`)
679
+ const found = find()
680
+ if (found) {
681
+ found.focus()
682
+ return
683
+ }
684
+ if (!canNavigateTo(target, args)) return
685
+ moveMonth(target, args, event)
686
+ queueMicrotask(() => find()?.focus())
687
+ })
688
+ }
689
+
690
+ const navigableMonth = (year: number, args: CalendarArgs) => {
691
+ const options = monthOptions(year)
692
+ return options.find(month => month.month === visible.month && canNavigateTo(month, args))
693
+ ?? options.find(month => canNavigateTo(month, args))
694
+ }
695
+
696
+ const canCommitYear = (year: number, args: CalendarArgs) =>
697
+ canNavigateTo({ day: 1, month: 1, year }, args)
698
+ && canNavigateTo({ day: 1, month: 12, year }, args)
699
+
700
+ const canUseYearCell = (year: number, args: CalendarArgs) =>
701
+ minimumView(args.minView) === 'year'
702
+ ? canCommitYear(year, args)
703
+ : canNavigateYear(year, visible, args, today(args.timeZone))
704
+
705
+ const focusYearCell = (year: number, args: CalendarArgs, event?: Event) => {
706
+ queueMicrotask(() => {
707
+ const find = () => this.querySelector<HTMLButtonElement>(`button[data-year="${year}"]:not(:disabled)`)
708
+ const found = find()
709
+ if (found) {
710
+ found.focus()
711
+ return
712
+ }
713
+ if (!canUseYearCell(year, args)) return
714
+ const month = navigableMonth(year, args)
715
+ if (!month) return
716
+ moveMonth(month, args, event)
717
+ queueMicrotask(() => find()?.focus())
718
+ })
719
+ }
720
+
721
+ const moveMonth = (next: PlainDate, args: CalendarArgs, event?: Event) => {
722
+ const target = monthStart(next)
723
+ if (!canNavigateTo(target, args)) return
724
+ monthState.set(target, event)
725
+ }
726
+
727
+ const selectPeriod = (date: PlainDate, view: CalendarView, args: CalendarArgs, event: Event) => {
728
+ const start = periodStart(date, view)
729
+ const end = periodEnd(date, view)
730
+ if (args.mode === 'multiple') {
731
+ const current = multipleState.value
732
+ const exists = current.some(item => samePeriod(item, start, view))
733
+ const next = exists ? current.filter(item => !samePeriod(item, start, view)) : [...current, start]
734
+ if (args.required && !next.length) return
735
+ multipleState.set(next, event)
736
+ return
737
+ }
738
+
739
+ if (args.mode === 'range') {
740
+ const current = rangeState.value
741
+ let next: { from?: PlainDate; to?: PlainDate }
742
+
743
+ if (!current.from || current.to) next = { from: start }
744
+ else if (comparePlain(start, periodStart(current.from, view)) < 0) next = { from: start, to: periodEnd(current.from, view) }
745
+ else if (samePeriod(start, current.from, view) && !args.required) next = {}
746
+ else next = { from: periodStart(current.from, view), to: end }
747
+
748
+ rangeState.set(next, event)
749
+ return
750
+ }
751
+
752
+ const current = singleState.value
753
+ const next = current && samePeriod(current, start, view) && !args.required ? null : start
754
+ singleState.set(next, event)
755
+ }
756
+
757
+ const selectDay = (date: PlainDate, args: CalendarArgs, event: Event) => {
758
+ if (disabledAvailability?.day(plainToDate(date, args.timeZone))) return
759
+ selectPeriod(date, 'day', args, event)
760
+ }
761
+
762
+ const changeView = (next: CalendarView, args: CalendarArgs, event?: Event) => {
763
+ const target = clampView(next, args.minView)
764
+ if (target === currentView) return
765
+ viewState.set(target, event)
766
+ }
767
+
768
+ const drillUp = (anchor: PlainDate, args: CalendarArgs, event: Event) => {
769
+ if (currentView === 'day') {
770
+ if (!samePeriod(anchor, visible, 'month')) moveMonth(anchor, args, event)
771
+ changeView('month', args, event)
772
+ focusMonthCell(anchor, args)
773
+ return
774
+ }
775
+ if (currentView === 'month') {
776
+ changeView('year', args, event)
777
+ focusYearCell(anchor.year, args)
778
+ }
779
+ }
780
+
781
+ const selectMonthCell = (month: PlainDate, args: CalendarArgs, event: Event) => {
782
+ if (minimumView(args.minView) === 'month') {
783
+ selectPeriod(month, 'month', args, event)
784
+ return
785
+ }
786
+ moveMonth(month, args, event)
787
+ changeView('day', args, event)
788
+ focusDay(monthStart(month), args)
789
+ }
790
+
791
+ const selectYearCell = (year: number, args: CalendarArgs, event: Event) => {
792
+ if (!canUseYearCell(year, args)) return
793
+ const month = navigableMonth(year, args)
794
+ if (!month) return
795
+ if (minimumView(args.minView) === 'year') {
796
+ selectPeriod(month, 'year', args, event)
797
+ return
798
+ }
799
+ moveMonth(month, args, event)
800
+ changeView('month', args, event)
801
+ focusMonthCell(month, args)
802
+ }
803
+
804
+ const onMove = (move: GridMove, event: KeyboardEvent) => {
805
+ const button = event.target instanceof Element
806
+ ? event.target.closest<HTMLButtonElement>('button[data-day],button[data-month],button[data-year]')
807
+ : null
808
+ const args = currentArgs
809
+ const month = button?.dataset.month ? parseMonthIso(button.dataset.month) : undefined
810
+ const year = button?.dataset.year ? Number(button.dataset.year) : undefined
811
+
812
+ if (month) {
813
+ if ('cols' in move) focusMonthCell(addMonths(month, move.cols), args, event)
814
+ else if ('rows' in move) focusMonthCell(addMonths(month, move.rows * 3), args, event)
815
+ else if ('page' in move) focusMonthCell(addMonths(month, move.page * 12), args, event)
816
+ else {
817
+ const rowStart = Math.floor((month.month - 1) / 3) * 3 + 1
818
+ const candidates = monthOptions(month.year).filter(candidate =>
819
+ canNavigateTo(candidate, args)
820
+ && (move.extent === 'all' || (rowStart <= candidate.month && candidate.month <= rowStart + 2)))
821
+ const target = move.edge === 'start' ? candidates[0] : candidates[candidates.length - 1]
822
+ if (target) focusMonthCell(target, args, event)
823
+ }
824
+ return
825
+ }
826
+
827
+ if (year != null && Number.isFinite(year)) {
828
+ if ('cols' in move) focusYearCell(year + move.cols, args, event)
829
+ else if ('rows' in move) focusYearCell(year + move.rows * 3, args, event)
830
+ else if ('page' in move) focusYearCell(year + move.page * 12, args, event)
831
+ else {
832
+ const page = yearPage(visible, args, today(args.timeZone))
833
+ const rowStart = page.start + Math.floor((year - page.start) / 3) * 3
834
+ const candidates = page.years.filter(candidate =>
835
+ canUseYearCell(candidate, args)
836
+ && (move.extent === 'all' || (rowStart <= candidate && candidate <= rowStart + 2)))
837
+ const target = move.edge === 'start' ? candidates[0] : candidates[candidates.length - 1]
838
+ if (target != null) focusYearCell(target, args, event)
839
+ }
840
+ return
841
+ }
842
+
843
+ const day = button?.dataset.day ? parseIso(button.dataset.day) : undefined
844
+ if (!day) return
845
+
846
+ if ('cols' in move) {
847
+ focusDay(addDays(day, move.cols), args)
848
+ return
849
+ }
850
+
851
+ if ('rows' in move) {
852
+ focusDay(addDays(day, move.rows * 7), args)
853
+ return
854
+ }
855
+
856
+ if ('page' in move) {
857
+ const count = move.page * (move.large ? 12 : 1)
858
+ const target = addMonths(day, count)
859
+ if (!canNavigateTo(monthStart(target), args)) return
860
+
861
+ moveMonth(addMonths(visible, count), args, event)
862
+ focusDay(target, args)
863
+ return
864
+ }
865
+
866
+ if (move.extent === 'row') {
867
+ const start = startOfWeek(day, args.weekStartsOn ?? 0)
868
+ focusDay(move.edge === 'start' ? start : addDays(start, 6), args)
869
+ return
870
+ }
871
+
872
+ const start = monthStart(day)
873
+ focusDay(move.edge === 'start' ? start : { ...start, day: daysInMonth(start) }, args)
874
+ }
875
+
876
+ const nav = grid(this, {
877
+ rtl: () => currentArgs.dir === 'rtl',
878
+ onMove,
879
+ })
880
+
881
+ const onCellKeydown = (event: KeyboardEvent) => {
882
+ if (event.key !== 'Escape' || viewRank[currentView] <= viewRank[minimumView(currentArgs.minView)]) {
883
+ nav.handle(event)
884
+ return
885
+ }
886
+ event.preventDefault()
887
+ event.stopPropagation()
888
+ if (currentView === 'year') {
889
+ changeView('month', currentArgs, event)
890
+ focusMonthCell(visible, currentArgs)
891
+ return
892
+ }
893
+ changeView('day', currentArgs, event)
894
+ focusDay(visible, currentArgs)
895
+ }
896
+
897
+ for (const args of this) {
898
+ currentArgs = args
899
+ syncAvailability(args)
900
+ monthState.sync(args.month ? monthStart(dateToPlain(args.month, args.timeZone)) : undefined)
901
+ viewState.sync(args.view === undefined ? undefined : clampView(args.view, args.minView))
902
+ const clampedView = clampView(viewState.value, args.minView)
903
+ if (!viewState.controlled && clampedView !== viewState.value) viewState.init(clampedView)
904
+ // selected !== undefined binds; null (single, range) and [] (multiple) are controlled-empty.
905
+ singleState.sync(args.mode === 'multiple' || args.mode === 'range' || args.selected === undefined
906
+ ? undefined
907
+ : args.selected && dateToPlain(args.selected, args.timeZone))
908
+ multipleState.sync(args.mode === 'multiple' && args.selected !== undefined
909
+ ? toPlainArray(args.selected, args.timeZone)
910
+ : undefined)
911
+ rangeState.sync(args.mode === 'range' && args.selected !== undefined
912
+ ? toRangePlain(args.selected ?? undefined, args.timeZone)
913
+ : undefined)
914
+
915
+ const now = today(args.timeZone)
916
+ visible = monthState.value
917
+ currentView = clampedView
918
+ if (renderedView !== currentView && domReady) {
919
+ const active = document.activeElement
920
+ if (active instanceof HTMLElement && this.contains(active)) {
921
+ if (currentView === 'day') focusDay(visible, args)
922
+ else if (currentView === 'month') focusMonthCell(visible, args)
923
+ else focusYearCell(visible.year, args)
924
+ }
925
+ }
926
+ renderedView = currentView
927
+ const weekStartsOn = args.weekStartsOn ?? 0
928
+ const count = Math.max(1, args.numberOfMonths ?? 1)
929
+ const locale = localeCode(args.locale)
930
+ const formats = { ...defaultFormatters, ...(args.formatters ?? {}) }
931
+ const shown = months(visible, count, Boolean(args.fixedWeeks), weekStartsOn)
932
+ const single = singleState.value
933
+ const multiple = multipleState.value
934
+ const range = rangeState.value
935
+ const years = yearRange(visible, args, now)
936
+ const dayColumns = gridTemplate(Boolean(args.showWeekNumber))
937
+ const captionLayout = args.captionLayout ?? 'button'
938
+ const MonthDropdown = args.monthDropdown ?? CalendarDropdown
939
+ const YearDropdown = args.yearDropdown ?? CalendarDropdown
940
+ const page = yearPage(visible, args, now)
941
+ const periodFlags = (date: PlainDate, view: CalendarView) => {
942
+ const range_start = args.mode === 'range' && Boolean(range.from && samePeriod(range.from, date, view))
943
+ const range_end = args.mode === 'range' && Boolean(range.to && samePeriod(range.to, date, view))
944
+ const range_middle = args.mode === 'range' && periodIntersects(range, date, view) && !range_start && !range_end
945
+ const selected = args.mode === 'multiple'
946
+ ? multiple.some(item => samePeriod(item, date, view))
947
+ : args.mode === 'range'
948
+ ? range_start || range_end || range_middle
949
+ : Boolean(single && samePeriod(single, date, view))
950
+ return { range_end, range_middle, range_start, selected }
951
+ }
952
+ const canPreviousView = currentView === 'day'
953
+ ? canNavigateTo(addMonths(visible, -1), args)
954
+ : currentView === 'month'
955
+ ? canNavigateYear(visible.year - 1, visible, args, now)
956
+ : page.start > page.allowed[0]!
957
+ const canNextView = currentView === 'day'
958
+ ? canNavigateTo(addMonths(visible, count), args)
959
+ : currentView === 'month'
960
+ ? canNavigateYear(visible.year + 1, visible, args, now)
961
+ : page.start + 12 <= page.allowed[page.allowed.length - 1]!
962
+ const navigateView = (direction: -1 | 1, event: Event) => {
963
+ if (currentView === 'day') {
964
+ moveMonth(addMonths(visible, direction < 0 ? -1 : count), args, event)
965
+ return
966
+ }
967
+ const year = visible.year + direction * (currentView === 'month' ? 1 : 12)
968
+ const month = navigableMonth(year, args)
969
+ if (month) moveMonth(month, args, event)
970
+ }
971
+ const previousLabel = args.previousMonthLabel ?? (currentView === 'day'
972
+ ? 'Previous month'
973
+ : currentView === 'month' ? 'Previous year' : 'Previous 12 years')
974
+ const nextLabel = args.nextMonthLabel ?? (currentView === 'day'
975
+ ? 'Next month'
976
+ : currentView === 'month' ? 'Next year' : 'Next 12 years')
977
+
978
+ yield (
979
+ <>
980
+ <div class={args.classNames?.months} data-slot="calendar-months">
981
+ {currentView === 'day' ? shown.map(({ month: item, weeks }, monthIndex) => {
982
+ const monthDate = plainToDate(item, args.timeZone)
983
+ const showMonthDropdown = captionLayout === 'dropdown' || captionLayout === 'dropdown-months'
984
+ const showYearDropdown = captionLayout === 'dropdown' || captionLayout === 'dropdown-years'
985
+ const firstMonth = monthIndex === 0
986
+ const lastMonth = monthIndex === shown.length - 1
987
+
988
+ return (
989
+ <div key={iso(item)} class={args.classNames?.month} data-month={iso(item)} data-slot="calendar-month">
990
+ {/* Nav buttons are caption-row siblings, so a wide month or year
991
+ select grows the calendar instead of colliding with them. */}
992
+ <div class={args.classNames?.caption} data-slot="calendar-caption">
993
+ {firstMonth ? (
994
+ <button
995
+ aria-disabled={canPreviousView ? undefined : 'true'}
996
+ aria-label={previousLabel}
997
+ class={args.navButtonClass}
998
+ data-slot="calendar-previous"
999
+ disabled={!canPreviousView}
1000
+ type="button"
1001
+ set:onclick={(event: Event) => navigateView(-1, event)}
1002
+ >
1003
+ {args.previousIcon ?? <span aria-hidden="true" class={args.previousIconClass} data-slot="calendar-previous-icon" />}
1004
+ </button>
1005
+ ) : (
1006
+ <span aria-hidden="true" class={args.navSpacerClass} data-slot="calendar-nav-spacer" />
1007
+ )}
1008
+ {captionLayout === 'button' ? (
1009
+ <button
1010
+ class={args.captionLabelClass}
1011
+ data-slot="calendar-view-trigger"
1012
+ type="button"
1013
+ set:onclick={(event: Event) => drillUp(item, args, event)}
1014
+ >
1015
+ {formats.monthCaption(monthDate, locale, args.timeZone)}
1016
+ </button>
1017
+ ) : captionLayout === 'label' ? (
1018
+ <div class={args.captionLabelClass} data-slot="calendar-caption-label">
1019
+ {formats.monthCaption(monthDate, locale, args.timeZone)}
1020
+ </div>
1021
+ ) : (
1022
+ <div class={args.classNames?.dropdowns} data-slot="calendar-dropdowns">
1023
+ {showMonthDropdown ? (
1024
+ <MonthDropdown
1025
+ label={args.monthSelectLabel ?? 'Month'}
1026
+ options={monthOptions(item.year).map(option => ({
1027
+ label: formats.monthDropdown(plainToDate(option, args.timeZone), locale, args.timeZone),
1028
+ value: String(option.month),
1029
+ }))}
1030
+ onValueChange={(value, event) => moveMonth({ ...item, month: Number(value) }, args, event)}
1031
+ value={String(item.month)}
1032
+ />
1033
+ ) : (
1034
+ <div class={args.monthLabelClass} data-slot="calendar-month-label">{formatter(locale, 'monthLabel', args.timeZone).format(monthDate)}</div>
1035
+ )}
1036
+ {showYearDropdown ? (
1037
+ <YearDropdown
1038
+ label={args.yearSelectLabel ?? 'Year'}
1039
+ onValueChange={(value, event) => moveMonth({ ...item, year: Number(value) }, args, event)}
1040
+ options={years.map(year => ({ label: year, value: String(year) }))}
1041
+ value={String(item.year)}
1042
+ />
1043
+ ) : (
1044
+ <div class={args.yearLabelClass} data-slot="calendar-year-label">{item.year}</div>
1045
+ )}
1046
+ </div>
1047
+ )}
1048
+ {lastMonth ? (
1049
+ <button
1050
+ aria-disabled={canNextView ? undefined : 'true'}
1051
+ aria-label={nextLabel}
1052
+ class={args.navButtonClass}
1053
+ data-slot="calendar-next"
1054
+ disabled={!canNextView}
1055
+ type="button"
1056
+ set:onclick={(event: Event) => navigateView(1, event)}
1057
+ >
1058
+ {args.nextIcon ?? <span aria-hidden="true" class={args.nextIconClass} data-slot="calendar-next-icon" />}
1059
+ </button>
1060
+ ) : (
1061
+ <span aria-hidden="true" class={args.navSpacerClass} data-slot="calendar-nav-spacer" />
1062
+ )}
1063
+ </div>
1064
+ <div class={args.classNames?.grid} data-slot="calendar-grid" role="grid" aria-label={formats.monthCaption(monthDate, locale, args.timeZone)}>
1065
+ <div class={args.classNames?.head} data-slot="calendar-weekdays" role="row" style={dayColumns}>
1066
+ {args.showWeekNumber && <div aria-hidden="true" class={args.classNames?.week_number} data-slot="calendar-week-number-header" />}
1067
+ {Array.from({ length: 7 }, (_, index) => addDays({ year: 2026, month: 7, day: 5 }, weekStartsOn + index)).map((day, index) => (
1068
+ <div key={index} class={args.classNames?.weekday} data-slot="calendar-weekday" role="columnheader">
1069
+ {formats.weekday(plainToDate(day, args.timeZone), locale, args.timeZone)}
1070
+ </div>
1071
+ ))}
1072
+ </div>
1073
+ {weeks.map((week, row) => (
1074
+ <div key={row} class={args.classNames?.week} data-slot="calendar-week" role="row" style={dayColumns}>
1075
+ {args.showWeekNumber && (
1076
+ <div class={args.classNames?.week_number} data-slot="calendar-week-number" role="rowheader">
1077
+ {formats.weekNumber(weekNumber(week[0]!), locale)}
1078
+ </div>
1079
+ )}
1080
+ {week.map(day => {
1081
+ const outside = day.month !== item.month || day.year !== item.year
1082
+ const date = plainToDate(day, args.timeZone)
1083
+ const disabled = outside && args.showOutsideDays === false
1084
+ ? true
1085
+ : Boolean(disabledAvailability?.day(date))
1086
+ const unavailable = Boolean(unavailableAvailability?.day(date))
1087
+ const range_start = !outside && args.mode === 'range' && Boolean(range.from && samePlain(range.from, day))
1088
+ const range_end = !outside && args.mode === 'range' && Boolean(range.to && samePlain(range.to, day))
1089
+ const rawRangeMiddle = !outside && args.mode === 'range' && rangeMiddle(range, day)
1090
+ const rangeGap = rawRangeMiddle && unavailable && Boolean(args.allowNonContiguous)
1091
+ const selected = !rangeGap && !outside && (args.mode === 'multiple'
1092
+ ? plainIn(day, multiple)
1093
+ : args.mode === 'range'
1094
+ ? Boolean((range.from && samePlain(range.from, day)) || (range.to && samePlain(range.to, day)) || rangeContains(range, day))
1095
+ : Boolean(single && samePlain(single, day)))
1096
+ const range_middle = rawRangeMiddle && !rangeGap
1097
+ const current = samePlain(day, now)
1098
+ const modifierList = modifierNames(modifierAvailability, date)
1099
+ const modifiers: CalendarModifiers = {
1100
+ disabled,
1101
+ outside,
1102
+ range_end,
1103
+ range_middle,
1104
+ range_start,
1105
+ selected,
1106
+ today: current,
1107
+ unavailable,
1108
+ }
1109
+ const hidden = outside && args.showOutsideDays === false
1110
+ const band = args.mode === 'range' && Boolean(range.from && range.to)
1111
+ const state: CalendarDayState = {
1112
+ date,
1113
+ hidden,
1114
+ modifierNames: modifierList,
1115
+ modifiers,
1116
+ range: band,
1117
+ }
1118
+
1119
+ return (
1120
+ <div
1121
+ key={iso(day)}
1122
+ class={args.dayClassName?.(state) ?? args.classNames?.day}
1123
+ data-disabled={disabled ? 'true' : undefined}
1124
+ data-outside={outside ? 'true' : undefined}
1125
+ data-selected={selected ? 'true' : undefined}
1126
+ data-slot="calendar-day"
1127
+ data-today={current ? 'true' : undefined}
1128
+ data-unavailable={unavailable ? 'true' : undefined}
1129
+ role="gridcell"
1130
+ {...modifierAttributes(modifierList)}
1131
+ >
1132
+ {hidden ? (
1133
+ <span aria-hidden="true" class={args.hiddenDayClass}>{day.day}</span>
1134
+ ) : (
1135
+ <CalendarDayButton
1136
+ aria-label={formats.fullDate(date, locale, args.timeZone)}
1137
+ class={args.dayButtonClass}
1138
+ date={date}
1139
+ day={args.renderDay?.(date, modifiers) ?? formats.day(date, locale, args.timeZone)}
1140
+ timeZone={args.timeZone}
1141
+ disabled={disabled || outside}
1142
+ modifiers={modifiers}
1143
+ set:onclick={(event: Event) => selectDay(day, args, event)}
1144
+ set:onkeydown={onCellKeydown}
1145
+ />
1146
+ )}
1147
+ </div>
1148
+ )
1149
+ })}
1150
+ </div>
1151
+ ))}
1152
+ </div>
1153
+ </div>
1154
+ )
1155
+ }) : (
1156
+ <div key={`${currentView}-${currentView === 'year' ? page.start : visible.year}`} class={args.classNames?.month}>
1157
+ <div class={args.classNames?.caption} data-slot="calendar-caption">
1158
+ <button
1159
+ aria-disabled={canPreviousView ? undefined : 'true'}
1160
+ aria-label={previousLabel}
1161
+ class={args.navButtonClass}
1162
+ data-slot="calendar-previous"
1163
+ disabled={!canPreviousView}
1164
+ type="button"
1165
+ set:onclick={(event: Event) => navigateView(-1, event)}
1166
+ >
1167
+ {args.previousIcon ?? <span aria-hidden="true" class={args.previousIconClass} data-slot="calendar-previous-icon" />}
1168
+ </button>
1169
+ {captionLayout === 'button' ? (
1170
+ <button
1171
+ aria-disabled={currentView === 'year' ? 'true' : undefined}
1172
+ class={args.captionLabelClass}
1173
+ data-slot="calendar-view-trigger"
1174
+ disabled={currentView === 'year'}
1175
+ type="button"
1176
+ set:onclick={(event: Event) => drillUp(visible, args, event)}
1177
+ >
1178
+ {currentView === 'month' ? visible.year : `${page.start}–${page.start + 11}`}
1179
+ </button>
1180
+ ) : (
1181
+ <div class={args.captionLabelClass} data-slot="calendar-caption-label">
1182
+ {currentView === 'month' ? visible.year : `${page.start}–${page.start + 11}`}
1183
+ </div>
1184
+ )}
1185
+ <button
1186
+ aria-disabled={canNextView ? undefined : 'true'}
1187
+ aria-label={nextLabel}
1188
+ class={args.navButtonClass}
1189
+ data-slot="calendar-next"
1190
+ disabled={!canNextView}
1191
+ type="button"
1192
+ set:onclick={(event: Event) => navigateView(1, event)}
1193
+ >
1194
+ {args.nextIcon ?? <span aria-hidden="true" class={args.nextIconClass} data-slot="calendar-next-icon" />}
1195
+ </button>
1196
+ </div>
1197
+ {currentView === 'month' ? (
1198
+ <div
1199
+ aria-label={String(visible.year)}
1200
+ class={args.classNames?.month_view}
1201
+ data-slot="calendar-month-view"
1202
+ role="grid"
1203
+ style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr))"
1204
+ >
1205
+ {monthOptions(visible.year).map(month => {
1206
+ const disabled = !canNavigateTo(month, args)
1207
+ const flags = periodFlags(month, 'month')
1208
+ const current = samePeriod(month, now, 'month')
1209
+ const value = monthIso(month)
1210
+ return (
1211
+ <div key={value} role="gridcell">
1212
+ <button
1213
+ aria-disabled={disabled ? 'true' : undefined}
1214
+ aria-label={formats.monthCaption(plainToDate(month, args.timeZone), locale, args.timeZone)}
1215
+ class={args.classNames?.month_cell}
1216
+ data-disabled={disabled ? 'true' : undefined}
1217
+ data-month={value}
1218
+ data-range-end={flags.range_end ? 'true' : undefined}
1219
+ data-range-middle={flags.range_middle ? 'true' : undefined}
1220
+ data-range-start={flags.range_start ? 'true' : undefined}
1221
+ data-selected={flags.selected ? 'true' : undefined}
1222
+ data-slot="calendar-month-cell"
1223
+ data-today={current ? 'true' : undefined}
1224
+ disabled={disabled}
1225
+ type="button"
1226
+ set:onclick={(event: Event) => selectMonthCell(month, args, event)}
1227
+ set:onkeydown={onCellKeydown}
1228
+ >
1229
+ {formatter(locale, 'monthLabel', args.timeZone).format(plainToDate(month, args.timeZone))}
1230
+ </button>
1231
+ </div>
1232
+ )
1233
+ })}
1234
+ </div>
1235
+ ) : (
1236
+ <div
1237
+ aria-label={`${page.start}–${page.start + 11}`}
1238
+ class={args.classNames?.year_view}
1239
+ data-slot="calendar-year-view"
1240
+ role="grid"
1241
+ style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr))"
1242
+ >
1243
+ {page.years.map(year => {
1244
+ const disabled = !canUseYearCell(year, args)
1245
+ const target = { day: 1, month: 1, year }
1246
+ const flags = periodFlags(target, 'year')
1247
+ const current = year === now.year
1248
+ return (
1249
+ <div key={year} role="gridcell">
1250
+ <button
1251
+ aria-disabled={disabled ? 'true' : undefined}
1252
+ class={args.classNames?.year_cell}
1253
+ data-disabled={disabled ? 'true' : undefined}
1254
+ data-range-end={flags.range_end ? 'true' : undefined}
1255
+ data-range-middle={flags.range_middle ? 'true' : undefined}
1256
+ data-range-start={flags.range_start ? 'true' : undefined}
1257
+ data-selected={flags.selected ? 'true' : undefined}
1258
+ data-slot="calendar-year-cell"
1259
+ data-today={current ? 'true' : undefined}
1260
+ data-year={String(year)}
1261
+ disabled={disabled}
1262
+ type="button"
1263
+ set:onclick={(event: Event) => selectYearCell(year, args, event)}
1264
+ set:onkeydown={onCellKeydown}
1265
+ >
1266
+ {year}
1267
+ </button>
1268
+ </div>
1269
+ )
1270
+ })}
1271
+ </div>
1272
+ )}
1273
+ </div>
1274
+ )}
1275
+ </div>
1276
+ </>
1277
+ )
1278
+ }
1279
+ }
1280
+
1281
+
1282
+ /** Unstyled calendar with single, multiple, and range selection. */
1283
+ const Calendar: Stateless<CalendarArgs> = ({
1284
+ allowNonContiguous,
1285
+ captionLabelClass,
1286
+ captionLayout,
1287
+ class: classes,
1288
+ classNames,
1289
+ dayButtonClass,
1290
+ dayClassName,
1291
+ defaultMonth,
1292
+ defaultSelected,
1293
+ defaultView,
1294
+ disabled,
1295
+ endMonth,
1296
+ fixedWeeks,
1297
+ formatters,
1298
+ fromYear,
1299
+ hiddenDayClass,
1300
+ locale,
1301
+ minView,
1302
+ month,
1303
+ monthDropdown,
1304
+ monthLabelClass,
1305
+ monthSelectLabel,
1306
+ nextMonthLabel,
1307
+ previousMonthLabel,
1308
+ yearSelectLabel,
1309
+ modifiers,
1310
+ navButtonClass,
1311
+ navSpacerClass,
1312
+ nextIcon,
1313
+ nextIconClass,
1314
+ numberOfMonths,
1315
+ onMonthChange,
1316
+ onViewChange,
1317
+ previousIcon,
1318
+ previousIconClass,
1319
+ renderDay,
1320
+ required,
1321
+ showOutsideDays = true,
1322
+ showWeekNumber,
1323
+ startMonth,
1324
+ timeZone,
1325
+ toYear,
1326
+ unavailable,
1327
+ view,
1328
+ weekStartsOn,
1329
+ yearDropdown,
1330
+ yearLabelClass,
1331
+ ...attrs
1332
+ }) => {
1333
+ const {
1334
+ dir,
1335
+ mode,
1336
+ onSelect,
1337
+ selected,
1338
+ ...rest
1339
+ } = attrs as CalendarArgs & Record<string, unknown>
1340
+ const resolvedDir = (dir as 'ltr' | 'rtl' | undefined) ?? DirectionContext()
1341
+ const rootArgs = {
1342
+ allowNonContiguous,
1343
+ captionLabelClass,
1344
+ captionLayout,
1345
+ classNames,
1346
+ dayButtonClass,
1347
+ dayClassName,
1348
+ defaultMonth,
1349
+ defaultSelected,
1350
+ defaultView,
1351
+ disabled,
1352
+ dir: resolvedDir,
1353
+ endMonth,
1354
+ fixedWeeks,
1355
+ formatters,
1356
+ fromYear,
1357
+ hiddenDayClass,
1358
+ locale,
1359
+ minView,
1360
+ mode,
1361
+ month,
1362
+ monthDropdown,
1363
+ monthLabelClass,
1364
+ monthSelectLabel,
1365
+ nextMonthLabel,
1366
+ previousMonthLabel,
1367
+ yearSelectLabel,
1368
+ modifiers,
1369
+ navButtonClass,
1370
+ navSpacerClass,
1371
+ nextIcon,
1372
+ nextIconClass,
1373
+ numberOfMonths,
1374
+ onMonthChange,
1375
+ onSelect,
1376
+ onViewChange,
1377
+ previousIcon,
1378
+ previousIconClass,
1379
+ renderDay,
1380
+ required,
1381
+ selected,
1382
+ showOutsideDays,
1383
+ showWeekNumber,
1384
+ startMonth,
1385
+ timeZone,
1386
+ toYear,
1387
+ unavailable,
1388
+ view,
1389
+ weekStartsOn,
1390
+ yearDropdown,
1391
+ yearLabelClass,
1392
+ } as CalendarArgs
1393
+
1394
+ return (
1395
+ <CalendarRoot
1396
+ {...rootArgs}
1397
+ {...rootAttrs(rest)}
1398
+ attr:class={classes}
1399
+ attr:data-slot="calendar"
1400
+ attr:dir={resolvedDir}
1401
+ />
1402
+ )
1403
+ }
1404
+
1405
+ /** Unstyled day button used by Calendar. */
1406
+ const CalendarDayButton: Stateless<CalendarDayButtonArgs> = ({
1407
+ class: classes,
1408
+ date,
1409
+ day,
1410
+ modifiers,
1411
+ timeZone,
1412
+ type = 'button',
1413
+ ...attrs
1414
+ }) => {
1415
+ const value = dateToPlain(date, timeZone)
1416
+ const single = modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
1417
+
1418
+ return (
1419
+ <button
1420
+ {...attrs}
1421
+ aria-disabled={modifiers.unavailable ? 'true' : attrs['aria-disabled']}
1422
+ class={classes}
1423
+ data-day={iso(value)}
1424
+ data-disabled={modifiers.disabled ? 'true' : undefined}
1425
+ data-outside={modifiers.outside ? 'true' : undefined}
1426
+ data-range-end={modifiers.range_end ? 'true' : undefined}
1427
+ data-range-middle={modifiers.range_middle ? 'true' : undefined}
1428
+ data-range-start={modifiers.range_start ? 'true' : undefined}
1429
+ data-selected-single={single ? 'true' : undefined}
1430
+ data-slot="calendar-day-button"
1431
+ data-state={modifiers.selected ? 'selected' : 'unselected'}
1432
+ data-today={modifiers.today ? 'true' : undefined}
1433
+ data-unavailable={modifiers.unavailable ? 'true' : undefined}
1434
+ type={type}
1435
+ >
1436
+ {day}
1437
+ </button>
1438
+ )
1439
+ }
1440
+
1441
+ export { Calendar, CalendarDayButton }