foldkit 0.79.0 → 0.81.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 (71) hide show
  1. package/README.md +1 -0
  2. package/dist/html/index.d.ts +34 -27
  3. package/dist/html/index.d.ts.map +1 -1
  4. package/dist/html/index.js +31 -26
  5. package/dist/html/public.d.ts +1 -1
  6. package/dist/html/public.d.ts.map +1 -1
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/mount/index.d.ts +42 -0
  11. package/dist/mount/index.d.ts.map +1 -0
  12. package/dist/mount/index.js +38 -0
  13. package/dist/mount/public.d.ts +3 -0
  14. package/dist/mount/public.d.ts.map +1 -0
  15. package/dist/mount/public.js +1 -0
  16. package/dist/runtime/public.d.ts +1 -1
  17. package/dist/runtime/public.d.ts.map +1 -1
  18. package/dist/runtime/runtime.d.ts +1 -23
  19. package/dist/runtime/runtime.d.ts.map +1 -1
  20. package/dist/runtime/runtime.js +1 -2
  21. package/dist/ui/anchor.d.ts +13 -6
  22. package/dist/ui/anchor.d.ts.map +1 -1
  23. package/dist/ui/anchor.js +91 -86
  24. package/dist/ui/calendar/index.d.ts +117 -28
  25. package/dist/ui/calendar/index.d.ts.map +1 -1
  26. package/dist/ui/calendar/index.js +393 -105
  27. package/dist/ui/calendar/public.d.ts +2 -2
  28. package/dist/ui/calendar/public.d.ts.map +1 -1
  29. package/dist/ui/calendar/public.js +1 -1
  30. package/dist/ui/combobox/multi.d.ts +15 -3
  31. package/dist/ui/combobox/multi.d.ts.map +1 -1
  32. package/dist/ui/combobox/public.d.ts +2 -2
  33. package/dist/ui/combobox/public.d.ts.map +1 -1
  34. package/dist/ui/combobox/public.js +1 -1
  35. package/dist/ui/combobox/shared.d.ts +22 -7
  36. package/dist/ui/combobox/shared.d.ts.map +1 -1
  37. package/dist/ui/combobox/shared.js +73 -37
  38. package/dist/ui/combobox/single.d.ts +15 -3
  39. package/dist/ui/combobox/single.d.ts.map +1 -1
  40. package/dist/ui/datePicker/index.d.ts +7 -4
  41. package/dist/ui/datePicker/index.d.ts.map +1 -1
  42. package/dist/ui/datePicker/index.js +8 -2
  43. package/dist/ui/listbox/multi.d.ts +10 -2
  44. package/dist/ui/listbox/multi.d.ts.map +1 -1
  45. package/dist/ui/listbox/public.d.ts +2 -2
  46. package/dist/ui/listbox/public.d.ts.map +1 -1
  47. package/dist/ui/listbox/public.js +1 -1
  48. package/dist/ui/listbox/shared.d.ts +16 -6
  49. package/dist/ui/listbox/shared.d.ts.map +1 -1
  50. package/dist/ui/listbox/shared.js +37 -25
  51. package/dist/ui/listbox/single.d.ts +10 -2
  52. package/dist/ui/listbox/single.d.ts.map +1 -1
  53. package/dist/ui/menu/index.d.ts +11 -5
  54. package/dist/ui/menu/index.d.ts.map +1 -1
  55. package/dist/ui/menu/index.js +36 -24
  56. package/dist/ui/menu/public.d.ts +2 -2
  57. package/dist/ui/menu/public.d.ts.map +1 -1
  58. package/dist/ui/menu/public.js +1 -1
  59. package/dist/ui/popover/index.d.ts +8 -5
  60. package/dist/ui/popover/index.d.ts.map +1 -1
  61. package/dist/ui/popover/index.js +23 -16
  62. package/dist/ui/popover/public.d.ts +2 -2
  63. package/dist/ui/popover/public.d.ts.map +1 -1
  64. package/dist/ui/popover/public.js +1 -1
  65. package/dist/ui/tooltip/index.d.ts +5 -2
  66. package/dist/ui/tooltip/index.d.ts.map +1 -1
  67. package/dist/ui/tooltip/index.js +17 -10
  68. package/dist/ui/tooltip/public.d.ts +1 -1
  69. package/dist/ui/tooltip/public.d.ts.map +1 -1
  70. package/dist/ui/tooltip/public.js +1 -1
  71. package/package.json +5 -1
@@ -7,14 +7,20 @@ import { m } from '../../message/index.js';
7
7
  import { evo } from '../../struct/index.js';
8
8
  import * as Task from '../../task/index.js';
9
9
  // MODEL
