@scalewing/react 1.28.0 → 1.30.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 (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +48 -4
  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/Checkbox.js +5 -4
  8. package/dist/components/FilterChips.d.ts +17 -1
  9. package/dist/components/FilterChips.js +11 -6
  10. package/dist/components/GlyphTile.d.ts +21 -0
  11. package/dist/components/GlyphTile.js +17 -0
  12. package/dist/components/MultiSelect.d.ts +10 -0
  13. package/dist/components/MultiSelect.js +62 -0
  14. package/dist/components/ScrollRegion.d.ts +6 -2
  15. package/dist/components/ScrollRegion.js +9 -3
  16. package/dist/components/SectionNav.d.ts +25 -2
  17. package/dist/components/SectionNav.js +13 -4
  18. package/dist/components/SegmentedControl.d.ts +8 -0
  19. package/dist/components/SegmentedControl.js +4 -3
  20. package/dist/components/Table.d.ts +49 -5
  21. package/dist/components/Table.js +11 -4
  22. package/dist/components/action-menu/ActionMenuList.js +7 -7
  23. package/dist/components/checkbox/CheckboxControl.d.ts +9 -0
  24. package/dist/components/checkbox/CheckboxControl.js +9 -0
  25. package/dist/components/multi-select/MultiSelectPanel.d.ts +25 -0
  26. package/dist/components/multi-select/MultiSelectPanel.js +41 -0
  27. package/dist/components/multi-select/types.d.ts +35 -0
  28. package/dist/components/multi-select/types.js +1 -0
  29. package/dist/components/multi-select/validate.d.ts +3 -0
  30. package/dist/components/multi-select/validate.js +25 -0
  31. package/dist/components/table/TableCell.d.ts +8 -0
  32. package/dist/components/table/TableCell.js +4 -2
  33. package/dist/components/table/TableRow.d.ts +39 -0
  34. package/dist/components/table/TableRow.js +8 -0
  35. package/dist/components/use-scroll-overflow.d.ts +8 -3
  36. package/dist/components/use-scroll-overflow.js +15 -4
  37. package/dist/css/css-action-menu.js +41 -0
  38. package/dist/css/css-button-group-attached.d.ts +2 -0
  39. package/dist/css/css-button-group-attached.js +70 -0
  40. package/dist/css/css-button.js +5 -1
  41. package/dist/css/css-components.js +8 -0
  42. package/dist/css/css-filter-chips.js +26 -0
  43. package/dist/css/css-glyph-tile.d.ts +5 -0
  44. package/dist/css/css-glyph-tile.js +57 -0
  45. package/dist/css/css-multi-select.d.ts +2 -0
  46. package/dist/css/css-multi-select.js +156 -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-segmented.js +58 -0
  51. package/dist/css/css-table-footer.d.ts +2 -0
  52. package/dist/css/css-table-footer.js +57 -0
  53. package/dist/css/css-table-ledger.d.ts +10 -0
  54. package/dist/css/css-table-ledger.js +122 -0
  55. package/dist/css/css-table.js +8 -0
  56. package/dist/css/css-variables.js +11 -1
  57. package/dist/css/stacking.d.ts +9 -3
  58. package/dist/css/stacking.js +29 -9
  59. package/dist/css/stylesheet.js +4 -0
  60. package/dist/index.d.ts +4 -2
  61. package/dist/index.js +4 -2
  62. package/dist/styles.css +501 -7
  63. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.30.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 63695b5: `ActionMenuItem` `detail`, `detailLang` and `separatorBefore` (`docs/requests/teisoro-action-menu-detail.md`, Teisoro F-011 release R4):
8
+
9
+ - `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`.
10
+ - `separatorBefore`: a `role="separator"` hairline before the command (never before the first), skipped by the arrow keys.
11
+ - New classes `sw-action-menu-detail`, `sw-action-menu-chevron`, `sw-action-menu-separator`. No new tokens or dependencies.
12
+
13
+ - 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.
14
+ - 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):
15
+
16
+ - `required`: each radio `required` and the required mark after the label.
17
+ - `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).
18
+ - `aria-describedby`: consumer description ids, before the error.
19
+ - `value` may be `''` (nothing chosen); a value that is neither `''` nor an option still throws a `RangeError`.
20
+ - New class `sw-filter-chips-error`. No new tokens or dependencies.
21
+
22
+ - a60f130: `SectionNav` item `count` and `appearance="filled"` (`docs/requests/teisoro-section-nav-count.md`, Teisoro F-011 release R4):
23
+
24
+ - `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`.
25
+ - `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.
26
+ - `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`.
27
+ - New classes `sw-section-nav-filled` and `sw-section-nav-count`. No new tokens or dependencies.
28
+
29
+ - 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).
30
+
31
+ - `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.
32
+ - In forced colors pinned footer and header cells are the solid system canvas.
33
+ - 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.
34
+ - New classes `sw-table-footer-sticky` and `sw-scroll-fits`. No new tokens or dependencies.
35
+
36
+ - d08712d: Table ledger extensions (`docs/requests/teisoro-table-ledger.md`, Teisoro F-011 release R4):
37
+
38
+ - `TableCell` `tone` (`success`, `danger`, `muted`, `accent`; `TableCellTone`, `tableCellTones`): the cell's text in the theme colour.
39
+ - `Table` `columnRules`: a hairline between columns, mirrored right to left.
40
+ - `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).
41
+ - A body or footer row header (`th scope="row"`) now reads as body text instead of a muted column header.
42
+ - An unknown `tone` or `variant` throws a `RangeError`. No new tokens or dependencies.
43
+
44
+ ## 1.29.0
45
+
46
+ ### Minor Changes
47
+
48
+ - 98f6f89: `GlyphTile` (with `GlyphTileProps`, `GlyphTileHue`, `GlyphTileSize` and `glyphTileSizes`): a small rounded square in a categorical colour holding the consumer's glyph in white, the mark of a kind beside its name (`docs/requests/teisoro-glyph-tile.md`, Teisoro F-011 release R3).
49
+
50
+ - `hue` (`green`, `blue`, `orange`, `purple`, `teal`, `rose` or `neutral`, from `@scalewing/tokens` categorical colours), `size` (`sm` 24, `md` 28 by default, `lg` 32), `children` (the glyph, drawn at spacing step 4).
51
+ - Always `aria-hidden`; forced colors draw its edge. An unknown `hue` or `size` throws a `RangeError`.
52
+ - New generated custom properties `--sw-categorical-<colour>` and `--sw-categorical-on` in both schemes, and classes `sw-glyph-tile`, `sw-glyph-tile-<hue>` and `sw-glyph-tile-<size>`. Needs `@scalewing/tokens` 1.5.0. No new dependencies.
53
+
54
+ - b8fe06f: `MultiSelect` (with `MultiSelectProps`, `MultiSelectOption` and `MultiSelectAlign`): several choices from a short list behind one trigger (`docs/requests/teisoro-multi-select.md`, Teisoro F-011 release R3).
55
+
56
+ - `label`, `options` (`{ value, label, icon?, separatorBefore? }`), `value`, `onChange` (checked values in the options' order), `allLabel`, `clearLabel`, `summary` (the trigger's words), `disabled`, `align`.
57
+ - A disclosure: a button with `aria-expanded` showing a group of native checkboxes, with All and Clear, on the popover layer beside the trigger; focus moves to the first checkbox, Escape returns it to the trigger, and focus or a press outside closes the panel.
58
+ - Blank words, no or duplicate options, an unknown value or `align` throw a `RangeError`.
59
+ - New generated `sw-multi-select-*` classes; the panel joins the popup layer in `css/stacking.ts`. No new tokens or dependencies.
60
+
61
+ - 4a648a9: `SegmentedControl` item `icon` (`docs/requests/teisoro-segmented-control-icon.md`, Teisoro F-011 release R3): a consumer mark (such as a `GlyphTile`) before an item's label, `aria-hidden`. A track with icons takes the `md` control height and label type, a subtle track fill and a raised selected item; a track without icons is unchanged. New generated classes `sw-segmented-with-icons` and `sw-segmented-item-icon`. No new tokens or dependencies.
62
+
3
63
  ## 1.28.0
