@uniflowed/ui 0.0.0-alpha.8 → 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 (64) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +560 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +235 -198
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +334 -0
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1254 -32
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +565 -0
  27. package/internal/collection.js +395 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/focus.js +64 -0
  32. package/internal/hover-intent.js +259 -0
  33. package/internal/menu-tree.js +228 -0
  34. package/internal/merge-props.js +117 -1
  35. package/internal/roving-focus.js +15 -4
  36. package/internal/segmented-field.js +316 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -25
  42. package/pagination.js +34 -22
  43. package/popover.js +367 -0
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +78 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +112 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +404 -0
  64. package/tree.js +8 -0
package/calendar.js ADDED
@@ -0,0 +1,560 @@
1
+ // @flow
2
+ //
3
+ // A month of dates, as one stop in the page's tab order.
4
+ //
5
+ // This is the largest component in the catalogue and the one where a small
6
+ // lookalike is most tempting, because a month of buttons in a grid *looks*
7
+ // finished from a screenshot. What makes a calendar usable is entirely in the
8
+ // keyboard and the announcements:
9
+ //
10
+ // * `ArrowRight` / `ArrowLeft` move by a day, mirrored in a right-to-left
11
+ // page; `ArrowDown` / `ArrowUp` move by a week.
12
+ // * `Home` / `End` go to the ends of the *week*, not of the month.
13
+ // * `PageUp` / `PageDown` change the month, `Shift` with either changes the
14
+ // year.
15
+ // * Running off the end of the month shows the next one and lands on its first
16
+ // day — the grid is re-rendered under the reader's focus, and focus has to
17
+ // arrive on a cell that did not exist when the key was pressed.
18
+ // * An unavailable day is `aria-disabled` and still reachable, which is the
19
+ // deliberate opposite of what a menu item does. `internal/date-grid.js` has
20
+ // the argument.
21
+ // * The month is announced when it changes, in a live region that was already
22
+ // in the document — because a sighted reader sees the caption change and a
23
+ // screen reader is told nothing at all otherwise.
24
+ //
25
+ // # One focused date, and everything else derived from it
26
+ //
27
+ // The component holds exactly one piece of grid state: which date has the tab
28
+ // stop. The month on display is that date's month, and there is deliberately no
29
+ // separate `month` prop to control. `role="grid"` requires that exactly one cell
30
+ // is `tabindex="0"` — a set with two tab stops takes two `Tab` presses to leave,
31
+ // and a set with none is unreachable — and two independent values are two ways
32
+ // to break that invariant. `Calendar.Previous` moves the focused date rather
33
+ // than a month cursor, and `onMonthChange` reports the result for a caller
34
+ // loading availability a month at a time.
35
+ //
36
+ // # Today comes from the clock seam, and is read once
37
+ //
38
+ // `Temporal.Now.plainDateISO()` inside a render is the bug `@uniflowed/core`'s
39
+ // temporal module exists to prevent: the server's date and the browser's are
40
+ // different, and React compares the markup. So it is read once, in a state
41
+ // initialiser, and it reads `@uniflowed/core/clock` rather than the machine —
42
+ // which is the seam a server installs per request and a test replaces outright.
43
+ // An application that server-renders a calendar and does neither can hydrate
44
+ // into a different month; the way out is that seam, or the `today` prop, and
45
+ // both are cheaper than a component that re-reads a clock it cannot trust.
46
+ //
47
+ // # Range selection is not here
48
+ //
49
+ // One date. A range is two values with both ends announced, `aria-selected` on
50
+ // everything between them, and a second set of keyboard rules for extending;
51
+ // none of that is written, and a `value` that quietly accepted a pair would be
52
+ // the lookalike this package refuses. It is tracked rather than implied.
53
+
54
+ "use client";
55
+
56
+ import * as React from "@uniflowed/react";
57
+ import {
58
+ createContext,
59
+ useContext,
60
+ useEffect,
61
+ useId,
62
+ useMemo,
63
+ useRef,
64
+ useState,
65
+ } from "@uniflowed/react";
66
+ import { useStableCallback } from "@uniflowed/hooks/lifecycle";
67
+ import type { DateTimeFormatOptions, PlainDate } from "@uniflowed/core/temporal";
68
+ import { Temporal } from "@uniflowed/core/temporal";
69
+
70
+ import { useLocale } from "./i18n-provider.js";
71
+ import { useControlled } from "./internal/controlled-state.js";
72
+ import type { Rest } from "./internal/merge-props.js";
73
+ import {
74
+ composeHandlers,
75
+ composeRefs,
76
+ forwarded,
77
+ withoutComposed,
78
+ } from "./internal/merge-props.js";
79
+ import { directionOf } from "./internal/roving-focus.js";
80
+ import {
81
+ firstDayOfWeekFor,
82
+ moveDate,
83
+ movementForDateKey,
84
+ weekdaysFrom,
85
+ weeksOf,
86
+ } from "./internal/date-grid.js";
87
+
88
+ /**
89
+ * A date, however the caller had one to hand.
90
+ *
91
+ * A string is accepted because `value="2026-10-14"` is what a form field, a URL
92
+ * parameter and a JSON payload all carry, and making every caller construct a
93
+ * `PlainDate` to pass one in would be ceremony. It is ISO 8601 — the format
94
+ * `Temporal.PlainDate.from` parses — and not a locale format: parsing
95
+ * `12/03/26` is a locale question that belongs to `@uniflowed/temporal` rather
96
+ * than to a UI package, and `date-picker.js` says what that means for a field a
97
+ * reader types into.
98
+ */
99
+ export type DateValue = PlainDate | string;
100
+
101
+ /**
102
+ * How the caption and the column headers are worded.
103
+ *
104
+ * Annotated rather than inferred: an object literal of strings is a
105
+ * `{month: string, ...}`, and `Intl` accepts four spellings of `month` and not
106
+ * every string, so the annotation is what makes a typo here a checker error
107
+ * instead of a caption that silently prints nothing.
108
+ */
109
+ const CAPTION_FORMAT: DateTimeFormatOptions = { month: "long", year: "numeric" };
110
+ const COLUMN_FORMAT: DateTimeFormatOptions = { weekday: "long" };
111
+ const COLUMN_ABBREVIATION: DateTimeFormatOptions = { weekday: "short" };
112
+
113
+ type CalendarState = {|
114
+ readonly base: string,
115
+ /** The month and year over the grid, and the sentence the live region says. */
116
+ readonly caption: string,
117
+ readonly focused: PlainDate,
118
+ /**
119
+ * The cell holding the tab stop, written after every render of the grid.
120
+ *
121
+ * For an overlay around the calendar to send focus there when it opens:
122
+ * `Popover.Body` and `Dialog.Body` both take an `initialFocus` ref, and a
123
+ * reader who opened a date picker is looking for the date rather than for the
124
+ * button that steps back a month. It is filled from an effect in
125
+ * `Calendar.Month`, which runs before any effect of a component *around* the
126
+ * calendar - React runs a child's effects first, and that ordering is what
127
+ * makes the ref already correct when the overlay reads it.
128
+ */
129
+ readonly focusedDayRef: { current: HTMLElement | null },
130
+ readonly isDisabled: (date: PlainDate) => boolean,
131
+ readonly isDateSelected: ((date: PlainDate) => boolean) | void,
132
+ readonly locale: string | void,
133
+ readonly moveFocus: (date: PlainDate, viaKeyboard: boolean) => void,
134
+ /**
135
+ * The cell the keyboard should be on after the next render, as an ISO date.
136
+ *
137
+ * A ref rather than state for the reason `Menu.Body`'s `pendingFocus` is one:
138
+ * it is an instruction for the next commit and not a value anything renders.
139
+ * It is what makes `ArrowRight` off the end of October land on the 1st of
140
+ * November — a cell that is rendered for the first time by the same update
141
+ * that asked for it, so nothing could have focused it when the key arrived.
142
+ */
143
+ readonly pendingFocusRef: { current: string | null },
144
+ readonly select: (date: PlainDate) => void,
145
+ readonly selected: PlainDate | null,
146
+ readonly today: PlainDate,
147
+ readonly weekStartsOn: number,
148
+ |};
149
+
150
+ const CalendarContext: React.Context<CalendarState | null> = createContext(null);
151
+
152
+ hook useCalendar(part: string): CalendarState {
153
+ const state = useContext(CalendarContext);
154
+ if (state == null) {
155
+ throw new Error(`${part} must be rendered inside a Calendar.Root`);
156
+ }
157
+ return state;
158
+ }
159
+
160
+ /** A `DateValue` as a `PlainDate`, whichever it arrived as. */
161
+ function toDate(value: DateValue): PlainDate {
162
+ return Temporal.PlainDate.from(value);
163
+ }
164
+
165
+ /**
166
+ * The calendar's state, its live region, and nothing else it renders.
167
+ *
168
+ * A `<div>` around whatever the caller laid out, plus one `role="status"` that
169
+ * is in the document from the first render. That timing is the whole point of
170
+ * it: a live region added to the page in the same commit as the text it holds
171
+ * is usually not announced, because the technology watching it had nothing to
172
+ * watch until it was too late. `Combobox.Status` documents the same constraint,
173
+ * and here there is no part for the region because a caller has no reason to
174
+ * place it — so `Calendar.Root` renders it and keeps it empty until the month
175
+ * actually changes.
176
+ */
177
+ export component CalendarRoot(
178
+ children: React.Node,
179
+ defaultFocused?: DateValue,
180
+ defaultValue?: DateValue | null = null,
181
+ /** Filled with the cell that holds the tab stop; see `CalendarState`. */
182
+ focusedDayRef?: { current: HTMLElement | null },
183
+ /** Whether a date may be chosen. A rejected date stays reachable; see the header. */
184
+ isDateDisabled?: (date: PlainDate) => boolean,
185
+ isDateSelected?: (date: PlainDate) => boolean,
186
+ locale?: string,
187
+ onMonthChange?: (firstOfMonth: PlainDate) => mixed,
188
+ onValueChange?: (value: PlainDate) => mixed,
189
+ today?: DateValue,
190
+ value?: DateValue | null,
191
+ /** ISO day numbers: 1 is Monday, 7 is Sunday. Defaults to the locale's own. */
192
+ weekStartsOn?: number,
193
+ ...rest: Rest
194
+ ) {
195
+ const inherited = useLocale();
196
+ const resolvedLocale = locale ?? inherited.locale;
197
+ const base = useId();
198
+ const pendingFocusRef = useRef<string | null>(null);
199
+ // One is always allocated, because a hook may not be called conditionally;
200
+ // the caller's is used when there is one.
201
+ const ownDayRef = useRef<HTMLElement | null>(null);
202
+ const dayRef = focusedDayRef ?? ownDayRef;
203
+
204
+ // Read once. The header says why this is not the `Now`-in-a-render bug, and
205
+ // why an application that server-renders a calendar wants the clock seam.
206
+ const [clockToday] = useState<PlainDate>(() => Temporal.Now.plainDateISO());
207
+ const currentDate = useMemo(
208
+ () => (today === undefined ? clockToday : toDate(today)),
209
+ [clockToday, today],
210
+ );
211
+
212
+ const controlled = useMemo(
213
+ () => (value === undefined ? undefined : value === null ? null : toDate(value)),
214
+ [value],
215
+ );
216
+ const initialValue = useMemo(
217
+ () => (defaultValue == null ? null : toDate(defaultValue)),
218
+ [defaultValue],
219
+ );
220
+ // Narrowed on the way out rather than in the prop's type: the component never
221
+ // clears a selection, so a caller's handler should not have to accept a `null`
222
+ // it can never be given.
223
+ const report = useStableCallback((next: PlainDate | null) => {
224
+ if (next != null) {
225
+ onValueChange?.(next);
226
+ }
227
+ });
228
+ const [selected, setSelected] = useControlled<PlainDate | null>(controlled, initialValue, report);
229
+
230
+ const [focused, setFocused] = useState<PlainDate>(() => {
231
+ const start = defaultFocused ?? value ?? defaultValue;
232
+ return start == null ? currentDate : toDate(start);
233
+ });
234
+
235
+ const weekStart = useMemo(
236
+ () => (weekStartsOn == null ? firstDayOfWeekFor(resolvedLocale) : weekStartsOn),
237
+ [resolvedLocale, weekStartsOn],
238
+ );
239
+
240
+ const isDisabled = useStableCallback((date: PlainDate) => isDateDisabled?.(date) === true);
241
+
242
+ const moveFocus = useStableCallback((date: PlainDate, viaKeyboard: boolean) => {
243
+ if (viaKeyboard) {
244
+ // Only for the keyboard. A press already put focus on the cell it landed
245
+ // on, and asking for it again would fight a caller who moved it.
246
+ pendingFocusRef.current = date.toString();
247
+ }
248
+ // Compared rather than assigned, because `Calendar.Day` reports the focus
249
+ // that this very call produced: a key moves the tab stop, the effect focuses
250
+ // the new cell, and the cell says so. Without the comparison that is one
251
+ // extra render of the whole month per arrow key, for a value that did not
252
+ // change.
253
+ setFocused((current) => (current.equals(date) ? current : date));
254
+ });
255
+
256
+ const select = useStableCallback((date: PlainDate) => {
257
+ if (isDisabled(date)) {
258
+ return;
259
+ }
260
+ setSelected(date);
261
+ setFocused((current) => (current.equals(date) ? current : date));
262
+ });
263
+
264
+ const caption = useMemo(
265
+ () => focused.toLocaleString(resolvedLocale, CAPTION_FORMAT),
266
+ [focused, resolvedLocale],
267
+ );
268
+
269
+ const [announcement, setAnnouncement] = useState("");
270
+ const shown = useRef(`${focused.year}-${focused.month}`);
271
+ const monthChanged = useStableCallback((first: PlainDate) => {
272
+ onMonthChange?.(first);
273
+ });
274
+
275
+ useEffect(() => {
276
+ const key = `${focused.year}-${focused.month}`;
277
+ if (shown.current === key) {
278
+ return;
279
+ }
280
+ shown.current = key;
281
+ // The caption, deliberately word for word: a reader who hears something
282
+ // other than what is written above the grid has to work out that the two
283
+ // are the same thing.
284
+ setAnnouncement(caption);
285
+ monthChanged(Temporal.PlainDate.from({ day: 1, month: focused.month, year: focused.year }));
286
+ }, [caption, focused, monthChanged]);
287
+
288
+ const state = useMemo(
289
+ () => ({
290
+ base,
291
+ caption,
292
+ focused,
293
+ focusedDayRef: dayRef,
294
+ isDisabled,
295
+ isDateSelected,
296
+ locale: resolvedLocale,
297
+ moveFocus,
298
+ pendingFocusRef,
299
+ select,
300
+ selected,
301
+ today: currentDate,
302
+ weekStartsOn: weekStart,
303
+ }),
304
+ [
305
+ base,
306
+ caption,
307
+ currentDate,
308
+ dayRef,
309
+ focused,
310
+ isDisabled,
311
+ isDateSelected,
312
+ resolvedLocale,
313
+ moveFocus,
314
+ select,
315
+ selected,
316
+ weekStart,
317
+ ],
318
+ );
319
+
320
+ return (
321
+ <CalendarContext.Provider value={state}>
322
+ <div {...rest}>
323
+ {children}
324
+ <div aria-atomic="true" aria-live="polite" id={`${base}-status`} role="status">
325
+ {announcement}
326
+ </div>
327
+ </div>
328
+ </CalendarContext.Provider>
329
+ );
330
+ }
331
+
332
+ /**
333
+ * The grid: a caption, seven named columns, and the month's days.
334
+ *
335
+ * `role="grid"` rather than a plain table, because the cells are the thing a
336
+ * reader operates rather than data they read past. The keys are handled here
337
+ * rather than on each day, for the reason `Menu.Body` handles its own: every one
338
+ * of them is a question about the *set* — "a week later", "the end of this
339
+ * week" — and a day knows nothing about the days around it.
340
+ *
341
+ * `children` is a function rather than a list of parts, and that is what a month
342
+ * needs: the caller is given each date and returns the cell for it, so a day
343
+ * with a dot under it for an appointment is a `Calendar.Day` with a child, not a
344
+ * fork of this component. Omitted, every day renders its own number.
345
+ */
346
+ export component CalendarMonth(children?: (date: PlainDate) => renders CalendarDay, ...rest: Rest) {
347
+ const calendar = useCalendar("Calendar.Month");
348
+ const gridRef = useRef<HTMLElement | null>(null);
349
+ const { focused, focusedDayRef, moveFocus, pendingFocusRef, weekStartsOn } = calendar;
350
+
351
+ const weeks = useMemo(
352
+ () => weeksOf(focused.year, focused.month, weekStartsOn),
353
+ [focused.month, focused.year, weekStartsOn],
354
+ );
355
+ const columns = useMemo(() => weekdaysFrom(weekStartsOn), [weekStartsOn]);
356
+
357
+ // No dependency list, and guarded by what it reads rather than by one: the
358
+ // cell it looks for may have been produced by a *caller's* render, which is a
359
+ // change no dependency list of this component's own values can describe. The
360
+ // work is one `querySelector` over a grid that is at most forty-two cells.
361
+ useEffect(() => {
362
+ const grid = gridRef.current;
363
+ if (grid == null) {
364
+ return;
365
+ }
366
+ const at = focused.toString();
367
+ const cell: HTMLElement | null = grid.querySelector(`[data-date="${at}"]`) as $FlowFixMe;
368
+ focusedDayRef.current = cell;
369
+ // Only when a key asked for it, and only once the render it asked for has
370
+ // happened: `ArrowRight` off the end of October sets this to the 1st of
371
+ // November, and the cell exists for the first time in the commit this
372
+ // effect belongs to.
373
+ if (pendingFocusRef.current !== at) {
374
+ return;
375
+ }
376
+ pendingFocusRef.current = null;
377
+ cell?.focus();
378
+ });
379
+
380
+ const passed = withoutComposed(rest, ["onKeyDown", "ref"]);
381
+
382
+ return (
383
+ <table
384
+ {...passed}
385
+ // Named by its own caption, and said twice on purpose. A `<caption>` is
386
+ // the table's name in HTML and needs no attribute — but `role="grid"`
387
+ // overrides the element's own role, and how a caption survives that is
388
+ // exactly the sort of thing that differs between screen readers. The
389
+ // attribute points at the caption that is there either way, so the two
390
+ // answers cannot disagree.
391
+ aria-labelledby={`${calendar.base}-caption`}
392
+ aria-multiselectable={calendar.isDateSelected != null || undefined}
393
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
394
+ const grid: $FlowFixMe = event.currentTarget;
395
+ const movement = movementForDateKey(event, directionOf(grid));
396
+ if (movement == null) {
397
+ return;
398
+ }
399
+ // Before moving, or the arrow scrolls the page under the cell that has
400
+ // just taken focus and the reader ends up looking somewhere else.
401
+ event.preventDefault();
402
+ moveFocus(moveDate(focused, movement, weekStartsOn), true);
403
+ })}
404
+ ref={composeRefs(rest.ref, (element) => {
405
+ gridRef.current = element;
406
+ })}
407
+ role="grid"
408
+ >
409
+ <caption id={`${calendar.base}-caption`}>{calendar.caption}</caption>
410
+ <thead>
411
+ <tr>
412
+ {columns.map((day) => (
413
+ <th
414
+ // The full day name is the accessible name and the short one is
415
+ // real header text, because axe rightly refuses an empty table
416
+ // header. `aria-label` wins over the contents for the announced
417
+ // name, so a reader still hears "Wednesday" rather than "Wed".
418
+ aria-label={day.toLocaleString(calendar.locale, COLUMN_FORMAT)}
419
+ key={day.dayOfWeek}
420
+ scope="col"
421
+ >
422
+ {day.toLocaleString(calendar.locale, COLUMN_ABBREVIATION)}
423
+ </th>
424
+ ))}
425
+ </tr>
426
+ </thead>
427
+ <tbody>
428
+ {weeks.map((week) => (
429
+ <tr key={week.find((day) => day != null)?.toString() ?? ""}>
430
+ {week.map((day, column) =>
431
+ day == null ? (
432
+ // A blank rather than the neighbouring month's day; see
433
+ // `internal/date-grid.js`. No `gridcell` role, so it is a cell a
434
+ // reader is told is empty rather than a date they cannot reach.
435
+ <td key={`blank-${column}`} />
436
+ ) : (
437
+ <React.Fragment key={day.toString()}>
438
+ {children == null ? <CalendarDay date={day} /> : children(day)}
439
+ </React.Fragment>
440
+ ),
441
+ )}
442
+ </tr>
443
+ ))}
444
+ </tbody>
445
+ </table>
446
+ );
447
+ }
448
+
449
+ /**
450
+ * One day.
451
+ *
452
+ * The cell itself is the focus stop rather than a button inside it. A grid's
453
+ * cells are what `role="grid"` says the arrow keys move between, and a button
454
+ * inside each one would put a second focusable element in every cell — which
455
+ * either doubles the tab stops or leaves the cell announced as empty.
456
+ *
457
+ * It carries no `aria-label`. The name of a `gridcell` is its contents, and a
458
+ * screen reader in a grid announces the column header and the caption around it
459
+ * — so "14" is heard as "Wednesday 14, October 2026" without this component
460
+ * inventing a second wording that a translation table would then have to own. A
461
+ * caller who wants one passes it; it is not overridden here.
462
+ */
463
+ export component CalendarDay(date: PlainDate, children?: React.Node, ...rest: Rest) {
464
+ const calendar = useCalendar("Calendar.Day");
465
+ const disabled = calendar.isDisabled(date);
466
+ const chosen =
467
+ calendar.isDateSelected?.(date) ??
468
+ (calendar.selected != null && calendar.selected.equals(date));
469
+ const passed = withoutComposed(rest, ["onClick", "onFocus", "onKeyDown"]);
470
+
471
+ return (
472
+ <td
473
+ {...passed}
474
+ aria-current={calendar.today.equals(date) ? "date" : undefined}
475
+ // `aria-disabled`, never the `disabled` a menu item would refuse with:
476
+ // this one stays in the accessibility tree and stays reachable, so a
477
+ // reader can find out that the 3rd is unavailable instead of finding a
478
+ // hole in the month where the 3rd should be.
479
+ aria-disabled={disabled ? "true" : undefined}
480
+ // Only on the chosen day. Every other cell saying `aria-selected="false"`
481
+ // makes a screen reader announce "not selected" on all thirty-one.
482
+ aria-selected={chosen ? "true" : undefined}
483
+ data-date={date.toString()}
484
+ onClick={composeHandlers(rest.onClick, () => calendar.select(date))}
485
+ // The tab stop follows real focus, which is the half of a roving set that
486
+ // is easy to leave out and impossible to see. A press on an *unavailable*
487
+ // day is the case: the browser focuses the cell, this component refuses
488
+ // the selection, and without this the tab stop would still be on the day
489
+ // the reader left — so the next arrow key would move from a date they are
490
+ // no longer looking at. `Menu.Item` keeps the same invariant the same way.
491
+ onFocus={composeHandlers(rest.onFocus, () => calendar.moveFocus(date, false))}
492
+ onKeyDown={composeHandlers(rest.onKeyDown, (event) => {
493
+ if (event.key !== "Enter" && event.key !== " ") {
494
+ return;
495
+ }
496
+ // A cell is not a button, so the two keys that activate one have to be
497
+ // handled here. `Space` in particular: unclaimed, it scrolls the page.
498
+ event.preventDefault();
499
+ calendar.select(date);
500
+ })}
501
+ role="gridcell"
502
+ // The roving tab stop: exactly one cell in the grid, always the focused
503
+ // date, which is why the displayed month is derived from it.
504
+ tabIndex={date.equals(calendar.focused) ? 0 : -1}
505
+ >
506
+ {children ?? date.day}
507
+ </td>
508
+ );
509
+ }
510
+
511
+ /**
512
+ * The button that shows the month before this one.
513
+ *
514
+ * `forwarded` rather than a bare spread, for the reason
515
+ * `internal/merge-props.js` gives at length: a part is spreadable onto a `<div>`
516
+ * and not onto a sibling part, because `Rest` names `key` out of its indexer and
517
+ * the receiving component's own indexer answers `mixed` for it.
518
+ */
519
+ export component CalendarPrevious(children: React.Node, ...rest: Rest) {
520
+ return (
521
+ <MonthStep {...forwarded(rest)} by={-1}>
522
+ {children}
523
+ </MonthStep>
524
+ );
525
+ }
526
+
527
+ /** The button that shows the month after this one. */
528
+ export component CalendarNext(children: React.Node, ...rest: Rest) {
529
+ return (
530
+ <MonthStep {...forwarded(rest)} by={1}>
531
+ {children}
532
+ </MonthStep>
533
+ );
534
+ }
535
+
536
+ /**
537
+ * The two month buttons, which differ by a sign.
538
+ *
539
+ * Focus stays on the button rather than following the month into the grid, which
540
+ * is what lets a reader step through several months in a row. The focused date
541
+ * moves with the month — `PlainDate.add` clamps it, so the 31st of March
542
+ * stepping back is the 28th or 29th of February rather than the 2nd or 3rd of
543
+ * March — because the grid must always contain exactly one tab stop.
544
+ */
545
+ component MonthStep(children: React.Node, by: number, ...rest: Rest) {
546
+ const calendar = useCalendar(by < 0 ? "Calendar.Previous" : "Calendar.Next");
547
+ const passed = withoutComposed(rest, ["onClick"]);
548
+
549
+ return (
550
+ <button
551
+ {...passed}
552
+ onClick={composeHandlers(rest.onClick, () => {
553
+ calendar.moveFocus(calendar.focused.add({ months: by }), false);
554
+ })}
555
+ type="button"
556
+ >
557
+ {children}
558
+ </button>
559
+ );
560
+ }