@foldkit/ui 0.112.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 (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/anchor.d.ts +38 -0
  4. package/dist/anchor.d.ts.map +1 -0
  5. package/dist/anchor.js +142 -0
  6. package/dist/animation/index.d.ts +49 -0
  7. package/dist/animation/index.d.ts.map +1 -0
  8. package/dist/animation/index.js +75 -0
  9. package/dist/animation/public.d.ts +3 -0
  10. package/dist/animation/public.d.ts.map +1 -0
  11. package/dist/animation/public.js +1 -0
  12. package/dist/animation/schema.d.ts +43 -0
  13. package/dist/animation/schema.d.ts.map +1 -0
  14. package/dist/animation/schema.js +41 -0
  15. package/dist/animation/update.d.ts +24 -0
  16. package/dist/animation/update.d.ts.map +1 -0
  17. package/dist/animation/update.js +67 -0
  18. package/dist/button/index.d.ts +17 -0
  19. package/dist/button/index.d.ts.map +1 -0
  20. package/dist/button/index.js +22 -0
  21. package/dist/button/public.d.ts +3 -0
  22. package/dist/button/public.d.ts.map +1 -0
  23. package/dist/button/public.js +1 -0
  24. package/dist/calendar/index.d.ts +462 -0
  25. package/dist/calendar/index.d.ts.map +1 -0
  26. package/dist/calendar/index.js +825 -0
  27. package/dist/calendar/public.d.ts +3 -0
  28. package/dist/calendar/public.d.ts.map +1 -0
  29. package/dist/calendar/public.js +1 -0
  30. package/dist/checkbox/index.d.ts +119 -0
  31. package/dist/checkbox/index.d.ts.map +1 -0
  32. package/dist/checkbox/index.js +111 -0
  33. package/dist/checkbox/public.d.ts +3 -0
  34. package/dist/checkbox/public.d.ts.map +1 -0
  35. package/dist/checkbox/public.js +1 -0
  36. package/dist/combobox/multi.d.ts +183 -0
  37. package/dist/combobox/multi.d.ts.map +1 -0
  38. package/dist/combobox/multi.js +81 -0
  39. package/dist/combobox/multiPublic.d.ts +3 -0
  40. package/dist/combobox/multiPublic.d.ts.map +1 -0
  41. package/dist/combobox/multiPublic.js +1 -0
  42. package/dist/combobox/public.d.ts +7 -0
  43. package/dist/combobox/public.d.ts.map +1 -0
  44. package/dist/combobox/public.js +3 -0
  45. package/dist/combobox/shared.d.ts +423 -0
  46. package/dist/combobox/shared.d.ts.map +1 -0
  47. package/dist/combobox/shared.js +708 -0
  48. package/dist/combobox/single.d.ts +198 -0
  49. package/dist/combobox/single.d.ts.map +1 -0
  50. package/dist/combobox/single.js +106 -0
  51. package/dist/datePicker/index.d.ts +457 -0
  52. package/dist/datePicker/index.d.ts.map +1 -0
  53. package/dist/datePicker/index.js +318 -0
  54. package/dist/datePicker/public.d.ts +3 -0
  55. package/dist/datePicker/public.d.ts.map +1 -0
  56. package/dist/datePicker/public.js +1 -0
  57. package/dist/dialog/index.d.ts +160 -0
  58. package/dist/dialog/index.d.ts.map +1 -0
  59. package/dist/dialog/index.js +211 -0
  60. package/dist/dialog/public.d.ts +3 -0
  61. package/dist/dialog/public.d.ts.map +1 -0
  62. package/dist/dialog/public.js +1 -0
  63. package/dist/disclosure/index.d.ts +110 -0
  64. package/dist/disclosure/index.d.ts.map +1 -0
  65. package/dist/disclosure/index.js +111 -0
  66. package/dist/disclosure/public.d.ts +3 -0
  67. package/dist/disclosure/public.d.ts.map +1 -0
  68. package/dist/disclosure/public.js +1 -0
  69. package/dist/dragAndDrop/index.d.ts +540 -0
  70. package/dist/dragAndDrop/index.d.ts.map +1 -0
  71. package/dist/dragAndDrop/index.js +535 -0
  72. package/dist/dragAndDrop/public.d.ts +3 -0
  73. package/dist/dragAndDrop/public.d.ts.map +1 -0
  74. package/dist/dragAndDrop/public.js +1 -0
  75. package/dist/fieldset/index.d.ts +21 -0
  76. package/dist/fieldset/index.d.ts.map +1 -0
  77. package/dist/fieldset/index.js +25 -0
  78. package/dist/fieldset/public.d.ts +3 -0
  79. package/dist/fieldset/public.d.ts.map +1 -0
  80. package/dist/fieldset/public.js +1 -0
  81. package/dist/fileDrop/index.d.ts +109 -0
  82. package/dist/fileDrop/index.d.ts.map +1 -0
  83. package/dist/fileDrop/index.js +127 -0
  84. package/dist/fileDrop/public.d.ts +3 -0
  85. package/dist/fileDrop/public.d.ts.map +1 -0
  86. package/dist/fileDrop/public.js +1 -0
  87. package/dist/group.d.ts +8 -0
  88. package/dist/group.d.ts.map +1 -0
  89. package/dist/group.js +13 -0
  90. package/dist/index.d.ts +25 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +24 -0
  93. package/dist/input/index.d.ts +26 -0
  94. package/dist/input/index.d.ts.map +1 -0
  95. package/dist/input/index.js +43 -0
  96. package/dist/input/public.d.ts +3 -0
  97. package/dist/input/public.d.ts.map +1 -0
  98. package/dist/input/public.js +1 -0
  99. package/dist/internal/optionExtensions.d.ts +6 -0
  100. package/dist/internal/optionExtensions.d.ts.map +1 -0
  101. package/dist/internal/optionExtensions.js +2 -0
  102. package/dist/keyboard.d.ts +6 -0
  103. package/dist/keyboard.d.ts.map +1 -0
  104. package/dist/keyboard.js +9 -0
  105. package/dist/listbox/multi.d.ts +189 -0
  106. package/dist/listbox/multi.d.ts.map +1 -0
  107. package/dist/listbox/multi.js +65 -0
  108. package/dist/listbox/multiPublic.d.ts +3 -0
  109. package/dist/listbox/multiPublic.d.ts.map +1 -0
  110. package/dist/listbox/multiPublic.js +1 -0
  111. package/dist/listbox/public.d.ts +7 -0
  112. package/dist/listbox/public.d.ts.map +1 -0
  113. package/dist/listbox/public.js +3 -0
  114. package/dist/listbox/shared.d.ts +432 -0
  115. package/dist/listbox/shared.d.ts.map +1 -0
  116. package/dist/listbox/shared.js +670 -0
  117. package/dist/listbox/single.d.ts +207 -0
  118. package/dist/listbox/single.d.ts.map +1 -0
  119. package/dist/listbox/single.js +73 -0
  120. package/dist/menu/index.d.ts +368 -0
  121. package/dist/menu/index.d.ts.map +1 -0
  122. package/dist/menu/index.js +682 -0
  123. package/dist/menu/public.d.ts +4 -0
  124. package/dist/menu/public.d.ts.map +1 -0
  125. package/dist/menu/public.js +1 -0
  126. package/dist/popover/index.d.ts +267 -0
  127. package/dist/popover/index.d.ts.map +1 -0
  128. package/dist/popover/index.js +346 -0
  129. package/dist/popover/public.d.ts +4 -0
  130. package/dist/popover/public.d.ts.map +1 -0
  131. package/dist/popover/public.js +1 -0
  132. package/dist/radioGroup/index.d.ts +169 -0
  133. package/dist/radioGroup/index.d.ts.map +1 -0
  134. package/dist/radioGroup/index.js +197 -0
  135. package/dist/radioGroup/public.d.ts +3 -0
  136. package/dist/radioGroup/public.d.ts.map +1 -0
  137. package/dist/radioGroup/public.js +1 -0
  138. package/dist/select/index.d.ts +24 -0
  139. package/dist/select/index.d.ts.map +1 -0
  140. package/dist/select/index.js +40 -0
  141. package/dist/select/public.d.ts +3 -0
  142. package/dist/select/public.d.ts.map +1 -0
  143. package/dist/select/public.js +1 -0
  144. package/dist/slider/index.d.ts +318 -0
  145. package/dist/slider/index.d.ts.map +1 -0
  146. package/dist/slider/index.js +337 -0
  147. package/dist/slider/public.d.ts +3 -0
  148. package/dist/slider/public.d.ts.map +1 -0
  149. package/dist/slider/public.js +1 -0
  150. package/dist/switch/index.d.ts +99 -0
  151. package/dist/switch/index.d.ts.map +1 -0
  152. package/dist/switch/index.js +107 -0
  153. package/dist/switch/public.d.ts +3 -0
  154. package/dist/switch/public.d.ts.map +1 -0
  155. package/dist/switch/public.js +1 -0
  156. package/dist/tabs/index.d.ts +155 -0
  157. package/dist/tabs/index.d.ts.map +1 -0
  158. package/dist/tabs/index.js +185 -0
  159. package/dist/tabs/public.d.ts +3 -0
  160. package/dist/tabs/public.d.ts.map +1 -0
  161. package/dist/tabs/public.js +1 -0
  162. package/dist/test/apps/disabledButton.d.ts +38 -0
  163. package/dist/test/apps/disabledButton.d.ts.map +1 -0
  164. package/dist/test/apps/disabledButton.js +71 -0
  165. package/dist/textarea/index.d.ts +26 -0
  166. package/dist/textarea/index.d.ts.map +1 -0
  167. package/dist/textarea/index.js +44 -0
  168. package/dist/textarea/public.d.ts +3 -0
  169. package/dist/textarea/public.d.ts.map +1 -0
  170. package/dist/textarea/public.js +1 -0
  171. package/dist/toast/index.d.ts +608 -0
  172. package/dist/toast/index.d.ts.map +1 -0
  173. package/dist/toast/index.js +146 -0
  174. package/dist/toast/public.d.ts +4 -0
  175. package/dist/toast/public.d.ts.map +1 -0
  176. package/dist/toast/public.js +1 -0
  177. package/dist/toast/schema.d.ts +154 -0
  178. package/dist/toast/schema.d.ts.map +1 -0
  179. package/dist/toast/schema.js +93 -0
  180. package/dist/toast/update.d.ts +510 -0
  181. package/dist/toast/update.d.ts.map +1 -0
  182. package/dist/toast/update.js +225 -0
  183. package/dist/tooltip/index.d.ts +170 -0
  184. package/dist/tooltip/index.d.ts.map +1 -0
  185. package/dist/tooltip/index.js +253 -0
  186. package/dist/tooltip/public.d.ts +4 -0
  187. package/dist/tooltip/public.d.ts.map +1 -0
  188. package/dist/tooltip/public.js +1 -0
  189. package/dist/typeahead.d.ts +4 -0
  190. package/dist/typeahead.d.ts.map +1 -0
  191. package/dist/typeahead.js +14 -0
  192. package/dist/virtualList/index.d.ts +203 -0
  193. package/dist/virtualList/index.d.ts.map +1 -0
  194. package/dist/virtualList/index.js +392 -0
  195. package/dist/virtualList/public.d.ts +3 -0
  196. package/dist/virtualList/public.d.ts.map +1 -0
  197. package/dist/virtualList/public.js +1 -0
  198. package/dist/vitest-setup.d.ts +2 -0
  199. package/dist/vitest-setup.d.ts.map +1 -0
  200. package/dist/vitest-setup.js +2 -0
  201. package/package.json +161 -0
@@ -0,0 +1,825 @@
1
+ import { Array, Effect, Function, Match as M, Number, Option, Schema as S, pipe, } from 'effect';
2
+ import * as Calendar from 'foldkit/calendar';
3
+ import * as Command from 'foldkit/command';
4
+ import * as Dom from 'foldkit/dom';
5
+ import { childAttributes, html, } from 'foldkit/html';
6
+ import { m } from 'foldkit/message';
7
+ import { evo } from 'foldkit/struct';
8
+ import { defineView } from 'foldkit/submodel';
9
+ import * as OptionExt from '../internal/optionExtensions.js';
10
+ // MODEL
11
+ /** Which grid the calendar is currently displaying. `Days` is the standard
12
+ * 6×7 day grid; `Months` is a 3×4 month-name grid for fast month jumps;
13
+ * `Years` is a 3×4 year grid paged in 12-year windows for fast year jumps. */
14
+ export const ViewMode = S.Literals(['Days', 'Months', 'Years']);
15
+ /** Schema for the calendar component's state. Tracks the visible month/year,
16
+ * the keyboard-focused and user-selected dates, the active view mode, and
17
+ * the configuration that governs navigation (locale, min/max, disabled
18
+ * days). */
19
+ export const Model = S.Struct({
20
+ id: S.String,
21
+ today: Calendar.CalendarDate,
22
+ viewYear: S.Int,
23
+ viewMonth: S.Int.check(S.isBetween({ minimum: 1, maximum: 12 })),
24
+ viewMode: ViewMode,
25
+ maybeFocusedDate: S.Option(Calendar.CalendarDate),
26
+ maybeSelectedDate: S.Option(Calendar.CalendarDate),
27
+ isGridFocused: S.Boolean,
28
+ locale: Calendar.LocaleConfig,
29
+ maybeMinDate: S.Option(Calendar.CalendarDate),
30
+ maybeMaxDate: S.Option(Calendar.CalendarDate),
31
+ disabledDaysOfWeek: S.Array(Calendar.DayOfWeek),
32
+ disabledDates: S.Array(Calendar.CalendarDate),
33
+ });
34
+ // MESSAGE
35
+ /** Sent when the user clicks a day cell in the grid. */
36
+ export const ClickedDay = m('ClickedDay', { date: Calendar.CalendarDate });
37
+ /** Sent when the user presses a key on the grid container. The update maps
38
+ * the key to a navigation or selection action. */
39
+ export const PressedKeyOnGrid = m('PressedKeyOnGrid', {
40
+ key: S.String,
41
+ isShift: S.Boolean,
42
+ });
43
+ /** Sent when the user clicks the previous-month navigation button in Days
44
+ * mode. (The Years mode prev/next-page buttons dispatch `PagedYears`.) */
45
+ export const ClickedPreviousMonthButton = m('ClickedPreviousMonthButton');
46
+ /** Sent when the user clicks the next-month navigation button in Days
47
+ * mode. (The Years mode prev/next-page buttons dispatch `PagedYears`.) */
48
+ export const ClickedNextMonthButton = m('ClickedNextMonthButton');
49
+ /** Sent when the user clicks the calendar heading. Zooms out one mode
50
+ * level: Days → Months, Months → Years. Terminal in Years mode. */
51
+ export const ClickedHeading = m('ClickedHeading');
52
+ /** Sent when the user picks a month from the months grid. Jumps the view
53
+ * to that month and returns the calendar to Days mode. */
54
+ export const SelectedMonth = m('SelectedMonth', { month: S.Int });
55
+ /** Sent when the user picks a year from the years grid. Jumps the view to
56
+ * that year and transitions the calendar to Months mode for further drilling. */
57
+ export const SelectedYear = m('SelectedYear', { year: S.Int });
58
+ /** Sent when the user pages the years grid forward or backward by one
59
+ * window. Direction is `1` for next, `-1` for previous. */
60
+ export const PagedYears = m('PagedYears', {
61
+ direction: S.Literals([1, -1]),
62
+ });
63
+ /** Sent when the grid container receives DOM focus. */
64
+ export const FocusedGrid = m('FocusedGrid');
65
+ /** Sent when the grid container loses DOM focus. */
66
+ export const BlurredGrid = m('BlurredGrid');
67
+ /** Sent when a long-lived session's "today" reference should be refreshed. */
68
+ export const RefreshedToday = m('RefreshedToday', {
69
+ today: Calendar.CalendarDate,
70
+ });
71
+ /** Sent when a FocusGrid command completes. */
72
+ export const CompletedFocusGrid = m('CompletedFocusGrid');
73
+ /** Union of all messages the calendar component can produce. */
74
+ export const Message = S.Union([
75
+ ClickedDay,
76
+ PressedKeyOnGrid,
77
+ ClickedPreviousMonthButton,
78
+ ClickedNextMonthButton,
79
+ ClickedHeading,
80
+ SelectedMonth,
81
+ SelectedYear,
82
+ PagedYears,
83
+ FocusedGrid,
84
+ BlurredGrid,
85
+ RefreshedToday,
86
+ CompletedFocusGrid,
87
+ ]);
88
+ // OUT MESSAGE
89
+ /** Emitted when the visible month changes due to navigation. Consumers of an
90
+ * inline calendar may use this to load month-scoped data (holidays, events).
91
+ * A click that commits a date in a different month emits `SelectedDate`, not
92
+ * `ChangedViewMonth`. The parent infers the month change from the date. */
93
+ export const ChangedViewMonth = m('ChangedViewMonth', {
94
+ year: S.Int,
95
+ month: S.Int,
96
+ });
97
+ /** Emitted when the user commits a date selection via click or keyboard. The
98
+ * calendar's internal state already reflects the new selection by the time
99
+ * this fires; consumers react by lifting the date into their domain state
100
+ * (closing a popover, advancing a form step, etc.). */
101
+ export const SelectedDate = m('SelectedDate', {
102
+ date: Calendar.CalendarDate,
103
+ });
104
+ /** Union of the calendar's OutMessages. */
105
+ export const OutMessage = S.Union([ChangedViewMonth, SelectedDate]);
106
+ /** Creates an initial calendar model. The view month defaults to the month
107
+ * of the initial selected date, or today if no date is pre-selected. */
108
+ export const init = (config) => {
109
+ const maybeInitialSelectedDate = Option.fromNullishOr(config.initialSelectedDate);
110
+ const initialFocus = Option.getOrElse(maybeInitialSelectedDate, () => config.today);
111
+ return {
112
+ id: config.id,
113
+ today: config.today,
114
+ viewYear: initialFocus.year,
115
+ viewMonth: initialFocus.month,
116
+ viewMode: 'Days',
117
+ maybeFocusedDate: Option.some(initialFocus),
118
+ maybeSelectedDate: maybeInitialSelectedDate,
119
+ isGridFocused: false,
120
+ locale: config.locale ?? Calendar.defaultEnglishLocale,
121
+ maybeMinDate: Option.fromNullishOr(config.minDate),
122
+ maybeMaxDate: Option.fromNullishOr(config.maxDate),
123
+ disabledDaysOfWeek: config.disabledDaysOfWeek ?? [],
124
+ disabledDates: config.disabledDates ?? [],
125
+ };
126
+ };
127
+ const withUpdateReturn = M.withReturnType();
128
+ const gridId = (modelId) => `${modelId}-grid`;
129
+ const gridSelector = (modelId) => `#${gridId(modelId)}`;
130
+ /** Focuses the calendar grid container. Parent components like DatePicker
131
+ * dispatch this after opening to hand focus to the grid's keyboard layer. */
132
+ export const FocusGrid = Command.define('FocusGrid', { id: S.String }, CompletedFocusGrid)(({ id }) => Dom.focus(gridSelector(id)).pipe(Effect.ignore, Effect.as(CompletedFocusGrid())));
133
+ /** Programmatically selects a date on the calendar, committing it as the
134
+ * chosen value and moving the cursor onto it. Use this in controlled-mode
135
+ * handlers (when the view's `onSelectedDate` callback is provided) to write
136
+ * the selection back to the calendar's internal state.
137
+ *
138
+ * Equivalent to dispatching `ClickedDay({ date })` through `update`. */
139
+ export const selectDate = (model, date) => update(model, ClickedDay({ date }));
140
+ /** Reflects an externally-sourced selected date onto the model without
141
+ * emitting an OutMessage. When a date is given, sets the selection and
142
+ * moves the view to the date's month so it stays visible, mirroring
143
+ * `selectDate`'s state change minus the `SelectedDate` announcement. Pass
144
+ * `Option.none()` to clear the selection (the view is left where it is).
145
+ * Use this to mirror external truth (a URL parameter, a saved draft) onto
146
+ * the calendar. Contrast with `selectDate`, a user or programmatic
147
+ * *choice* that emits `SelectedDate`. Returns the model directly because
148
+ * it produces no commands and no OutMessage. */
149
+ export const reflectSelectedDate = Function.dual(2, (model, maybeDate) => Option.match(maybeDate, {
150
+ onNone: () => evo(model, { maybeSelectedDate: () => Option.none() }),
151
+ onSome: date => evo(model, {
152
+ maybeSelectedDate: () => Option.some(date),
153
+ maybeFocusedDate: () => Option.some(date),
154
+ viewYear: () => date.year,
155
+ viewMonth: () => date.month,
156
+ }),
157
+ }));
158
+ /** Reflects the minimum selectable date onto the model. Pass `Option.none()`
159
+ * to remove the minimum. Use this when the minimum derives from other Model
160
+ * state (e.g. a start date field whose current selection constrains an end
161
+ * date picker).
162
+ *
163
+ * Does NOT reconcile the current selection. If a previously-selected date
164
+ * is now below the new minimum, it remains selected. Callers should clear or
165
+ * reassign the selection explicitly if their domain requires it. */
166
+ export const reflectMinDate = Function.dual(2, (model, maybeMinDate) => evo(model, { maybeMinDate: () => maybeMinDate }));
167
+ /** Reflects the maximum selectable date onto the model. Pass `Option.none()`
168
+ * to remove the maximum. Does NOT reconcile the current selection. */
169
+ export const reflectMaxDate = Function.dual(2, (model, maybeMaxDate) => evo(model, { maybeMaxDate: () => maybeMaxDate }));
170
+ /** Reflects the list of individually-disabled dates onto the model. Pass an
171
+ * empty array to clear. Does NOT reconcile the current selection. */
172
+ export const reflectDisabledDates = Function.dual(2, (model, disabledDates) => evo(model, { disabledDates: () => disabledDates }));
173
+ /** Reflects the days of the week that are disabled (e.g. weekends) onto the
174
+ * model. Pass an empty array to clear. Does NOT reconcile the current
175
+ * selection. */
176
+ export const reflectDisabledDaysOfWeek = Function.dual(2, (model, disabledDaysOfWeek) => evo(model, { disabledDaysOfWeek: () => disabledDaysOfWeek }));
177
+ /** Returns the calendar to Days mode regardless of current depth. Useful for
178
+ * standalone (non-popovered) consumers that want to wire their own back-out
179
+ * gesture. Popovered consumers like `DatePicker` don't need this. Escape
180
+ * closes the popover, and the calendar resets to Days on next open.
181
+ *
182
+ * Reconciles `maybeFocusedDate` to a date inside the visible (`viewYear`,
183
+ * `viewMonth`). Months/Years navigation can leave the cursor on a date
184
+ * outside the days grid (paged-away year, etc.), which would otherwise
185
+ * cause `aria-activedescendant` to point at a non-rendered cell and the
186
+ * next ArrowLeft to jump to the cursor's stale year. */
187
+ export const dropToDays = (model) => {
188
+ const focusedDay = Option.match(model.maybeFocusedDate, {
189
+ onNone: () => 1,
190
+ onSome: date => Math.min(date.day, Calendar.daysInMonth(model.viewYear, model.viewMonth)),
191
+ });
192
+ return evo(model, {
193
+ viewMode: () => 'Days',
194
+ maybeFocusedDate: () => Option.some(Calendar.make(model.viewYear, model.viewMonth, focusedDay)),
195
+ });
196
+ };
197
+ const DAY_SKIP_CAP = 31;
198
+ const MONTH_SKIP_CAP = 12;
199
+ /** Number of years per Years-mode page. A 3×4 grid renders one window. */
200
+ const YEARS_PAGE_SIZE = 12;
201
+ const isDateDisabled = (model, date) => Option.exists(model.maybeMinDate, min => Calendar.isBefore(date, min)) ||
202
+ Option.exists(model.maybeMaxDate, max => Calendar.isAfter(date, max)) ||
203
+ model.disabledDaysOfWeek.includes(Calendar.dayOfWeek(date)) ||
204
+ model.disabledDates.some(Calendar.isEqual(date));
205
+ /** Walks from `start` in `direction`, returning the first non-disabled date
206
+ * within `cap` steps. Falls back to `start` if every candidate is disabled. */
207
+ const skipDisabled = (model, start, direction, cap) => pipe(cap, Array.makeBy(step => Calendar.addDays(start, step * direction)), Array.findFirst(date => !isDateDisabled(model, date)), Option.getOrElse(() => start));
208
+ const clampToRange = (model, candidate) => {
209
+ const afterMin = Option.match(model.maybeMinDate, {
210
+ onNone: () => candidate,
211
+ onSome: min => Calendar.max(candidate, min),
212
+ });
213
+ return Option.match(model.maybeMaxDate, {
214
+ onNone: () => afterMin,
215
+ onSome: max => Calendar.min(afterMin, max),
216
+ });
217
+ };
218
+ /** Resolves a navigation key press to the next focused date candidate,
219
+ * along with the direction and search cap for disabled-date skipping. */
220
+ const resolveNavigationKey = (key, isShift, focused, firstDayOfWeek) => M.value(key).pipe(M.withReturnType(), M.when('ArrowLeft', () => [
221
+ Calendar.addDays(focused, -1),
222
+ -1,
223
+ DAY_SKIP_CAP,
224
+ ]), M.when('ArrowRight', () => [Calendar.addDays(focused, 1), 1, DAY_SKIP_CAP]), M.when('ArrowUp', () => [Calendar.addDays(focused, -7), -1, DAY_SKIP_CAP]), M.when('ArrowDown', () => [Calendar.addDays(focused, 7), 1, DAY_SKIP_CAP]), M.when('Home', () => [
225
+ Calendar.startOfWeek(focused, firstDayOfWeek),
226
+ -1,
227
+ DAY_SKIP_CAP,
228
+ ]), M.when('End', () => [
229
+ Calendar.endOfWeek(focused, firstDayOfWeek),
230
+ 1,
231
+ DAY_SKIP_CAP,
232
+ ]), M.when('PageUp', () => [
233
+ isShift
234
+ ? Calendar.addYears(focused, -1)
235
+ : Calendar.addMonths(focused, -1),
236
+ -1,
237
+ MONTH_SKIP_CAP,
238
+ ]), M.when('PageDown', () => [
239
+ isShift ? Calendar.addYears(focused, 1) : Calendar.addMonths(focused, 1),
240
+ 1,
241
+ MONTH_SKIP_CAP,
242
+ ]), M.option);
243
+ const isCommitKey = (key) => key === 'Enter' || key === ' ';
244
+ const currentOrFallbackFocus = (model) => Option.getOrElse(model.maybeFocusedDate, () => Calendar.make(model.viewYear, model.viewMonth, 1));
245
+ /** Applies a date selection to the model: commits the selection, moves the
246
+ * cursor onto the date, and syncs the view month if the selection crosses a
247
+ * month boundary. Always emits `SelectedDate` carrying the committed date;
248
+ * the parent infers month transitions from the date itself rather than from
249
+ * a separate `ChangedViewMonth` signal that would race with the selection. */
250
+ const commitSelection = (model, date) => {
251
+ const nextModel = evo(model, {
252
+ maybeSelectedDate: () => Option.some(date),
253
+ maybeFocusedDate: () => Option.some(date),
254
+ viewYear: () => date.year,
255
+ viewMonth: () => date.month,
256
+ });
257
+ return [nextModel, Option.some(SelectedDate({ date }))];
258
+ };
259
+ /** Applies a focus move to the model, clamping to the allowed range and
260
+ * skipping disabled dates. Emits `ChangedViewMonth` if the move crossed a
261
+ * month boundary. */
262
+ const applyFocusMove = (model, candidate, direction, cap) => {
263
+ const clamped = clampToRange(model, candidate);
264
+ const nextFocus = skipDisabled(model, clamped, direction, cap);
265
+ const crossedMonth = nextFocus.year !== model.viewYear || nextFocus.month !== model.viewMonth;
266
+ const nextModel = evo(model, {
267
+ maybeFocusedDate: () => Option.some(nextFocus),
268
+ viewYear: () => nextFocus.year,
269
+ viewMonth: () => nextFocus.month,
270
+ });
271
+ const maybeOutMessage = OptionExt.when(crossedMonth, ChangedViewMonth({ year: nextFocus.year, month: nextFocus.month }));
272
+ return [nextModel, maybeOutMessage];
273
+ };
274
+ /** Computes the focused-date cursor for a view-month change. Preserves the
275
+ * current day-of-month (clamping to the new month's length when needed),
276
+ * then runs the candidate through min/max clamping and disabled-date skipping
277
+ * so the cursor always lands on a real, navigable cell. */
278
+ const moveFocusForViewChange = (model, year, month, direction) => {
279
+ const currentDay = Option.match(model.maybeFocusedDate, {
280
+ onNone: () => 1,
281
+ onSome: focused => focused.day,
282
+ });
283
+ const dayInNewMonth = Math.min(currentDay, Calendar.daysInMonth(year, month));
284
+ const candidate = Calendar.make(year, month, dayInNewMonth);
285
+ const clamped = clampToRange(model, candidate);
286
+ return skipDisabled(model, clamped, direction, DAY_SKIP_CAP);
287
+ };
288
+ const applyViewMonthChange = (model, year, month, direction) => {
289
+ if (year === model.viewYear && month === model.viewMonth) {
290
+ return [model, [], Option.none()];
291
+ }
292
+ const nextFocus = moveFocusForViewChange(model, year, month, direction);
293
+ const nextModel = evo(model, {
294
+ viewYear: () => year,
295
+ viewMonth: () => month,
296
+ maybeFocusedDate: () => Option.some(nextFocus),
297
+ });
298
+ return [nextModel, [], Option.some(ChangedViewMonth({ year, month }))];
299
+ };
300
+ /** Direction the user moved when jumping to a new view year/month via grid
301
+ * selection. Used by `skipDisabled` so a forward jump skips forward through
302
+ * disabled dates and a backward jump skips backward. */
303
+ const jumpDirection = (model, year, month) => {
304
+ const next = Calendar.make(year, month, 1);
305
+ const current = Calendar.make(model.viewYear, model.viewMonth, 1);
306
+ return Calendar.isAfter(next, current) ? 1 : -1;
307
+ };
308
+ /** Maps a keyboard key to a months-grid focus shift (in months). Months
309
+ * mode supports horizontal (±1), vertical (±row width), and PageUp/Down
310
+ * (±12) navigation. */
311
+ const resolveMonthsKey = (key) => M.value(key).pipe(M.withReturnType(), M.when('ArrowLeft', () => -1), M.when('ArrowRight', () => 1), M.when('ArrowUp', () => -MONTHS_GRID_COLUMNS), M.when('ArrowDown', () => MONTHS_GRID_COLUMNS), M.when('PageUp', () => -MONTHS_IN_YEAR), M.when('PageDown', () => MONTHS_IN_YEAR), M.option);
312
+ /** Maps a keyboard key to a years-grid focus shift (in years). Years mode
313
+ * supports horizontal (±1), vertical (±row width), and PageUp/Down (±12 =
314
+ * one window) navigation. */
315
+ const resolveYearsKey = (key) => M.value(key).pipe(M.withReturnType(), M.when('ArrowLeft', () => -1), M.when('ArrowRight', () => 1), M.when('ArrowUp', () => -YEARS_GRID_COLUMNS), M.when('ArrowDown', () => YEARS_GRID_COLUMNS), M.when('PageUp', () => -YEARS_PAGE_SIZE), M.when('PageDown', () => YEARS_PAGE_SIZE), M.option);
316
+ /** Applies a months-grid focus shift, updating `maybeFocusedDate` and
317
+ * `viewYear` to reflect the new focused date. `viewMonth` is preserved.
318
+ * Months mode keyboard navigation moves the cursor without committing. */
319
+ const applyMonthsFocusShift = (model, monthShift) => {
320
+ const focused = currentOrFallbackFocus(model);
321
+ const nextFocus = Calendar.addMonths(focused, monthShift);
322
+ return [
323
+ evo(model, {
324
+ maybeFocusedDate: () => Option.some(nextFocus),
325
+ viewYear: () => nextFocus.year,
326
+ }),
327
+ [],
328
+ Option.none(),
329
+ ];
330
+ };
331
+ /** Applies a years-grid focus shift, updating only `maybeFocusedDate`.
332
+ * `viewYear` is preserved so the "selected" highlight (`year === viewYear`)
333
+ * stays on the calendar's centered year while the cursor moves freely. The
334
+ * visible 12-year page is derived from the cursor in the view layer. */
335
+ const applyYearsFocusShift = (model, yearShift) => {
336
+ const focused = currentOrFallbackFocus(model);
337
+ const nextFocus = Calendar.addYears(focused, yearShift);
338
+ return [
339
+ evo(model, {
340
+ maybeFocusedDate: () => Option.some(nextFocus),
341
+ }),
342
+ [],
343
+ Option.none(),
344
+ ];
345
+ };
346
+ /** Processes a calendar message and returns the next model, commands, and
347
+ * optional OutMessage. */
348
+ export const update = (model, message) => M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
349
+ ClickedDay: ({ date }) => {
350
+ if (isDateDisabled(model, date)) {
351
+ return [model, [], Option.none()];
352
+ }
353
+ else {
354
+ const [nextModel, maybeOutMessage] = commitSelection(model, date);
355
+ return [nextModel, [], maybeOutMessage];
356
+ }
357
+ },
358
+ PressedKeyOnGrid: ({ key, isShift }) => M.value(model.viewMode).pipe(withUpdateReturn, M.when('Days', () => {
359
+ const focused = currentOrFallbackFocus(model);
360
+ if (isCommitKey(key)) {
361
+ if (isDateDisabled(model, focused)) {
362
+ return [model, [], Option.none()];
363
+ }
364
+ else {
365
+ const [nextModel, maybeOutMessage] = commitSelection(model, focused);
366
+ return [nextModel, [], maybeOutMessage];
367
+ }
368
+ }
369
+ else {
370
+ return Option.match(resolveNavigationKey(key, isShift, focused, model.locale.firstDayOfWeek), {
371
+ onNone: () => [model, [], Option.none()],
372
+ onSome: ([candidate, direction, cap]) => {
373
+ const [nextModel, maybeOutMessage] = applyFocusMove(model, candidate, direction, cap);
374
+ return [nextModel, [], maybeOutMessage];
375
+ },
376
+ });
377
+ }
378
+ }), M.when('Months', () => Option.match(resolveMonthsKey(key), {
379
+ onNone: () => [model, [], Option.none()],
380
+ onSome: shift => applyMonthsFocusShift(model, shift),
381
+ })), M.when('Years', () => Option.match(resolveYearsKey(key), {
382
+ onNone: () => [model, [], Option.none()],
383
+ onSome: shift => applyYearsFocusShift(model, shift),
384
+ })), M.exhaustive),
385
+ ClickedPreviousMonthButton: () => {
386
+ const next = Calendar.subtractMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
387
+ return applyViewMonthChange(model, next.year, next.month, -1);
388
+ },
389
+ ClickedNextMonthButton: () => {
390
+ const next = Calendar.addMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
391
+ return applyViewMonthChange(model, next.year, next.month, 1);
392
+ },
393
+ ClickedHeading: () => M.value(model.viewMode).pipe(withUpdateReturn, M.when('Days', () => [
394
+ evo(model, { viewMode: () => 'Months' }),
395
+ [FocusGrid({ id: model.id })],
396
+ Option.none(),
397
+ ]), M.when('Months', () => [
398
+ evo(model, { viewMode: () => 'Years' }),
399
+ [FocusGrid({ id: model.id })],
400
+ Option.none(),
401
+ ]), M.when('Years', () => [model, [], Option.none()]), M.exhaustive),
402
+ SelectedMonth: ({ month }) => {
403
+ if (isMonthDisabled(model, model.viewYear, month)) {
404
+ return [model, [], Option.none()];
405
+ }
406
+ else {
407
+ const [nextModel, commands, maybeOutMessage] = applyViewMonthChange(model, model.viewYear, month, jumpDirection(model, model.viewYear, month));
408
+ return [
409
+ evo(nextModel, { viewMode: () => 'Days' }),
410
+ [...commands, FocusGrid({ id: model.id })],
411
+ maybeOutMessage,
412
+ ];
413
+ }
414
+ },
415
+ SelectedYear: ({ year }) => {
416
+ if (isYearDisabled(model, year)) {
417
+ return [model, [], Option.none()];
418
+ }
419
+ else {
420
+ const [nextModel, commands, maybeOutMessage] = applyViewMonthChange(model, year, model.viewMonth, jumpDirection(model, year, model.viewMonth));
421
+ return [
422
+ evo(nextModel, { viewMode: () => 'Months' }),
423
+ [...commands, FocusGrid({ id: model.id })],
424
+ maybeOutMessage,
425
+ ];
426
+ }
427
+ },
428
+ PagedYears: ({ direction }) => applyYearsFocusShift(model, direction * YEARS_PAGE_SIZE),
429
+ FocusedGrid: () => [
430
+ evo(model, { isGridFocused: () => true }),
431
+ [],
432
+ Option.none(),
433
+ ],
434
+ BlurredGrid: () => [
435
+ evo(model, { isGridFocused: () => false }),
436
+ [],
437
+ Option.none(),
438
+ ],
439
+ RefreshedToday: ({ today }) => [
440
+ evo(model, { today: () => today }),
441
+ [],
442
+ Option.none(),
443
+ ],
444
+ CompletedFocusGrid: () => [model, [], Option.none()],
445
+ }));
446
+ // VIEW
447
+ const headingId = (modelId) => `${modelId}-heading`;
448
+ const dayCellId = (modelId, date) => `${modelId}-cell-${date.year}-${date.month}-${date.day}`;
449
+ const monthCellId = (modelId, month) => `${modelId}-cell-month-${month}`;
450
+ const yearCellId = (modelId, year) => `${modelId}-cell-year-${year}`;
451
+ const DAY_NAMES_SUNDAY_FIRST = [
452
+ 'Sunday',
453
+ 'Monday',
454
+ 'Tuesday',
455
+ 'Wednesday',
456
+ 'Thursday',
457
+ 'Friday',
458
+ 'Saturday',
459
+ ];
460
+ const DAY_OF_WEEK_INDEX = {
461
+ Sunday: 0,
462
+ Monday: 1,
463
+ Tuesday: 2,
464
+ Wednesday: 3,
465
+ Thursday: 4,
466
+ Friday: 5,
467
+ Saturday: 6,
468
+ };
469
+ /** Rotates the Sunday-first day-name array so that `firstDayOfWeek` becomes
470
+ * the first entry. Used to build column headers in locale-appropriate order. */
471
+ const rotateDayNames = (names, firstDayOfWeek) => {
472
+ const [front, back] = Array.splitAt(names, DAY_OF_WEEK_INDEX[firstDayOfWeek]);
473
+ return [...back, ...front];
474
+ };
475
+ const WEEKS_IN_GRID = 6;
476
+ const DAYS_IN_WEEK = 7;
477
+ /** Builds the 6×7 grid of dates that a calendar view renders for a given
478
+ * month. The grid always has 6 rows to keep height stable across months.
479
+ * Returns the 2D grid alongside the starting date (top-left cell) so
480
+ * callers can derive per-week positions without recomputing. */
481
+ const buildGrid = (viewYear, viewMonth, firstDayOfWeek) => {
482
+ const firstOfMonth = Calendar.make(viewYear, viewMonth, 1);
483
+ const gridStart = Calendar.startOfWeek(firstOfMonth, firstDayOfWeek);
484
+ const weeks = Array.makeBy(WEEKS_IN_GRID, weekIndex => Array.makeBy(DAYS_IN_WEEK, dayIndex => Calendar.addDays(gridStart, weekIndex * DAYS_IN_WEEK + dayIndex)));
485
+ return { gridStart, weeks };
486
+ };
487
+ const NAV_KEYS = new Set([
488
+ 'ArrowLeft',
489
+ 'ArrowRight',
490
+ 'ArrowUp',
491
+ 'ArrowDown',
492
+ 'Home',
493
+ 'End',
494
+ 'PageUp',
495
+ 'PageDown',
496
+ 'Enter',
497
+ ' ',
498
+ ]);
499
+ const MONTHS_GRID_COLUMNS = 3;
500
+ const YEARS_GRID_COLUMNS = 3;
501
+ const MONTHS_IN_YEAR = 12;
502
+ /** Returns the start year of the 12-year window the years grid renders. */
503
+ const yearsPageStart = (viewYear) => Math.floor(viewYear / YEARS_PAGE_SIZE) * YEARS_PAGE_SIZE;
504
+ /** A month range is fully disabled when its last day is below the minimum
505
+ * or its first day is above the maximum. */
506
+ const isMonthDisabled = (model, year, month) => {
507
+ const monthStart = Calendar.make(year, month, 1);
508
+ const monthEnd = Calendar.make(year, month, Calendar.daysInMonth(year, month));
509
+ return (Option.exists(model.maybeMinDate, min => Calendar.isBefore(monthEnd, min)) ||
510
+ Option.exists(model.maybeMaxDate, max => Calendar.isAfter(monthStart, max)));
511
+ };
512
+ /** A year is fully disabled when it falls entirely below the minimum date's
513
+ * year or entirely above the maximum date's year. */
514
+ const isYearDisabled = (model, year) => Option.exists(model.maybeMinDate, min => year < min.year) ||
515
+ Option.exists(model.maybeMaxDate, max => year > max.year);
516
+ const buildDaysAttributes = (model, viewInputs) => {
517
+ const h = html();
518
+ const { id, viewYear, viewMonth, maybeFocusedDate, maybeSelectedDate, today, locale, isGridFocused, } = model;
519
+ const previousMonthLabel = viewInputs.previousMonthLabel ?? 'Previous month';
520
+ const nextMonthLabel = viewInputs.nextMonthLabel ?? 'Next month';
521
+ const headingButtonLabel = viewInputs.daysHeadingButtonLabel ?? 'Switch to month picker';
522
+ const headingText = `${locale.monthNames[viewMonth - 1]} ${viewYear}`;
523
+ const rotatedDayNames = rotateDayNames(DAY_NAMES_SUNDAY_FIRST, locale.firstDayOfWeek);
524
+ const rotatedShortDayNames = rotateDayNames(locale.shortDayNames, locale.firstDayOfWeek);
525
+ const { gridStart, weeks: weeksDates } = buildGrid(viewYear, viewMonth, locale.firstDayOfWeek);
526
+ const rootAttributes = [h.Id(id), h.Key('Days')];
527
+ const previousMonthButton = [
528
+ h.Type('button'),
529
+ h.AriaLabel(previousMonthLabel),
530
+ h.OnClick(ClickedPreviousMonthButton()),
531
+ ];
532
+ const nextMonthButton = [
533
+ h.Type('button'),
534
+ h.AriaLabel(nextMonthLabel),
535
+ h.OnClick(ClickedNextMonthButton()),
536
+ ];
537
+ const headingButton = [
538
+ h.Type('button'),
539
+ h.AriaLabel(headingButtonLabel),
540
+ h.OnClick(ClickedHeading()),
541
+ ];
542
+ const handleKeyDown = (key, modifiers) => {
543
+ if (!NAV_KEYS.has(key)) {
544
+ return Option.none();
545
+ }
546
+ if (isCommitKey(key)) {
547
+ const maybeCommit = pipe(maybeFocusedDate, Option.filter(date => !isDateDisabled(model, date)), Option.map(date => ClickedDay({ date })));
548
+ if (Option.isSome(maybeCommit)) {
549
+ return maybeCommit;
550
+ }
551
+ }
552
+ return Option.some(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey }));
553
+ };
554
+ const activeDescendantAttributes = pipe(maybeFocusedDate, Option.map(date => h.AriaActiveDescendant(dayCellId(id, date))), Option.toArray);
555
+ const gridAttributes = [
556
+ h.Id(gridId(id)),
557
+ h.Role('grid'),
558
+ h.AriaLabel(`Calendar, ${headingText}`),
559
+ h.AriaRowcount(Number.increment(WEEKS_IN_GRID)),
560
+ h.AriaColcount(DAYS_IN_WEEK),
561
+ h.Tabindex(0),
562
+ h.OnFocus(FocusedGrid()),
563
+ h.OnBlur(BlurredGrid()),
564
+ h.OnKeyDownPreventDefault(handleKeyDown),
565
+ ...activeDescendantAttributes,
566
+ ];
567
+ const headerRowAttributes = [h.Role('row'), h.AriaRowindex(1)];
568
+ const buildDayCell = (date, columnIndex) => {
569
+ const isSelected = Option.exists(maybeSelectedDate, Calendar.isEqual(date));
570
+ const isFocused = Option.exists(maybeFocusedDate, Calendar.isEqual(date));
571
+ const isToday = Calendar.isEqual(today, date);
572
+ const isInViewMonth = date.month === viewMonth && date.year === viewYear;
573
+ const isDisabled = isDateDisabled(model, date);
574
+ const stateDataAttributes = Array.getSomes([
575
+ OptionExt.when(isToday, h.DataAttribute('today', '')),
576
+ OptionExt.when(isSelected, h.DataAttribute('selected', '')),
577
+ OptionExt.when(isFocused && isGridFocused, h.DataAttribute('focused', '')),
578
+ OptionExt.when(!isInViewMonth, h.DataAttribute('outside-month', '')),
579
+ OptionExt.when(isDisabled, h.DataAttribute('disabled', '')),
580
+ ]);
581
+ const cellAttributes = [
582
+ h.Id(dayCellId(id, date)),
583
+ h.Role('gridcell'),
584
+ h.AriaSelected(isSelected),
585
+ h.AriaColindex(Number.increment(columnIndex)),
586
+ ...stateDataAttributes,
587
+ ];
588
+ const buttonAttributes = [
589
+ h.Type('button'),
590
+ h.Tabindex(-1),
591
+ h.AriaLabel(Calendar.formatAriaLabel(date, locale)),
592
+ h.AriaDisabled(isDisabled),
593
+ ...(isDisabled ? [] : [h.OnClick(ClickedDay({ date }))]),
594
+ ];
595
+ return {
596
+ date,
597
+ label: String(date.day),
598
+ cellAttributes: childAttributes(cellAttributes),
599
+ buttonAttributes: childAttributes(buttonAttributes),
600
+ isSelected,
601
+ isFocused: isFocused && isGridFocused,
602
+ isToday,
603
+ isInViewMonth,
604
+ isDisabled,
605
+ };
606
+ };
607
+ const weeks = weeksDates.map((weekDates, weekIndex) => {
608
+ const weekStart = Calendar.addDays(gridStart, weekIndex * DAYS_IN_WEEK);
609
+ return {
610
+ attributes: childAttributes([
611
+ h.Role('row'),
612
+ h.AriaRowindex(weekIndex + 2),
613
+ h.AriaLabel(`Week of ${Calendar.formatLong(weekStart, locale)}`),
614
+ ]),
615
+ cells: weekDates.map(buildDayCell),
616
+ };
617
+ });
618
+ const wrappedColumnHeaders = Array.zipWith(rotatedShortDayNames, rotatedDayNames, (name, fullName) => ({ name, fullName })).map(({ name, fullName }, columnIndex) => ({
619
+ name,
620
+ attributes: childAttributes([
621
+ h.Role('columnheader'),
622
+ h.AriaLabel(fullName),
623
+ h.AriaColindex(Number.increment(columnIndex)),
624
+ ]),
625
+ }));
626
+ return {
627
+ _tag: 'Days',
628
+ root: childAttributes(rootAttributes),
629
+ previousMonthButton: childAttributes(previousMonthButton),
630
+ nextMonthButton: childAttributes(nextMonthButton),
631
+ headingButton: childAttributes(headingButton),
632
+ heading: { id: headingId(id), text: headingText },
633
+ grid: childAttributes(gridAttributes),
634
+ headerRow: childAttributes(headerRowAttributes),
635
+ columnHeaders: wrappedColumnHeaders,
636
+ weeks,
637
+ };
638
+ };
639
+ const buildMonthsAttributes = (model, viewInputs) => {
640
+ const h = html();
641
+ const { id, viewYear, viewMonth, maybeFocusedDate, today, locale, isGridFocused, } = model;
642
+ const headingButtonLabel = viewInputs.monthsHeadingButtonLabel ?? 'Switch to year picker';
643
+ const headingText = `${viewYear}`;
644
+ const rootAttributes = [h.Id(id), h.Key('Months')];
645
+ const headingButton = [
646
+ h.Type('button'),
647
+ h.AriaLabel(headingButtonLabel),
648
+ h.OnClick(ClickedHeading()),
649
+ ];
650
+ const focusedMonth = Option.match(maybeFocusedDate, {
651
+ onNone: () => viewMonth,
652
+ onSome: date => (date.year === viewYear ? date.month : viewMonth),
653
+ });
654
+ const handleKeyDown = (key, modifiers) => {
655
+ if (!NAV_KEYS.has(key)) {
656
+ return Option.none();
657
+ }
658
+ else if (isCommitKey(key)) {
659
+ return OptionExt.when(!isMonthDisabled(model, viewYear, focusedMonth), SelectedMonth({ month: focusedMonth }));
660
+ }
661
+ else {
662
+ return Option.some(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey }));
663
+ }
664
+ };
665
+ const activeDescendantAttributes = [
666
+ h.AriaActiveDescendant(monthCellId(id, focusedMonth)),
667
+ ];
668
+ const gridAttributes = [
669
+ h.Id(gridId(id)),
670
+ h.Role('grid'),
671
+ h.AriaLabel(`Month picker, ${headingText}`),
672
+ h.Tabindex(0),
673
+ h.OnFocus(FocusedGrid()),
674
+ h.OnBlur(BlurredGrid()),
675
+ h.OnKeyDownPreventDefault(handleKeyDown),
676
+ ...activeDescendantAttributes,
677
+ ];
678
+ const buildMonthCell = (month) => {
679
+ const label = locale.monthNames[month - 1] ?? String(month);
680
+ const shortLabel = locale.shortMonthNames[month - 1] ?? label;
681
+ const isSelected = month === viewMonth;
682
+ const isFocused = month === focusedMonth;
683
+ const isCurrentMonth = today.year === viewYear && today.month === month;
684
+ const isDisabled = isMonthDisabled(model, viewYear, month);
685
+ const stateDataAttributes = Array.getSomes([
686
+ OptionExt.when(isCurrentMonth, h.DataAttribute('today', '')),
687
+ OptionExt.when(isSelected, h.DataAttribute('selected', '')),
688
+ OptionExt.when(isFocused && isGridFocused, h.DataAttribute('focused', '')),
689
+ OptionExt.when(isDisabled, h.DataAttribute('disabled', '')),
690
+ ]);
691
+ const cellAttributes = [
692
+ h.Id(monthCellId(id, month)),
693
+ h.Role('gridcell'),
694
+ h.AriaSelected(isSelected),
695
+ ...stateDataAttributes,
696
+ ];
697
+ const buttonAttributes = [
698
+ h.Type('button'),
699
+ h.Tabindex(-1),
700
+ h.AriaLabel(`${label} ${viewYear}`),
701
+ h.AriaDisabled(isDisabled),
702
+ ...(isDisabled ? [] : [h.OnClick(SelectedMonth({ month }))]),
703
+ ];
704
+ return {
705
+ month,
706
+ label,
707
+ shortLabel,
708
+ cellAttributes: childAttributes(cellAttributes),
709
+ buttonAttributes: childAttributes(buttonAttributes),
710
+ isSelected,
711
+ isFocused: isFocused && isGridFocused,
712
+ isCurrentMonth,
713
+ isDisabled,
714
+ };
715
+ };
716
+ const cells = Array.makeBy(MONTHS_IN_YEAR, monthIndex => buildMonthCell(Number.increment(monthIndex)));
717
+ return {
718
+ _tag: 'Months',
719
+ root: childAttributes(rootAttributes),
720
+ headingButton: childAttributes(headingButton),
721
+ heading: { id: headingId(id), text: headingText },
722
+ grid: childAttributes(gridAttributes),
723
+ cells,
724
+ };
725
+ };
726
+ const buildYearsAttributes = (model, viewInputs) => {
727
+ const h = html();
728
+ const { id, viewYear, maybeFocusedDate, today, isGridFocused } = model;
729
+ const previousYearsPageLabel = viewInputs.previousYearsPageLabel ?? 'Previous 12 years';
730
+ const nextYearsPageLabel = viewInputs.nextYearsPageLabel ?? 'Next 12 years';
731
+ const cursorYear = Option.match(maybeFocusedDate, {
732
+ onNone: () => viewYear,
733
+ onSome: date => date.year,
734
+ });
735
+ const pageStart = yearsPageStart(cursorYear);
736
+ const pageEnd = pageStart + YEARS_PAGE_SIZE - 1;
737
+ const headingText = `${pageStart}–${pageEnd}`;
738
+ const rootAttributes = [h.Id(id), h.Key('Years')];
739
+ const previousPageButton = [
740
+ h.Type('button'),
741
+ h.AriaLabel(previousYearsPageLabel),
742
+ h.OnClick(PagedYears({ direction: -1 })),
743
+ ];
744
+ const nextPageButton = [
745
+ h.Type('button'),
746
+ h.AriaLabel(nextYearsPageLabel),
747
+ h.OnClick(PagedYears({ direction: 1 })),
748
+ ];
749
+ const focusedYear = cursorYear;
750
+ const handleKeyDown = (key, modifiers) => {
751
+ if (!NAV_KEYS.has(key)) {
752
+ return Option.none();
753
+ }
754
+ else if (isCommitKey(key)) {
755
+ return OptionExt.when(!isYearDisabled(model, focusedYear), SelectedYear({ year: focusedYear }));
756
+ }
757
+ else {
758
+ return Option.some(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey }));
759
+ }
760
+ };
761
+ const activeDescendantAttributes = [
762
+ h.AriaActiveDescendant(yearCellId(id, focusedYear)),
763
+ ];
764
+ const gridAttributes = [
765
+ h.Id(gridId(id)),
766
+ h.Role('grid'),
767
+ h.AriaLabel(`Year picker, ${headingText}`),
768
+ h.Tabindex(0),
769
+ h.OnFocus(FocusedGrid()),
770
+ h.OnBlur(BlurredGrid()),
771
+ h.OnKeyDownPreventDefault(handleKeyDown),
772
+ ...activeDescendantAttributes,
773
+ ];
774
+ const buildYearCell = (year) => {
775
+ const label = String(year);
776
+ const isSelected = year === viewYear;
777
+ const isFocused = year === focusedYear;
778
+ const isCurrentYear = today.year === year;
779
+ const isDisabled = isYearDisabled(model, year);
780
+ const stateDataAttributes = Array.getSomes([
781
+ OptionExt.when(isCurrentYear, h.DataAttribute('today', '')),
782
+ OptionExt.when(isSelected, h.DataAttribute('selected', '')),
783
+ OptionExt.when(isFocused && isGridFocused, h.DataAttribute('focused', '')),
784
+ OptionExt.when(isDisabled, h.DataAttribute('disabled', '')),
785
+ ]);
786
+ const cellAttributes = [
787
+ h.Id(yearCellId(id, year)),
788
+ h.Role('gridcell'),
789
+ h.AriaSelected(isSelected),
790
+ ...stateDataAttributes,
791
+ ];
792
+ const buttonAttributes = [
793
+ h.Type('button'),
794
+ h.Tabindex(-1),
795
+ h.AriaLabel(label),
796
+ h.AriaDisabled(isDisabled),
797
+ ...(isDisabled ? [] : [h.OnClick(SelectedYear({ year }))]),
798
+ ];
799
+ return {
800
+ year,
801
+ label,
802
+ cellAttributes: childAttributes(cellAttributes),
803
+ buttonAttributes: childAttributes(buttonAttributes),
804
+ isSelected,
805
+ isFocused: isFocused && isGridFocused,
806
+ isCurrentYear,
807
+ isDisabled,
808
+ };
809
+ };
810
+ const cells = Array.makeBy(YEARS_PAGE_SIZE, offset => buildYearCell(pageStart + offset));
811
+ return {
812
+ _tag: 'Years',
813
+ root: childAttributes(rootAttributes),
814
+ previousPageButton: childAttributes(previousPageButton),
815
+ nextPageButton: childAttributes(nextPageButton),
816
+ heading: { id: headingId(id), text: headingText },
817
+ grid: childAttributes(gridAttributes),
818
+ cells,
819
+ };
820
+ };
821
+ /** Renders an accessible calendar. Publishes mode-specific ARIA attribute
822
+ * bundles + derived cell data, then delegates layout to the consumer's
823
+ * `toView` callback. The variant of `CalendarAttributes` passed to
824
+ * `toView` matches `model.viewMode`. */
825
+ export const view = defineView((model, viewInputs) => viewInputs.toView(M.value(model.viewMode).pipe(M.withReturnType(), M.when('Days', () => buildDaysAttributes(model, viewInputs)), M.when('Months', () => buildMonthsAttributes(model, viewInputs)), M.when('Years', () => buildYearsAttributes(model, viewInputs)), M.exhaustive)));