@scalewing/react 1.8.0 → 1.10.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 (85) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/README.md +93 -2
  3. package/dist/anchored-position.d.ts +28 -0
  4. package/dist/anchored-position.js +17 -0
  5. package/dist/calendar-button-labels.d.ts +19 -0
  6. package/dist/calendar-button-labels.js +10 -0
  7. package/dist/calendar-keys.d.ts +19 -0
  8. package/dist/calendar-keys.js +39 -0
  9. package/dist/calendar-labels.d.ts +19 -0
  10. package/dist/calendar-labels.js +41 -0
  11. package/dist/calendar-month.d.ts +38 -0
  12. package/dist/calendar-month.js +116 -0
  13. package/dist/calendar-years.d.ts +14 -0
  14. package/dist/calendar-years.js +20 -0
  15. package/dist/components/Accordion.d.ts +6 -1
  16. package/dist/components/Accordion.js +12 -3
  17. package/dist/components/ActionBar.d.ts +30 -0
  18. package/dist/components/ActionBar.js +13 -0
  19. package/dist/components/Box.d.ts +7 -3
  20. package/dist/components/Box.js +2 -2
  21. package/dist/components/CalendarButton.d.ts +43 -0
  22. package/dist/components/CalendarButton.js +47 -0
  23. package/dist/components/DateField.d.ts +15 -1
  24. package/dist/components/DateField.js +47 -18
  25. package/dist/components/Field.d.ts +5 -1
  26. package/dist/components/Field.js +16 -4
  27. package/dist/components/FieldAdornment.d.ts +20 -0
  28. package/dist/components/FieldAdornment.js +34 -0
  29. package/dist/components/Grid.d.ts +4 -1
  30. package/dist/components/Grid.js +2 -2
  31. package/dist/components/Progress.d.ts +3 -1
  32. package/dist/components/Progress.js +2 -2
  33. package/dist/components/date-field/CalendarDialog.d.ts +26 -0
  34. package/dist/components/date-field/CalendarDialog.js +93 -0
  35. package/dist/components/date-field/CalendarGrid.d.ts +23 -0
  36. package/dist/components/date-field/CalendarGrid.js +36 -0
  37. package/dist/components/date-field/CalendarHeader.d.ts +18 -0
  38. package/dist/components/date-field/CalendarHeader.js +33 -0
  39. package/dist/components/date-field/DateFieldGlyph.d.ts +9 -0
  40. package/dist/components/date-field/DateFieldGlyph.js +15 -0
  41. package/dist/components/date-field/use-anchored-popover.d.ts +8 -0
  42. package/dist/components/date-field/use-anchored-popover.js +61 -0
  43. package/dist/components/date-field/use-calendar-popup.d.ts +36 -0
  44. package/dist/components/date-field/use-calendar-popup.js +43 -0
  45. package/dist/components/date-field/use-date-entry.d.ts +20 -0
  46. package/dist/components/date-field/use-date-entry.js +61 -0
  47. package/dist/components/date-field/use-lang-locale.d.ts +7 -0
  48. package/dist/components/date-field/use-lang-locale.js +17 -0
  49. package/dist/css/css-accordion.js +55 -8
  50. package/dist/css/css-action-bar.d.ts +4 -0
  51. package/dist/css/css-action-bar.js +99 -0
  52. package/dist/css/css-action-menu.js +2 -1
  53. package/dist/css/css-box-border.d.ts +5 -0
  54. package/dist/css/css-box-border.js +24 -0
  55. package/dist/css/css-calendar-button.d.ts +2 -0
  56. package/dist/css/css-calendar-button.js +33 -0
  57. package/dist/css/css-chrome.js +2 -1
  58. package/dist/css/css-components.js +13 -6
  59. package/dist/css/css-data.js +2 -1
  60. package/dist/css/css-date-field.js +241 -11
  61. package/dist/css/css-denomination-grid.js +2 -1
  62. package/dist/css/css-document.d.ts +2 -0
  63. package/dist/css/css-document.js +2 -1
  64. package/dist/css/css-field.js +61 -1
  65. package/dist/css/css-select.js +2 -1
  66. package/dist/css/css-tooltip.js +2 -1
  67. package/dist/css/stacking.d.ts +10 -0
  68. package/dist/css/stacking.js +49 -0
  69. package/dist/css/stylesheet.js +6 -0
  70. package/dist/date-entry-validity.d.ts +10 -0
  71. package/dist/date-entry-validity.js +16 -0
  72. package/dist/date-entry.d.ts +32 -0
  73. package/dist/date-entry.js +106 -0
  74. package/dist/date-field-labels.d.ts +38 -0
  75. package/dist/date-field-labels.js +22 -0
  76. package/dist/date-field-locale.d.ts +8 -0
  77. package/dist/date-field-locale.js +27 -0
  78. package/dist/date-formatters.d.ts +13 -0
  79. package/dist/date-formatters.js +28 -0
  80. package/dist/date-only.d.ts +23 -0
  81. package/dist/date-only.js +63 -9
  82. package/dist/index.d.ts +6 -3
  83. package/dist/index.js +4 -2
  84. package/dist/styles.css +410 -21
  85. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,158 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 964e461: New `CalendarButton`: an icon-only `Button` that opens the `DateField` calendar dialog for a date the page already shows, such as a day heading with its own previous and next buttons (`docs/requests/teisoro-calendar-button.md`).
