@uniflowed/ui 0.0.0-alpha.4 → 0.0.0-alpha.41

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