4
64
 
5
65
  ### Minor Changes
package/README.md CHANGED
@@ -213,9 +213,11 @@ A `Button`'s own children are laid out in a row one token gap apart (spacing ste
213
213
 
214
214
  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
215
 
216
+ `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.
217
+
216
218
  `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
219
 
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.
220
+ `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
221
 
220
222
  `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
223
 
@@ -327,7 +329,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
327
329
  />
328
330
  ```
329
331
 
330
- `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.
332
+ `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. An item's `icon` (such as a small `GlyphTile`) sits before its label, hidden from assistive technology, so the label names the segment; when any item has one, a compact track takes the `md` control height and label type on a subtle fill, with the selected item raised, and a filled track keeps its look and only gains the marks.
331
333
 
332
334
  ```tsx
333
335
  <SegmentedControl
@@ -348,7 +350,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
348
350
 
349
351
  `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
352
 
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.
353
+ `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
354
 
353
355
  ```tsx
354
356
  <SectionNav
@@ -387,10 +389,52 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
387
389
 
388
390
  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
391
 
392
+ 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`.
393
+
394
+ `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.
395
+
390
396
  `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
397
 
392
398
  `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.
393
399
 
400
+ `GlyphTile` is a small rounded square in a categorical colour with the consumer's glyph in white: the mark of a kind beside its name, such as a service in a list. `hue` is one of the `@scalewing/tokens` categorical colours (`green`, `blue`, `orange`, `purple`, `teal`, `rose`, or `neutral`); which kind takes which hue is yours. `size` is `sm` (24), `md` (28, the default) or `lg` (32); the glyph is drawn at spacing step 4 in white, which keeps 3:1 on every fill; pass the SVG (a Lucide component) as the tile's direct child, since only a direct-child SVG is sized. It is always `aria-hidden`, so put the kind's name in visible text beside it. An unknown `hue` or `size` throws a `RangeError`.
401
+
402
+ ```tsx
403
+ <Inline gap={2} align="center">
404
+ <GlyphTile hue="teal">
405
+ <Waves />
406
+ </GlyphTile>
407
+ <Text>Reef</Text>
408
+ </Inline>
409
+ ```
410
+
411
+ `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`.
412
+
413
+ ```tsx
414
+ <MultiSelect
415
+ label="Show"
416
+ summary={
417
+ shown.length === options.length ? 'All sightings' : `${shown.length} kinds`
418
+ }
419
+ options={[
420
+ {
421
+ value: 'forest',
422
+ label: 'Forest',
423
+ icon: (
424
+ <GlyphTile hue="green" size="sm">
425
+ <Trees />
426
+ </GlyphTile>
427
+ ),
428
+ },
429
+ { value: 'station', label: 'Station moves', separatorBefore: true },
430
+ ]}
431
+ value={shown}
432
+ onChange={setShown}
433
+ allLabel="All"
434
+ clearLabel="Clear"
435
+ />
436
+ ```
437
+
394
438
  `Notice` is a tinted note in the page's flow (`tone`: `info`, `success`, `warning` or `danger`). The fill is the tone mixed into the surface (the accent tint for `info`) and the words are the tone moved toward the text color, so they keep 4.5:1 on the fill in every palette (only where a palette's own `danger` is under 4.5:1 on its surface does the note stay at least as readable as that). `title` is a bold lead; a text message (values interpolated in it included) shares its row while it fits and wraps under it otherwise, and a message with any element (a `span`, or a sentence and a `Checkbox` or `Field` in a `Stack`) is stacked under it. Write the message as plain text or a `span`, not a `Text`, which brings its own color. A control inside keeps its own colors; its muted description takes the notice's ink so it stays readable on the fill. `action` is one link or small `Button` at the end of the row; `icon` is a consumer glyph before the text, hidden from assistive technology. `danger` is `role="alert"` and the other tones `role="status"`; `role="none"` is for a note that is part of the page from the start (no live region). Screen readers announce an `alert` that appears, but not reliably a `status` mounted with its words: to have a status heard, keep the Notice mounted and change its words, or move focus to it. It never dismisses itself. Pass `tabIndex={-1}` and a `ref` to move focus to it. `variant="band"` makes it a full-width square strip of page chrome in caption type with its words centered, at least the `xs` control height, such as an environment strip first on the page (`<Notice tone="warning" variant="band" title="Field station">Records here stay out of the census.</Notice>`). It is as wide as its container, so put it outside any padded column, as the page's first child. A glyph or an action stays on the words' row and the words wrap. A band has no live region unless you pass `role`. An unknown `tone`, `role` or `variant`, a blank `title`, or a note with neither a title nor a message throws a `RangeError`.
395
439
 
396
440
  ```tsx
