@scalewing/react 1.29.0 → 1.31.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 (61) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +13 -3
  3. package/dist/components/ActionMenu.d.ts +14 -0
  4. package/dist/components/ActionMenu.js +4 -0
  5. package/dist/components/ButtonGroup.d.ts +9 -1
  6. package/dist/components/ButtonGroup.js +2 -2
  7. package/dist/components/Combobox.d.ts +14 -0
  8. package/dist/components/Combobox.js +125 -0
  9. package/dist/components/Field.d.ts +17 -0
  10. package/dist/components/Field.js +54 -5
  11. package/dist/components/FilterChips.d.ts +17 -1
  12. package/dist/components/FilterChips.js +11 -6
  13. package/dist/components/ScrollRegion.d.ts +6 -2
  14. package/dist/components/ScrollRegion.js +9 -3
  15. package/dist/components/SectionNav.d.ts +25 -2
  16. package/dist/components/SectionNav.js +13 -4
  17. package/dist/components/Table.d.ts +49 -5
  18. package/dist/components/Table.js +11 -4
  19. package/dist/components/action-menu/ActionMenuList.js +7 -7
  20. package/dist/components/combobox/ComboboxPopup.d.ts +25 -0
  21. package/dist/components/combobox/ComboboxPopup.js +68 -0
  22. package/dist/components/combobox/entries.d.ts +18 -0
  23. package/dist/components/combobox/entries.js +34 -0
  24. package/dist/components/combobox/types.d.ts +63 -0
  25. package/dist/components/combobox/types.js +1 -0
  26. package/dist/components/combobox/validate.d.ts +3 -0
  27. package/dist/components/combobox/validate.js +44 -0
  28. package/dist/components/field/SearchFrame.d.ts +16 -0
  29. package/dist/components/field/SearchFrame.js +32 -0
  30. package/dist/components/table/TableCell.d.ts +8 -0
  31. package/dist/components/table/TableCell.js +4 -2
  32. package/dist/components/table/TableRow.d.ts +39 -0
  33. package/dist/components/table/TableRow.js +8 -0
  34. package/dist/components/use-scroll-overflow.d.ts +8 -3
  35. package/dist/components/use-scroll-overflow.js +15 -4
  36. package/dist/css/css-action-menu.js +41 -0
  37. package/dist/css/css-button-group-attached.d.ts +2 -0
  38. package/dist/css/css-button-group-attached.js +70 -0
  39. package/dist/css/css-button.js +5 -1
  40. package/dist/css/css-combobox.d.ts +2 -0
  41. package/dist/css/css-combobox.js +135 -0
  42. package/dist/css/css-components.js +4 -0
  43. package/dist/css/css-field-search.d.ts +2 -0
  44. package/dist/css/css-field-search.js +118 -0
  45. package/dist/css/css-field.js +4 -0
  46. package/dist/css/css-filter-chips.js +26 -0
  47. package/dist/css/css-section-nav-filled.d.ts +6 -0
  48. package/dist/css/css-section-nav-filled.js +80 -0
  49. package/dist/css/css-section-nav.js +4 -0
  50. package/dist/css/css-table-footer.d.ts +2 -0
  51. package/dist/css/css-table-footer.js +57 -0
  52. package/dist/css/css-table-ledger.d.ts +10 -0
  53. package/dist/css/css-table-ledger.js +122 -0
  54. package/dist/css/css-table.js +8 -0
  55. package/dist/css/stacking.d.ts +9 -3
  56. package/dist/css/stacking.js +28 -8
  57. package/dist/css/stylesheet.js +2 -0
  58. package/dist/index.d.ts +4 -3
  59. package/dist/index.js +4 -3
  60. package/dist/styles.css +483 -7
  61. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.31.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ae2d883: `Combobox` (with `ComboboxProps`, `ComboboxGroup`, `ComboboxOption`, `ComboboxBadge`, `ComboboxAction`, `ComboboxSize` and `comboboxSizes`): a text input with a list of suggestions, an ARIA 1.2 combobox with list autocomplete (`docs/requests/teisoro-combobox.md`, Teisoro F-011 release R5).