8
+
9
+ - Props: `label`, `value` (`YYYY-MM-DD`, never empty), `onChange`, and optional `min`, `max`, `disabled`, `locale`, `weekStartsOn`, `id`, `labels` (the calendar's words `previousMonth`, `nextMonth`, `month`, `year`, `today`, and `nameSeparator`), `size` (`xs`, `sm`, `md`; default `md`), and `variant` (a Button variant; default `ghost`). A `ref` reaches the `<button>`. New types `CalendarButtonProps` and `CalendarButtonLabels`.
10
+ - The accessible name is `label`, `labels.nameSeparator` (default `", "`, overridable per locale, such as `"、"` in Japanese), then the spoken date ("Choose survey day, Tuesday, September 22, 2026"), with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open. Enter, Space, or a press opens the calendar on `value` (a `value` outside `min`/`max` is kept, as on `DateField`, and the calendar opens on the nearest allowed day); choosing a day calls `onChange`, closes, and returns focus to the button; Escape and a press outside close without a change.
11
+ - An empty or invalid `value`, invalid bounds, locale, week start, or calendar words throw a `RangeError` with `DateField`'s messages. Two errors are new: `label must be non-empty text` and `labels.nameSeparator must be non-empty text`.
12
+ - New generated class `sw-calendar-button`: square at every Button size, and at least the 44 px md control height on a coarse pointer. The calendar reuses `sw-date-field-calendar` and its existing stacking layer.
13
+
14
+ No new dependencies.
15
+
16
+ ## 1.9.0
17
+
18
+ ### Minor Changes
19
+
20
+ - a4a30e8: `DateField` draws its own calendar instead of using the browser's `<input type="date">`, so it looks the same in every browser, follows Scalewing's tokens, and shows up in screenshots. The props, the date-only `YYYY-MM-DD` contract, and the `RangeError` guards are unchanged.
21
+
22
+ - People type the date into a text entry in the locale's numeric order (`MM/DD/YYYY` for en-US, `DD/MM/YYYY` for es); ISO `YYYY-MM-DD` and eight bare digits also work. A keystroke commits once the entry's last field is at full width (a four-digit year, or two digits for a trailing day or month); a shorter last field commits on blur or Enter. Typed text stays when the parent keeps the old value. Text that is not a date keeps the last value and, after the person leaves the field, sets `aria-invalid` with a message. As with the native input, the entry blocks form submission while its text is not a date or its date is outside `min`/`max` (`setCustomValidity`).
23
+ - A calendar button opens a WAI-ARIA date picker dialog anchored under the field on the popover layer, with month and year selectors for jumping decades, Today, and Clear (only when not `required`). Arrows, Home/End, PageUp/PageDown, and Shift+PageUp/PageDown move focus; Enter or Space selects; Escape closes and returns focus to the button. Days outside `min`/`max` are `aria-disabled`, the month selector offers only months inside them, and padding past `0001-01-01` or `9999-12-31` is blank and inert.
24
+ - New optional props: `locale` (BCP 47; defaults to the nearest `lang` attribute, then `en-US`), `weekStartsOn` (`0` Sunday by default, or `1` Monday), and `labels` for the control's own words (English defaults), including `outOfRange`, the form validation message for a date outside `min`/`max`. New types `DateFieldLabels` and `WeekStart`. New generated `sw-date-field-*` classes for the control and calendar.
25
+ - Migration for tests: there is no `input[type=date]` any more. Find the entry by its label (`getByLabel('Hire date')` or `getByRole('textbox', { name: 'Hire date' })`) and `fill` it in the locale's order or in ISO, or open the calendar with the **Choose date** button. The input's value is now the locale's display text (`03/10/2024`), not `2024-03-10`; assert the serialized value from your own state.
26
+
27
+ No new dependencies.
28
+
29
+ - 4548fc2: `Accordion` takes `subtitle`, one muted caption line under the title, and
30
+ `size="sm"` for a quieter disclosure nested inside other content (a
31
+ label-size title, spacing step 3, `md` corners, and a header that is an `sm`
32
+ control of at least 32px instead of 44px). The header now draws a
33
+ token chevron at its inline end in place of the browser's `details` triangle;
34
+ it turns when the disclosure opens and holds still under reduced motion. See
35
+ `docs/requests/teisoro-accordion-summary.md`.
36
+
37
+ ```tsx
38
+ <Accordion
39
+ open={open}
40
+ onOpenChange={setOpen}
41
+ subtitle="Twelve sightings · Two nests"
42
+ title="Wetlands"
43
+ >
44
+ {children}
45
+ </Accordion>
46
+ ```
47
+
48
+ New generated classes `sw-accordion-sm`, `sw-accordion-heading` and
49
+ `sw-accordion-marker`, and a new exported type `AccordionSize`. The summary is
50
+ now a flex row instead of `display: list-item`, so the native marker is gone;
51
+ an app that styled `.sw-accordion-summary::marker` has nothing left to style.
52
+ Additive for the public API.
53
+
54
+ - 38e6f09: New `ActionBar`: a long page's actions and one short `status` line on a glass
55
+ bar that sticks to the bottom of the viewport while the content above it
56
+ scrolls, then rests in place after it (see
57
+ `docs/requests/teisoro-action-bar.md`). `stickyBelow="md"` sticks only below
58
+ the `md` breakpoint and keeps the bar in page flow from `md` up. The bar clears
59
+ the safe-area insets (with `viewport-fit=cover`), is solid under Reduce
60
+ Transparency, and on a phone puts the status on its own line with the actions
61
+ sharing the row under it. The status is a polite live region
62
+ (`role="status"`), always rendered so the first status is announced too; an
63
+ empty status takes no room.
64
+
65
+ ```tsx
66
+ <Stack gap={4}>
67
+ {longForm}
68
+ <ActionBar status="Draft saved at 5:00 PM" stickyBelow="md">
69
+ <Button variant="secondary" onPress={save}>
70
+ Save draft
71
+ </Button>
72
+ <Button onPress={finish}>Finish</Button>
73
+ </ActionBar>
74
+ </Stack>
75
+ ```
76
+
77
+ New generated classes `sw-action-bar`, `sw-action-bar-status`,
78
+ `sw-action-bar-actions`, `sw-action-bar-sticky` and
79
+ `sw-action-bar-sticky-below-md`; new exported types `ActionBarProps` and `Breakpoint` (the breakpoint names
80
+ `stickyBelow`, `hideBelow` and `columnsBelow` already take, today only
81
+ `'md'`). Additive.
82
+
83
+ Every generated `z-index` now comes from one stacking order
84
+ (`src/css/stacking.ts`): sticky table cells 1, the ActionBar 2, a glass `Card`
85
+ or `Accordion` holding an open popup 3 (was 1), the sticky `AppHeader` 4 (was
86
+ 2), and popups 10 (the `Select` list was 3). An open `Select`, `ActionMenu`
87
+ or `Tooltip` inside a glass surface now paints over a stuck ActionBar, and
88
+ still under the AppHeader. An app that layered its own element between these
89
+ values should check it against the new numbers.
90
+
91
+ - affd090: `Box` `border` accepts `'dashed'` as well as `true`: a hairline dashed border
92
+ in the `border` color token, for a space to fill in by hand such as a blank on
93
+ a printed form (see `docs/requests/teisoro-box-border-style.md`). Every
94
+ Box-based component takes it.
95
+
96
+ ```tsx
97
+ <Box border="dashed" padding={2} radius="sm">
98
+ {hint}
99
+ </Box>
100
+ ```
101
+
102
+ New generated classes `sw-border` and `sw-border-dashed` and a new exported
103
+ type `BoxBorder` (`boolean | 'dashed'`). `border` and `border={true}` look the
104
+ same as before, but the solid hairline is now the `sw-border` class instead of
105
+ an inline `style.border`; a consumer `style` still wins, and the prop still
106
+ wins over a Box-based component's own frame. A test that read
107
+ `element.style.border` should check the class instead. Additive for the
108
+ public API.
109
+
110
+ - 6a4c4ab: `Field` takes `prefix` and `suffix`: short text inside the control's frame
111
+ before or after the value, such as a currency sign or a unit (see
112
+ `docs/requests/teisoro-field-adornment.md`).
113
+
114
+ ```tsx
115
+ <Field label="Drop amount" prefix="$">
116
+ <input inputMode="decimal" name="drop" />
117
+ </Field>
118
+ ```
119
+
120
+ The text is not part of the value. The generated `sw-field-adorned` wrapper
121
+ draws the control frame and focus ring, `sw-field-prefix` and
122
+ `sw-field-suffix` are muted, and the input is named by its label plus the
123
+ adornment ("Drop amount $") through `aria-labelledby`. An input that names
124
+ itself with its own `aria-label` or `aria-labelledby` keeps that name, and the
125
+ adornment joins its `aria-describedby` instead. A press on the prefix, the
126
+ suffix or the frame focuses the input. Adornments need one
127
+ native `<input>` child; anything else throws a `TypeError`. Additive; an
128
+ unadorned `Field` renders as before.
129
+
130
+ - 988bd2c: `Grid` takes `align` (`start`, `center`, `end`, `stretch`): where each child
131
+ sits in the height of its row, through the same generated `sw-align-*` classes
132
+ and `Align` type as `Stack` and `Inline` (see the 2026-09-28 follow-up in
133
+ `docs/requests/teisoro-grid.md`). `align="end"` keeps a row of fields level
134
+ when one label wraps:
135
+
136
+ ```tsx
137
+ <Grid align="end" columns={2} gap={3}>
138
+ {fields}
139
+ </Grid>
140
+ ```
141
+
142
+ Additive; unset, no class is added and children stretch as before.
143
+
144
+ - c3d04a8: `Progress` takes `showCount` (default `true`). `showCount={false}` hides the
145
+ visible `value / max` count beside the label when the page shows its own count
146
+ caption, so the count appears once (see the 2026-09-28 follow-up in
147
+ `docs/requests/teisoro-progress.md`). The progress bar still exposes its value
148
+ and maximum to assistive tech.
149
+
150
+ ```tsx
151
+ <Progress label="Registers closed" max={2} showCount={false} value={0} />
152
+ ```
153
+
154
+ Additive; the count shows as before when the prop is left out.
155
+
3
156
  ## 1.8.0
4
157
 
5
158
  ### Minor Changes
package/README.md CHANGED
@@ -7,10 +7,12 @@ import '@scalewing/react/styles.css';
7
7
  import '@scalewing/react/palette/cerulean.css';
8
8
  import {
9
9
  Accordion,
10
+ ActionBar,
10
11
  ActionMenu,
11
12
  BarChart,
12
13
  Box,
13
14
  Button,
15
+ CalendarButton,
14
16
  Card,
15
17
  Checkbox,
16
18
  RadioGroup,
@@ -44,10 +46,54 @@ import {
44
46
 
45
47
  Import the CSS once at the application entry. Do not copy it into your source tree. Optional: import one `@scalewing/react/palette/<id>.css` file after it, or set `data-palette` on the `data-theme` node. React apps can set `palette` on `ThemeProvider` instead.
46
48
 
49
+ `Box` `border` draws a hairline in the `border` color token: `border` (or `border={true}`) is solid, and `border="dashed"` is dashed (the generated `sw-border` and `sw-border-dashed` classes; a `style` you pass still wins), for a space to fill in by hand such as a blank on a printed form. It does not make the box look or act like an input. Every `Box`-based component takes it.
50
+
47
51
  `Box hideBelow="md"` hides a region under 48rem and `hideFrom="md"` hides it at 48rem and wider, so a product can swap a wide layout for a narrow one without its own stylesheet. Hidden regions leave the accessibility tree; do not use this to protect data. `Box as="a"` is a layout link. Use `Button` for press actions. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Select` is a labeled listbox menu when the open list must match the canvas.
48
52
 
49
53
  `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby`, appends a stable message ID, and replaces the hint with an alert when an error is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The consumer owns validation and localized messages.
50
54
 
55
+ `Field` `prefix` and `suffix` put short text such as `$` or `%` inside the frame of one native `<input>` child, before or after the value. The text is not part of the value; the input is named by its label plus the adornment (`"Drop amount $"`), unless it names itself with its own `aria-label` or `aria-labelledby`, in which case the adornment joins its description. A press anywhere on the frame focuses the input. Formatting the value stays with the consumer. Any other child throws a `TypeError`.
56
+
57
+ ```tsx
58
+ <Field label="Drop amount" prefix="$">
59
+ <input inputMode="decimal" name="drop" />
60
+ </Field>
61
+ ```
62
+
63
+ `Accordion` is a native `<details>` disclosure controlled by `open` and `onOpenChange`. A token chevron at the end of the header replaces the browser triangle and turns when it opens (it holds still under reduced motion). `subtitle` adds one muted caption line under the title, such as a summary of what the section holds; it wraps rather than truncating and is read after the title. `size="sm"` is a quieter disclosure nested inside other content: a label-size title, tighter padding, smaller corners and a header that is an `sm` control (at least 32px).
64
+
65
+ ```tsx
66
+ <Accordion
67
+ open={open}
68
+ onOpenChange={setOpen}
69
+ subtitle="Twelve sightings · Two nests"
70
+ title="Wetlands"
71
+ >
72
+ <Accordion
73
+ open={methodOpen}
74
+ onOpenChange={setMethodOpen}
75
+ size="sm"
76
+ title="How the count is taken"
77
+ >
78
+ {method}
79
+ </Accordion>
80
+ </Accordion>
81
+ ```
82
+
83
+ `ActionBar` keeps the actions of a long page on a glass bar stuck to the bottom of the viewport, with an optional one-line `status` (such as when the work was last saved). Put it last in the content it acts on: it stays stuck while that content scrolls by and then rests in its own place at the end. `stickyBelow="md"` sticks only on a phone and leaves the bar in page flow from `md` up. The bar clears `env(safe-area-inset-bottom)` (set `viewport-fit=cover` in the page's viewport meta for the inset to apply). Its children are the actions: on a phone they share one row under the status. The status is a polite live region (`role="status"`, kept in the page even while empty), so a new status such as "Draft saved at 5:00 PM" is announced; do not announce the same save a second time with your own notice. An open `Select`, `ActionMenu` or `Tooltip` paints over the bar, including inside a glass `Card` or `Accordion`.
84
+
85
+ ```tsx
86
+ <Stack gap={4}>
87
+ {longForm}
88
+ <ActionBar status="Draft saved at 5:00 PM" stickyBelow="md">
89
+ <Button variant="secondary" onPress={save}>
90
+ Save draft
91
+ </Button>
92
+ <Button onPress={finish}>Finish</Button>
93
+ </ActionBar>
94
+ </Stack>
95
+ ```
96
+
51
97
  `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape and choosing a command both return focus to the trigger (before the command runs, so a `Dialog` it opens hands focus back to the trigger on close), and outside interaction dismisses the menu.
52
98
 
53
99
  `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true.
@@ -60,7 +106,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
60
106
 
61
107
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
62
108
 
63
- `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`.
109
+ `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`. `showCount={false}` hides the visible `value / max` count when the page shows its own count caption, so the count appears once; the progress bar still exposes its value and maximum.
64
110
 
65
111
  `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger. Supply localized `content` and an existing trigger element with its own accessible name. It opens on hover or focus, closes on pointer leave, blur, Escape, or outside touch, and toggles on touch. Keep required instructions visible outside the tooltip.
66
112
 
@@ -87,11 +133,24 @@ Every `Box`-based component (`Box`, `Stack`, `Inline`, `Card`, `Grid`, …) take
87
133
 
88
134
  Values outside the catalog throw a `RangeError`. There is no per-breakpoint span and no arbitrary column template.
89
135
 
136
+ `Grid` `align` (`start`, `center`, `end`, `stretch`) sets where each child sits in the height of its row, with the same generated `sw-align-*` classes as `Stack` and `Inline`. Unset, children stretch. `align="end"` keeps a row of fields level when one label wraps:
137
+
138
+ ```tsx
139
+ <Grid align="end" columns={2} gap={3}>
140
+ <Field label="Nest height above the waterline">{heightInput}</Field>
141
+ <Field label="Eggs">{eggsInput}</Field>
142
+ </Grid>
143
+ ```
144
+
90
145
  `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that moves under the label on a phone. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start; the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
91
146
 
92
147
  `FilterChips` presents a long, wrapping single-choice filter set. Provide a localized group `label`, controlled `value`, `onChange`, and options with stable values and localized labels. An optional non-negative integer `count` renders as a tabular chicklet after the label and is part of the option's accessible name; a zero count quiets the chip until it is selected. Native radios provide arrow and Space navigation; disabled options are skipped.
93
148
 
94
- `DateField` is a controlled native date input. Supply `value`, `min`, and `max` as valid `YYYY-MM-DD` dates; use an empty `value` for a blank field. Its callback returns a date-only string or an empty string, without timezone conversion. Invalid serialized dates and inverted bounds throw; a value outside the bounds remains visible with invalid styling. For a blank required field, supply `error` when form validation runs to show the message and invalid styling. The browser chooses the calendar and localized display.
149
+ `DateField` is a controlled date-only field that draws its own calendar. Supply `value`, `min`, and `max` as valid `YYYY-MM-DD` dates; use an empty `value` for a blank field. Its callback returns a date-only string or an empty string, without timezone conversion. Invalid serialized dates and inverted bounds throw; a value outside the bounds remains visible with invalid styling. For a blank required field, supply `error` when form validation runs to show the message and invalid styling.
150
+
151
+ People type the date into a text entry in the locale's numeric order (`MM/DD/YYYY` for en-US, `DD/MM/YYYY` for es), with any non-digit separator, eight bare digits, or ISO `YYYY-MM-DD`. A keystroke calls `onChange` once the entry is complete, meaning its last field is at full width (four year digits, or two digits for a trailing day or month, as in ISO); otherwise the date commits when the person leaves the field or presses Enter, so typing `2024-03-10` never sends `2024-03-01` on the way. If the parent keeps the old value, the typed text stays on screen; any other outside change replaces it. Text that is not a date keeps the last value and, once the person leaves the field, sets `aria-invalid` and shows `labels.invalidEntry`. Like the native date input, the entry blocks form submission through `setCustomValidity`: `labels.invalidEntry` while its text is not a date, and `labels.outOfRange` while its date, typed or given, is outside `min`/`max`. The calendar button beside it opens a WAI-ARIA date picker dialog anchored under the field on the popover layer: the month grid shows today, the selected day, and days outside `min`/`max` as `aria-disabled` (padding before `0001-01-01` or after `9999-12-31` is blank and inert); arrows move by day and week, Home and End to the week's edges, PageUp and PageDown by month, Shift with them by year, Enter or Space selects and closes, and Escape closes and returns focus to the button. Month and year selectors in the header jump decades (the year list spans 120 years back and 20 ahead of today and the shown month, cut to `min` and `max`; the month list offers only months with a day inside them). A new `value`, `min`, or `max` while the calendar is open moves it to the new value or back inside the bounds. **Today** selects today; **Clear** appears only when the field is not `required`.
152
+
153
+ `locale` (BCP 47) sets month and weekday names, spoken dates, and the typed order; it defaults to the nearest `lang` attribute when the field mounts, then `en-US`, and a malformed tag throws. `weekStartsOn` is `0` (Sunday, the default) or `1` (Monday). `labels` overrides the control's own words, English by default: `chooseDate`, `previousMonth`, `nextMonth`, `month`, `year`, `today`, `clear`, `invalidEntry`, `outOfRange`, and the placeholder letters `dayPlaceholder`, `monthPlaceholder`, `yearPlaceholder`. The field label names only the text entry; the calendar button is named `labels.chooseDate` and described by the field label, so tests find the entry with `getByLabel` or `getByRole('textbox', { name })`.
95
154
 
96
155
  ```tsx
97
156
  <DateField
@@ -101,6 +160,38 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
101
160
  min="2024-01-01"
102
161
  max="2024-12-31"
103
162
  />
163
+
164
+ <DateField
165
+ label="Fecha del avistamiento"
166
+ locale="es"
167
+ weekStartsOn={1}
168
+ labels={{ chooseDate: 'Elegir fecha', today: 'Hoy', clear: 'Borrar' }}
169
+ value={fecha}
170
+ onChange={setFecha}
171
+ />
172
+ ```
173
+
174
+ `CalendarButton` is an icon-only `Button` that opens the same calendar dialog for a date the page already shows, such as a day heading with its own previous and next steps: `‹ Tuesday, September 22, 2026 [calendar] ›`. It has no text entry and no empty value. Supply `label` (what pressing it does, such as "Choose survey day"), a `YYYY-MM-DD` `value`, and `onChange`; optional `min`, `max`, `disabled`, `locale`, and `weekStartsOn` behave as on `DateField`, and `id` and a `ref` reach the `<button>`. `labels` takes the calendar's words (`previousMonth`, `nextMonth`, `month`, `year`, `today`) and `nameSeparator`. The button's accessible name is `label`, `labels.nameSeparator` (default `", "`), then the spoken date (`"Choose survey day, Tuesday, September 22, 2026"`); pass the locale's own pause where a comma does not fit, such as `"、"` in Japanese. It comes with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open; the calendar dialog is named by `label`. Enter, Space, or a press opens it on `value`. As on `DateField`, a `value` outside `min`/`max` is kept rather than refused, and the calendar then opens on the nearest allowed day with `value` shown selected and disabled; a button has no invalid state, so the page that shows the date flags it if it must. Choosing a day or **Today** calls `onChange` with the new date only when it changed, closes, and returns focus to the button; Escape closes without a change and returns focus; a press outside closes without a change and leaves focus where it landed. An empty or malformed `value`, `min`, or `max`, inverted bounds, a bad `locale` or `weekStartsOn`, an empty `label`, or an empty label word throws a `RangeError`. `size` (`xs`, `sm`, `md`; default `md`) and `variant` (default `ghost`) follow `Button`; the button is square at every size, and on a coarse pointer it is at least 44 px at every size. Place it beside the heading, not inside it, so the heading's text stays only the date.
175
+
176
+ ```tsx
177
+ <Inline gap={1}>
178
+ <Button aria-label="Previous day" variant="ghost" onPress={previousDay}>
179
+ ‹
180
+ </Button>
181
+ <Text as="h2" variant="title">
182
+ {spokenSurveyDay}
183
+ </Text>
184
+ <CalendarButton
185
+ label="Choose survey day"
186
+ value={surveyDay}
187
+ onChange={setSurveyDay}
188
+ min="2026-09-01"
189
+ max="2026-09-30"
190
+ />
191
+ <Button aria-label="Next day" variant="ghost" onPress={nextDay}>
192
+ ›
193
+ </Button>
194
+ </Inline>
104
195
  ```
105
196
 
106
197
  ```tsx
@@ -0,0 +1,28 @@
1
+ export type AnchorRect = {
2
+ top: number;
3
+ bottom: number;
4
+ left: number;
5
+ right: number;
6
+ };
7
+ export type PopoverSize = {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ export type Viewport = {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ export type AnchoredPosition = {
16
+ left: number;
17
+ top: number;
18
+ };
19
+ /**
20
+ * Where a fixed popover goes beside its anchor: below and aligned to the
21
+ * anchor's inline start (its end in right-to-left), above when it does not
22
+ * fit below but does above, and always kept inside the viewport by `inset`.
23
+ */
24
+ export declare function anchoredPosition(anchor: AnchorRect, size: PopoverSize, viewport: Viewport, { gap, inset, rtl }?: {
25
+ gap?: number | undefined;
26
+ inset?: number | undefined;
27
+ rtl?: boolean | undefined;
28
+ }): AnchoredPosition;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Where a fixed popover goes beside its anchor: below and aligned to the
3
+ * anchor's inline start (its end in right-to-left), above when it does not
4
+ * fit below but does above, and always kept inside the viewport by `inset`.
5
+ */
6
+ export function anchoredPosition(anchor, size, viewport, { gap = 0, inset = 0, rtl = false } = {}) {
7
+ const preferredLeft = rtl ? anchor.right - size.width : anchor.left;
8
+ const maxLeft = viewport.width - inset - size.width;
9
+ const left = Math.max(inset, Math.min(preferredLeft, maxLeft));
10
+ const below = anchor.bottom + gap;
11
+ const above = anchor.top - gap - size.height;
12
+ const fitsBelow = below + size.height <= viewport.height - inset;
13
+ const fitsAbove = above >= inset;
14
+ const top = fitsBelow || !fitsAbove ? below : above;
15
+ const maxTop = viewport.height - inset - size.height;
16
+ return { left, top: Math.max(inset, Math.min(top, maxTop)) };
17
+ }
@@ -0,0 +1,19 @@
1
+ import { type DateFieldLabels } from './date-field-labels.js';
2
+ /**
3
+ * CalendarButton's own words: the calendar's words from DateFieldLabels,
4
+ * plus the text between its label and the spoken date in its name.
5
+ */
6
+ export type CalendarButtonLabels = Pick<DateFieldLabels, 'previousMonth' | 'nextMonth' | 'month' | 'year' | 'today'> & {
7
+ /**
8
+ * Joins the label and the spoken date in the button's name. Defaults to
9
+ * ", "; pass the locale's own pause, such as "、" in Japanese.
10
+ */
11
+ nameSeparator: string;
12
+ };
13
+ export declare const defaultNameSeparator = ", ";
14
+ export type ResolvedCalendarButtonLabels = {
15
+ calendar: DateFieldLabels;
16
+ nameSeparator: string;
17
+ };
18
+ /** Consumer words over the English defaults; empty words fail closed. */
19
+ export declare function resolveCalendarButtonLabels(labels?: Partial<CalendarButtonLabels>): ResolvedCalendarButtonLabels;
@@ -0,0 +1,10 @@
1
+ import { resolveDateFieldLabels, } from './date-field-labels.js';
2
+ export const defaultNameSeparator = ', ';
3
+ /** Consumer words over the English defaults; empty words fail closed. */
4
+ export function resolveCalendarButtonLabels(labels = {}) {
5
+ const { nameSeparator = defaultNameSeparator, ...calendar } = labels;
6
+ // Only an empty string is refused: a lone space is a valid separator.
7
+ if (typeof nameSeparator !== 'string' || nameSeparator === '')
8
+ throw new RangeError('labels.nameSeparator must be non-empty text');
9
+ return { calendar: resolveDateFieldLabels(calendar), nameSeparator };
10
+ }
@@ -0,0 +1,19 @@
1
+ import { type WeekStart } from './calendar-month.js';
2
+ export type CalendarKey = {
3
+ key: string;
4
+ shiftKey: boolean;
5
+ };
6
+ export type CalendarKeyContext = {
7
+ weekStartsOn: WeekStart;
8
+ min?: string;
9
+ max?: string;
10
+ /** Right-to-left grids swap the meaning of the horizontal arrows. */
11
+ rtl?: boolean;
12
+ };
13
+ /**
14
+ * The day a calendar grid key moves focus to, following the WAI-ARIA date
15
+ * picker dialog pattern: arrows by day and week, Home and End to the week's
16
+ * edges, PageUp and PageDown by month, and with Shift by year. The result
17
+ * stays inside `[min, max]`. Keys the grid does not own return null.
18
+ */
19
+ export declare function calendarKeyTarget(key: CalendarKey, focused: string, context: CalendarKeyContext): string | null;
@@ -0,0 +1,39 @@
1
+ import { addDays, addMonths, weekdayOf, } from './calendar-month.js';
2
+ import { clampDateOnly } from './date-only.js';
3
+ function unclampedTarget({ key, shiftKey }, focused, { weekStartsOn, rtl = false }) {
4
+ const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
5
+ const back = rtl ? 'ArrowRight' : 'ArrowLeft';
6
+ const column = (weekdayOf(focused) - weekStartsOn + 7) % 7;
7
+ switch (key) {
8
+ case forward:
9
+ return addDays(focused, 1);
10
+ case back:
11
+ return addDays(focused, -1);
12
+ case 'ArrowDown':
13
+ return addDays(focused, 7);
14
+ case 'ArrowUp':
15
+ return addDays(focused, -7);
16
+ case 'Home':
17
+ return addDays(focused, -column);
18
+ case 'End':
19
+ return addDays(focused, 6 - column);
20
+ case 'PageUp':
21
+ return addMonths(focused, shiftKey ? -12 : -1);
22
+ case 'PageDown':
23
+ return addMonths(focused, shiftKey ? 12 : 1);
24
+ default:
25
+ return null;
26
+ }
27
+ }
28
+ /**
29
+ * The day a calendar grid key moves focus to, following the WAI-ARIA date
30
+ * picker dialog pattern: arrows by day and week, Home and End to the week's
31
+ * edges, PageUp and PageDown by month, and with Shift by year. The result
32
+ * stays inside `[min, max]`. Keys the grid does not own return null.
33
+ */
34
+ export function calendarKeyTarget(key, focused, context) {
35
+ const target = unclampedTarget(key, focused, context);
36
+ return target === null
37
+ ? null
38
+ : clampDateOnly(target, context.min, context.max);
39
+ }
@@ -0,0 +1,19 @@
1
+ import { type CalendarMonth, type WeekStart } from './calendar-month.js';
2
+ export type WeekdayLabel = {
3
+ short: string;
4
+ long: string;
5
+ };
6
+ /** Weekday column headers in display order, named by `Intl` for `locale`. */
7
+ export declare function weekdayLabels(locale: string, weekStartsOn: WeekStart): WeekdayLabel[];
8
+ /** The twelve month names, January first, as `locale` writes them alone. */
9
+ export declare function monthNames(locale: string): string[];
10
+ export declare function monthTitle(month: CalendarMonth, locale: string): string;
11
+ /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
12
+ export declare function formatDateLabel(value: string, locale: string): string;
13
+ /**
14
+ * An icon-only calendar trigger's name: its purpose, a separator, then the
15
+ * date it holds spoken in full, such as "Choose survey day, Tuesday,
16
+ * September 22, 2026". `Intl.ListFormat` has no neutral join (Spanish unit
17
+ * lists add "y", Chinese adds nothing), so the separator is the caller's.
18
+ */
19
+ export declare function calendarTriggerName(purpose: string, value: string, locale: string, separator: string): string;
@@ -0,0 +1,41 @@
1
+ import { weekdayOrder, } from './calendar-month.js';
2
+ import { dateFormatter, formatterDate } from './date-formatters.js';
3
+ import { parseDateOnly } from './date-only.js';
4
+ /** A Sunday; any week works because only the weekday names are read. */
5
+ const REFERENCE_SUNDAY = { year: 2023, month: 1, day: 1 };
6
+ /** Weekday column headers in display order, named by `Intl` for `locale`. */
7
+ export function weekdayLabels(locale, weekStartsOn) {
8
+ const short = dateFormatter(locale, { weekday: 'short' });
9
+ const long = dateFormatter(locale, { weekday: 'long' });
10
+ return weekdayOrder(weekStartsOn).map((weekday) => {
11
+ const date = formatterDate({
12
+ ...REFERENCE_SUNDAY,
13
+ day: REFERENCE_SUNDAY.day + weekday,
14
+ });
15
+ return { short: short.format(date), long: long.format(date) };
16
+ });
17
+ }
18
+ /** The twelve month names, January first, as `locale` writes them alone. */
19
+ export function monthNames(locale) {
20
+ const format = dateFormatter(locale, { month: 'long' });
21
+ return Array.from({ length: 12 }, (_, index) => format.format(formatterDate({ year: 2023, month: index + 1, day: 1 })));
22
+ }
23
+ export function monthTitle(month, locale) {
24
+ return dateFormatter(locale, { month: 'long', year: 'numeric' }).format(formatterDate({ ...month, day: 1 }));
25
+ }
26
+ /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
27
+ export function formatDateLabel(value, locale) {
28
+ const parts = parseDateOnly(value);
29
+ if (!parts)
30
+ return '';
31
+ return dateFormatter(locale, { dateStyle: 'full' }).format(formatterDate(parts));
32
+ }
33
+ /**
34
+ * An icon-only calendar trigger's name: its purpose, a separator, then the
35
+ * date it holds spoken in full, such as "Choose survey day, Tuesday,
36
+ * September 22, 2026". `Intl.ListFormat` has no neutral join (Spanish unit
37
+ * lists add "y", Chinese adds nothing), so the separator is the caller's.
38
+ */
39
+ export function calendarTriggerName(purpose, value, locale, separator) {
40
+ return `${purpose}${separator}${formatDateLabel(value, locale)}`;
41
+ }
@@ -0,0 +1,38 @@
1
+ export type CalendarMonth = {
2
+ year: number;
3
+ /** 1–12. */
4
+ month: number;
5
+ };
6
+ /** 0 is Sunday, 1 is Monday, matching `Date.prototype.getDay`. */
7
+ export type WeekStart = 0 | 1;
8
+ /** Fails closed on a first weekday other than Sunday or Monday. */
9
+ export declare function assertWeekStart(weekStartsOn: number): void;
10
+ export type CalendarCell = {
11
+ value: string;
12
+ day: number;
13
+ inMonth: boolean;
14
+ };
15
+ /** 0 is Sunday. */
16
+ export declare function weekdayOf(value: string): number;
17
+ export declare function addDays(value: string, delta: number): string;
18
+ /** Same day of the month `delta` months away, capped at that month's length. */
19
+ export declare function addMonths(value: string, delta: number): string;
20
+ export declare function monthOf(value: string): CalendarMonth;
21
+ export declare function shiftMonth(month: CalendarMonth, delta: number): CalendarMonth;
22
+ export declare function firstOfMonth(month: CalendarMonth): string;
23
+ export declare function lastOfMonth(month: CalendarMonth): string;
24
+ /** Whether any day of the month falls inside `[min, max]`. */
25
+ export declare function monthInRange(month: CalendarMonth, min?: string, max?: string): boolean;
26
+ /**
27
+ * The months of `year` with any day inside `[min, max]`, 1–12, for a month
28
+ * selector that only offers months a person can open.
29
+ */
30
+ export declare function monthsInRange(year: number, min?: string, max?: string): number[];
31
+ /**
32
+ * Six rows of seven cells covering the month, padded with the neighboring
33
+ * months so the grid never changes height. Padding before 0001-01-01 or
34
+ * after 9999-12-31 is `null`: no four-digit date exists there.
35
+ */
36
+ export declare function monthGrid(month: CalendarMonth, weekStartsOn: WeekStart): (CalendarCell | null)[][];
37
+ /** Weekday indexes in display order for the given week start. */
38
+ export declare function weekdayOrder(weekStartsOn: WeekStart): number[];