@@ -513,7 +557,7 @@ A strip row with `entry` makes its counts editable: each column holds a `Stepper
513
557
  />
514
558
  ```
515
559
 
516
- `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.
560
+ `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`.
517
561
 
518
562
  `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.
519
563
 
@@ -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
  }
@@ -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 { CheckboxControl } from './checkbox/CheckboxControl.js';
4
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
5
6
  export function Checkbox({ label, checked, onCheckedChange, disabled = false, required = false, description, error, }) {
6
7
  const descriptionId = useId();
@@ -11,8 +12,8 @@ export function Checkbox({ label, checked, onCheckedChange, disabled = false, re
11
12
  ]
12
13
  .filter((id) => id !== null)
13
14
  .join(' ');
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) => {
15
- if (!disabled)
16
- onCheckedChange(event.currentTarget.checked);
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 })] }));
15
+ return (_jsxs("div", { className: "sw-checkbox", children: [_jsxs("label", { className: "sw-checkbox-label", children: [_jsx(CheckboxControl, { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), checked: checked, disabled: disabled, onChange: (event) => {
16
+ if (!disabled)
17
+ onCheckedChange(event.currentTarget.checked);
18
+ }, required: required }), _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 })] }));
18
19
  }
@@ -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
  }
@@ -0,0 +1,21 @@
1
+ import { type CategoricalColor } from '@scalewing/tokens';
2
+ import { type HTMLAttributes, type ReactNode } from 'react';
3
+ import { type GlyphTileSize, glyphTileSizes } from '../css/css-glyph-tile.js';
4
+ export { glyphTileSizes };
5
+ export type { GlyphTileSize };
6
+ /** A categorical hue (`green`, `blue`, `orange`, `purple`, `teal`, `rose`) or `neutral`. */
7
+ export type GlyphTileHue = CategoricalColor;
8
+ export type GlyphTileProps = Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'aria-hidden'> & {
9
+ /** The categorical fill: a hue that tells a kind apart, or `neutral`. */
10
+ hue: GlyphTileHue;
11
+ /** `sm` 24, `md` 28 (default), `lg` 32. */
12
+ size?: GlyphTileSize;
13
+ /** The consumer's glyph (Lucide), drawn in white at spacing step 4. */
14
+ children: ReactNode;
15
+ };
16
+ /**
17
+ * A small rounded square in a categorical colour holding a white glyph: a
18
+ * kind's mark beside its name, such as a service in a list. Decorative
19
+ * (`aria-hidden`): the visible label beside it carries the meaning.
20
+ */
21
+ export declare function GlyphTile({ children, className, hue, size, ...rest }: GlyphTileProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isCategoricalColor } from '@scalewing/tokens';
3
+ import { cx } from '../class-names.js';
4
+ import { glyphTileSizes } from '../css/css-glyph-tile.js';
5
+ export { glyphTileSizes };
6
+ /**
7
+ * A small rounded square in a categorical colour holding a white glyph: a
8
+ * kind's mark beside its name, such as a service in a list. Decorative
9
+ * (`aria-hidden`): the visible label beside it carries the meaning.
10
+ */
11
+ export function GlyphTile({ children, className, hue, size = 'md', ...rest }) {
12
+ if (!isCategoricalColor(hue))
13
+ throw new RangeError(`Unknown GlyphTile hue: ${String(hue)}`);
14
+ if (!glyphTileSizes.includes(size))
15
+ throw new RangeError(`Unknown GlyphTile size: ${String(size)}`);
16
+ return (_jsx("span", { ...rest, "aria-hidden": "true", className: cx('sw-glyph-tile', `sw-glyph-tile-${hue}`, `sw-glyph-tile-${size}`, className), children: children }));
17
+ }
@@ -0,0 +1,10 @@
1
+ import { type MultiSelectProps } from './multi-select/types.js';
2
+ export type { MultiSelectAlign, MultiSelectOption, MultiSelectProps, } from './multi-select/types.js';
3
+ /**
4
+ * Several choices from a short list behind one trigger: "Show All entries ▾"
5
+ * opens a panel with All, Clear and a checkbox per option. A disclosure (a
6
+ * button that shows a group of native checkboxes), not a listbox, so each
7
+ * option is an ordinary checkbox to every user. The consumer owns the
8
+ * checked values and the trigger's summary words.
9
+ */
10
+ export declare function MultiSelect({ align, allLabel, clearLabel, disabled, label, onChange, options, summary, value, }: MultiSelectProps): import("react").JSX.Element;
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId, useRef, useState } from 'react';
4
+ import { MultiSelectPanel } from './multi-select/MultiSelectPanel.js';
5
+ import { assertMultiSelect } from './multi-select/validate.js';
6
+ /**
7
+ * Several choices from a short list behind one trigger: "Show All entries ▾"
8
+ * opens a panel with All, Clear and a checkbox per option. A disclosure (a
9
+ * button that shows a group of native checkboxes), not a listbox, so each
10
+ * option is an ordinary checkbox to every user. The consumer owns the
11
+ * checked values and the trigger's summary words.
12
+ */
13
+ export function MultiSelect({ align = 'start', allLabel, clearLabel, disabled = false, label, onChange, options, summary, value, }) {
14
+ assertMultiSelect({
15
+ allLabel,
16
+ align,
17
+ clearLabel,
18
+ label,
19
+ options,
20
+ summary,
21
+ value,
22
+ });
23
+ const panelId = useId();
24
+ const triggerRef = useRef(null);
25
+ const [open, setOpen] = useState(false);
26
+ const checked = new Set(value);
27
+ const shown = open && !disabled;
28
+ // Disabling while open closes the panel for good (state adjusted during
29
+ // render), so enabling it again never reopens it, or pulls focus into it,
30
+ // on its own.
31
+ if (disabled && open)
32
+ setOpen(false);
33
+ function close(restoreFocus) {
34
+ setOpen(false);
35
+ if (restoreFocus)
36
+ triggerRef.current?.focus();
37
+ }
38
+ function report(next) {
39
+ onChange(options
40
+ .filter((option) => next.has(option.value))
41
+ .map((option) => option.value));
42
+ }
43
+ function onToggle(changed, isChecked) {
44
+ const next = new Set(checked);
45
+ if (isChecked)
46
+ next.add(changed);
47
+ else
48
+ next.delete(changed);
49
+ report(next);
50
+ }
51
+ function onTriggerKeyDown(event) {
52
+ if (event.key === 'ArrowDown' && !shown) {
53
+ event.preventDefault();
54
+ setOpen(true);
55
+ }
56
+ else if (event.key === 'Escape' && shown) {
57
+ event.preventDefault();
58
+ close(true);
59
+ }
60
+ }
61
+ return (_jsxs("div", { className: "sw-multi-select", children: [_jsxs("button", { "aria-controls": shown ? panelId : undefined, "aria-expanded": shown, className: "sw-multi-select-trigger", disabled: disabled, onClick: () => (shown ? close(true) : setOpen(true)), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: [_jsx("span", { className: "sw-multi-select-label", children: label }), ' ', _jsx("span", { className: "sw-multi-select-summary", children: summary })] }), shown ? (_jsx(MultiSelectPanel, { align: align, allLabel: allLabel, checked: checked, clearLabel: clearLabel, id: panelId, label: label, onAll: () => report(new Set(options.map((option) => option.value))), onClear: () => report(new Set()), onClose: close, onToggle: onToggle, options: options, triggerRef: triggerRef })) : null] }));
62
+ }
@@ -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
  }