10
+ /** Which grid the calendar is currently displaying. `Days` is the standard
11
+ * 6×7 day grid; `Months` is a 3×4 month-name grid for fast month jumps;
12
+ * `Years` is a 3×4 year grid paged in 12-year windows for fast year jumps. */
13
+ export const ViewMode = S.Literal('Days', 'Months', 'Years');
10
14
  /** Schema for the calendar component's state. Tracks the visible month/year,
11
- * the keyboard-focused and user-selected dates, and the configuration that
12
- * governs navigation (locale, min/max, disabled days). */
15
+ * the keyboard-focused and user-selected dates, the active view mode, and
16
+ * the configuration that governs navigation (locale, min/max, disabled
17
+ * days). */
13
18
  export const Model = S.Struct({
14
19
  id: S.String,
15
20
  today: Calendar.CalendarDate,
16
21
  viewYear: S.Int,
17
22
  viewMonth: S.Int.pipe(S.between(1, 12)),
23
+ viewMode: ViewMode,
18
24
  maybeFocusedDate: S.OptionFromSelf(Calendar.CalendarDate),
19
25
  maybeSelectedDate: S.OptionFromSelf(Calendar.CalendarDate),
20
26
  isGridFocused: S.Boolean,
@@ -33,18 +39,24 @@ export const PressedKeyOnGrid = m('PressedKeyOnGrid', {
33
39
  key: S.String,
34
40
  isShift: S.Boolean,
35
41
  });
36
- /** Sent when the user clicks the previous-month navigation button. */
42
+ /** Sent when the user clicks the previous-month navigation button in Days
43
+ * mode. (The Years mode prev/next-page buttons dispatch `PagedYears`.) */
37
44
  export const ClickedPreviousMonthButton = m('ClickedPreviousMonthButton');
38
- /** Sent when the user clicks the next-month navigation button. */
45
+ /** Sent when the user clicks the next-month navigation button in Days
46
+ * mode. (The Years mode prev/next-page buttons dispatch `PagedYears`.) */
39
47
  export const ClickedNextMonthButton = m('ClickedNextMonthButton');
40
- /** Sent when the user picks a month from the month dropdown. */
41
- export const SelectedMonthFromDropdown = m('SelectedMonthFromDropdown', {
42
- month: S.Int,
43
- });
44
- /** Sent when the user picks a year from the year dropdown. */
45
- export const SelectedYearFromDropdown = m('SelectedYearFromDropdown', {
46
- year: S.Int,
47
- });
48
+ /** Sent when the user clicks the calendar heading. Zooms out one mode
49
+ * level: Days → Months, Months → Years. Terminal in Years mode. */
50
+ export const ClickedHeading = m('ClickedHeading');
51
+ /** Sent when the user picks a month from the months grid. Jumps the view
52
+ * to that month and returns the calendar to Days mode. */
53
+ export const SelectedMonth = m('SelectedMonth', { month: S.Int });
54
+ /** Sent when the user picks a year from the years grid. Jumps the view to
55
+ * that year and transitions the calendar to Months mode for further drilling. */
56
+ export const SelectedYear = m('SelectedYear', { year: S.Int });
57
+ /** Sent when the user pages the years grid forward or backward by one
58
+ * window. Direction is `1` for next, `-1` for previous. */
59
+ export const PagedYears = m('PagedYears', { direction: S.Literal(1, -1) });
48
60
  /** Sent when the grid container receives DOM focus. */
49
61
  export const FocusedGrid = m('FocusedGrid');
50
62
  /** Sent when the grid container loses DOM focus. */
@@ -56,7 +68,7 @@ export const RefreshedToday = m('RefreshedToday', {
56
68
  /** Sent when a FocusGrid command completes. */
57
69
  export const CompletedFocusGrid = m('CompletedFocusGrid');
58
70
  /** Union of all messages the calendar component can produce. */
59
- export const Message = S.Union(ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid);
71
+ export const Message = S.Union(ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, ClickedHeading, SelectedMonth, SelectedYear, PagedYears, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid);
60
72
  // OUT MESSAGE
61
73
  /** Emitted when the visible month changes due to navigation. Consumers of an
62
74
  * inline calendar may use this to load month-scoped data (holidays, events).
@@ -83,6 +95,7 @@ export const init = (config) => {
83
95
  today: config.today,
84
96
  viewYear: initialFocus.year,
85
97
  viewMonth: initialFocus.month,
98
+ viewMode: 'Days',
86
99
  maybeFocusedDate: Option.some(initialFocus),
87
100
  maybeSelectedDate: maybeInitialSelectedDate,
88
101
  isGridFocused: false,
@@ -126,25 +139,34 @@ export const setDisabledDates = (model, disabledDates) => evo(model, { disabledD
126
139
  /** Sets the days of the week that are disabled (e.g. weekends). Pass an
127
140
  * empty array to clear. Does NOT reconcile the current selection. */
128
141
  export const setDisabledDaysOfWeek = (model, disabledDaysOfWeek) => evo(model, { disabledDaysOfWeek: () => disabledDaysOfWeek });
142
+ /** Returns the calendar to Days mode regardless of current depth. Useful for
143
+ * standalone (non-popovered) consumers that want to wire their own back-out
144
+ * gesture. Popovered consumers like `Ui.DatePicker` don't need this — Escape
145
+ * closes the popover, and the calendar resets to Days on next open.
146
+ *
147
+ * Reconciles `maybeFocusedDate` to a date inside the visible (`viewYear`,
148
+ * `viewMonth`) — Months/Years navigation can leave the cursor on a date
149
+ * outside the days grid (paged-away year, etc.), which would otherwise
150
+ * cause `aria-activedescendant` to point at a non-rendered cell and the
151
+ * next ArrowLeft to jump to the cursor's stale year. */
152
+ export const dropToDays = (model) => {
153
+ const focusedDay = Option.match(model.maybeFocusedDate, {
154
+ onNone: () => 1,
155
+ onSome: date => Math.min(date.day, Calendar.daysInMonth(model.viewYear, model.viewMonth)),
156
+ });
157
+ return evo(model, {
158
+ viewMode: () => 'Days',
159
+ maybeFocusedDate: () => Option.some(Calendar.make(model.viewYear, model.viewMonth, focusedDay)),
160
+ });
161
+ };
129
162
  const DAY_SKIP_CAP = 31;
130
163
  const MONTH_SKIP_CAP = 12;
131
- const isDateDisabled = (model, date) => {
132
- const belowMin = Option.exists(model.maybeMinDate, min => Calendar.isBefore(date, min));
133
- if (belowMin) {
134
- return true;
135
- }
136
- const aboveMax = Option.exists(model.maybeMaxDate, max => Calendar.isAfter(date, max));
137
- if (aboveMax) {
138
- return true;
139
- }
140
- if (model.disabledDaysOfWeek.includes(Calendar.dayOfWeek(date))) {
141
- return true;
142
- }
143
- if (model.disabledDates.some(Calendar.isEqual(date))) {
144
- return true;
145
- }
146
- return false;
147
- };
164
+ /** Number of years per Years-mode page. A 3×4 grid renders one window. */
165
+ const YEARS_PAGE_SIZE = 12;
166
+ const isDateDisabled = (model, date) => Option.exists(model.maybeMinDate, min => Calendar.isBefore(date, min)) ||
167
+ Option.exists(model.maybeMaxDate, max => Calendar.isAfter(date, max)) ||
168
+ model.disabledDaysOfWeek.includes(Calendar.dayOfWeek(date)) ||
169
+ model.disabledDates.some(Calendar.isEqual(date));
148
170
  /** Walks from `start` in `direction`, returning the first non-disabled date
149
171
  * within `cap` steps. Falls back to `start` if every candidate is disabled. */
150
172
  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));
@@ -241,14 +263,52 @@ const applyViewMonthChange = (model, year, month, direction) => {
241
263
  });
242
264
  return [nextModel, [], Option.some(ChangedViewMonth({ year, month }))];
243
265
  };