8
+
9
+ - `label`, `inputValue`, `onInputChange`, `groups` (`{ label?, options: { value, label, description?, badge?, disabled? }[] }[]`, filtered by the consumer), `onSelect`, `action?` (`{ label, onPress, badge? }`), `emptyText`, `note?`, `listLabel?`, `countText?` (`(count) => string`), and the Field props `labelVisuallyHidden`, `placeholder`, `description`, `error`, `required`, `disabled`, `size` (`md` | `xs`).
10
+ - Focus stays in the input; the arrow keys move `aria-activedescendant` and wrap; Enter or a press chooses; a disabled option is never chosen; Escape closes, then empties; Alt+ArrowUp, blur, Tab or a press outside closes; the pointer moves the active option; the active option is held by value across new results; input-method keys are ignored; an always-present polite live region announces the empty text and `countText`.
11
+ - The list is on the popup layer (`openPopups` gains `.sw-combobox-popup`), solid under reduced transparency, with forced-colours rules. Blank words, duplicate values, an unknown size or badge tone throw a `RangeError`.
12
+ - New classes `sw-combobox*`. No new tokens or dependencies.
13
+
14
+ - c79024a: `Field` `variant="search"` (`fieldVariants`, `FieldVariant`), the web twin of the native search field (`docs/requests/teisoro-field-search.md`, Teisoro F-011 release R5): a hidden label that still names a `type="search"` input, a `leading` glyph slot, and, while the input holds text and is enabled and not read-only, a clear button named `clearLabel` that returns focus to the input and calls `onClear` (Escape too). The browser's own clear button is hidden; `enterKeyHint` defaults to `search`. Missing or blank `clearLabel`, missing `onClear`, `prefix`/`suffix`, a non-input or uncontrolled child, or the search props on an outlined field throw. New classes `sw-field-search-field`, `sw-field-search`, `sw-field-search-leading`, `sw-field-search-clear`, `sw-field-search-cross`. No new tokens or dependencies.
15
+
16
+ ## 1.30.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 63695b5: `ActionMenuItem` `detail`, `detailLang` and `separatorBefore` (`docs/requests/teisoro-action-menu-detail.md`, Teisoro F-011 release R4):
21
+
22
+ - `detail`: a short current value at the command's end in the muted colour with a chevron (mirrored right to left, `CanvasText` in forced colours), read in its name; it wraps between words. `detailLang` marks its language. A blank `detail`, a blank `detailLang` or one without `detail` throws a `RangeError`.
23
+ - `separatorBefore`: a `role="separator"` hairline before the command (never before the first), skipped by the arrow keys.
24
+ - New classes `sw-action-menu-detail`, `sw-action-menu-chevron`, `sw-action-menu-separator`. No new tokens or dependencies.
25
+
26
+ - 180e140: `ButtonGroup` `attached` (`docs/requests/teisoro-button-group-attached.md`, Teisoro F-011 release R4): one joined control, such as Previous, Today and Next. Its direct `Button` children at one height with no gap, single dividers (overlapping borders; a borderless variant's in its text colour), square inner and `sm`-radius outer corners; an isolated stacking context raises a pressed toggle and the focused button's outer ring over their neighbours (a new `joinedControlOrder` in `css/stacking.ts`). It keeps its row on a phone and sizes to its buttons. New class `sw-button-group-attached`. No new tokens or dependencies.
27
+ - b64e6ce: `FilterChips` `required`, `error` and `aria-describedby`, and `value: ''` for nothing chosen yet (`docs/requests/teisoro-filter-chips-validation.md`, Teisoro F-011 release R4):
28
+
29
+ - `required`: each radio `required` and the required mark after the label.
30
+ - `error`: a polite message under the chips linked by `aria-describedby`, the group `aria-invalid`, and every chip's edge in danger (`Mark` in forced colors).
31
+ - `aria-describedby`: consumer description ids, before the error.
32
+ - `value` may be `''` (nothing chosen); a value that is neither `''` nor an option still throws a `RangeError`.
33
+ - New class `sw-filter-chips-error`. No new tokens or dependencies.
34
+
35
+ - a60f130: `SectionNav` item `count` and `appearance="filled"` (`docs/requests/teisoro-section-nav-count.md`, Teisoro F-011 release R4):
36
+
37
+ - `SectionNavItem.count?: number`: a 20 px filled pill after the label (danger with its on-colour figures, a `CanvasText` edge in forced colours), part of the link's accessible name; 0 shows none; a count that is not a whole number from 0 throws a `RangeError`.
38
+ - `SectionNavItem.countLabel?: string`: the count in words, read in place of the bare number (the pill then hidden from assistive technology); blank or without `count` throws.
39
+ - `appearance?: 'underline' | 'filled'` (`SectionNavAppearance`, `sectionNavAppearances`): `filled` drops the hairline and the underline or start bar, sets every link at the `sm` control (44 px on a coarse pointer) at weight 500 and fills the current one at 600 (a 2 px `Highlight` edge in forced colours), in the row or the side list; an unknown value throws a `RangeError`.
40
+ - New classes `sw-section-nav-filled` and `sw-section-nav-count`. No new tokens or dependencies.
41
+
42
+ - 3beef0d: `TableFooter` (with `TableFooterProps`): the table's `tfoot` for a total row, its cells at weight 600 under a 2 px rule (`docs/requests/teisoro-table-footer.md`, Teisoro F-011 release R4).
43
+
44
+ - `sticky` pins the footer to the bottom edge on the header's glass, on a new stacking layer `stickyFooter` (2) between sticky table cells (1) and the sticky `ActionBar`. The other layers move up one: `bottomChrome` 3, `popupHost` 4, `topChrome` 5 (`popup` stays 10); update any test or stylesheet that pins those numbers. A sticky `ActionBar` or a `Dialog` footer covers a pinned footer, so do not pair them.
45
+ - In forced colors pinned footer and header cells are the solid system canvas.
46
+ - While a table with a sticky footer fits its width, its region stops being a scroll box (`sw-scroll-fits`, now on every measured `Table` region whose content fits, which changes its class list), so the footer and the sticky header pin to the page; a wider table scrolls sideways and pins inside its region.
47
+ - New classes `sw-table-footer-sticky` and `sw-scroll-fits`. No new tokens or dependencies.
48
+
49
+ - d08712d: Table ledger extensions (`docs/requests/teisoro-table-ledger.md`, Teisoro F-011 release R4):
50
+
51
+ - `TableCell` `tone` (`success`, `danger`, `muted`, `accent`; `TableCellTone`, `tableCellTones`): the cell's text in the theme colour.
52
+ - `Table` `columnRules`: a hairline between columns, mirrored right to left.
53
+ - `TableRow` `variant="sub"` (`TableRowVariant`, `tableRowVariants`) indents the row's header (`th scope="row"`) with a guide bar under the row above, and `TableRow` `struck` keeps a voided row on the record: subtle fill, muted, struck through (a Badge in a line of text stays plain).
54
+ - A body or footer row header (`th scope="row"`) now reads as body text instead of a muted column header.
55
+ - An unknown `tone` or `variant` throws a `RangeError`. No new tokens or dependencies.
56
+
3
57
  ## 1.29.0
4
58
 
5
59
  ### Minor Changes
package/README.md CHANGED
@@ -99,6 +99,8 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
99
99
 
100
100
  `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`.
101
101
 
102
+ `Field` `variant="search"` (default `"outlined"`; `fieldVariants`) is a search box over one native `<input>` child with a `value`: the `label` is not drawn but still names the input, which becomes `type="search"` (a `searchbox`); `leading` is your glyph at the frame's start, such as a magnifier, hidden from assistive technology; and while the input holds text and is enabled and not read-only, a clear button named `clearLabel` ("Clear search") ends the frame, puts focus back in the input and calls `onClear` (set the value to `''`); Escape with text calls `onClear` too. The browser's own clear button is hidden, so there is one. The input gets `enterKeyHint="search"` unless you pass one. The frame is the adorned field's, so focus, `error`, `description`, `invalid` and the disabled look work as on any field; a press on the glyph focuses the input; the clear button is the 44 px target on a coarse pointer. A search field without a nonempty `clearLabel` or `onClear`, with `prefix` or `suffix`, or with a child that is not an `<input>` with a `value` throws; `leading`, `clearLabel` and `onClear` on an outlined field, or an unknown `variant`, throw too.
103
+
102
104
  ```tsx
103
105
  <Field label="Drop amount" prefix="$">
104
106
  <input inputMode="decimal" name="drop" />
@@ -213,9 +215,11 @@ A `Button`'s own children are laid out in a row one token gap apart (spacing ste
213
215
 
214
216
  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`.
215
217
 
218
+ `ButtonGroup` is the action row of a form or dialog (`role="group"`, named by `aria-label`): one line from `md` up, aligned by `justify`, and full-width stacked buttons below it. `attached` makes it one joined control instead, such as Previous, Today and Next: its direct `Button` children sit side by side at one height with no gap, their borders overlapping into single dividers (a borderless variant draws its divider in its own text color at low strength), with square inner corners and `sm`-radius outer ones, mirrored right to left. It keeps its row on a phone and sizes to its buttons, so `justify` does not apply. The group is its own stacking context: a pressed toggle's ring and the focused button's usual outer ring paint over the neighbours. Give every button the same variant and size, such as `secondary`; a wrapper around a button (a tooltip's span) is not joined.
219
+
216
220
  `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.
217
221
 
218
- `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.
222
+ `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. An item's `detail` is its current value at the command's end, such as "English" on a Language command that opens a choice: muted, before a chevron that points to the inline end (mirrored right to left), and read as part of the command's name ("Language English"); `detailLang` marks its language. It is meant for a short value; a long one wraps between words, never inside one. The command runs like any other and opens no submenu, so a command that opens a dialog or a picker says so in its label. A blank `detail`, or a blank `detailLang` or one without a `detail`, throws a `RangeError`. `separatorBefore` draws a hairline (`role="separator"`) before a command, setting it apart, such as Sign out; the arrow keys skip it, and it is never drawn before the first command.
219
223
 
220
224
  `ActionMenu` `header` is a non-interactive block above the commands, such as who is signed in: each child a line of its own, in the caption size and the muted color, set off by a hairline. It sits outside the `role="menu"` element and the arrow keys, and is the menu's description (`aria-describedby`), read as the menu opens; `null`, `false` and `''` are no header. An item's `lang` (a BCP 47 tag) marks its label as another language than the page's, such as "English" on a Spanish page (WCAG 3.1.2).
221
225
 
@@ -348,7 +352,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
348
352
 
349
353
  `Nav` is a labelled `nav` of a workspace's destinations, links or buttons one token gap apart. A `Button` inside it is at least 44 × 44 px on a coarse pointer, whatever its `size`, so a compact row of destinations is still a touch target on a phone or a tablet.
350
354
 
351
- `SectionNav` is the navigation between the sections of one area, such as a portal's pages: a labelled `nav` (`aria-label` or `aria-labelledby`) of links from `items` (`{ id, label, href, current?, icon? }`), quieter than a workspace's `Nav`. `current` is `'page'`, `'location'` (a page inside that section, such as a detail page under its list) or a boolean, written as `aria-current`; the current item is in the text color over an accent underline, or, from `verticalFrom` (a breakpoint) up, where the items stack as a side list, a bar at its start. `icon` is a decorative glyph before the label. `onNavigate` takes a plain press after preventing the browser's navigation, for a client router; a press with a modifier key or the middle button keeps the browser's behavior, and without `onNavigate` the links carry no handler, so a server component can render the nav. A coarse pointer gets 44 px targets. Empty or duplicate ids, empty labels and an unknown `verticalFrom` throw a `RangeError`. Use `Tabs` for panels on one page.
355
+ `SectionNav` is the navigation between the sections of one area, such as a portal's pages: a labelled `nav` (`aria-label` or `aria-labelledby`) of links from `items` (`{ id, label, href, current?, icon?, count? }`), quieter than a workspace's `Nav`. `current` is `'page'`, `'location'` (a page inside that section, such as a detail page under its list) or a boolean, written as `aria-current`; the current item is in the text color over an accent underline, or, from `verticalFrom` (a breakpoint) up, where the items stack as a side list, a bar at its start. `icon` is a decorative glyph before the label. `onNavigate` takes a plain press after preventing the browser's navigation, for a client router; a press with a modifier key or the middle button keeps the browser's behavior, and without `onNavigate` the links carry no handler, so a server component can render the nav. `count` (a whole number) is how many things wait in a section, such as tasks: a filled pill after the label, in the danger color with its on-color figures, read as part of the link's name ("Tasks 3"); 0 shows none, and there is no cap. `countLabel` says what the count means in your words ("3 tasks to do"): it is read in place of the bare number and the pill is hidden from assistive technology. `appearance="filled"` (default `"underline"`) is a quiet row of page buttons for an area's sub-navigation beside its name: no hairline and no underline or start bar, every link the `sm` control with the small radius at weight 500, the current one on a soft muted fill in the text color at 600 (a 2 px `Highlight` edge in forced colors), in the row or the side list. A coarse pointer gets 44 px targets. Empty or duplicate ids, empty labels, a `count` that is not a whole number from 0, a blank `countLabel` or one without a `count`, and an unknown `verticalFrom` or `appearance` throw a `RangeError`. Use `Tabs` for panels on one page.
352
356
 
353
357
  ```tsx
354
358
  <SectionNav
@@ -387,6 +391,10 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
387
391
 
388
392
  A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `'none'`) and `onSort` is its column's sort control: its children become a button in the header's own style with a chevron (before the name in a `numeric` or end-aligned column), and the cell carries `aria-sort` only while it is the sorted column. `sort` on a data cell, or one of the two without the other, throws a `TypeError`. `Table` `layout="fixed"` (default `auto`) sizes the columns from the header row alone, so a filter that hides a row moves no column, and `verticalAlign="top"` (default `middle`) starts every cell of a tall row on its first line. `TableCell` `width` is `min` (an auto table's column takes the least room its content allows, on one line; set it on each cell of the column) or a size, `xs` to `xl` (4, 6, 8, 12 and 16 rem); in a fixed table a column without a width shares what is left, so leave one column, such as a description, unsized. The HTML `width` attribute is not accepted. Unknown values throw a `RangeError`.
389
393
 
394
+ A ledger read across as well as down takes `Table` `columnRules` (a hairline at the inline start of every cell after a row's first, mirrored right to left) and `TableCell` `tone` (`success`, `danger`, `muted` or `accent`: the cell's text in the theme colour, as `Text color` uses it; never the only signal, so keep the sign or the words). `TableRow` `variant="sub"` (default `"default"`) sets a row under the one above, as a batch's items: its row header, a `TableCell as="th" scope="row"`, is indented a `space-6` step with a guide bar at the indent (a sub row without a row header is not indented). `TableRow` `struck` keeps a voided row on the record: the subtle fill and the text muted and struck through, toned cells included; it works with either variant. A line-through is not read aloud, so say why in the row, such as a `Badge` "Voided": a Badge in a line of text is not struck, but one that is itself a flex item (a direct child of `Inline`) is, so put it in the title's line. A row header in the body or footer (`th scope="row"`) reads as body text. An unknown `tone` or `variant` throws a `RangeError`.
395
+
396
+ `TableFooter` is the table's `tfoot`, for a total: its cells are set at weight 600 under a 2 px rule across its first row (drawn on each cell's `::before`, so forced colors keep it), and a `TableCell as="th" scope="row"` in it names the total. `sticky` pins the footer to the bottom edge on the header's glass (solid under Reduce Transparency, the system canvas in forced colors), on its own layer over the cells and the pinned header. While the table fits its width, a table with a sticky footer stops being a scroll box, so the footer pins to the page's bottom and the sticky header to its top as the page scrolls; a sticky `AppHeader` covers that header. A table wider than its region scrolls sideways in it and pins to the region instead. A sticky `ActionBar` covers a pinned footer, and so does a `Dialog`'s pinned footer, so do not pair them.
397
+
390
398
  `BarChart` `formatValue` writes the axis's values and each value without its own `valueLabel`, such as a currency. `diverging` puts zero in the middle of each track behind a hairline: a positive bar grows toward the inline end and a negative one toward the start, on one scale, and the axis reads minus the maximum, zero and the maximum. Without it every bar grows from the start. An item's `tone` (`BarChartTone`: `'accent'`, `'success'`, `'warning'` or `'danger'`, listed in the exported `barChartTones`) colors its bar by what it means in place of its sign's default (the accent, or danger for a negative value), such as a diverging chart's overage in `'warning'` and shortage in `'danger'`; a toned bar keeps its sign's place, every tone keeps 3:1 against the track, and an unknown tone throws a `RangeError`. Forced colors draw every bar in the system text color, so the value and its side of zero carry the meaning there.
391
399
 
392
400
  `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.
@@ -402,6 +410,8 @@ A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `
402
410
  </Inline>
403
411
  ```
404
412
 
413
+ `Combobox` is a text input with a list of suggestions, an ARIA 1.2 combobox with list autocomplete. Pass the `label` (drawn above the input unless `labelVisuallyHidden`), the controlled `inputValue` and `onInputChange`, and `groups` you filter from the text yourself: `{ label?, options }`, each option `{ value, label, description?, badge?: { label, tone? }, disabled? }`. Typing, a press on the input or ArrowDown opens the list on the popover layer under the input, at least as wide; each group with a `label` is a `role="group"` under its heading. ArrowDown and ArrowUp move the active option (named by the input's `aria-activedescendant`; focus stays in the input) and wrap; Enter or a press chooses it and calls `onSelect(value)` (set `inputValue` to its label yourself), and a disabled option is shown and read as unavailable but never chosen. `action` (`{ label, onPress, badge? }`) ends the list as one more option, such as adding a new entry. `emptyText` shows when no group has an option, and a polite live region, always in the page, announces it (and, with `countText: (count) => string`, the number of options); `note` is a muted line under the list that describes the input while open. `listLabel` names the list when it differs from the input's label. The active option is held by its value, so it stays put when your results change under it. Escape closes the list and a second Escape empties the input; Alt+ArrowUp, Tab, a blur or a press outside closes it. The input is a `Field` input, so `description`, `error`, `required`, `disabled`, `placeholder` and `size` (`md` or `xs`; `comboboxSizes`) work as on any field. Blank words, duplicate option values, an unknown `size` or badge tone throw a `RangeError`, and an `action` without `onPress` a `TypeError`.
414
+
405
415
  `MultiSelect` is several choices from a short list behind one trigger, such as which kinds a list shows. The trigger reads the muted `label` and then `summary` (your words for what is chosen, such as "All entries" or "2 services") with a chevron; it opens a panel named by the label with `allLabel` and `clearLabel` commands over one checkbox per option, each with an optional `icon` (use a small `GlyphTile`, never a bare dot) and a hairline before an option with `separatorBefore`. `onChange` reports the checked values in the options' order. It is a disclosure of native checkboxes, not a listbox: the first checkbox takes focus as it opens, Escape closes it and returns focus to the trigger, focus or a press outside closes it, and disabling it while open closes it. Blank words, no or duplicate options, a value that is not an option, or an unknown `align` throw a `RangeError`.
406
416
 
407
417
  ```tsx
@@ -551,7 +561,7 @@ A strip row with `entry` makes its counts editable: each column holds a `Stepper
551
561
  />
552
562
  ```
553
563
 
554
- `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.
564
+ `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. A `value` of `''` is nothing chosen yet, for a choice the person must still make, such as a reason. `required` sets `required` on each radio and shows the required mark after the label (hidden from assistive technology). `error` puts a message under the chips, as `Field`'s does (a polite live region kept in the page, out of the layout while empty), links it to the group by `aria-describedby`, sets `aria-invalid` on the group and edges every chip in the danger color (`Mark` in forced colors); an empty string is no error. `aria-describedby` adds your own description ids, such as a hint, before the error. A value that is neither `''` nor an option throws a `RangeError`; a filter set always passes a real option. Inside a `<form>`, the browser checks `required` radios on submit and shows its own message first, so a form that shows `error` itself sets `noValidate`.
555
565
 
556
566
  `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.
557
567
 
@@ -13,6 +13,20 @@ export type ActionMenuItem = {
13
13
  icon?: ReactNode;
14
14
  disabled?: boolean;
15
15
  destructive?: boolean;
16
+ /**
17
+ * A short current value at the command's end with a chevron, such as
18
+ * "English" on a Language command that opens a choice: muted, read as
19
+ * part of the command's name ("Language English"). The chevron is
20
+ * decorative and points to the inline end. Meant for a short value; a
21
+ * long one wraps between words. The command still runs like any other:
22
+ * it opens no submenu, so a command that opens a dialog or a picker
23
+ * says so in its label or what it opens.
24
+ */
25
+ detail?: string;
26
+ /** The BCP 47 language of `detail` when it is not the page's. */
27
+ detailLang?: string;
28
+ /** Draws a hairline before the command, setting it apart from the ones above. */
29
+ separatorBefore?: boolean;
16
30
  };
17
31
  export type ActionMenuProps = {
18
32
  label: string;
@@ -18,6 +18,10 @@ export function ActionMenu({ label, items, header, trigger, disabled = false, al
18
18
  const openRef = useRef(false);
19
19
  const [open, setOpen] = useState(false);
20
20
  const [focusIndex, setFocusIndex] = useState(-1);
21
+ if (items.some((item) => (item.detail !== undefined && !item.detail.trim()) ||
22
+ (item.detailLang !== undefined &&
23
+ (!item.detailLang.trim() || item.detail === undefined))))
24
+ throw new RangeError('ActionMenu item detail and detailLang must not be blank; detailLang needs a detail');
21
25
  const enabled = !disabled && items.some((item) => !item.disabled);
22
26
  useLayoutEffect(() => {
23
27
  if (!enabled && open)
@@ -3,6 +3,14 @@ export type ButtonGroupJustify = 'start' | 'end' | 'between';
3
3
  export type ButtonGroupProps = BoxProps & {
4
4
  /** Where the row sits from the `md` breakpoint up; below it every button stacks full width in source order. */
5
5
  justify?: ButtonGroupJustify;
6
+ /**
7
+ * One joined control instead of a row of separate buttons, such as
8
+ * Previous, Today and Next: no gap, single dividers, square inner and
9
+ * rounded outer corners. It keeps its row on a phone and sizes to its
10
+ * buttons, so `justify` does not apply. Its children are `Button`s; name
11
+ * the group with `aria-label`.
12
+ */
13
+ attached?: boolean;
6
14
  };
7
15
  /**
8
16
  * The action row of a form or dialog: buttons on one line, wrapping when
@@ -11,4 +19,4 @@ export type ButtonGroupProps = BoxProps & {
11
19
  * (put the primary action last, as the row already does). It is a `group`,
12
20
  * so an `aria-label` names the row for assistive tech.
13
21
  */
14
- export declare function ButtonGroup({ className, justify, ...rest }: ButtonGroupProps): import("react").JSX.Element;
22
+ export declare function ButtonGroup({ attached, className, justify, ...rest }: ButtonGroupProps): import("react").JSX.Element;
@@ -8,6 +8,6 @@ import { Box } from './Box.js';
8
8
  * (put the primary action last, as the row already does). It is a `group`,
9
9
  * so an `aria-label` names the row for assistive tech.
10
10
  */
11
- export function ButtonGroup({ className, justify = 'end', ...rest }) {
12
- return (_jsx(Box, { className: cx('sw-button-group', `sw-button-group-${justify}`, className), role: "group", ...rest }));
11
+ export function ButtonGroup({ attached = false, className, justify = 'end', ...rest }) {
12
+ return (_jsx(Box, { className: cx('sw-button-group', `sw-button-group-${justify}`, attached && 'sw-button-group-attached', className), role: "group", ...rest }));
13
13
  }
@@ -0,0 +1,14 @@
1
+ import { type ComboboxProps } from './combobox/types.js';
2
+ export { comboboxSizes, type ComboboxAction, type ComboboxBadge, type ComboboxGroup, type ComboboxOption, type ComboboxProps, type ComboboxSize, } from './combobox/types.js';
3
+ /**
4
+ * A text input with a list of suggestions (ARIA 1.2 combobox, list
5
+ * autocomplete): typing, a press on the input or ArrowDown opens the list
6
+ * and the consumer filters `groups` from `inputValue`; the arrow keys move
7
+ * the active option (`aria-activedescendant`) while focus stays in the
8
+ * input; Enter or a press chooses it (`onSelect`) or runs the `action`;
9
+ * Escape closes the list, and a second Escape empties the input. A
10
+ * disabled option is shown and read as unavailable but never chosen. The
11
+ * active option is held by its value, so it stays the same option (or
12
+ * none) when the results change under it.
13
+ */
14
+ export declare function Combobox(props: ComboboxProps): import("react").JSX.Element;
@@ -0,0 +1,125 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef, useState } from 'react';
4
+ import { ComboboxPopup } from './combobox/ComboboxPopup.js';
5
+ import { comboboxEntries } from './combobox/entries.js';
6
+ import { assertCombobox } from './combobox/validate.js';
7
+ import { Field } from './Field.js';
8
+ export { comboboxSizes, } from './combobox/types.js';
9
+ /** A key typed into an input method (Safari reports it as keyCode 229). */
10
+ function isComposing(event) {
11
+ return event.nativeEvent.isComposing || event.keyCode === 229;
12
+ }
13
+ /**
14
+ * A text input with a list of suggestions (ARIA 1.2 combobox, list
15
+ * autocomplete): typing, a press on the input or ArrowDown opens the list
16
+ * and the consumer filters `groups` from `inputValue`; the arrow keys move
17
+ * the active option (`aria-activedescendant`) while focus stays in the
18
+ * input; Enter or a press chooses it (`onSelect`) or runs the `action`;
19
+ * Escape closes the list, and a second Escape empties the input. A
20
+ * disabled option is shown and read as unavailable but never chosen. The
21
+ * active option is held by its value, so it stays the same option (or
22
+ * none) when the results change under it.
23
+ */
24
+ export function Combobox(props) {
25
+ assertCombobox(props);
26
+ const { action, countText, description, disabled = false, emptyText, error, groups, inputValue, label, labelVisuallyHidden = false, listLabel, note, onInputChange, onSelect, placeholder, required = false, size = 'md', } = props;
27
+ const listId = useId();
28
+ const noteId = useId();
29
+ const inputRef = useRef(null);
30
+ const [open, setOpen] = useState(false);
31
+ const [activeKey, setActiveKey] = useState(null);
32
+ // Disabling closes the list for good, so enabling never reopens it.
33
+ if (disabled && open) {
34
+ setOpen(false);
35
+ setActiveKey(null);
36
+ }
37
+ const entries = comboboxEntries(listId, groups, action);
38
+ const shown = open && !disabled;
39
+ const active = shown
40
+ ? entries.find((entry) => entry.key === activeKey)
41
+ : undefined;
42
+ // An active option that leaves the results is dropped, so it never comes
43
+ // back active on its own.
44
+ if (shown && activeKey !== null && !active)
45
+ setActiveKey(null);
46
+ const hasList = entries.length > 0;
47
+ const optionCount = entries.filter((entry) => entry.kind === 'option').length;
48
+ const announcement = !shown
49
+ ? ''
50
+ : optionCount === 0
51
+ ? emptyText
52
+ : (countText?.(optionCount) ?? '');
53
+ function close() {
54
+ setOpen(false);
55
+ setActiveKey(null);
56
+ }
57
+ function choose(entry) {
58
+ if (entry.kind === 'option') {
59
+ if (entry.option.disabled)
60
+ return;
61
+ close();
62
+ onSelect(entry.option.value);
63
+ }
64
+ else {
65
+ close();
66
+ entry.action.onPress();
67
+ }
68
+ }
69
+ function move(step) {
70
+ if (entries.length === 0)
71
+ return;
72
+ const last = entries.length - 1;
73
+ if (!shown || !active) {
74
+ setOpen(true);
75
+ setActiveKey(entries[step === 1 ? 0 : last].key);
76
+ return;
77
+ }
78
+ const next = (active.index + step + entries.length) % entries.length;
79
+ setActiveKey(entries[next].key);
80
+ }
81
+ function onKeyDown(event) {
82
+ if (isComposing(event))
83
+ return;
84
+ if (event.altKey && event.key === 'ArrowDown') {
85
+ event.preventDefault();
86
+ setOpen(true);
87
+ }
88
+ else if (event.altKey && event.key === 'ArrowUp') {
89
+ event.preventDefault();
90
+ close();
91
+ }
92
+ else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
93
+ event.preventDefault();
94
+ move(event.key === 'ArrowDown' ? 1 : -1);
95
+ }
96
+ else if (event.key === 'Enter') {
97
+ if (active) {
98
+ event.preventDefault();
99
+ choose(active);
100
+ }
101
+ }
102
+ else if (event.key === 'Escape') {
103
+ if (shown) {
104
+ event.preventDefault();
105
+ close();
106
+ }
107
+ else if (inputValue !== '') {
108
+ event.preventDefault();
109
+ onInputChange('');
110
+ }
111
+ }
112
+ // Tab moves focus out, and the blur closes the list.
113
+ }
114
+ return (_jsxs("div", { className: "sw-combobox", children: [_jsx(Field, { description: description, error: error, label: label, labelVisuallyHidden: labelVisuallyHidden, required: required, size: size, children: _jsx("input", { "aria-activedescendant": active?.id, "aria-autocomplete": "list", "aria-controls": shown && hasList ? listId : undefined, "aria-describedby": shown && note ? noteId : undefined, "aria-expanded": shown && hasList, autoComplete: "off", disabled: disabled, onBlur: close, onChange: (event) => {
115
+ onInputChange(event.currentTarget.value);
116
+ setOpen(true);
117
+ setActiveKey(null);
118
+ },
119
+ // A press on the input opens the list, as the boards show it
120
+ // under an empty field.
121
+ onClick: () => {
122
+ if (!shown)
123
+ setOpen(true);
124
+ }, onKeyDown: onKeyDown, placeholder: placeholder, ref: inputRef, role: "combobox", spellCheck: false, type: "text", value: inputValue }) }), _jsx("span", { "aria-live": "polite", className: "sw-sr-only", role: "status", children: announcement }), shown ? (_jsx(ComboboxPopup, { activeId: active?.id, emptyText: emptyText, entries: entries, groups: groups, inputRef: inputRef, listId: listId, listLabel: listLabel ?? label, note: note, noteId: noteId, onActivate: (entry) => setActiveKey(entry.key), onChoose: choose, onDismiss: close })) : null] }));
125
+ }
@@ -1,6 +1,8 @@
1
1
  import { type SpacingStep } from '@scalewing/tokens';
2
2
  import { type ReactNode } from 'react';
3
3
  export type FieldSize = 'xs' | 'md';
4
+ export declare const fieldVariants: readonly ["outlined", "search"];
5
+ export type FieldVariant = (typeof fieldVariants)[number];
4
6
  export type FieldProps = {
5
7
  children: ReactNode;
6
8
  gap?: SpacingStep;
@@ -35,5 +37,20 @@ export type FieldProps = {
35
37
  prefix?: string;
36
38
  /** Short text inside the control frame after the value, such as a unit. Not part of the value. */
37
39
  suffix?: string;
40
+ /**
41
+ * `outlined` (default) is the labelled form field. `search` is a search
42
+ * box: the label is not drawn (it stays the input's name), a `leading`
43
+ * glyph sits at the frame's start, the input is `type="search"`, and a
44
+ * clear button named `clearLabel` shows while the input holds text and
45
+ * is enabled; it calls `onClear` (set the value to `''`) and puts focus
46
+ * back in the input. Needs one native `input` child with a `value`.
47
+ */
48
+ variant?: FieldVariant;
49
+ /** A search field's glyph at its start, such as a magnifier; hidden from assistive technology. */
50
+ leading?: ReactNode;
51
+ /** A search field's clear button name, such as "Clear search". */
52
+ clearLabel?: string;
53
+ /** Called by a search field's clear button; the consumer empties the value. */
54
+ onClear?: () => void;
38
55
  };
39
56
  export declare function Field(props: FieldProps): import("react").JSX.Element;
@@ -1,10 +1,34 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { cloneElement, isValidElement, useId } from 'react';
3
+ import { cloneElement, isValidElement, useId, } from 'react';
4
4
  import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
6
  import { FieldLabelText } from './FieldLabelText.js';
7
+ import { SearchFrame } from './field/SearchFrame.js';
7
8
  import { Stack } from './Stack.js';
9
+ export const fieldVariants = ['outlined', 'search'];
10
+ function assertSearch(props, child) {
11
+ const { variant = 'outlined' } = props;
12
+ if (!fieldVariants.includes(variant))
13
+ throw new RangeError(`Unknown Field variant: ${String(variant)}`);
14
+ if (variant !== 'search') {
15
+ if (props.leading !== undefined ||
16
+ props.clearLabel !== undefined ||
17
+ props.onClear !== undefined)
18
+ throw new TypeError('Field leading, clearLabel and onClear are for variant="search"');
19
+ return;
20
+ }
21
+ if (!props.clearLabel?.trim())
22
+ throw new RangeError('A search Field needs a nonempty clearLabel');
23
+ if (typeof props.onClear !== 'function')
24
+ throw new TypeError('A search Field needs onClear');
25
+ if (props.prefix || props.suffix)
26
+ throw new TypeError('A search Field takes leading, not prefix or suffix');
27
+ if (!isValidElement(child) || child.type !== 'input')
28
+ throw new TypeError('A search Field needs one native input child');
29
+ if (child.props.value === undefined)
30
+ throw new TypeError('A search Field needs a controlled input: pass value (and onChange)');
31
+ }
8
32
  /**
9
33
  * `invalid` was passed, even as `false`, but not as `undefined` (a wrapper
10
34
  * forwarding an optional prop passes nothing): such a field needs one native
@@ -17,7 +41,9 @@ function passesInvalid(props) {
17
41
  return props.invalid !== undefined || props.changed !== undefined;
18
42
  }
19
43
  export function Field(props) {
20
- const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, changed = false, required = false, prefix, suffix, } = props;
44
+ const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, changed = false, required = false, prefix, suffix, variant = 'outlined', leading, clearLabel = '', onClear, } = props;
45
+ assertSearch(props, children);
46
+ const search = variant === 'search';
21
47
  const controlId = useId();
22
48
  const descriptionId = useId();
23
49
  const errorId = useId();
@@ -32,17 +58,20 @@ export function Field(props) {
32
58
  'sw-field',
33
59
  size === 'xs' && 'sw-field-xs',
34
60
  marksInvalid && 'sw-field-invalid',
61
+ search && 'sw-field-search-field',
35
62
  changed && !marksInvalid && 'sw-field-changed',
36
63
  ]
37
64
  .filter(Boolean)
38
65
  .join(' ');
39
- const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelVisuallyHidden }));
66
+ // A search field hides its whole label element instead (see below).
67
+ const labelHidden = labelVisuallyHidden && !search;
68
+ const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelHidden }));
40
69
  if (!isValidElement(children) ||
41
70
  typeof children.type !== 'string' ||
42
71
  !['input', 'select', 'textarea'].includes(children.type)) {
43
72
  if (needsControl)
44
73
  throw new TypeError('Field validation and adornments require one native input, select, or textarea child');
45
- return (_jsxs(Stack, { as: "label", className: className, gap: labelVisuallyHidden ? 0 : gap, children: [labelText, children] }));
74
+ return (_jsxs(Stack, { as: "label", className: className, gap: labelHidden ? 0 : gap, children: [labelText, children] }));
46
75
  }
47
76
  if (adorned && children.type !== 'input')
48
77
  throw new TypeError('Field prefix and suffix require a native input child');
@@ -63,6 +92,26 @@ export function Field(props) {
63
92
  'aria-invalid': marksInvalid ? true : children.props['aria-invalid'],
64
93
  'aria-labelledby': adornment.labelledBy,
65
94
  required: required || children.props.required,
95
+ ...(search
96
+ ? {
97
+ type: 'search',
98
+ enterKeyHint: children.props.enterKeyHint ?? 'search',
99
+ // Escape empties the box through onClear, the one clearing path
100
+ // (WebKit and Blink would otherwise clear it themselves).
101
+ onKeyDown: (event) => {
102
+ children.props.onKeyDown?.(event);
103
+ if (event.key === 'Escape' &&
104
+ !event.defaultPrevented &&
105
+ !event.currentTarget.readOnly &&
106
+ event.currentTarget.value !== '') {
107
+ event.preventDefault();
108
+ onClear?.();
109
+ }
110
+ },
111
+ }
112
+ : {}),
66
113
  });
67
- 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 })] }));
114
+ const value = children.props.value;
115
+ const hasText = value !== undefined && value !== null && String(value) !== '';
116
+ return (_jsxs(Stack, { as: "div", className: className, gap: labelHidden ? 0 : gap, children: [_jsx("label", { className: search ? 'sw-sr-only' : undefined, htmlFor: children.props.id || controlId, id: labelId, children: labelText }), search ? (_jsx(SearchFrame, { clearLabel: clearLabel, leading: leading, onClear: onClear, showClear: hasText && !children.props.disabled && !children.props.readOnly, children: control })) : 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 })] }));
68
117
  }
@@ -6,8 +6,24 @@ export type FilterChipOption = {
6
6
  };
7
7
  export type FilterChipsProps = {
8
8
  label: string;
9
+ /** The chosen option's value, or `''` while nothing is chosen yet. */
9
10
  value: string;
10
11
  onChange: (value: string) => void;
11
12
  options: readonly FilterChipOption[];
13
+ /**
14
+ * A choice must be made, such as a reason: each radio is `required` and
15
+ * the label shows the required mark (hidden from assistive technology,
16
+ * which reads the radios' own state).
17
+ */
18
+ required?: boolean;
19
+ /**
20
+ * A message under the chips, such as "Pick a reason": a polite live
21
+ * region linked to the group by `aria-describedby`; the group is marked
22
+ * invalid and every chip takes the danger edge. An empty string is no
23
+ * error.
24
+ */
25
+ error?: string;
26
+ /** More ids that describe the group, such as a hint beside it. */
27
+ 'aria-describedby'?: string;
12
28
  };
13
- export declare function FilterChips({ label, value, onChange, options, }: FilterChipsProps): import("react").JSX.Element;
29
+ export declare function FilterChips({ label, value, onChange, options, required, error, 'aria-describedby': describedByProp, }: FilterChipsProps): import("react").JSX.Element;
@@ -1,12 +1,17 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
4
  import { cx } from '../class-names.js';
5
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
5
6
  function isCount(count) {
6
7
  return count === undefined || (Number.isInteger(count) && count >= 0);
7
8
  }
8
- export function FilterChips({ label, value, onChange, options, }) {
9
+ export function FilterChips({ label, value, onChange, options, required = false, error, 'aria-describedby': describedByProp, }) {
9
10
  const name = useId();
11
+ const errorId = useId();
12
+ const describedBy = [describedByProp?.trim() || null, error ? errorId : null]
13
+ .filter((id) => id !== null)
14
+ .join(' ');
10
15
  const values = options.map((option) => option.value);
11
16
  if (!label.trim())
12
17
  throw new RangeError('label must not be empty');
@@ -15,12 +20,12 @@ export function FilterChips({ label, value, onChange, options, }) {
15
20
  throw new RangeError('options must have nonempty values and labels');
16
21
  if (new Set(values).size !== values.length)
17
22
  throw new RangeError('options must have unique values');
18
- if (!values.includes(value))
19
- throw new RangeError('value must match an option');
23
+ if (value !== '' && !values.includes(value))
24
+ throw new RangeError('value must match an option or be empty');
20
25
  if (options.some((option) => !isCount(option.count)))
21
26
  throw new RangeError('count must be a non-negative integer');
22
- return (_jsxs("fieldset", { className: "sw-filter-chips", children: [_jsx("legend", { className: "sw-filter-chips-legend", children: label }), _jsx("span", { className: "sw-filter-chips-options", children: options.map((option) => (_jsxs("label", { className: cx('sw-filter-chip', option.count === 0 && 'sw-filter-chip-quiet'), children: [_jsx("input", { checked: option.value === value, className: "sw-filter-chip-input", disabled: option.disabled, name: name, onChange: (event) => {
27
+ return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": error ? true : undefined, className: "sw-filter-chips", children: [_jsxs("legend", { className: "sw-filter-chips-legend", children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }), _jsx("span", { className: "sw-filter-chips-options", children: options.map((option) => (_jsxs("label", { className: cx('sw-filter-chip', option.count === 0 && 'sw-filter-chip-quiet'), children: [_jsx("input", { checked: option.value === value, className: "sw-filter-chip-input", disabled: option.disabled, name: name, onChange: (event) => {
23
28
  if (event.currentTarget.checked && !option.disabled)
24
29
  onChange(option.value);
25
- }, type: "radio", value: option.value }), _jsxs("span", { className: "sw-filter-chip-face", children: [option.label, option.count === undefined ? null : (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-filter-chip-count", children: option.count })] }))] })] }, option.value))) })] }));
30
+ }, required: required, type: "radio", value: option.value }), _jsxs("span", { className: "sw-filter-chip-face", children: [option.label, option.count === undefined ? null : (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-filter-chip-count", children: option.count })] }))] })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-filter-chips-error", id: errorId, message: error })] }));
26
31
  }
@@ -4,6 +4,8 @@ type ScrollRegionProps = {
4
4
  className: string;
5
5
  'aria-label'?: string;
6
6
  'aria-labelledby'?: string;
7
+ /** Adds `sw-scroll-fits` once measured with content that fits its width. */
8
+ markFits?: boolean;
7
9
  };
8
10
  /**
9
11
  * Private wrapper that scrolls wide content inside its own box (see
@@ -12,7 +14,9 @@ type ScrollRegionProps = {
12
14
  * keyboard and is announced as one group. While content is scrolled out past
13
15
  * an inline edge, that edge draws a shade (`sw-scroll-more-start`,
14
16
  * `sw-scroll-more-end`), so a phone, whose scrollbars hide, still shows that
15
- * the table goes on.
17
+ * the table goes on. With `markFits`, a measured region whose content fits
18
+ * its width carries `sw-scroll-fits`, so a table with a sticky footer can
19
+ * stop being a scroll box and pin its header and footer to the page.
16
20
  */
17
- export declare function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }: ScrollRegionProps): import("react").JSX.Element;
21
+ export declare function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, markFits, }: ScrollRegionProps): import("react").JSX.Element;
18
22
  export {};
@@ -10,10 +10,16 @@ import { useScrollOverflow } from './use-scroll-overflow.js';
10
10
  * keyboard and is announced as one group. While content is scrolled out past
11
11
  * an inline edge, that edge draws a shade (`sw-scroll-more-start`,
12
12
  * `sw-scroll-more-end`), so a phone, whose scrollbars hide, still shows that
13
- * the table goes on.
13
+ * the table goes on. With `markFits`, a measured region whose content fits
14
+ * its width carries `sw-scroll-fits`, so a table with a sticky footer can
15
+ * stop being a scroll box and pin its header and footer to the page.
14
16
  */
15
- export function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, }) {
17
+ export function ScrollRegion({ children, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, markFits = false, }) {
16
18
  const ref = useRef(null);
17
19
  const overflow = useScrollOverflow(ref);
18
- return (_jsx("div", { ref: ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: cx(className, overflow.start && 'sw-scroll-more-start', overflow.end && 'sw-scroll-more-end'), role: "group", tabIndex: 0, children: children }));
20
+ return (_jsx("div", { ref: ref, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, className: cx(className, overflow.start && 'sw-scroll-more-start', overflow.end && 'sw-scroll-more-end', markFits &&
21
+ overflow.measured &&
22
+ !overflow.start &&
23
+ !overflow.end &&
24
+ 'sw-scroll-fits'), role: "group", tabIndex: 0, children: children }));
19
25
  }