@@ -1,5 +1,8 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import { type Breakpoint } from '../css/breakpoints.js';
3
+ import { type SectionNavAppearance } from '../css/css-section-nav-filled.js';
4
+ export type { SectionNavAppearance };
5
+ export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
3
6
  export type SectionNavItem = {
4
7
  id: string;
5
8
  label: string;
@@ -11,6 +14,20 @@ export type SectionNavItem = {
11
14
  current?: 'page' | 'location' | boolean;
12
15
  /** A decorative glyph before the label. */
13
16
  icon?: ReactNode;
17
+ /**
18
+ * How many things wait in the section, such as tasks to do: a filled pill
19
+ * after the label, read as part of the link's name ("Tasks 3"). A whole
20
+ * number; 0 (or none) shows no pill. There is no cap: a long number
21
+ * widens the link.
22
+ */
23
+ count?: number;
24
+ /**
25
+ * What the count means, in the consumer's words ("3 tasks to do"): read
26
+ * in the link's name in place of the bare number ("Tasks 3 tasks to
27
+ * do"; "Tasks 3 waiting" with "3 waiting"), the pill then hidden from
28
+ * assistive technology. Needs `count`.
29
+ */
30
+ countLabel?: string;
14
31
  };
15
32
  type SectionNavLabel = {
16
33
  'aria-label': string;
@@ -33,6 +50,13 @@ export type SectionNavProps = SectionNavLabel & {
33
50
  * marked by a bar at its start) instead of a row (marked by an underline).
34
51
  */
35
52
  verticalFrom?: Breakpoint;
53
+ /**
54
+ * How the current item is marked: `underline` (default) under a hairline
55
+ * row, or a bar at its start in a side list; `filled`, a quiet row of
56
+ * page buttons whose current one takes a soft fill, for an area's
57
+ * sub-navigation beside its name.
58
+ */
59
+ appearance?: SectionNavAppearance;
36
60
  };
37
61
  /**
38
62
  * A page's secondary navigation between the sections of one area, such as
@@ -40,5 +64,4 @@ export type SectionNavProps = SectionNavLabel & {
40
64
  * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
41
65
  * a side list. The current item carries `aria-current`.
42
66
  */
43
- export declare function SectionNav({ items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
44
- export {};
67
+ export declare function SectionNav({ appearance, items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
@@ -1,7 +1,9 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
3
  import { breakpoints } from '../css/breakpoints.js';
4
4
  import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
+ import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
6
+ export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
5
7
  function plainPress(event) {
6
8
  return (event.button === 0 &&
7
9
  !event.defaultPrevented &&
@@ -20,6 +22,12 @@ function assertItems(items) {
20
22
  throw new RangeError('SectionNav items need nonempty ids and labels');
21
23
  if (new Set(items.map((item) => item.id)).size !== items.length)
22
24
  throw new RangeError('SectionNav items need unique ids');
25
+ if (items.some((item) => item.count !== undefined &&
26
+ (!Number.isInteger(item.count) || item.count < 0)))
27
+ throw new RangeError('SectionNav item counts are whole numbers from 0');
28
+ if (items.some((item) => item.countLabel !== undefined &&
29
+ (!item.countLabel.trim() || item.count === undefined)))
30
+ throw new RangeError('SectionNav item countLabel is nonempty and comes with a count');
23
31
  }
24
32
  function assertBreakpoint(verticalFrom) {
25
33
  if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
@@ -31,10 +39,11 @@ function assertBreakpoint(verticalFrom) {
31
39
  * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
32
40
  * a side list. The current item carries `aria-current`.
33
41
  */
34
- export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
42
+ export function SectionNav({ appearance = 'underline', items, onNavigate, verticalFrom, ...label }) {
35
43
  assertItems(items);
36
44
  assertBreakpoint(verticalFrom);
37
- return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom)), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
45
+ assertSectionNavAppearance(appearance);
46
+ return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
38
47
  // Only a given onNavigate attaches a handler, so the links
39
48
  // render from a server component without one.
40
49
  onClick: onNavigate
@@ -44,5 +53,5 @@ export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
44
53
  event.preventDefault();
45
54
  onNavigate(item);
46
55
  }
47
- : undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label] }) }, item.id))) }) }));
56
+ : undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label, item.count ? (_jsxs(_Fragment, { children: [item.countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${item.countLabel}` })) : (' '), _jsx("span", { "aria-hidden": item.countLabel ? true : undefined, className: "sw-section-nav-count", children: item.count })] })) : null] }) }, item.id))) }) }));
48
57
  }