@scalewing/react 1.11.0 → 1.13.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 (66) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +62 -7
  3. package/dist/anchored-position.d.ts +14 -6
  4. package/dist/anchored-position.js +10 -9
  5. package/dist/components/ActionMenu.d.ts +9 -1
  6. package/dist/components/ActionMenu.js +2 -2
  7. package/dist/components/Checkbox.js +2 -1
  8. package/dist/components/DateField.js +4 -2
  9. package/dist/components/DenominationGrid.d.ts +1 -1
  10. package/dist/components/DenominationGrid.js +4 -2
  11. package/dist/components/Field.js +9 -6
  12. package/dist/components/FieldErrorRegion.d.ts +14 -0
  13. package/dist/components/FieldErrorRegion.js +13 -0
  14. package/dist/components/FieldLabelText.d.ts +13 -0
  15. package/dist/components/FieldLabelText.js +12 -0
  16. package/dist/components/RadioGroup.d.ts +7 -0
  17. package/dist/components/RadioGroup.js +2 -1
  18. package/dist/components/SegmentedControl.d.ts +8 -0
  19. package/dist/components/SegmentedControl.js +16 -11
  20. package/dist/components/Select.d.ts +10 -1
  21. package/dist/components/Select.js +17 -86
  22. package/dist/components/Toast.d.ts +15 -1
  23. package/dist/components/Toast.js +15 -5
  24. package/dist/components/action-menu/ActionMenuList.d.ts +6 -4
  25. package/dist/components/action-menu/ActionMenuList.js +5 -4
  26. package/dist/components/denomination-grid/DenominationStrip.d.ts +2 -2
  27. package/dist/components/denomination-grid/DenominationStrip.js +13 -3
  28. package/dist/components/denomination-grid/DenominationTiles.js +27 -8
  29. package/dist/components/denomination-grid/types.d.ts +5 -0
  30. package/dist/components/select/SelectListbox.d.ts +20 -0
  31. package/dist/components/select/SelectListbox.js +16 -0
  32. package/dist/components/select/SelectValue.d.ts +15 -0
  33. package/dist/components/select/SelectValue.js +16 -0
  34. package/dist/components/select/use-select-list.d.ts +26 -0
  35. package/dist/components/select/use-select-list.js +85 -0
  36. package/dist/components/use-anchored-popover.d.ts +5 -2
  37. package/dist/components/use-anchored-popover.js +6 -5
  38. package/dist/css/chevron.d.ts +5 -0
  39. package/dist/css/chevron.js +9 -0
  40. package/dist/css/css-accordion.js +2 -5
  41. package/dist/css/css-button.d.ts +2 -0
  42. package/dist/css/css-button.js +28 -4
  43. package/dist/css/css-checkbox.js +1 -0
  44. package/dist/css/css-components.js +6 -0
  45. package/dist/css/css-data.js +17 -163
  46. package/dist/css/css-date-field.js +14 -6
  47. package/dist/css/css-denomination-grid.js +11 -1
  48. package/dist/css/css-document.d.ts +2 -0
  49. package/dist/css/css-document.js +35 -0
  50. package/dist/css/css-field.js +15 -2
  51. package/dist/css/css-filter-chips.js +8 -2
  52. package/dist/css/css-radio-group.js +12 -2
  53. package/dist/css/css-segmented.d.ts +2 -0
  54. package/dist/css/css-segmented.js +127 -0
  55. package/dist/css/css-select.js +57 -19
  56. package/dist/css/css-table.d.ts +2 -0
  57. package/dist/css/css-table.js +99 -0
  58. package/dist/css/css-toast.d.ts +2 -0
  59. package/dist/css/css-toast.js +42 -1
  60. package/dist/css/css-utilities.d.ts +2 -0
  61. package/dist/css/css-utilities.js +3 -1
  62. package/dist/css/stylesheet.js +4 -0
  63. package/dist/index.d.ts +3 -3
  64. package/dist/index.js +1 -1
  65. package/dist/styles.css +216 -48
  66. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,105 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ca9350c: `RadioGroup` options take an `icon` (`docs/requests/teisoro-radio-group.md`, 2026-09-28 follow-up, Teisoro CHK-13). `RadioGroupOption.icon?: ReactNode` renders a decorative glyph between the radio and its label, `aria-hidden`, in the text color (new generated class `sw-radio-group-icon`). The option's accessible name stays its `label` text, and a press on the glyph chooses the option. Without `icon` nothing changes. No new dependencies.