244
- /** Direction the user moved when jumping to a new view month via dropdown.
245
- * Used by `skipDisabled` so a forward jump skips forward through disabled
246
- * dates and a backward jump skips backward. */
247
- const dropdownDirection = (model, year, month) => {
266
+ /** Direction the user moved when jumping to a new view year/month via grid
267
+ * selection. Used by `skipDisabled` so a forward jump skips forward through
268
+ * disabled dates and a backward jump skips backward. */
269
+ const jumpDirection = (model, year, month) => {
248
270
  const next = Calendar.make(year, month, 1);
249
271
  const current = Calendar.make(model.viewYear, model.viewMonth, 1);
250
272
  return Calendar.isAfter(next, current) ? 1 : -1;
251
273
  };
274
+ /** Maps a keyboard key to a months-grid focus shift (in months). Months
275
+ * mode supports horizontal (±1), vertical (±row width), and PageUp/Down
276
+ * (±12) navigation. */
277
+ 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);
278
+ /** Maps a keyboard key to a years-grid focus shift (in years). Years mode
279
+ * supports horizontal (±1), vertical (±row width), and PageUp/Down (±12 =
280
+ * one window) navigation. */
281
+ 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);
282
+ /** Applies a months-grid focus shift, updating `maybeFocusedDate` and
283
+ * `viewYear` to reflect the new focused date. `viewMonth` is preserved —
284
+ * Months mode keyboard navigation moves the cursor without committing. */
285
+ const applyMonthsFocusShift = (model, monthShift) => {
286
+ const focused = currentOrFallbackFocus(model);
287
+ const nextFocus = Calendar.addMonths(focused, monthShift);
288
+ return [
289
+ evo(model, {
290
+ maybeFocusedDate: () => Option.some(nextFocus),
291
+ viewYear: () => nextFocus.year,
292
+ }),
293
+ [],
294
+ Option.none(),
295
+ ];
296
+ };
297
+ /** Applies a years-grid focus shift, updating only `maybeFocusedDate`.
298
+ * `viewYear` is preserved so the "selected" highlight (`year === viewYear`)
299
+ * stays on the calendar's centered year while the cursor moves freely. The
300
+ * visible 12-year page is derived from the cursor in the view layer. */
301
+ const applyYearsFocusShift = (model, yearShift) => {
302
+ const focused = currentOrFallbackFocus(model);
303
+ const nextFocus = Calendar.addYears(focused, yearShift);
304
+ return [
305
+ evo(model, {
306
+ maybeFocusedDate: () => Option.some(nextFocus),
307
+ }),
308
+ [],
309
+ Option.none(),
310
+ ];
311
+ };
252
312
  /** Processes a calendar message and returns the next model, commands, and
253
313
  * optional OutMessage. */
254
314
  export const update = (model, message) => M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
@@ -256,26 +316,38 @@ export const update = (model, message) => M.value(message).pipe(withUpdateReturn
256
316
  if (isDateDisabled(model, date)) {
257
317
  return [model, [], Option.none()];
258
318
  }
259
- const [nextModel, maybeOutMessage] = commitSelection(model, date);
260
- return [nextModel, [], maybeOutMessage];
319
+ else {
320
+ const [nextModel, maybeOutMessage] = commitSelection(model, date);
321
+ return [nextModel, [], maybeOutMessage];
322
+ }
261
323
  },