8
+ - 4b0a029: `SegmentedControl` takes `error` and `required` (`docs/requests/teisoro-segmented-control.md`, 2026-09-28 follow-up, Teisoro DRW-20):
9
+
10
+ - `error?: string` renders a `sw-field-error` message under the track in the same polite live region as `Field`'s (always rendered, never an alert), links it to the `radiogroup` by `aria-describedby`, and sets `aria-invalid` and a danger outline on the group (`Mark` in forced colors). An empty string is no error.
11
+ - `required?: boolean` sets `aria-required` on the `radiogroup`; the element that labels the control shows the visible mark.
12
+ - The track now renders inside a `.sw-segmented-field` wrapper (new generated classes `sw-segmented-field` and `sw-segmented-field-filled`) that takes its place in the layout, so the track's width in a Stack, an Inline or block flow is unchanged and a `ref` still reaches the `radiogroup`. A test that measured the control against its `parentElement` should use the wrapper's parent. `error` is meant for a Stack or block layout: while a message shows, the field is at least 24ch wide (capped at its container), so in an `Inline` the row grows.
13
+
14
+ Without the new props nothing else changes. No new dependencies.
15
+
16
+ ### Patch Changes
17
+
18
+ - 6a5b226: A pressed toggle `Button` keeps its label readable in forced colors (`docs/requests/teisoro-button.md`, 2026-09-28 follow-up; a regression from 1.12.0). The `Highlight` fill with `HighlightText` is gone: Chromium paints the forced backplate behind a button's text in `Canvas`, which erased the label (1.00:1 painted). The pressed button now keeps the forced button colors, so its label, glyphs and badges stay readable, and it is marked by the same 2 px ring as outside forced colors, drawn as a `Highlight` border on an out-of-flow `::after`, with a `Highlight` border on the button itself. No `forced-color-adjust`, no API change and no new dependencies.
19
+ - d4ef0dc: `DateField`'s label row matches `Field`'s (`docs/requests/teisoro-date-field.md`, 2026-09-28 label row follow-up, Teisoro NSF-35). Its label now uses `Field`'s own label markup (the label words as a `Text` label span and the `aria-hidden` required mark, inside a `<label>` that keeps the canvas type), so a `DateField` beside a `Field` lines up at the label and the control; before, its entry sat 5 px higher. `.sw-date-field-label` no longer sets its own font size, weight or line height. No API change and no new dependencies.
20
+ - 58ea2f8: A lone `DenominationGrid` tiles row without an icon or `total`, whose `label` is the grid's own `label`, no longer names itself (`docs/requests/teisoro-denomination-grid.md`, 2026-09-28 lone-row name follow-up, Teisoro SDAY-31). It was a region with the grid's own words inside the grid's group; now the group, named by `label`, is its only name. Every other row, including a lone plain row with different words, is still a region named by its `label`. No API change and no new dependencies.
21
+ - 6f5a37c: A disabled text control looks locked (`docs/requests/teisoro-field-disabled.md`, Teisoro DRW-17). A disabled native `input`, `select` or `textarea` on the canvas, `Field`'s adorned frame and `DateField`'s entry take the subtle fill, a dashed border and `cursor: not-allowed`; the value stays in the text color at full opacity (4.5:1 or more in every palette), and forced colors keep the dashed border in `GrayText`. `DateField`'s entry no longer fades to the disabled opacity; its calendar button still does. Every typed input's placeholder is now drawn in `--sw-color-muted` (4.5:1 or more on every palette's field fill) instead of the browser's fixed gray, which fell to 2.3:1 on dark fields. No API change and no new dependencies.
22
+ - a747fc5: `Select`'s label row matches `Field`'s (`docs/requests/teisoro-select.md`, 2026-09-28 label row follow-up). Its label now uses `Field`'s own label markup (the label words as a `Text` span and the `aria-hidden` required mark, inside a plain `<label>` that keeps the canvas type), so a `Select` beside a `Field` lines up at the label and the control; before, its trigger sat 5 px higher. `labelVisuallyHidden` hides the span inside the label, as `Field` does. No API change and no new dependencies.
23
+
24
+ Consumer and migration notes:
25
+
26
+ - The `<label>` element no longer carries `sw-text-label` (or `sw-sr-only` with `labelVisuallyHidden`); a `<span>` inside it does. A selector or test that targeted `label.sw-text-label` in a `Select` should target the label element (`label[for]`, or `getByText(label)` for the span) instead.
27
+ - A visible `size="xs"` label row goes from 18 px (the caption line height on the label itself) to 25 px (the canvas body line box, as a visible `xs` `Field` label has), so an `xs` Select with a visible label sits 7 px lower. Toolbar `xs` Selects with `labelVisuallyHidden` are unchanged: the hidden label takes no row.
28
+ - A visible `md` label row goes from 20 px to 25 px, so the trigger sits 5 px lower, level with a `Field` beside it.
29
+
30
+ - 2f7e4b0: `TableRow selected` marks the row without moving a column (`docs/requests/teisoro-table.md`, 2026-09-28 follow-up, Teisoro NSF-12). The first cell no longer gains `padding-inline-start` and the 8 px dot is gone. A 4 px accent bar at the row's inline start (3:1 or more against the page and the surface) is drawn inside the first cell's padding, out of flow; it follows the writing direction. The row takes no fill, so text, muted and accent text keep their contrast. Forced colors keep the bar in `Highlight`. No API change and no new dependencies.
31
+
32
+ ## 1.12.0
33
+
34
+ ### Minor Changes
35
+
36
+ - f389c10: `ActionMenu` takes `align?: 'start' | 'end'` (default `'start'`, unchanged; type `ActionMenuAlign`) (`docs/requests/teisoro-action-menu.md`, 2026-09-28 align follow-up, Teisoro ENT-13). With `'end'` the open menu lines up with the trigger's inline end even where the start would fit, so a menu opened from the end of a card stays over that card instead of hanging past it toward the next one. It still takes the trigger's other edge when the preferred one would leave the screen, mirrored in right-to-left.
37
+
38
+ No breaking change and no new dependencies.
39
+
40
+ - 37ef947: `DenominationGrid`'s strip reads its totals to a screen reader at every width (`docs/requests/teisoro-denomination-grid.md`, 2026-09-28 follow-up, Teisoro SDAY-6). Below `md` the total cell was `display: none`, so on a phone a screen reader never heard a row's total; it now stays in the table, visually hidden, while the `aria-hidden` copy under the row label is what shows.
41
+
42
+ - New optional prop `totalLabel?: string` names the total column with a visually hidden `th scope="col"` (class `sw-denomination-total-head`). Without it the header corner is the empty cell it was, and it carries the same class, so below `md` the hidden total column takes no width either way. A blank `totalLabel` throws a `RangeError`.
43
+ - New generated classes `sw-denomination-total-head` and `sw-denomination-total-value` (the total's text inside its cell).
44
+
45
+ No breaking change and no new dependencies.
46
+
47
+ - 9a11c40: `Select` takes `placeholder`, `required` and `error` (`docs/requests/teisoro-select.md`, 2026-09-28 placeholder follow-up, Teisoro DRW-12), consistent with `Field`:
48
+
49
+ - `placeholder?: string` shows in the closed trigger, muted, while `value` matches no option (such as `''`). It is not an option, never becomes the value, and counts toward the trigger's width. A blank placeholder throws a `RangeError`.
50
+ - `required?: boolean` marks the label with `Field`'s `aria-hidden` asterisk and sets `aria-required` on the trigger.
51
+ - `error?: string` renders a `sw-field-error` message under the control, linked by `aria-describedby`, with `aria-invalid` and a danger border. Like `Field`'s, it is announced from a polite live region that is always rendered, never as an alert; an empty string is no error.
52
+ - New generated classes `sw-select-placeholder` and `sw-select-invalid`.
53
+
54
+ Without the new props nothing changes. No new dependencies.
55
+
56
+ - cab64c8: `Toast` takes `tone` and `icon` (`docs/requests/teisoro-toast.md`, Teisoro DRW-14):
57
+
58
+ - `tone?: 'neutral' | 'success' | 'warning' | 'danger'` (default `neutral`, unchanged) tints the toast's border and icon; the message keeps the text color. A `danger` toast is `role="alert"`, the others `role="status"`. `ToastTone` and `toastTones` are exported.
59
+ - `icon?: ReactNode` puts a consumer glyph, hidden from assistive technology, before the message. Without it the children render unwrapped, as before.
60
+ - New generated classes `sw-toast-success`, `sw-toast-warning`, `sw-toast-danger`, `sw-toast-row`, `sw-toast-icon` and `sw-toast-body`.
61
+
62
+ Without `timeoutMs`, a `warning` or `danger` toast now stays 6000 ms instead of 800 ms, so it can be read; `neutral` and `success` keep 800 ms, and a given `timeoutMs` wins. A toned toast still dismisses itself. No breaking change and no new dependencies.
63
+
64
+ ### Patch Changes
65
+
66
+ - a0550bd: A `Badge` inside a filled `Button` (`primary`, `secondary`, `tertiary` or `danger`) sits on `--sw-color-surface` with its words in `--sw-color-text` and its tone on its border (`docs/requests/teisoro-badge.md`, Teisoro NSF-1, WCAG 1.4.3). Its tone was drawn on the button's fill: a warning badge on the accent measured about 1.1:1. The text color is 4.5:1 or more on the surface in every palette and scheme, which the tone colors are not (for example `mocha` dark danger at 3.29:1); every tone border is at least 3:1.
67
+
68
+ No API change and no new dependencies.
69
+
70
+ - 91249c3: A toggle `Button` (`aria-pressed`) no longer fades when it is not pressed (`docs/requests/teisoro-button.md`, Teisoro NSF-1, WCAG 1.4.3). `.sw-button[aria-pressed='false']` set `opacity: var(--sw-quiet-opacity)` (0.55), which took an unpressed button's label to about 3.8:1 and any badge inside it lower, although the button could be pressed.
71
+
72
+ - The unpressed button is drawn at full strength.
73
+ - The pressed button gets a 2 px accent ring outside its fill, past a 2 px gap in `--sw-color-background` (`box-shadow`), so it keeps the accent's contrast on the canvas (4.5:1 or more in every palette) whatever its variant or fill, and a toggle whose states share one variant still shows which is pressed. A focused pressed button moves its focus outline out past the ring.
74
+ - In forced colors, which drop box shadows, the pressed button is filled with `Highlight` and `HighlightText`, as a checked `FilterChips` chip is.
75
+
76
+ A visible change for every toggle button. No API change and no new dependencies.
77
+
78
+ - d872a1c: A `required` `DateField` marks its label with the same `aria-hidden` asterisk as `Field` (`sw-field-required`) (`docs/requests/teisoro-date-field.md`, 2026-09-28 follow-up, Teisoro NSF-15). Before, a required date looked optional beside required fields. The entry's accessible name is unchanged; the label's text content now ends in " *", so a test that finds the entry with an exact `getByLabelText` should use its role and name instead.
79
+
80
+ No API change and no new dependencies.
81
+
82
+ - 0d2c9ad: `DenominationGrid`'s tiles layout shows a lone row's `total` (`docs/requests/teisoro-denomination-grid.md`, 2026-09-28 follow-up, Teisoro ENT-7). A grid with one row and no icon dropped the `total` it was given; the row's label line, with the label and the total, now shows whenever the row has a total. A lone row with neither an icon nor a total is unchanged.
83
+
84
+ No API change and no new dependencies.
85
+
86
+ - d8dc697: Field errors are announced politely from a live region instead of as alerts (`docs/requests/teisoro-field-validation.md`, 2026-09-28 follow-up, Teisoro CHK-3 and DRW-6). `Field`'s error was `role="alert"`, so a refused submit with several invalid fields fired one alert per field at once, while a `Checkbox` error beside them was not announced at all.
87
+
88
+ - `Field`, `Checkbox`, `RadioGroup` and `DateField` keep their error in an `aria-live="polite"` region that is always rendered, empty while there is no error, and swap the error's text into it: a new error is announced once, politely, whether it appears while typing or after a submit. The control lists the region in `aria-describedby` with `aria-invalid` while it has text. An empty region takes no room (`:empty { position: absolute; }`).
89
+ - `Field` still shows the error in place of the hint, but the two now have their own ids. `RadioGroup`'s invalid marks key off the fieldset's `aria-invalid`.
90
+
91
+ Consumer note: a refused submit is now announced politely rather than as several alerts; the form should also move focus to the first invalid control (or to one form-level summary). Tests that found the error by the hint's id should find it by its text. No API change and no new dependencies.
92
+
93
+ - a522627: A zero-count `FilterChips` chip is quiet without fading (`docs/requests/teisoro-filter-chips.md`, 2026-09-28 follow-up, Teisoro NSF-1, WCAG 1.4.3). Its face had `opacity: var(--sw-quiet-opacity)` (0.55), about 3.8:1 for its label; it now drops the glass fill and sets its label in `--sw-color-muted` (4.5:1 or more in every palette). In forced colors it is drawn like the other chips instead of in `GrayText`.
94
+
95
+ No API change and no new dependencies.
96
+
97
+ - 7effb60: `Select`'s closed trigger centres its text, ends in the `Accordion` chevron, and keeps its width when the value changes (`docs/requests/teisoro-select.md`, 2026-09-28 trigger follow-up, Teisoro DRW-12). It was sized to the current label, so it jumped when a longer value was chosen; it is now as wide as its longest option, as a native select is, and ellipsizes past the available width. The gradient-triangle caret is replaced by the stroked chevron (shared with `Accordion` through an internal `css/chevron.ts`).
98
+
99
+ - New generated classes `sw-select-value`, `sw-select-value-text` and `sw-select-value-sizer`. The trigger's text content and accessible value are still only the current label.
100
+
101
+ A visible change to every `Select`. No API change and no new dependencies.
102
+
3
103
  ## 1.11.0
4
104
 
5
105
  ### Minor Changes
package/README.md CHANGED
@@ -50,7 +50,9 @@ Import the CSS once at the application entry. Do not copy it into your source tr
50
50
 
51
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.
52
52
 
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.
53
+ `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby` and appends a stable ID for the hint, or for the error, which replaces the hint on screen while it is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The error sits in a polite live region (`aria-live="polite"`) that is always rendered, empty while there is no error, so an error that appears while the person types, or after a submit, is announced once, politely, when its text is swapped in; it is never `role="alert"`, so a submit that finds several errors does not fire several assertive alerts at once. Empty, the region takes no room in the layout. The consumer owns validation and localized messages, and after a refused submit it moves focus to the first invalid control (or to one form-level summary). `Checkbox`, `RadioGroup` and `DateField` expose their `error` the same way.
54
+
55
+ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the canvas, a `Field` frame with a `prefix` or `suffix`, or a `DateField` entry) takes the quiet subtle fill, a dashed border and a not-allowed cursor. Its value stays in the text color at full strength, so a locked value is still readable; forced colors keep the dashed border in the system's disabled color. A typed input's placeholder, enabled or disabled, is drawn in the muted color, which keeps 4.5:1 on the field in every palette.
54
56
 
55
57
  `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
58
 
@@ -60,6 +62,22 @@ Import the CSS once at the application entry. Do not copy it into your source tr
60
62
  </Field>
61
63
  ```
62
64
 
65
+ `Select` is a labeled listbox menu: supply `label`, `options` (`value` and `label`), a controlled `value` and `onChange`; `size="xs"` and `labelVisuallyHidden` are for toolbar chrome, and `action` is a last listbox command that never becomes the value. The closed trigger matches `Field`: its text is centred in the control, it ends in the `Accordion` chevron, and it is as wide as its longest option (as a native select is), so it keeps its width when the value changes; past the available width the label ellipsizes. The option labels that size it are drawn by CSS from `data-label`, so the trigger's text content and accessible value are only the current label. Its label is drawn as `Field`'s (the same label words and mark inside a `<label>` that keeps the canvas type), so a `Select` beside a `Field` lines up at the label and the control.
66
+
67
+ `Select` `placeholder` shows in the closed trigger, in the muted color, while `value` matches no option (such as `''`); it is not an option, is never selected in the list and never becomes the value, and it counts toward the trigger's width. `required` marks the label with `Field`'s `aria-hidden` asterisk and sets `aria-required` on the trigger. `error` renders a message under the control (`sw-field-error`), links it by `aria-describedby`, sets `aria-invalid` and the danger border; like `Field`'s it is announced politely from a live region that is always there, never as an alert, and an empty string is no error. A blank `placeholder` throws a `RangeError`.
68
+
69
+ ```tsx
70
+ <Select
71
+ error={reasonError}
72
+ label="Reason"
73
+ onChange={setReason}
74
+ options={reasons}
75
+ placeholder="Choose a reason"
76
+ required
77
+ value={reason}
78
+ />
79
+ ```
80
+
63
81
  `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
82
 
65
83
  ```tsx
@@ -94,15 +112,52 @@ Import the CSS once at the application entry. Do not copy it into your source tr
94
112
  </Stack>
95
113
  ```
96
114
 
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. The open menu sits on the popover layer a `space-1` gap below its trigger (above it when it only fits there), at least `space-2` from every viewport edge (a classic scrollbar is not part of the viewport), and aligned to the trigger's inline start (its right edge in right-to-left), or to its other edge when the trigger ends a row and the menu would not fit from its start. The menu is as wide as its longest command up to the viewport less both insets, and a longer command wraps. The menu follows its trigger through scroll, window resize, and a change in the menu's or the trigger's size. The menu is in the DOM only while it is open. The trigger and its commands use the compact xs control height for a fine pointer; on a coarse pointer the trigger is at least 44 × 44 px (the md control height, as on `CalendarButton`) and every command is at least 44 px tall.
115
+ A `Button` that toggles something passes `aria-pressed`. The pressed button gets a 2 px accent ring outside its fill, past a 2 px gap in `--sw-color-background`, so the ring meets only the page and keeps the accent's contrast on the canvas (4.5:1 or more in every palette, at least the 3:1 a state indicator needs) whatever its `variant` or fill. A focused pressed button moves its focus outline out past the ring. In forced colors, which drop shadows, the pressed button keeps the forced button colors, so its label and any glyph or badge inside it stay readable, and draws the same ring and its own border in the system highlight (`Highlight`); nothing opts out of forced colors. An unpressed button is drawn at full strength, so its label keeps its contrast. To make the choice stand out further, give the pressed button `primary` and the others `secondary`.
116
+
117
+ `Badge` is a non-interactive chicklet with a `tone` (`neutral`, `accent`, `success`, `danger`, `warning`) and a `size` (`md`, or `sm` for a table cell). A badge inside a filled `Button` (every variant but `ghost`) sits on `--sw-color-surface` with its words in the text color and its tone on its border, so it reads at 4.5:1 in every palette whatever the button's fill.
118
+
119
+ `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. The open menu sits on the popover layer a `space-1` gap below its trigger (above it when it only fits there), at least `space-2` from every viewport edge (a classic scrollbar is not part of the viewport), and aligned to the trigger's inline start (its right edge in right-to-left), or to its other edge when the trigger ends a row and the menu would not fit from its start. `align="end"` (default `"start"`) lines the menu up with the trigger's inline end even where the start would fit, for a trigger that ends a card or a list row, so the menu stays over that card instead of hanging past it; it takes the start when the end would leave the screen. The menu is as wide as its longest command up to the viewport less both insets, and a longer command wraps. The menu follows its trigger through scroll, window resize, and a change in the menu's or the trigger's size. The menu is in the DOM only while it is open. The trigger and its commands use the compact xs control height for a fine pointer; on a coarse pointer the trigger is at least 44 × 44 px (the md control height, as on `CalendarButton`) and every command is at least 44 px tall.
98
120
 
99
121
  `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.
100
122
 
123
+ `Toast` is an auto-dismiss confirmation on the popover layer, controlled by `open` and `onOpenChange`; it does not trap focus and closes after `timeoutMs` (default 800, or 6000 for a `warning` or `danger` toast, which must be read). `anchor` and `target` send it from a press to a destination. `tone` (`neutral` by default, `success`, `warning`, `danger`) tints its border and an optional consumer `icon` (a Lucide glyph, hidden from assistive technology); the message keeps the text color, so its words carry the meaning. A `danger` toast is announced as `role="alert"`, the others as `role="status"`, so do not wrap the message in another live region. A toast still dismisses itself: an error the person must act on belongs in the page.
124
+
101
125
  `Switch` is a controlled on/off input. It keeps native checkbox keyboard behavior and exposes switch semantics. Provide the current `checked` value and update it in `onCheckedChange`.
102
126
 
103
- `Checkbox` is a controlled native checkbox for one independent form or confirmation choice. `checked` and `onCheckedChange` own its state. The consumer supplies `error` after validation to announce and style an invalid choice; `required` retains native form semantics.
127
+ `Checkbox` is a controlled native checkbox for one independent form or confirmation choice. `checked` and `onCheckedChange` own its state. The consumer supplies `error` after validation to describe and style an invalid choice (associated by `aria-describedby` with `aria-invalid` and announced from a polite live region, as `Field`'s error is, never an alert); `required` retains native form semantics.
128
+
129
+ `RadioGroup` is a controlled fieldset of native radio choices. Supply a unique nonempty `value` for each option, one selected `value` or an empty value for no selection, a `legend`, and `onChange`. Long labels wrap in a vertical group. The consumer provides localized option labels and validation `error`; `required` keeps native form semantics. An option's optional `icon` (a `ReactNode`, such as a Lucide glyph) sits between the radio and the label, in the text color and the option's gap; it is `aria-hidden`, so the radio's accessible name stays the `label` text, and a press on it chooses the option as the label does.
130
+
131
+ ```tsx
132
+ <RadioGroup
133
+ legend="Check type"
134
+ onChange={setCheckType}
135
+ options={[
136
+ { value: 'personal', label: 'Personal check', icon: <User /> },
137
+ { value: 'company', label: 'Company check', icon: <Building2 /> },
138
+ ]}
139
+ value={checkType}
140
+ />
141
+ ```
142
+
143
+ `SegmentedControl` is one exclusive choice shown as a track of segments (`role="radiogroup"`), named by `aria-label` or `aria-labelledby`, with a controlled `value` and `onChange`; `variant="filled"` gives every segment the same width and fills the selection with the accent, and `disabled` keeps the choice visible but inert. `error` puts a message under the track (`sw-field-error`), links it to the group by `aria-describedby` and sets `aria-invalid` and a danger outline on the group (`Mark` in forced colors); as `Field`'s it is announced politely from a live region that is always there, never as an alert, and an empty string is no error. `required` sets `aria-required` on the group; since the control has no label of its own, the element that labels it shows the mark. The track sits in a `.sw-segmented-field` wrapper that holds the message and takes the track's place in the layout, so a `ref` still reaches the `radiogroup`. `error` is for a control in a `Stack` or block layout, where the track keeps its width and the message wraps under it. While it shows a message the field is at least 24ch wide, capped at its container, so a short compact track does not squeeze the message into a column. In an `Inline` the field therefore grows and the rest of the row moves, while the track keeps its labels' width.
144
+
145
+ ```tsx
146
+ <SegmentedControl
147
+ aria-labelledby={directionLabelId}
148
+ error={directionError}
149
+ items={[
150
+ { id: 'add', label: 'Add' },
151
+ { id: 'remove', label: 'Remove' },
152
+ ]}
153
+ onChange={setDirection}
154
+ required
155
+ value={direction}
156
+ variant="filled"
157
+ />
158
+ ```
104
159
 
105
- `RadioGroup` is a controlled fieldset of native radio choices. Supply a unique nonempty `value` for each option, one selected `value` or an empty value for no selection, a `legend`, and `onChange`. Long labels wrap in a vertical group. The consumer provides localized option labels and validation `error`; `required` keeps native form semantics.
160
+ `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
106
161
 
107
162
  `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.
108
163
 
@@ -146,11 +201,11 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
146
201
  </Grid>
147
202
  ```
148
203
 
149
- `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.
204
+ `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 shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. 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. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
150
205
 
151
- `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.
206
+ `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: it drops its glass fill and sets its label in the muted color (4.5:1 in every palette), without fading, since it can still be chosen. Native radios provide arrow and Space navigation; disabled options are skipped.
152
207
 
153
- `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.
208
+ `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. `required` marks the label with the same `aria-hidden` asterisk as `Field` and sets the entry's native `required`. Its label is drawn as `Field`'s (the same label words and mark inside a `<label>` that keeps the canvas type), so its label row and its gap to the entry match a `Field` beside it. For a blank required field, supply `error` when form validation runs to show the message and invalid styling.
154
209
 
155
210
  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`.
156
211
 
@@ -16,6 +16,8 @@ export type AnchoredPosition = {
16
16
  left: number;
17
17
  top: number;
18
18
  };
19
+ /** Which of the anchor's inline edges the popover lines up with. */
20
+ export type InlineAlign = 'start' | 'end';
19
21
  export type AnchoredPositionOptions = {
20
22
  /** Space between the anchor and the popover, in px. */
21
23
  gap?: number;
@@ -24,17 +26,23 @@ export type AnchoredPositionOptions = {
24
26
  /** Right-to-left text: the anchor's inline start is its right edge. */
25
27
  rtl?: boolean;
26
28
  /**
27
- * When the popover would cross the viewport's inline end aligned to the
28
- * anchor's start, align it to the anchor's end instead (a menu opened from
29
+ * The anchor edge the popover prefers to line up with: its inline start
30
+ * (default) or its inline end, for a menu that should stay under the end
31
+ * of a card or row.
32
+ */
33
+ align?: InlineAlign;
34
+ /**
35
+ * When the popover would leave the viewport lined up with its preferred
36
+ * edge, line it up with the anchor's other edge instead (a menu opened from
29
37
  * a trigger at the end of a row), as long as that fits.
30
38
  */
31
39
  flipInline?: boolean;
32
40
  };
33
41
  /**
34
42
  * Where a fixed popover goes beside its anchor: below and aligned to the
35
- * anchor's inline start (its end in right-to-left), above when it does not
36
- * fit below but does above, and always kept inside the viewport by `inset`.
37
- * With `flipInline`, a popover that does not fit start-aligned lines up with
38
- * the anchor's end instead.
43
+ * anchor's inline start (or its inline end with `align: 'end'`; start and end
44
+ * mirror in right-to-left), above when it does not fit below but does above,
45
+ * and always kept inside the viewport by `inset`. With `flipInline`, a
46
+ * popover that does not fit on its preferred edge lines up with the other.
39
47
  */
40
48
  export declare function anchoredPosition(anchor: AnchorRect, size: PopoverSize, viewport: Viewport, options?: AnchoredPositionOptions): AnchoredPosition;
@@ -1,23 +1,24 @@
1
1
  function fitsInline(left, width, viewport, inset) {
2
2
  return left >= inset && left + width <= viewport.width - inset;
3
3
  }
4
- function inlineLeft(anchor, size, viewport, { inset = 0, rtl = false, flipInline = false }) {
4
+ function inlineLeft(anchor, size, viewport, { inset = 0, rtl = false, align = 'start', flipInline = false, }) {
5
5
  const startAligned = rtl ? anchor.right - size.width : anchor.left;
6
6
  const endAligned = rtl ? anchor.left : anchor.right - size.width;
7
+ const [first, other] = align === 'end' ? [endAligned, startAligned] : [startAligned, endAligned];
7
8
  const preferred = flipInline &&
8
- !fitsInline(startAligned, size.width, viewport, inset) &&
9
- fitsInline(endAligned, size.width, viewport, inset)
10
- ? endAligned
11
- : startAligned;
9
+ !fitsInline(first, size.width, viewport, inset) &&
10
+ fitsInline(other, size.width, viewport, inset)
11
+ ? other
12
+ : first;
12
13
  const maxLeft = viewport.width - inset - size.width;
13
14
  return Math.max(inset, Math.min(preferred, maxLeft));
14
15
  }
15
16
  /**
16
17
  * Where a fixed popover goes beside its anchor: below and aligned to the
17
- * anchor's inline start (its end in right-to-left), above when it does not
18
- * fit below but does above, and always kept inside the viewport by `inset`.
19
- * With `flipInline`, a popover that does not fit start-aligned lines up with
20
- * the anchor's end instead.
18
+ * anchor's inline start (or its inline end with `align: 'end'`; start and end
19
+ * mirror in right-to-left), above when it does not fit below but does above,
20
+ * and always kept inside the viewport by `inset`. With `flipInline`, a
21
+ * popover that does not fit on its preferred edge lines up with the other.
21
22
  */
22
23
  export function anchoredPosition(anchor, size, viewport, options = {}) {
23
24
  const { gap = 0, inset = 0 } = options;
@@ -1,4 +1,6 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type InlineAlign } from '../anchored-position.js';
3
+ export type ActionMenuAlign = InlineAlign;
2
4
  export type ActionMenuItem = {
3
5
  id: string;
4
6
  label: string;
@@ -12,5 +14,11 @@ export type ActionMenuProps = {
12
14
  items: readonly ActionMenuItem[];
13
15
  trigger?: ReactNode;
14
16
  disabled?: boolean;
17
+ /**
18
+ * The trigger edge the menu lines up with: its inline start (default) or
19
+ * its inline end, for a trigger that ends a card or row. Either way the
20
+ * menu takes the other edge when the preferred one would leave the screen.
21
+ */
22
+ align?: ActionMenuAlign;
15
23
  };
16
- export declare function ActionMenu({ label, items, trigger, disabled, }: ActionMenuProps): import("react").JSX.Element;
24
+ export declare function ActionMenu({ label, items, trigger, disabled, align, }: ActionMenuProps): import("react").JSX.Element;
@@ -12,7 +12,7 @@ function nextEnabled(items, current, direction) {
12
12
  }
13
13
  return -1;
14
14
  }
15
- export function ActionMenu({ label, items, trigger, disabled = false, }) {
15
+ export function ActionMenu({ label, items, trigger, disabled = false, align = 'start', }) {
16
16
  const menuId = useId();
17
17
  const triggerRef = useRef(null);
18
18
  const openRef = useRef(false);
@@ -84,5 +84,5 @@ export function ActionMenu({ label, items, trigger, disabled = false, }) {
84
84
  close(true);
85
85
  item.onSelect();
86
86
  }
87
- return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: "sw-action-menu-trigger", disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: trigger ?? label }), open ? (_jsx(ActionMenuList, { focusIndex: focusIndex, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
87
+ return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: "sw-action-menu-trigger", disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: trigger ?? label }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
88
88
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
4
5
  export function Checkbox({ label, checked, onCheckedChange, disabled = false, required = false, description, error, }) {
5
6
  const descriptionId = useId();
6
7
  const errorId = useId();
@@ -13,5 +14,5 @@ export function Checkbox({ label, checked, onCheckedChange, disabled = false, re
13
14
  return (_jsxs("div", { className: "sw-checkbox", children: [_jsxs("label", { className: "sw-checkbox-label", children: [_jsxs("span", { className: "sw-checkbox-control", children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), checked: checked, className: "sw-checkbox-input", disabled: disabled, onChange: (event) => {
14
15
  if (!disabled)
15
16
  onCheckedChange(event.currentTarget.checked);
16
- }, required: required, type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }), _jsx("span", { className: "sw-checkbox-text", children: label })] }), description ? (_jsx("span", { className: "sw-checkbox-description", id: descriptionId, children: description })) : null, error ? (_jsx("span", { className: "sw-checkbox-error", id: errorId, children: error })) : null] }));
17
+ }, required: required, type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }), _jsx("span", { className: "sw-checkbox-text", children: label })] }), description ? (_jsx("span", { className: "sw-checkbox-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-checkbox-error", id: errorId, message: error })] }));
17
18
  }
@@ -6,6 +6,8 @@ import { resolveDateFieldLabels, } from '../date-field-labels.js';
6
6
  import { dateEntryPattern, dateEntryPlaceholder } from '../date-entry.js';
7
7
  import { dateEntryValidity } from '../date-entry-validity.js';
8
8
  import { assertDateBounds, isOutsideDateRange } from '../date-only.js';
9
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
10
+ import { FieldLabelText } from './FieldLabelText.js';
9
11
  import { CalendarDialog } from './date-field/CalendarDialog.js';
10
12
  import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
11
13
  import { useCalendarPopup } from './date-field/use-calendar-popup.js';
@@ -48,12 +50,12 @@ export function DateField({ label, value, onChange, min, max, disabled = false,
48
50
  .filter((id) => id !== null)
49
51
  .join(' ');
50
52
  const invalid = Boolean(error) || entry.invalid || isOutsideDateRange(value, min, max);
51
- return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: label }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
53
+ return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required }) }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
52
54
  if (event.key === 'Enter')
53
55
  entry.check();
54
56
  }, placeholder: dateEntryPlaceholder(pattern, {
55
57
  day: words.dayPlaceholder,
56
58
  month: words.monthPlaceholder,
57
59
  year: words.yearPlaceholder,
58
- }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { ...trigger, "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: toggle, ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, entry.invalid ? (_jsx("span", { className: "sw-date-field-error", id: entryErrorId, children: words.invalidEntry })) : null, error ? (_jsx("span", { className: "sw-date-field-error", id: errorId, children: error })) : null, shown ? (_jsx(CalendarDialog, { ...dialog, buttonRef: buttonRef, anchorRef: controlRef, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, required: required, value: value, weekStartsOn: weekStartsOn })) : null] }));
60
+ }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { ...trigger, "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: toggle, ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: entryErrorId, message: entry.invalid ? words.invalidEntry : undefined }), _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: errorId, message: error }), shown ? (_jsx(CalendarDialog, { ...dialog, buttonRef: buttonRef, anchorRef: controlRef, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, required: required, value: value, weekStartsOn: weekStartsOn })) : null] }));
59
61
  }
@@ -1,3 +1,3 @@
1
1
  import { type DenominationGridProps } from './denomination-grid/types.js';
2
2
  export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridTone, } from './denomination-grid/types.js';
3
- export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, }: DenominationGridProps): import("react").JSX.Element;
3
+ export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, totalLabel, }: DenominationGridProps): import("react").JSX.Element;
@@ -2,11 +2,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { DenominationStrip } from './denomination-grid/DenominationStrip.js';
3
3
  import { DenominationTiles } from './denomination-grid/DenominationTiles.js';
4
4
  import { assertDenominationGrid } from './denomination-grid/validate.js';
5
- export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', }) {
5
+ export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', totalLabel, }) {
6
6
  assertDenominationGrid(label, columns, rows);
7
7
  if (!zeroLabel)
8
8
  throw new RangeError('zeroLabel must not be empty');
9
+ if (totalLabel !== undefined && !totalLabel.trim())
10
+ throw new RangeError('totalLabel must not be empty');
9
11
  if (layout === 'tiles')
10
12
  return (_jsx(DenominationTiles, { columns: columns, label: label, rows: rows, subtotal: subtotal, zeroLabel: zeroLabel }));
11
- return (_jsx(DenominationStrip, { columns: columns, label: label, rows: rows, zeroLabel: zeroLabel }));
13
+ return (_jsx(DenominationStrip, { columns: columns, label: label, rows: rows, totalLabel: totalLabel, zeroLabel: zeroLabel }));
12
14
  }
@@ -1,12 +1,14 @@
1
1
  'use client';
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cloneElement, isValidElement, useId } from 'react';
4
4
  import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
5
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
6
+ import { FieldLabelText } from './FieldLabelText.js';
5
7
  import { Stack } from './Stack.js';
6
- import { Text } from './Text.js';
7
8
  export function Field({ children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, required = false, prefix, suffix, }) {
8
9
  const controlId = useId();
9
- const messageId = useId();
10
+ const descriptionId = useId();
11
+ const errorId = useId();
10
12
  const labelId = useId();
11
13
  const prefixId = useId();
12
14
  const suffixId = useId();
@@ -20,7 +22,7 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
20
22
  ]
21
23
  .filter(Boolean)
22
24
  .join(' ');
23
- const labelText = (_jsxs(Text, { as: "span", className: labelVisuallyHidden ? 'sw-sr-only' : undefined, variant: size === 'xs' ? 'caption' : 'label', children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }));
25
+ const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelVisuallyHidden }));
24
26
  if (!isValidElement(children) ||
25
27
  typeof children.type !== 'string' ||
26
28
  !['input', 'select', 'textarea'].includes(children.type)) {
@@ -36,7 +38,8 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
36
38
  const describedBy = [
37
39
  children.props['aria-describedby'],
38
40
  adornment.describedBy,
39
- message ? messageId : null,
41
+ description && !error ? descriptionId : null,
42
+ error ? errorId : null,
40
43
  ]
41
44
  .filter(Boolean)
42
45
  .join(' ');
@@ -47,5 +50,5 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
47
50
  'aria-labelledby': adornment.labelledBy,
48
51
  required: required || children.props.required,
49
52
  });
50
- return (_jsxs(Stack, { as: "div", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [_jsx("label", { htmlFor: children.props.id || controlId, id: labelId, children: labelText }), adorned ? (_jsx(FieldAdornment, { prefix: prefix, prefixId: prefixId, suffix: suffix, suffixId: suffixId, children: control })) : (control), message ? (_jsx("span", { className: error ? 'sw-field-error' : 'sw-field-description', id: messageId, role: error ? 'alert' : undefined, children: message })) : null] }));
53
+ return (_jsxs(Stack, { as: "div", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [_jsx("label", { htmlFor: children.props.id || controlId, id: labelId, children: labelText }), adorned ? (_jsx(FieldAdornment, { prefix: prefix, prefixId: prefixId, suffix: suffix, suffixId: suffixId, children: control })) : (control), description && !error ? (_jsx("span", { className: "sw-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
51
54
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A field control's error message as a polite live region. It is always
3
+ * rendered, empty while there is no error, so a screen reader announces a
4
+ * new error once when its text is swapped in, while the person types or
5
+ * after a submit, without the assertive pile of one alert per field. The
6
+ * control lists it in `aria-describedby` while it has text. Generated CSS
7
+ * takes an empty region out of the layout (`position: absolute`), never out
8
+ * of the accessibility tree, which would stop the announcement.
9
+ */
10
+ export declare function FieldErrorRegion({ className, id, message, }: {
11
+ className: string;
12
+ id: string;
13
+ message?: string;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * A field control's error message as a polite live region. It is always
4
+ * rendered, empty while there is no error, so a screen reader announces a
5
+ * new error once when its text is swapped in, while the person types or
6
+ * after a submit, without the assertive pile of one alert per field. The
7
+ * control lists it in `aria-describedby` while it has text. Generated CSS
8
+ * takes an empty region out of the layout (`position: absolute`), never out
9
+ * of the accessibility tree, which would stop the announcement.
10
+ */
11
+ export function FieldErrorRegion({ className, id, message, }) {
12
+ return (_jsx("span", { "aria-live": "polite", className: className, id: id, children: message || '' }));
13
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A form control's label words and its required mark, as `Field` draws
3
+ * them. The `<label>` around it inherits the canvas's body type, so its
4
+ * line box, and the gap to the control below, is the same for every field
5
+ * that uses it (`Field`, `DateField` and `Select`). The mark is `aria-hidden`: the
6
+ * control's own required state is what assistive technology reads.
7
+ */
8
+ export declare function FieldLabelText({ label, required, size, visuallyHidden, }: {
9
+ label: string;
10
+ required?: boolean;
11
+ size?: 'xs' | 'md';
12
+ visuallyHidden?: boolean;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './Text.js';
3
+ /**
4
+ * A form control's label words and its required mark, as `Field` draws
5
+ * them. The `<label>` around it inherits the canvas's body type, so its
6
+ * line box, and the gap to the control below, is the same for every field
7
+ * that uses it (`Field`, `DateField` and `Select`). The mark is `aria-hidden`: the
8
+ * control's own required state is what assistive technology reads.
9
+ */
10
+ export function FieldLabelText({ label, required = false, size = 'md', visuallyHidden = false, }) {
11
+ return (_jsxs(Text, { as: "span", className: visuallyHidden ? 'sw-sr-only' : undefined, variant: size === 'xs' ? 'caption' : 'label', children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }));
12
+ }
@@ -1,7 +1,14 @@
1
+ import { type ReactNode } from 'react';
1
2
  export type RadioGroupOption = {
2
3
  value: string;
3
4
  label: string;
4
5
  disabled?: boolean;
6
+ /**
7
+ * A decorative glyph between the radio and the label, such as a person or
8
+ * a building for the kind of check. It is `aria-hidden` and drawn in the
9
+ * text color: the option's accessible name stays its `label` text.
10
+ */
11
+ icon?: ReactNode;
5
12
  };
6
13
  export type RadioGroupProps = {
7
14
  legend: string;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
4
5
  export function RadioGroup({ legend, value, onChange, options, required = false, disabled = false, description, error, }) {
5
6
  const name = useId();
6
7
  const descriptionId = useId();
@@ -23,5 +24,5 @@ export function RadioGroup({ legend, value, onChange, options, required = false,
23
24
  !option.disabled &&
24
25
  event.currentTarget.checked)
25
26
  onChange(option.value);
26
- }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), _jsx("span", { className: "sw-radio-group-text", children: option.label })] }, option.value))) }), error ? (_jsx("span", { className: "sw-radio-group-error", id: errorId, children: error })) : null] }));
27
+ }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-radio-group-icon", children: option.icon })) : null, _jsx("span", { className: "sw-radio-group-text", children: option.label })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
27
28
  }
@@ -33,6 +33,14 @@ export type SegmentedControlProps = SegmentedLabel & SegmentedChange & {
33
33
  items: readonly SegmentedItem[];
34
34
  value: string;
35
35
  variant?: SegmentedControlVariant;
36
+ /**
37
+ * A validation message under the control, as `Field`'s: announced from a
38
+ * polite live region, linked by `aria-describedby`, with `aria-invalid`
39
+ * and a danger outline on the group. An empty string is no error.
40
+ */
41
+ error?: string;
42
+ /** Sets `aria-required` on the group; the visible mark belongs to the element that labels it. */
43
+ required?: boolean;
36
44
  };
37
45
  export declare const SegmentedControl: import("react").ForwardRefExoticComponent<SegmentedControlProps & import("react").RefAttributes<HTMLDivElement>>;
38
46
  export {};