262
- PressedKeyOnGrid: ({ key, isShift }) => {
324
+ PressedKeyOnGrid: ({ key, isShift }) => M.value(model.viewMode).pipe(withUpdateReturn, M.when('Days', () => {
263
325
  const focused = currentOrFallbackFocus(model);
264
326
  if (isCommitKey(key)) {
265
327
  if (isDateDisabled(model, focused)) {
266
328
  return [model, [], Option.none()];
267
329
  }
268
- const [nextModel, maybeOutMessage] = commitSelection(model, focused);
269
- return [nextModel, [], maybeOutMessage];
270
- }
271
- return Option.match(resolveNavigationKey(key, isShift, focused, model.locale.firstDayOfWeek), {
272
- onNone: () => [model, [], Option.none()],
273
- onSome: ([candidate, direction, cap]) => {
274
- const [nextModel, maybeOutMessage] = applyFocusMove(model, candidate, direction, cap);
330
+ else {
331
+ const [nextModel, maybeOutMessage] = commitSelection(model, focused);
275
332
  return [nextModel, [], maybeOutMessage];
276
- },
277
- });
278
- },
333
+ }
334
+ }
335
+ else {
336
+ return Option.match(resolveNavigationKey(key, isShift, focused, model.locale.firstDayOfWeek), {
337
+ onNone: () => [model, [], Option.none()],
338
+ onSome: ([candidate, direction, cap]) => {
339
+ const [nextModel, maybeOutMessage] = applyFocusMove(model, candidate, direction, cap);
340
+ return [nextModel, [], maybeOutMessage];
341
+ },
342
+ });
343
+ }
344
+ }), M.when('Months', () => Option.match(resolveMonthsKey(key), {
345
+ onNone: () => [model, [], Option.none()],
346
+ onSome: shift => applyMonthsFocusShift(model, shift),
347
+ })), M.when('Years', () => Option.match(resolveYearsKey(key), {
348
+ onNone: () => [model, [], Option.none()],
349
+ onSome: shift => applyYearsFocusShift(model, shift),
350
+ })), M.exhaustive),
279
351
  ClickedPreviousMonthButton: () => {
280
352
  const next = Calendar.subtractMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
281
353
  return applyViewMonthChange(model, next.year, next.month, -1);
@@ -284,8 +356,42 @@ export const update = (model, message) => M.value(message).pipe(withUpdateReturn
284
356
  const next = Calendar.addMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
285
357
  return applyViewMonthChange(model, next.year, next.month, 1);
286
358
  },
287
- SelectedMonthFromDropdown: ({ month }) => applyViewMonthChange(model, model.viewYear, month, dropdownDirection(model, model.viewYear, month)),
288
- SelectedYearFromDropdown: ({ year }) => applyViewMonthChange(model, year, model.viewMonth, dropdownDirection(model, year, model.viewMonth)),
359
+ ClickedHeading: () => M.value(model.viewMode).pipe(withUpdateReturn, M.when('Days', () => [
360
+ evo(model, { viewMode: () => 'Months' }),
361
+ [focusGrid(model.id)],
362
+ Option.none(),
363
+ ]), M.when('Months', () => [
364
+ evo(model, { viewMode: () => 'Years' }),
365
+ [focusGrid(model.id)],
366
+ Option.none(),
367
+ ]), M.when('Years', () => [model, [], Option.none()]), M.exhaustive),
368
+ SelectedMonth: ({ month }) => {
369
+ if (isMonthDisabled(model, model.viewYear, month)) {
370
+ return [model, [], Option.none()];
371
+ }
372
+ else {
373
+ const [nextModel, commands, maybeOutMessage] = applyViewMonthChange(model, model.viewYear, month, jumpDirection(model, model.viewYear, month));
374
+ return [
375
+ evo(nextModel, { viewMode: () => 'Days' }),
376
+ [...commands, focusGrid(model.id)],
377
+ maybeOutMessage,
378
+ ];
379
+ }
380
+ },
381
+ SelectedYear: ({ year }) => {
382
+ if (isYearDisabled(model, year)) {
383
+ return [model, [], Option.none()];
384
+ }
385
+ else {
386
+ const [nextModel, commands, maybeOutMessage] = applyViewMonthChange(model, year, model.viewMonth, jumpDirection(model, year, model.viewMonth));
387
+ return [
388
+ evo(nextModel, { viewMode: () => 'Months' }),
389
+ [...commands, focusGrid(model.id)],
390
+ maybeOutMessage,
391
+ ];
392
+ }
393
+ },
394
+ PagedYears: ({ direction }) => applyYearsFocusShift(model, direction * YEARS_PAGE_SIZE),
289
395
  FocusedGrid: () => [
290
396
  evo(model, { isGridFocused: () => true }),
291
397
  [],
@@ -305,7 +411,9 @@ export const update = (model, message) => M.value(message).pipe(withUpdateReturn
305
411
  }));
306
412
  // VIEW
307
413
  const headingId = (modelId) => `${modelId}-heading`;
308
- const cellId = (modelId, date) => `${modelId}-cell-${date.year}-${date.month}-${date.day}`;
414
+ const dayCellId = (modelId, date) => `${modelId}-cell-${date.year}-${date.month}-${date.day}`;
415
+ const monthCellId = (modelId, month) => `${modelId}-cell-month-${month}`;
416
+ const yearCellId = (modelId, year) => `${modelId}-cell-year-${year}`;
309
417
  const DAY_NAMES_SUNDAY_FIRST = [
310
418
  'Sunday',
311
419
  'Monday',
@@ -342,24 +450,6 @@ const buildGrid = (viewYear, viewMonth, firstDayOfWeek) => {
342
450
  const weeks = Array.makeBy(WEEKS_IN_GRID, weekIndex => Array.makeBy(DAYS_IN_WEEK, dayIndex => Calendar.addDays(gridStart, weekIndex * DAYS_IN_WEEK + dayIndex)));
343
451
  return { gridStart, weeks };
344
452
  };
345
- const YEAR_RANGE_LOOKAHEAD = 10;
346
- const YEAR_RANGE_LOOKBEHIND = 100;
347
- /** Computes the inclusive year range the year dropdown should expose,
348
- * derived from min/max constraints if set, else a sensible default window
349
- * around today. */
350
- const resolveYearRange = (model) => {
351
- const defaultStart = model.today.year - YEAR_RANGE_LOOKBEHIND;
352
- const defaultEnd = model.today.year + YEAR_RANGE_LOOKAHEAD;
353
- const start = Option.match(model.maybeMinDate, {
354
- onNone: () => defaultStart,
355
- onSome: min => min.year,
356
- });
357
- const end = Option.match(model.maybeMaxDate, {
358
- onNone: () => defaultEnd,
359
- onSome: max => max.year,
360
- });
361
- return [start, end];
362
- };
363
453
  const NAV_KEYS = new Set([
364
454
  'ArrowLeft',
365
455
  'ArrowRight',
@@ -372,52 +462,55 @@ const NAV_KEYS = new Set([
372
462
  'Enter',
373
463
  ' ',
374
464
  ]);
375
- /** Renders an accessible calendar grid. Builds ARIA attribute groups (grid,
376
- * row, gridcell, column header) plus the derived month grid, then delegates
377
- * layout to the consumer's `toView` callback. */
378
- export const view = (config) => {
379
- const { AriaActiveDescendant, AriaColcount, AriaColindex, AriaDisabled, AriaLabel, AriaRowcount, AriaRowindex, AriaSelected, DataAttribute, Id, OnBlur, OnChange, OnClick, OnFocus, OnKeyDownPreventDefault, Role, Tabindex, Type, } = html();
380
- const { model, toParentMessage, toView, onSelectedDate } = config;
465
+ const MONTHS_GRID_COLUMNS = 3;
466
+ const YEARS_GRID_COLUMNS = 3;
467
+ const MONTHS_IN_YEAR = 12;
468
+ /** Returns the start year of the 12-year window the years grid renders. */
469
+ const yearsPageStart = (viewYear) => Math.floor(viewYear / YEARS_PAGE_SIZE) * YEARS_PAGE_SIZE;
470
+ /** A month range is fully disabled when its last day is below the minimum
471
+ * or its first day is above the maximum. */
472
+ const isMonthDisabled = (model, year, month) => {
473
+ const monthStart = Calendar.make(year, month, 1);
474
+ const monthEnd = Calendar.make(year, month, Calendar.daysInMonth(year, month));
475
+ return (Option.exists(model.maybeMinDate, min => Calendar.isBefore(monthEnd, min)) ||
476
+ Option.exists(model.maybeMaxDate, max => Calendar.isAfter(monthStart, max)));
477
+ };
478
+ /** A year is fully disabled when it falls entirely below the minimum date's
479
+ * year or entirely above the maximum date's year. */
480
+ const isYearDisabled = (model, year) => Option.exists(model.maybeMinDate, min => year < min.year) ||
481
+ Option.exists(model.maybeMaxDate, max => year > max.year);
482
+ const buildDaysAttributes = (config) => {
483
+ const { AriaActiveDescendant, AriaColcount, AriaColindex, AriaDisabled, AriaLabel, AriaRowcount, AriaRowindex, AriaSelected, DataAttribute, Id, Key, OnBlur, OnClick, OnFocus, OnKeyDownPreventDefault, Role, Tabindex, Type, } = html();
484
+ const { model, toParentMessage, onSelectedDate } = config;
381
485
  const { id, viewYear, viewMonth, maybeFocusedDate, maybeSelectedDate, today, locale, isGridFocused, } = model;
382
- /** Returns the parent message to dispatch when the user commits a date. In
383
- * controlled mode (when `onSelectedDate` is provided), dispatches the
384
- * callback directly. In uncontrolled mode, routes through the internal
385
- * `ClickedDay` message so the calendar's own update manages selection. */
386
486
  const dispatchSelectedDate = (date) => onSelectedDate !== undefined
387
487
  ? onSelectedDate(date)
388
488
  : toParentMessage(ClickedDay({ date }));
389
489
  const previousMonthLabel = config.previousMonthLabel ?? 'Previous month';
390
490
  const nextMonthLabel = config.nextMonthLabel ?? 'Next month';
391
- const monthSelectLabel = config.monthSelectLabel ?? 'Select month';
392
- const yearSelectLabel = config.yearSelectLabel ?? 'Select year';
491
+ const headingButtonLabel = config.daysHeadingButtonLabel ?? 'Switch to month picker';
393
492
  const headingText = `${locale.monthNames[viewMonth - 1]} ${viewYear}`;
394
- const monthOptions = locale.monthNames.map((label, index) => ({
395
- value: index + 1,
396
- label,
397
- }));
398
- const [yearRangeStart, yearRangeEnd] = resolveYearRange(model);
399
- const yearOptions = Array.makeBy(Math.max(0, yearRangeEnd - yearRangeStart + 1), index => yearRangeStart + index);
400
493
  const rotatedDayNames = rotateDayNames(DAY_NAMES_SUNDAY_FIRST, locale.firstDayOfWeek);
401
494
  const rotatedShortDayNames = rotateDayNames(locale.shortDayNames, locale.firstDayOfWeek);
402
495
  const { gridStart, weeks: weeksDates } = buildGrid(viewYear, viewMonth, locale.firstDayOfWeek);
403
- const rootAttributes = [Id(id)];
404
- const previousMonthButtonAttributes = [
496
+ const rootAttributes = [
497
+ Id(id),
498
+ Key('Days'),
499
+ ];
500
+ const previousMonthButton = [
405
501
  Type('button'),
406
502
  AriaLabel(previousMonthLabel),
407
503
  OnClick(toParentMessage(ClickedPreviousMonthButton())),
408
504
  ];
409
- const nextMonthButtonAttributes = [
505
+ const nextMonthButton = [
410
506
  Type('button'),
411
507
  AriaLabel(nextMonthLabel),
412
508
  OnClick(toParentMessage(ClickedNextMonthButton())),
413
509
  ];
414
- const monthSelectAttributes = [
415
- AriaLabel(monthSelectLabel),
416
- OnChange(value => toParentMessage(SelectedMonthFromDropdown({ month: Number(value) }))),
417
- ];
418
- const yearSelectAttributes = [
419
- AriaLabel(yearSelectLabel),
420
- OnChange(value => toParentMessage(SelectedYearFromDropdown({ year: Number(value) }))),
510
+ const headingButton = [
511
+ Type('button'),
512
+ AriaLabel(headingButtonLabel),
513
+ OnClick(toParentMessage(ClickedHeading())),
421
514
  ];
422
515
  const handleKeyDown = (key, modifiers) => {
423
516
  if (!NAV_KEYS.has(key)) {
@@ -428,7 +521,7 @@ export const view = (config) => {
428
521
  }
429
522
  return Option.some(toParentMessage(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey })));
430
523
  };
431
- const activeDescendantAttributes = pipe(maybeFocusedDate, Option.map(date => AriaActiveDescendant(cellId(id, date))), Option.toArray);
524
+ const activeDescendantAttributes = pipe(maybeFocusedDate, Option.map(date => AriaActiveDescendant(dayCellId(id, date))), Option.toArray);
432
525
  const gridAttributes = [
433
526
  Id(gridId(id)),
434
527
  Role('grid'),
@@ -470,7 +563,7 @@ export const view = (config) => {
470
563
  OptionExt.when(isDisabled, DataAttribute('disabled', '')),
471
564
  ]);
472
565
  const cellAttributes = [
473
- Id(cellId(id, date)),
566
+ Id(dayCellId(id, date)),
474
567
  Role('gridcell'),
475
568
  AriaSelected(isSelected),
476
569
  AriaColindex(columnIndex + 1),
@@ -506,21 +599,216 @@ export const view = (config) => {
506
599
  cells: weekDates.map(buildDayCell),
507
600
  };
508
601
  });
509
- return toView({
602
+ return {
603
+ _tag: 'Days',
510
604
  root: rootAttributes,
511
- previousMonthButton: previousMonthButtonAttributes,
512
- nextMonthButton: nextMonthButtonAttributes,
605
+ previousMonthButton,
606
+ nextMonthButton,
607
+ headingButton,
513
608
  heading: { id: headingId(id), text: headingText },
514
- monthSelect: monthSelectAttributes,
515
- monthOptions,
516
- yearSelect: yearSelectAttributes,
517
- yearOptions,
518
609
  grid: gridAttributes,
519
610
  headerRow: headerRowAttributes,
520
611
  columnHeaders,
521
612
  weeks,
613
+ };
614
+ };
615
+ const buildMonthsAttributes = (config) => {
616
+ const { AriaActiveDescendant, AriaDisabled, AriaLabel, AriaSelected, DataAttribute, Id, Key, OnBlur, OnClick, OnFocus, OnKeyDownPreventDefault, Role, Tabindex, Type, } = html();
617
+ const { model, toParentMessage } = config;
618
+ const { id, viewYear, viewMonth, maybeFocusedDate, today, locale, isGridFocused, } = model;
619
+ const headingButtonLabel = config.monthsHeadingButtonLabel ?? 'Switch to year picker';
620
+ const headingText = `${viewYear}`;
621
+ const rootAttributes = [
622
+ Id(id),
623
+ Key('Months'),
624
+ ];
625
+ const headingButton = [
626
+ Type('button'),
627
+ AriaLabel(headingButtonLabel),
628
+ OnClick(toParentMessage(ClickedHeading())),
629
+ ];
630
+ const focusedMonth = Option.match(maybeFocusedDate, {
631
+ onNone: () => viewMonth,
632
+ onSome: date => (date.year === viewYear ? date.month : viewMonth),
522
633
  });
634
+ const handleKeyDown = (key, modifiers) => {
635
+ if (!NAV_KEYS.has(key)) {
636
+ return Option.none();
637
+ }
638
+ else if (isCommitKey(key)) {
639
+ return OptionExt.when(!isMonthDisabled(model, viewYear, focusedMonth), toParentMessage(SelectedMonth({ month: focusedMonth })));
640
+ }
641
+ else {
642
+ return Option.some(toParentMessage(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey })));
643
+ }
644
+ };
645
+ const activeDescendantAttributes = [
646
+ AriaActiveDescendant(monthCellId(id, focusedMonth)),
647
+ ];
648
+ const gridAttributes = [
649
+ Id(gridId(id)),
650
+ Role('grid'),
651
+ AriaLabel(`Month picker, ${headingText}`),
652
+ Tabindex(0),
653
+ OnFocus(toParentMessage(FocusedGrid())),
654
+ OnBlur(toParentMessage(BlurredGrid())),
655
+ OnKeyDownPreventDefault(handleKeyDown),
656
+ ...activeDescendantAttributes,
657
+ ];
658
+ const buildMonthCell = (month) => {
659
+ const label = locale.monthNames[month - 1] ?? String(month);
660
+ const shortLabel = locale.shortMonthNames[month - 1] ?? label;
661
+ const isSelected = month === viewMonth;
662
+ const isFocused = month === focusedMonth;
663
+ const isCurrentMonth = today.year === viewYear && today.month === month;
664
+ const isDisabled = isMonthDisabled(model, viewYear, month);
665
+ const stateDataAttributes = Array.getSomes([
666
+ OptionExt.when(isCurrentMonth, DataAttribute('today', '')),
667
+ OptionExt.when(isSelected, DataAttribute('selected', '')),
668
+ OptionExt.when(isFocused && isGridFocused, DataAttribute('focused', '')),
669
+ OptionExt.when(isDisabled, DataAttribute('disabled', '')),
670
+ ]);
671
+ const cellAttributes = [
672
+ Id(monthCellId(id, month)),
673
+ Role('gridcell'),
674
+ AriaSelected(isSelected),
675
+ ...stateDataAttributes,
676
+ ];
677
+ const buttonAttributes = [
678
+ Type('button'),
679
+ Tabindex(-1),
680
+ AriaLabel(`${label} ${viewYear}`),
681
+ AriaDisabled(isDisabled),
682
+ ...(isDisabled
683
+ ? []
684
+ : [OnClick(toParentMessage(SelectedMonth({ month })))]),
685
+ ];
686
+ return {
687
+ month,
688
+ label,
689
+ shortLabel,
690
+ cellAttributes,
691
+ buttonAttributes,
692
+ isSelected,
693
+ isFocused: isFocused && isGridFocused,
694
+ isCurrentMonth,
695
+ isDisabled,
696
+ };
697
+ };
698
+ const cells = Array.makeBy(MONTHS_IN_YEAR, monthIndex => buildMonthCell(monthIndex + 1));
699
+ return {
700
+ _tag: 'Months',
701
+ root: rootAttributes,
702
+ headingButton,
703
+ heading: { id: headingId(id), text: headingText },
704
+ grid: gridAttributes,
705
+ cells,
706
+ };
707
+ };
708
+ const buildYearsAttributes = (config) => {
709
+ const { AriaActiveDescendant, AriaDisabled, AriaLabel, AriaSelected, DataAttribute, Id, Key, OnBlur, OnClick, OnFocus, OnKeyDownPreventDefault, Role, Tabindex, Type, } = html();
710
+ const { model, toParentMessage } = config;
711
+ const { id, viewYear, maybeFocusedDate, today, isGridFocused } = model;
712
+ const previousYearsPageLabel = config.previousYearsPageLabel ?? 'Previous 12 years';
713
+ const nextYearsPageLabel = config.nextYearsPageLabel ?? 'Next 12 years';
714
+ const cursorYear = Option.match(maybeFocusedDate, {
715
+ onNone: () => viewYear,
716
+ onSome: date => date.year,
717
+ });
718
+ const pageStart = yearsPageStart(cursorYear);
719
+ const pageEnd = pageStart + YEARS_PAGE_SIZE - 1;
720
+ const headingText = `${pageStart}–${pageEnd}`;
721
+ const rootAttributes = [
722
+ Id(id),
723
+ Key('Years'),
724
+ ];
725
+ const previousPageButton = [
726
+ Type('button'),
727
+ AriaLabel(previousYearsPageLabel),
728
+ OnClick(toParentMessage(PagedYears({ direction: -1 }))),
729
+ ];
730
+ const nextPageButton = [
731
+ Type('button'),
732
+ AriaLabel(nextYearsPageLabel),
733
+ OnClick(toParentMessage(PagedYears({ direction: 1 }))),
734
+ ];
735
+ const focusedYear = cursorYear;
736
+ const handleKeyDown = (key, modifiers) => {
737
+ if (!NAV_KEYS.has(key)) {
738
+ return Option.none();
739
+ }
740
+ else if (isCommitKey(key)) {
741
+ return OptionExt.when(!isYearDisabled(model, focusedYear), toParentMessage(SelectedYear({ year: focusedYear })));
742
+ }
743
+ else {
744
+ return Option.some(toParentMessage(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey })));
745
+ }
746
+ };
747
+ const activeDescendantAttributes = [
748
+ AriaActiveDescendant(yearCellId(id, focusedYear)),
749
+ ];
750
+ const gridAttributes = [
751
+ Id(gridId(id)),
752
+ Role('grid'),
753
+ AriaLabel(`Year picker, ${headingText}`),
754
+ Tabindex(0),
755
+ OnFocus(toParentMessage(FocusedGrid())),
756
+ OnBlur(toParentMessage(BlurredGrid())),
757
+ OnKeyDownPreventDefault(handleKeyDown),
758
+ ...activeDescendantAttributes,
759
+ ];
760
+ const buildYearCell = (year) => {
761
+ const label = String(year);
762
+ const isSelected = year === viewYear;
763
+ const isFocused = year === focusedYear;
764
+ const isCurrentYear = today.year === year;
765
+ const isDisabled = isYearDisabled(model, year);
766
+ const stateDataAttributes = Array.getSomes([
767
+ OptionExt.when(isCurrentYear, DataAttribute('today', '')),
768
+ OptionExt.when(isSelected, DataAttribute('selected', '')),
769
+ OptionExt.when(isFocused && isGridFocused, DataAttribute('focused', '')),
770
+ OptionExt.when(isDisabled, DataAttribute('disabled', '')),
771
+ ]);
772
+ const cellAttributes = [
773
+ Id(yearCellId(id, year)),
774
+ Role('gridcell'),
775
+ AriaSelected(isSelected),
776
+ ...stateDataAttributes,
777
+ ];
778
+ const buttonAttributes = [
779
+ Type('button'),
780
+ Tabindex(-1),
781
+ AriaLabel(label),
782
+ AriaDisabled(isDisabled),
783
+ ...(isDisabled ? [] : [OnClick(toParentMessage(SelectedYear({ year })))]),
784
+ ];
785
+ return {
786
+ year,
787
+ label,
788
+ cellAttributes,
789
+ buttonAttributes,
790
+ isSelected,
791
+ isFocused: isFocused && isGridFocused,
792
+ isCurrentYear,
793
+ isDisabled,
794
+ };
795
+ };
796
+ const cells = Array.makeBy(YEARS_PAGE_SIZE, offset => buildYearCell(pageStart + offset));
797
+ return {
798
+ _tag: 'Years',
799
+ root: rootAttributes,
800
+ previousPageButton,
801
+ nextPageButton,
802
+ heading: { id: headingId(id), text: headingText },
803
+ grid: gridAttributes,
804
+ cells,
805
+ };
523
806
  };
807
+ /** Renders an accessible calendar. Builds mode-specific ARIA attribute
808
+ * groups and derived cell data, then delegates layout to the consumer's
809
+ * `toView` callback. The variant of `CalendarAttributes` passed to `toView`
810
+ * matches `model.viewMode`. */
811
+ export const view = (config) => config.toView(M.value(config.model.viewMode).pipe(M.withReturnType(), M.when('Days', () => buildDaysAttributes(config)), M.when('Months', () => buildMonthsAttributes(config)), M.when('Years', () => buildYearsAttributes(config)), M.exhaustive));
524
812
  /** Creates a memoized calendar view. Static config is captured in a closure;
525
813
  * only `model` and `toParentMessage` are compared per render via `createLazy`. */
526
814
  export const lazy = (staticConfig) => {
@@ -1,3 +1,3 @@
1
- export { init, update, view, lazy, focusGrid, selectDate, setMinDate, setMaxDate, setDisabledDates, setDisabledDaysOfWeek, Model, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index.js';
2
- export type { InitConfig, ViewConfig, CalendarAttributes, DayCell, ColumnHeader, Week, } from './index.js';
1
+ export { init, update, view, lazy, focusGrid, selectDate, setMinDate, setMaxDate, setDisabledDates, setDisabledDaysOfWeek, dropToDays, Model, ViewMode, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, ClickedHeading, SelectedMonth, SelectedYear, PagedYears, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index.js';
2
+ export type { InitConfig, ViewConfig, CalendarAttributes, DaysModeAttributes, MonthsModeAttributes, YearsModeAttributes, DayCell, ColumnHeader, Week, MonthCell, YearCell, } from './index.js';
3
3
  //# sourceMappingURL=public.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/calendar/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,qBAAqB,EACrB,KAAK,EACL,OAAO,EACP,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EACxB,WAAW,EACX,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,YAAY,CAAA;AAEnB,YAAY,EACV,UAAU,EACV,UAAU,EACV,kBAAkB,EAClB,OAAO,EACP,YAAY,EACZ,IAAI,GACL,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/calendar/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,qBAAqB,EACrB,UAAU,EACV,KAAK,EACL,QAAQ,EACR,OAAO,EACP,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,cAAc,EACd,aAAa,EACb,YAAY,EACZ,UAAU,EACV,WAAW,EACX,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,YAAY,CAAA;AAEnB,YAAY,EACV,UAAU,EACV,UAAU,EACV,kBAAkB,EAClB,kBAAkB,EAClB,oBAAoB,EACpB,mBAAmB,EACnB,OAAO,EACP,YAAY,EACZ,IAAI,EACJ,SAAS,EACT,QAAQ,GACT,MAAM,YAAY,CAAA"}
@@ -1 +1 @@
1
- export { init, update, view, lazy, focusGrid, selectDate, setMinDate, setMaxDate, setDisabledDates, setDisabledDaysOfWeek, Model, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index.js';
1
+ export { init, update, view, lazy, focusGrid, selectDate, setMinDate, setMaxDate, setDisabledDates, setDisabledDaysOfWeek, dropToDays, Model, ViewMode, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, ClickedHeading, SelectedMonth, SelectedYear, PagedYears, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index.js';