@scalewing/react 1.21.0 → 1.22.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 (65) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +46 -4
  3. package/dist/components/ActionMenu.d.ts +13 -1
  4. package/dist/components/ActionMenu.js +2 -2
  5. package/dist/components/BarChart.d.ts +28 -0
  6. package/dist/components/BarChart.js +5 -4
  7. package/dist/components/CalendarButton.d.ts +10 -0
  8. package/dist/components/CalendarButton.js +4 -3
  9. package/dist/components/DenominationGrid.d.ts +2 -2
  10. package/dist/components/DenominationGrid.js +5 -2
  11. package/dist/components/Field.d.ts +9 -0
  12. package/dist/components/Field.js +3 -2
  13. package/dist/components/SectionNav.d.ts +44 -0
  14. package/dist/components/SectionNav.js +48 -0
  15. package/dist/components/Table.d.ts +38 -13
  16. package/dist/components/Table.js +7 -12
  17. package/dist/components/Tabs.d.ts +3 -1
  18. package/dist/components/Tabs.js +8 -2
  19. package/dist/components/Tooltip.js +19 -3
  20. package/dist/components/action-menu/ActionMenuList.d.ts +3 -2
  21. package/dist/components/action-menu/ActionMenuList.js +16 -4
  22. package/dist/components/date-field/CalendarDialog.d.ts +4 -1
  23. package/dist/components/date-field/CalendarDialog.js +2 -2
  24. package/dist/components/date-field/CalendarGrid.d.ts +4 -1
  25. package/dist/components/date-field/CalendarGrid.js +11 -3
  26. package/dist/components/denomination-grid/DenominationStrip.d.ts +2 -2
  27. package/dist/components/denomination-grid/DenominationStrip.js +17 -6
  28. package/dist/components/denomination-grid/types.d.ts +15 -2
  29. package/dist/components/table/TableCell.d.ts +42 -0
  30. package/dist/components/table/TableCell.js +27 -0
  31. package/dist/components/table/table-layout.d.ts +10 -0
  32. package/dist/components/table/table-layout.js +14 -0
  33. package/dist/components/tooltip/tooltip-open.d.ts +8 -0
  34. package/dist/components/tooltip/tooltip-open.js +60 -0
  35. package/dist/components/tooltip/use-tooltip-placement.d.ts +11 -0
  36. package/dist/components/tooltip/use-tooltip-placement.js +46 -0
  37. package/dist/components/use-anchored-popover.d.ts +2 -0
  38. package/dist/components/use-anchored-popover.js +2 -1
  39. package/dist/css/css-action-menu.js +24 -0
  40. package/dist/css/css-chart.js +45 -1
  41. package/dist/css/css-components.js +3 -0
  42. package/dist/css/css-date-field.js +47 -1
  43. package/dist/css/css-denomination-aligned.d.ts +7 -0
  44. package/dist/css/css-denomination-aligned.js +68 -0
  45. package/dist/css/css-denomination-grid.js +7 -1
  46. package/dist/css/css-field.js +14 -0
  47. package/dist/css/css-scroll-region.d.ts +8 -0
  48. package/dist/css/css-scroll-region.js +9 -3
  49. package/dist/css/css-section-nav.d.ts +4 -0
  50. package/dist/css/css-section-nav.js +120 -0
  51. package/dist/css/css-table-layout.d.ts +20 -0
  52. package/dist/css/css-table-layout.js +59 -0
  53. package/dist/css/css-table-sort.d.ts +2 -0
  54. package/dist/css/css-table-sort.js +105 -0
  55. package/dist/css/css-table.js +8 -0
  56. package/dist/css/css-tabs.d.ts +6 -1
  57. package/dist/css/css-tabs.js +9 -1
  58. package/dist/css/css-tooltip.js +7 -4
  59. package/dist/css/stylesheet.js +2 -0
  60. package/dist/date-only.d.ts +12 -0
  61. package/dist/date-only.js +24 -0
  62. package/dist/index.d.ts +4 -3
  63. package/dist/index.js +1 -0
  64. package/dist/styles.css +363 -6
  65. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.22.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2737927: `ActionMenu` `header` (`docs/requests/teisoro-action-menu.md`, 2026-10-08 follow-up, Teisoro F-006-S11, SET-2). `header?: ReactNode` 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 from the commands by a hairline and lined up with their words. It is not a menu item: it sits in the popover beside the `role="menu"` element, outside the arrow-key order, and is the menu's description (`aria-describedby`), so it is read as the menu opens. Without a header the markup is unchanged (the menu is the popover); with one, the popover is a `div.sw-action-menu-list` holding `.sw-action-menu-header` and the menu (`.sw-action-menu-items`), and the trigger's `aria-controls` names the popover. `null`, `false` and `''` are no header. New generated classes `sw-action-menu-header` and `sw-action-menu-items`. No new tokens and no new dependencies.
8
+ - 2737927: `ActionMenuItem` `lang` (`docs/requests/teisoro-action-menu.md`, 2026-10-08 follow-up, Teisoro F-006-S11, SET-14). `lang?: string`, a BCP 47 tag, marks a command's label as another language than the page's, such as "English" on a Spanish page, so assistive technology reads it in its own language (WCAG 3.1.2). It is set on the label's own element, not the command, so the command keeps the page's language for anything else. Without it the markup is unchanged. No new classes, tokens or dependencies.
9
+ - 94a2f1d: `BarChart` `formatValue` and `diverging` (`docs/requests/teisoro-bar-chart.md`, Teisoro F-006-S11, RPT-14). `formatValue?: (value: number) => string` writes the axis's values and every item's value that has no `valueLabel`, such as a currency (default: the plain number it wrote before, so the axis's "0" is now `formatValue(0)`). `diverging?: boolean`, off by default, puts zero in the middle of each track behind a hairline: a positive value's bar grows toward the inline end and a negative value's toward the start, each up to half the track on the same scale, with a square end at zero, and the axis reads minus `max`, 0 and `max`. Without it every bar still grows from the start. New generated class `sw-bar-chart-diverging`. No new tokens and no new dependencies.
10
+ - 637d17e: `CalendarButton` `range` (`docs/requests/teisoro-calendar-button.md`, 2026-10-08 follow-up, Teisoro F-006-S11, RPT-17). `range?: { start: string; end: string }` (`YYYY-MM-DD`, both ends included) is the span a page shows around `value`, such as a week or a month: the open calendar tints its days as one band per week row (the accent at 16 % over the calendar's glass, 24 % under the pointer; square inside, rounded at the start and the end, both for a one-day range; `Mark` and `MarkText` in forced colors, today included). Every number on the band is in the text color, which keeps 4.5:1 on it in every palette and scheme, as muted and accent do not; today keeps its accent ring, 3:1 or more on the band. A picker for a period thus shows the whole period. `value` stays the selected day, now always a pill, and today's ring is unchanged. The tint is not announced; the page's own heading names the period. A malformed end or a start after the end throws a `RangeError`. New exported type `CalendarButtonRange`; new generated classes `sw-date-field-day-in-range`, `sw-date-field-day-range-start`, `sw-date-field-day-range-end`. No new tokens and no new dependencies.
11
+ - 9ab7c15: `DenominationGrid` `labelWidth` (`docs/requests/teisoro-denomination-grid.md`, 2026-10-08 follow-up, Teisoro F-006-S11, DRW-27 and VLT-14). `labelWidth?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'` (4, 6, 8, 12 and 16 rem, the `Table` column sizes) lines a strip's columns up with every other strip of the same columns in a container of the same width, such as the cards of a feed: the row labels take that width and the count columns (and the total column from md up) share the rest of the strip's scroll region equally, measured from the region (a size container, `100cqi`) rather than from the counts, so every column is sized and no content moves one. A count wider than its share widens its column and the strip scrolls as a plain one does; below md the total column still takes no width. Without it each strip sizes its columns by its own content, as before. The tiles layout ignores it. An unknown width throws a `RangeError`. New exported type `DenominationLabelWidth`; new generated classes `sw-denomination-scroll-aligned`, `sw-denomination-strip-aligned`, `sw-denomination-label-{xs,sm,md,lg,xl}`; the strip sets `--sw-denomination-columns` and `--sw-denomination-total` on its table. No new tokens and no new dependencies.
12
+ - 9ab7c15: `DenominationGrid` shows its `totalLabel` (`docs/requests/teisoro-denomination-grid.md`, 2026-10-08 follow-up, Teisoro F-006-S11, HIS-11). The strip's total column header, which was visually hidden at every width, now shows the label over the totals from md up, in the column heads' style (`sw-denomination-head`), its end on theirs. Below md it stays visually hidden with the column, as each total sits under its row's label there. A strip without `totalLabel` keeps its empty corner. The label's span is now `sw-denomination-total-label` (was `sw-sr-only`). New generated class `sw-denomination-total-label`. No API change and no new dependencies.
13
+ - cdd92aa: `Field` `changed` (`docs/requests/teisoro-field-validation.md`, 2026-10-08 follow-up, Teisoro F-006-S11, COR-5). `changed?: boolean` marks a value the person changed from a saved one, such as a field in a correction: the control's border, or an adorned field's frame, in the accent color and a hairline thicker through an inset shadow, so nothing moves (`Highlight` in forced colors). It is a cue beside the words: say what the value was in `description`. `invalid` and `error` win over it. It sets no ARIA state. Like `invalid`, passing it, even `false` but not `undefined`, requires one native control child from the first render (a `TypeError` otherwise). New generated class `sw-field-changed`. No new tokens and no new dependencies.
14
+ - c6e46b3: `SectionNav` (`docs/requests/teisoro-section-nav.md`, Teisoro F-006-S11, ADM-1), a new component: 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 navigation: label-size words in the muted color, no fill, the current item in the text color over an accent underline on the row's hairline. `current` is `'page'`, `'location'` (a page inside the section) or a boolean, written as `aria-current`. `onNavigate?: (item) => void` takes a plain press (main button, no modifier key) after preventing the browser's navigation, for a client router; a modified or middle press keeps the browser's behavior. `verticalFrom?: Breakpoint` stacks the items as a side list from that breakpoint, the current one marked by a bar at its start. A coarse pointer gets 44 px targets; forced colors draw `LinkText` and a `Highlight` mark. The links carry a click handler only when `onNavigate` is given, so a server component can render the nav without one. Empty or duplicate ids, empty labels and an unknown `verticalFrom` throw a `RangeError`. New exported types `SectionNavItem`, `SectionNavProps`; new generated classes `sw-section-nav`, `sw-section-nav-list`, `sw-section-nav-link`, `sw-section-nav-icon`, `sw-section-nav-vertical-from-md`. No new tokens and no new dependencies.
15
+ - 864119d: `Table` `layout` and `verticalAlign`, and `TableCell` `width` (`docs/requests/teisoro-table.md`, 2026-10-08 follow-up, Teisoro F-006-S11, CHG-15 and HIS-12). `layout?: 'auto' | 'fixed'` (default `auto`): `fixed` sizes the columns from the header row's widths alone (`table-layout: fixed`), so a filter that hides a row moves no column; a column without a width shares what is left (when every column has one, spare width is shared in proportion to them). `width?: 'min' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'` on a cell: the sizes are 4, 6, 8, 12 and 16 rem; `min` gives an auto table's column the least room its content allows and keeps that cell on one line (set it on each cell of the column), and is no width in a fixed table. `verticalAlign?: 'middle' | 'top'` (default `middle`): `top` starts every cell of a tall row on its first line. The deprecated HTML `width` attribute is no longer accepted on `TableCell`: browsers honoured it as a presentational hint, so a cell given `width="120"` had that width, and it is now a type error and a `RangeError`. Migration: pass one of the sizes above (`width="sm"` and so on), or a class of your own; no known consumer passed it. An unknown value throws a `RangeError`. New exported types `TableLayout`, `TableVerticalAlign`, `TableColumnWidth`; new generated classes `sw-table-fixed`, `sw-table-top`, `sw-table-col-{min,xs,sm,md,lg,xl}`. `TableCell` moved to its own module; its import path from the package is unchanged. No new tokens and no new dependencies.
16
+ - 0f613ea: `TableCell` `sort` and `onSort` (`docs/requests/teisoro-table.md`, 2026-10-08 follow-up, Teisoro F-006-S11, RPT-13): a header cell (`as="th"`) with `sort?: 'ascending' | 'descending' | 'none'` and `onSort?: () => void` is its column's sort control. Its children become a `button` in the header's own text style (caption, semibold, muted; the sorted column's name in the text color) with a stroked chevron after them: up when `ascending`, down when `descending`, a quieter pair when `none`. In a `numeric` or `align="end"` column the chevron goes before the name, so the name lines up with the figures. The cell carries `aria-sort` only while it is the sorted column. The button has the accent focus ring and a 44 px target on a coarse pointer. `sort` on a data cell, or one of the two without the other, throws a `TypeError`; an unknown `sort` throws a `RangeError`. New exported type `TableSort`; new generated classes `sw-table-sort`, `sw-table-sort-glyph`, `sw-table-sort-{ascending,descending,none}`. No new tokens and no new dependencies.
17
+ - 5e52402: A `Tabs` strip shows that it scrolls (`docs/requests/teisoro-tabs.md`, 2026-10-08 follow-up, Teisoro F-006-S11, RPT-11). While its labels are scrolled out past an inline edge, the tablist adds `sw-scroll-more-start` and/or `sw-scroll-more-end` and that edge draws the same inset shade as a wide `Table`'s scroll region, mirrored right to left, under the tabs so nothing moves. A strip whose labels fit is unchanged: no class, no shadow. It measures on mount, on scroll and on resize; before mount and on the server it reports no overflow. Forced colors draw no shade. `sticky` strips take it too. The classes already exist (`scrollShadeRules` now generates them for the strip); no prop changes, no new tokens and no new dependencies.
18
+ - ac55076: `Tooltip` opens on a visible focus only, and keeps its bubble inside the screen (`docs/requests/teisoro-tooltip.md`, 2026-10-08 follow-up, Teisoro F-006-S11, SET-9). Behavior changes, no API change:
19
+
20
+ - A focus opens the tooltip only when it is `:focus-visible` (the keyboard, or a script after keyboard use). The focus a click or a tap gives no longer opens it, so a pressed control does not keep its help shown, even on the next page, until the focus moves. Hover still opens it. A browser without `:focus-visible` counts every focus as visible, as before.
21
+ - A tap toggles the tooltip only on a trigger that does nothing else on a tap, such as a focusable badge. A tap on a control (a button, a link or image-map area, a form control or its label, media with controls, an editable region or an element with a widget role such as combobox, slider, textbox or treeitem, or anything inside one) does what the control does and leaves the tooltip closed. Consumers that relied on a tap on an icon button showing its help should show the name as text on touch screens.
22
+ - The bubble is a manual popover on the top layer while it shows, placed beside its trigger as `ActionMenu` is: under it (over it near the bottom of the screen), lined up with its start or, where that would leave the screen, its end, a `space-2` inset from every edge, following scrolling and resizing. While hidden it stays in the page (a `label` tooltip still names its trigger) and is not a popover. `.sw-tooltip` is now `position: fixed` and capped at `min(18rem, 100% − 2 × space-2)` of the viewport.
23
+
24
+ No new classes, tokens or dependencies.
25
+
3
26
  ## 1.21.0
4
27
 
5
28
  ### Minor Changes
package/README.md CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  Card,
17
17
  Checkbox,
18
18
  RadioGroup,
19
+ SectionNav,
19
20
  Spinner,
20
21
  Progress,
21
22
  Tooltip,
@@ -52,6 +53,8 @@ Import the CSS once at the application entry. Do not copy it into your source tr
52
53
 
53
54
  `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby` and appends a stable ID for the hint, or for the error, which replaces the hint on screen while it is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The error sits in a polite live region (`aria-live="polite"`) that is always rendered, empty while there is no error, so an error that appears while the person types, or after a submit, is announced once, politely, when its text is swapped in; it is never `role="alert"`, so a submit that finds several errors does not fire several assertive alerts at once. Empty, the region takes no room in the layout. The consumer owns validation and localized messages, and after a refused submit it moves focus to the first invalid control (or to one form-level summary). `Checkbox`, `RadioGroup` and `DateField` expose their `error` the same way. `invalid` marks the control invalid the same way (`aria-invalid` and the danger border, `Mark` in forced colors) without a message of its own, for fields whose one error is shown elsewhere, such as a count that adds up to nothing under a group of count fields: point each control's `aria-describedby` at that message so it is read with the field. The field's own polite region stays empty and its `description` stays on screen; `error` implies `invalid`. Passing `invalid`, even `invalid={false}` but not `invalid={undefined}`, needs one native control child and throws a `TypeError` on the first render otherwise, so a form learns it while the fields are valid, not on its first failed submit. `error` keeps its rule: it needs the native control only while it holds a message. `invalid` is `Field`'s only: `DateField`, `Select` and the other controls with `error` do not have it yet.
54
55
 
56
+ `Field` `changed` marks a value the person changed from a saved one, such as a field in a correction: the control's border, or an adorned field's frame, takes the accent color a hairline thicker (an inset shadow, so nothing moves; `Highlight` in forced colors). It is a cue beside the words, so say what the value was in `description`; it sets no ARIA state, and `invalid` and `error` win over it. Like `invalid`, passing it, even `false` but not `undefined`, needs one native control child from the first render (a `TypeError` otherwise).
57
+
55
58
  ```tsx
56
59
  <Field invalid={countIsZero} label="$1 bills">
57
60
  <input aria-describedby={countIsZero ? countErrorId : undefined} />
@@ -155,6 +158,27 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
155
158
 
156
159
  `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.
157
160
 
161
+ `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).
162
+
163
+ ```tsx
164
+ <ActionMenu
165
+ header={
166
+ <>
167
+ <Text as="strong" variant="label">
168
+ Estación Laguna Azul
169
+ </Text>
170
+ <Text as="span" color="muted" variant="caption">
171
+ Aves acuáticas
172
+ </Text>
173
+ </>
174
+ }
175
+ items={[
176
+ { id: 'language', label: 'English', lang: 'en', onSelect: toEnglish },
177
+ ]}
178
+ label="Estación Laguna Azul"
179
+ />
180
+ ```
181
+
158
182
  `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true. The `title` is an `h3` in the title style by default; `titleLevel={2}` renders it as an `h2` in the same style, for a dialog whose own section labels are `h3` headings, so they read as parts of the dialog and not as its peers. The default stays `3` so existing dialogs keep their outline; any value other than `2` renders an `h3`. `sheetBelow="md"` makes the dialog a bottom sheet below `md`: docked to the bottom edge at the full viewport width, top corners rounded and bottom corners square, a `space-8` strip of backdrop above it, and bottom padding that clears `env(safe-area-inset-bottom)` (with `viewport-fit=cover`); it slides up on open unless reduced motion is asked for, and has no grabber, since there is no drag. From `md` up it is the centered dialog at its `size`. `closeLabel` (such as "Close") adds an icon-only ghost close button at the end of the title row, named by the label, which calls `onClose` like Escape; the title still names the dialog. The button is the dialog's first control, so the browser focuses it on open; give a field `autoFocus` to start there instead. Without `closeLabel` the dialog has no close button and its markup is unchanged.
159
183
 
160
184
  `Toast` is an auto-dismiss confirmation on the popover layer, controlled by `open` and `onOpenChange`; it does not trap focus and closes after `timeoutMs` (default 800, or 6000 for a `warning` or `danger` toast, which must be read). `anchor` and `target` send it from a press to a destination. `tone` (`neutral` by default, `success`, `warning`, `danger`) tints its border and an optional consumer `icon` (a Lucide glyph, hidden from assistive technology); the message keeps the text color, so its words carry the meaning. A `danger` toast is announced as `role="alert"`, the others as `role="status"`, so do not wrap the message in another live region. A toast still dismisses itself: an error the person must act on belongs in the page. A toast is as wide as its message, up to the page's gutter (spacing step 4) on each side, so on a phone a long message wraps inside the gutter instead of running to the screen's edges.
@@ -218,7 +242,21 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
218
242
  />
219
243
  ```
220
244
 
221
- `Tabs` is the strip for the sections of one page (`role="tablist"`, one `role="tab"` button per item; arrow keys, Home and End move and select), and `TabPanel` is each section's content, hidden while another tab is current. `sticky` (default `false`) keeps the strip at the top of the viewport, under `env(safe-area-inset-top)`, while a long panel scrolls under it, on the same layer as a sticky `AppHeader` (so a page uses one or the other at the top edge). The stuck strip is a full-bleed band of the page canvas, not a glass card: the canvas color at nine parts in ten over the glass blur, with the strip's hairline under it, solid canvas under Reduce Transparency and in forced colors. A sticky element only sticks within its parent: make the strip a direct child of the long page container that also holds the panels, not of a padded `Box` round the strip alone, and keep `overflow` visible on its ancestors. Put the page's side gutter on the title and the panels instead: each tab's inline padding is the md control's 16 px (spacing step 4), so the first label lines up with a `space-4` gutter when the strip runs edge to edge.
245
+ `Tabs` is the strip for the sections of one page (`role="tablist"`, one `role="tab"` button per item; arrow keys, Home and End move and select), and `TabPanel` is each section's content, hidden while another tab is current. `sticky` (default `false`) keeps the strip at the top of the viewport, under `env(safe-area-inset-top)`, while a long panel scrolls under it, on the same layer as a sticky `AppHeader` (so a page uses one or the other at the top edge). The stuck strip is a full-bleed band of the page canvas, not a glass card: the canvas color at nine parts in ten over the glass blur, with the strip's hairline under it, solid canvas under Reduce Transparency and in forced colors. A sticky element only sticks within its parent: make the strip a direct child of the long page container that also holds the panels, not of a padded `Box` round the strip alone, and keep `overflow` visible on its ancestors. Put the page's side gutter on the title and the panels instead: each tab's inline padding is the md control's 16 px (spacing step 4), so the first label lines up with a `space-4` gutter when the strip runs edge to edge. A strip whose labels do not fit scrolls sideways, and while labels are scrolled out past an inline edge that edge draws the same inset shade as a wide `Table` (none in forced colors), so a phone shows there are more tabs.
246
+
247
+ `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.
248
+
249
+ ```tsx
250
+ <SectionNav
251
+ aria-label="Reserve office"
252
+ items={[
253
+ { id: 'counts', label: 'Counts', href: '/office/counts', current: 'page' },
254
+ { id: 'rangers', label: 'Rangers', href: '/office/rangers' },
255
+ ]}
256
+ onNavigate={(item) => navigate(item.href)}
257
+ verticalFrom="md"
258
+ />
259
+ ```
222
260
 
223
261
  ```tsx
224
262
  <Stack gap={3}>
@@ -243,11 +281,15 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
243
281
 
244
282
  `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. While the table is wider than the region, each inline edge with columns scrolled out past it draws a soft inset shade (`sw-scroll-more-start`, `sw-scroll-more-end`, mirrored right to left), so a phone, whose scrollbars stay hidden, still shows that the table goes on; a table that fits draws none, and in forced colors the system scrollbar is the cue. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
245
283
 
284
+ 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`.
285
+
286
+ `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.
287
+
246
288
  `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.
247
289
 
248
290
  `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`. `showCount={false}` hides the visible `value / max` count when the page shows its own count caption, so the count appears once; the progress bar still exposes its value and maximum.
249
291
 
250
- `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger, or names an icon-only one (`relationship="label"`, below). Supply localized `content` and an existing trigger element; by default it has its own accessible name. It opens on hover or focus, closes on pointer leave, blur, Escape, or outside touch, and toggles on touch. Keep required instructions visible outside the tooltip. `disabled` turns the tooltip off, not its trigger: the trigger stays mounted, enabled and focusable, and there is no tooltip, no `aria-describedby` from it, and Escape is left to the page. Use it when what the tooltip says is visible at some widths, such as text shown only from `md` up, instead of rendering the trigger with and without a `Tooltip`, which mounts a new trigger and drops its focus. Focus, hover and touch are still followed, so a trigger that still has focus, the pointer or an open touch toggle shows the tooltip as soon as it is enabled again (Escape pressed while disabled reaches the page and does not stop that). `disabled` must be the same in the server render and the first client render, or hydration mismatches; read a breakpoint through a hydration-safe store, such as `useSyncExternalStore` with a server snapshot. `relationship="label"` makes the tooltip its trigger's name instead of its description, for an icon-only control whose tooltip says what it is: the trigger gets `aria-labelledby` (after its own, if any) and no `aria-describedby` from the tooltip, so the text is read once. The tooltip stays in the page, hidden, so it names the trigger while it is not shown, and it wins over an `aria-label`. While `disabled` there is no tooltip to name the trigger, so give the trigger its own name for that state: visible text, such as a label shown only from `md` up, or an `aria-label`. Do not use `relationship="label"` while the trigger shows visible text that differs from `content`: the tooltip would replace that visible label as the name (WCAG 2.5.3, label in name). The default, `relationship="description"`, is the supplementary help above. `relationship` must also be the same in the server render and the first client render.
292
+ `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger, or names an icon-only one (`relationship="label"`, below). Supply localized `content` and an existing trigger element; by default it has its own accessible name. It opens on hover or on a visible focus (`:focus-visible`: the keyboard, not the focus a click or a tap gives), closes on pointer leave, blur, Escape, or outside touch, and a tap toggles it only on a trigger that does nothing else on a tap, such as a focusable badge; a tap on a control (a button, a link, a form control or its label, an editable region, an element with a widget role, or anything inside one) does what the control does. The bubble is placed beside its trigger on the top layer while it shows and stays a `space-2` inset inside the screen. Keep required instructions visible outside the tooltip. `disabled` turns the tooltip off, not its trigger: the trigger stays mounted, enabled and focusable, and there is no tooltip, no `aria-describedby` from it, and Escape is left to the page. Use it when what the tooltip says is visible at some widths, such as text shown only from `md` up, instead of rendering the trigger with and without a `Tooltip`, which mounts a new trigger and drops its focus. Focus, hover and touch are still followed, so a trigger that still has focus, the pointer or an open touch toggle shows the tooltip as soon as it is enabled again (Escape pressed while disabled reaches the page and does not stop that). `disabled` must be the same in the server render and the first client render, or hydration mismatches; read a breakpoint through a hydration-safe store, such as `useSyncExternalStore` with a server snapshot. `relationship="label"` makes the tooltip its trigger's name instead of its description, for an icon-only control whose tooltip says what it is: the trigger gets `aria-labelledby` (after its own, if any) and no `aria-describedby` from the tooltip, so the text is read once. The tooltip stays in the page, hidden, so it names the trigger while it is not shown, and it wins over an `aria-label`. While `disabled` there is no tooltip to name the trigger, so give the trigger its own name for that state: visible text, such as a label shown only from `md` up, or an `aria-label`. Do not use `relationship="label"` while the trigger shows visible text that differs from `content`: the tooltip would replace that visible label as the name (WCAG 2.5.3, label in name). The default, `relationship="description"`, is the supplementary help above. `relationship` must also be the same in the server render and the first client render.
251
293
 
252
294
  `Separator` divides sections with a token-colored line. It is horizontal by default; use `orientation="vertical"` in a flex row. Set `decorative` when the line only supports layout so assistive technology ignores it.
253
295
 
@@ -285,7 +327,7 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
285
327
  </Grid>
286
328
  ```
287
329
 
288
- `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start and the same edge shade as `Table` while counts are scrolled out of view (the start shade is cast from the pinned labels' edge, since they cover the region's own); the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. `rowRole="group"` (default `"region"`) keeps every named row's name but makes it a `group` instead of a landmark region, for a page with many grids (one per order card, say) whose rows would otherwise fill the landmark list; the strip ignores it, since its rows are table rows, and an unknown `rowRole` throws a `RangeError` in either layout. A row's icon and its label words stay on one line in both layouts (`sw-denomination-label-line`): a long label wraps its words beside the icon, never under it, and on a phone the strip's total goes on its own line under them. A row's `cellTones` (one Badge tone or `null` per column) tones a single count in place: the count is set in the tone over its signed or zero look, and in the tiles layout its tile's border takes the tone too, so a short bill turns red without leaving its row; `neutral` or `null` leaves a count as it is, a row's `tone` still colors only its label, and a `cellTones` that is not one known tone or `null` per column throws. The tone never says why on its own; put that in words beside the grid. Every negative count, signed or not and in either layout, is written with the typographic minus ("−1"), never a hyphen. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
330
+ `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column: it renders a `th scope="col"` shown over the totals from `md` up and visually hidden below it, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). `labelWidth` (`xs` to `xl`, the `Table` column sizes) lines a strip's columns up with every other strip of the same columns in a container of the same width, such as the cards of a feed: the row labels take that width and the count columns share the rest of the region equally, so no content moves a column. On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start and the same edge shade as `Table` while counts are scrolled out of view (the start shade is cast from the pinned labels' edge, since they cover the region's own); the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. `rowRole="group"` (default `"region"`) keeps every named row's name but makes it a `group` instead of a landmark region, for a page with many grids (one per order card, say) whose rows would otherwise fill the landmark list; the strip ignores it, since its rows are table rows, and an unknown `rowRole` throws a `RangeError` in either layout. A row's icon and its label words stay on one line in both layouts (`sw-denomination-label-line`): a long label wraps its words beside the icon, never under it, and on a phone the strip's total goes on its own line under them. A row's `cellTones` (one Badge tone or `null` per column) tones a single count in place: the count is set in the tone over its signed or zero look, and in the tiles layout its tile's border takes the tone too, so a short bill turns red without leaving its row; `neutral` or `null` leaves a count as it is, a row's `tone` still colors only its label, and a `cellTones` that is not one known tone or `null` per column throws. The tone never says why on its own; put that in words beside the grid. Every negative count, signed or not and in either layout, is written with the typographic minus ("−1"), never a hyphen. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
289
331
 
290
332
  `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.
291
333
 
@@ -314,7 +356,7 @@ People type the date into a text entry in the locale's numeric order (`MM/DD/YYY
314
356
  />
315
357
  ```
316
358
 
317
- `CalendarButton` is an icon-only `Button` that opens the same calendar dialog for a date the page already shows, such as a day heading with its own previous and next steps: `‹ Tuesday, September 22, 2026 [calendar] ›`. It has no text entry and no empty value. Supply `label` (what pressing it does, such as "Choose survey day"), a `YYYY-MM-DD` `value`, and `onChange`; optional `min`, `max`, `today`, `disabled`, `locale`, and `weekStartsOn` behave as on `DateField`, and `id` and a `ref` reach the `<button>`. `labels` takes the calendar's words (`previousMonth`, `nextMonth`, `month`, `year`, `today`) and `nameSeparator`. The button's accessible name is `label`, `labels.nameSeparator` (default `", "`), then the spoken date (`"Choose survey day, Tuesday, September 22, 2026"`); pass the locale's own pause where a comma does not fit, such as `"、"` in Japanese. It comes with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open; the calendar dialog is named by `label`. Enter, Space, or a press opens it on `value`. As on `DateField`, a `value` outside `min`/`max` is kept rather than refused, and the calendar then opens on the nearest allowed day with `value` shown selected and disabled; a button has no invalid state, so the page that shows the date flags it if it must. Choosing a day or **Today** calls `onChange` with the new date only when it changed, closes, and returns focus to the button; Escape closes without a change and returns focus; a press outside closes without a change and leaves focus where it landed. An empty or malformed `value`, `min`, `max`, or `today`, inverted bounds, a bad `locale` or `weekStartsOn`, an empty `label`, or an empty label word throws a `RangeError`. `size` (`xs`, `sm`, `md`; default `md`) and `variant` (default `ghost`) follow `Button`; the button is square at every size, and on a coarse pointer it is at least 44 px at every size. Place it beside the heading, not inside it, so the heading's text stays only the date.
359
+ `CalendarButton` is an icon-only `Button` that opens the same calendar dialog for a date the page already shows, such as a day heading with its own previous and next steps: `‹ Tuesday, September 22, 2026 [calendar] ›`. It has no text entry and no empty value. Supply `label` (what pressing it does, such as "Choose survey day"), a `YYYY-MM-DD` `value`, and `onChange`; optional `min`, `max`, `today`, `disabled`, `locale`, and `weekStartsOn` behave as on `DateField`, and `id` and a `ref` reach the `<button>`. `labels` takes the calendar's words (`previousMonth`, `nextMonth`, `month`, `year`, `today`) and `nameSeparator`. The button's accessible name is `label`, `labels.nameSeparator` (default `", "`), then the spoken date (`"Choose survey day, Tuesday, September 22, 2026"`); pass the locale's own pause where a comma does not fit, such as `"、"` in Japanese. It comes with `aria-haspopup="dialog"`, `aria-expanded`, and `aria-controls` while open; the calendar dialog is named by `label`. Enter, Space, or a press opens it on `value`. As on `DateField`, a `value` outside `min`/`max` is kept rather than refused, and the calendar then opens on the nearest allowed day with `value` shown selected and disabled; a button has no invalid state, so the page that shows the date flags it if it must. Choosing a day or **Today** calls `onChange` with the new date only when it changed, closes, and returns focus to the button; Escape closes without a change and returns focus; a press outside closes without a change and leaves focus where it landed. An empty or malformed `value`, `min`, `max`, or `today`, inverted bounds, a bad `locale` or `weekStartsOn`, an empty `label`, or an empty label word throws a `RangeError`. `size` (`xs`, `sm`, `md`; default `md`) and `variant` (default `ghost`) follow `Button`; the button is square at every size, and on a coarse pointer it is at least 44 px at every size. Place it beside the heading, not inside it, so the heading's text stays only the date. `range` (`{ start, end }`, both `YYYY-MM-DD` and included) is the period a page shows around `value`, such as a week: the open calendar tints its days as one band of the accent per week row, rounded at its ends, with every number on it in the text color (today keeps its accent ring); `value` stays the selected day. The tint is not announced, so the page's heading names the period. A start after the end throws a `RangeError`.
318
360
 
319
361
  ```tsx
320
362
  <Inline gap={1}>
@@ -4,6 +4,11 @@ export type ActionMenuAlign = InlineAlign;
4
4
  export type ActionMenuItem = {
5
5
  id: string;
6
6
  label: string;
7
+ /**
8
+ * The BCP 47 language of `label` when it is not the page's, such as
9
+ * "English" on a Spanish page, so it is read in its own language.
10
+ */
11
+ lang?: string;
7
12
  onSelect: () => void;
8
13
  icon?: ReactNode;
9
14
  disabled?: boolean;
@@ -12,6 +17,13 @@ export type ActionMenuItem = {
12
17
  export type ActionMenuProps = {
13
18
  label: string;
14
19
  items: readonly ActionMenuItem[];
20
+ /**
21
+ * A non-interactive block above the commands, such as who is signed in:
22
+ * muted, set off by a hairline, outside the arrow-key order, and the
23
+ * menu's description (`aria-describedby`), so it is read as the menu
24
+ * opens. Text and other phrasing content, nothing focusable.
25
+ */
26
+ header?: ReactNode;
15
27
  trigger?: ReactNode;
16
28
  disabled?: boolean;
17
29
  /**
@@ -21,4 +33,4 @@ export type ActionMenuProps = {
21
33
  */
22
34
  align?: ActionMenuAlign;
23
35
  };
24
- export declare function ActionMenu({ label, items, trigger, disabled, align, }: ActionMenuProps): import("react").JSX.Element;
36
+ export declare function ActionMenu({ label, items, header, trigger, disabled, align, }: ActionMenuProps): import("react").JSX.Element;
@@ -12,7 +12,7 @@ function nextEnabled(items, current, direction) {
12
12
  }
13
13
  return -1;
14
14
  }
15
- export function ActionMenu({ label, items, trigger, disabled = false, align = 'start', }) {
15
+ export function ActionMenu({ label, items, header, trigger, disabled = false, align = 'start', }) {
16
16
  const menuId = useId();
17
17
  const triggerRef = useRef(null);
18
18
  const openRef = useRef(false);
@@ -84,5 +84,5 @@ export function ActionMenu({ label, items, trigger, disabled = false, align = 's
84
84
  close(true);
85
85
  item.onSelect();
86
86
  }
87
- return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: "sw-action-menu-trigger", disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: trigger ?? label }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
87
+ return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: "sw-action-menu-trigger", disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: trigger ?? label }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, header: header, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
88
88
  }
@@ -8,9 +8,37 @@ export type BarChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
8
8
  'aria-label': string;
9
9
  items: readonly BarChartItem[];
10
10
  max?: number;
11
+ /**
12
+ * Writes the axis's values and each item's value that has no `valueLabel`,
13
+ * such as a currency: `(value) => formatMoney(value)`. Defaults to a plain
14
+ * number with at most one decimal.
15
+ */
16
+ formatValue?: (value: number) => string;
17
+ /**
18
+ * Puts zero in the middle of each track: a positive value's bar grows
19
+ * toward the inline end and a negative value's toward the start, both on
20
+ * the same scale, and the axis reads from minus `max` through 0 to `max`.
21
+ * Off by default, where every bar grows from the start and its color
22
+ * alone tells a negative value.
23
+ */
24
+ diverging?: boolean;
11
25
  };
12
26
  export declare const BarChart: import("react").ForwardRefExoticComponent<Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
13
27
  'aria-label': string;
14
28
  items: readonly BarChartItem[];
15
29
  max?: number;
30
+ /**
31
+ * Writes the axis's values and each item's value that has no `valueLabel`,
32
+ * such as a currency: `(value) => formatMoney(value)`. Defaults to a plain
33
+ * number with at most one decimal.
34
+ */
35
+ formatValue?: (value: number) => string;
36
+ /**
37
+ * Puts zero in the middle of each track: a positive value's bar grows
38
+ * toward the inline end and a negative value's toward the start, both on
39
+ * the same scale, and the axis reads from minus `max` through 0 to `max`.
40
+ * Off by default, where every bar grows from the start and its color
41
+ * alone tells a negative value.
42
+ */
43
+ diverging?: boolean;
16
44
  } & import("react").RefAttributes<HTMLDivElement>>;
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { barChartFillRatio, barChartScaleMax, formatBarChartValue, } from '@scalewing/tokens';
3
3
  import { forwardRef } from 'react';
4
4
  import { cx } from '../class-names.js';
5
- export const BarChart = forwardRef(function BarChart({ 'aria-label': ariaLabel, className, items, max, style, ...rest }, ref) {
5
+ export const BarChart = forwardRef(function BarChart({ 'aria-label': ariaLabel, className, diverging = false, formatValue = formatBarChartValue, items, max, style, ...rest }, ref) {
6
6
  const scaleMax = barChartScaleMax(items.map((item) => item.value), max);
7
- return (_jsxs("div", { ref: ref, className: cx('sw-bar-chart', className), style: style, ...rest, children: [_jsx("ul", { "aria-label": ariaLabel, className: "sw-bar-chart-plot", children: items.map((item, index) => {
7
+ const axis = diverging ? [-scaleMax, 0, scaleMax] : [0, scaleMax];
8
+ return (_jsxs("div", { ref: ref, className: cx('sw-bar-chart', diverging && 'sw-bar-chart-diverging', className), style: style, ...rest, children: [_jsx("ul", { "aria-label": ariaLabel, className: "sw-bar-chart-plot", children: items.map((item, index) => {
8
9
  const ratio = barChartFillRatio(item.value, scaleMax);
9
10
  return (_jsxs("li", { className: "sw-bar-chart-row", children: [_jsx("span", { className: "sw-bar-chart-label", children: item.label }), _jsx("span", { className: "sw-bar-chart-track", children: _jsx("span", { className: cx('sw-bar-chart-fill', item.value < 0 && 'sw-bar-chart-fill-negative'), style: {
10
11
  '--sw-bar-fill': ratio,
11
- } }) }), _jsx("span", { className: "sw-bar-chart-value", children: item.valueLabel ?? formatBarChartValue(item.value) })] }, `${item.label}-${index}`));
12
- }) }), _jsxs("div", { "aria-hidden": "true", className: "sw-bar-chart-axis", children: [_jsx("span", {}), _jsxs("span", { className: "sw-bar-chart-axis-track", children: [_jsx("span", { children: "0" }), _jsx("span", { children: formatBarChartValue(scaleMax) })] }), _jsx("span", {})] })] }));
12
+ } }) }), _jsx("span", { className: "sw-bar-chart-value", children: item.valueLabel ?? formatValue(item.value) })] }, `${item.label}-${index}`));
13
+ }) }), _jsxs("div", { "aria-hidden": "true", className: "sw-bar-chart-axis", children: [_jsx("span", {}), _jsx("span", { className: "sw-bar-chart-axis-track", children: axis.map((value) => (_jsx("span", { children: formatValue(value) }, value))) }), _jsx("span", {})] })] }));
13
14
  });
@@ -1,7 +1,10 @@
1
1
  import { type CalendarButtonLabels } from '../calendar-button-labels.js';
2
2
  import { type WeekStart } from '../calendar-month.js';
3
+ import { type DateOnlyRange } from '../date-only.js';
3
4
  import { type ButtonSize, type ButtonVariant } from './Button.js';
4
5
  export type { CalendarButtonLabels };
6
+ /** A span of calendar days `YYYY-MM-DD`, both ends included. */
7
+ export type CalendarButtonRange = DateOnlyRange;
5
8
  export type CalendarButtonProps = {
6
9
  /**
7
10
  * What pressing it does, such as "Choose survey day". The button's name
@@ -17,6 +20,13 @@ export type CalendarButtonProps = {
17
20
  value: string;
18
21
  /** Receives the chosen `YYYY-MM-DD`, never converted through UTC. */
19
22
  onChange: (value: string) => void;
23
+ /**
24
+ * The span the page shows around `value`, such as a week or a month:
25
+ * the open calendar tints its days, from `start` to `end`, so a picker
26
+ * for a period shows the whole period. `value` stays the selected day.
27
+ * The tint is not announced; the page's own label names the period.
28
+ */
29
+ range?: CalendarButtonRange;
20
30
  min?: string;
21
31
  max?: string;
22
32
  /**
@@ -4,7 +4,7 @@ import { forwardRef, useCallback, useId } from 'react';
4
4
  import { resolveCalendarButtonLabels, } from '../calendar-button-labels.js';
5
5
  import { calendarTriggerName } from '../calendar-labels.js';
6
6
  import { assertWeekStart } from '../calendar-month.js';
7
- import { assertDateBounds, assertDateOnly } from '../date-only.js';
7
+ import { assertDateBounds, assertDateOnly, assertDateRange, } from '../date-only.js';
8
8
  import { Button } from './Button.js';
9
9
  import { CalendarDialog } from './date-field/CalendarDialog.js';
10
10
  import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
@@ -26,10 +26,11 @@ function setForwardedRef(ref, node) {
26
26
  * date for something the page already shows, such as a day heading. It has
27
27
  * no text entry and no empty value. The ref reaches the `<button>`.
28
28
  */
29
- export const CalendarButton = forwardRef(function CalendarButton({ label, value, onChange, min, max, today, disabled = false, id, locale, weekStartsOn = 0, labels, size = 'md', variant = 'ghost', }, ref) {
29
+ export const CalendarButton = forwardRef(function CalendarButton({ label, value, onChange, range, min, max, today, disabled = false, id, locale, weekStartsOn = 0, labels, size = 'md', variant = 'ghost', }, ref) {
30
30
  assertLabel(label);
31
31
  assertDateOnly('value', value);
32
32
  assertDateBounds(value, min, max);
33
+ assertDateRange(range);
33
34
  assertWeekStart(weekStartsOn);
34
35
  const words = resolveCalendarButtonLabels(labels);
35
36
  const { buttonRef, shown, toggle, trigger, dialog } = useCalendarPopup({
@@ -44,5 +45,5 @@ export const CalendarButton = forwardRef(function CalendarButton({ label, value,
44
45
  buttonRef.current = node;
45
46
  setForwardedRef(ref, node);
46
47
  }, [buttonRef, ref]);
47
- return (_jsxs(_Fragment, { children: [_jsxs(Button, { ...trigger, className: "sw-calendar-button", disabled: disabled, id: id, onPress: toggle, ref: setButton, size: size, variant: variant, children: [_jsx("span", { className: "sw-sr-only", children: calendarTriggerName(label, value, resolvedLocale, words.nameSeparator) }), _jsx(DateFieldGlyph, { name: "calendar" })] }), shown ? (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, id: labelId, children: label }), _jsx(CalendarDialog, { ...dialog, anchorRef: buttonRef, buttonRef: buttonRef, labelId: labelId, labels: words.calendar, locale: resolvedLocale, max: max, min: min, required: true, value: value, weekStartsOn: weekStartsOn })] })) : null] }));
48
+ return (_jsxs(_Fragment, { children: [_jsxs(Button, { ...trigger, className: "sw-calendar-button", disabled: disabled, id: id, onPress: toggle, ref: setButton, size: size, variant: variant, children: [_jsx("span", { className: "sw-sr-only", children: calendarTriggerName(label, value, resolvedLocale, words.nameSeparator) }), _jsx(DateFieldGlyph, { name: "calendar" })] }), shown ? (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, id: labelId, children: label }), _jsx(CalendarDialog, { ...dialog, anchorRef: buttonRef, buttonRef: buttonRef, labelId: labelId, labels: words.calendar, locale: resolvedLocale, max: max, min: min, range: range, required: true, value: value, weekStartsOn: weekStartsOn })] })) : null] }));
48
49
  });
@@ -1,3 +1,3 @@
1
1
  import { type DenominationGridProps } from './denomination-grid/types.js';
2
- export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridRowRole, DenominationGridTone, } from './denomination-grid/types.js';
3
- export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, totalLabel, rowRole, }: DenominationGridProps): import("react").JSX.Element;
2
+ export type { DenominationGridColumn, DenominationGridLayout, DenominationGridProps, DenominationGridRow, DenominationGridRowRole, DenominationGridTone, DenominationLabelWidth, } from './denomination-grid/types.js';
3
+ export declare function DenominationGrid({ label, columns, rows, layout, subtotal, zeroLabel, totalLabel, rowRole, labelWidth, }: DenominationGridProps): import("react").JSX.Element;
@@ -1,8 +1,9 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { denominationLabelWidths } from '../css/css-denomination-aligned.js';
2
3
  import { DenominationStrip } from './denomination-grid/DenominationStrip.js';
3
4
  import { DenominationTiles } from './denomination-grid/DenominationTiles.js';
4
5
  import { assertDenominationGrid } from './denomination-grid/validate.js';
5
- export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', totalLabel, rowRole = 'region', }) {
6
+ export function DenominationGrid({ label, columns, rows, layout = 'strip', subtotal, zeroLabel = '—', totalLabel, rowRole = 'region', labelWidth, }) {
6
7
  assertDenominationGrid(label, columns, rows);
7
8
  if (!zeroLabel)
8
9
  throw new RangeError('zeroLabel must not be empty');
@@ -10,7 +11,9 @@ export function DenominationGrid({ label, columns, rows, layout = 'strip', subto
10
11
  throw new RangeError('totalLabel must not be empty');
11
12
  if (rowRole !== 'region' && rowRole !== 'group')
12
13
  throw new RangeError("rowRole must be 'region' or 'group'");
14
+ if (labelWidth !== undefined && !denominationLabelWidths.includes(labelWidth))
15
+ throw new RangeError(`Unknown labelWidth: ${String(labelWidth)}`);
13
16
  if (layout === 'tiles')
14
17
  return (_jsx(DenominationTiles, { columns: columns, label: label, rowRole: rowRole, rows: rows, subtotal: subtotal, zeroLabel: zeroLabel }));
15
- return (_jsx(DenominationStrip, { columns: columns, label: label, rows: rows, totalLabel: totalLabel, zeroLabel: zeroLabel }));
18
+ return (_jsx(DenominationStrip, { columns: columns, label: label, labelWidth: labelWidth, rows: rows, totalLabel: totalLabel, zeroLabel: zeroLabel }));
16
19
  }
@@ -18,6 +18,15 @@ export type FieldProps = {
18
18
  * child from the first render.
19
19
  */
20
20
  invalid?: boolean;
21
+ /**
22
+ * Marks a value the person changed from a saved one, such as a field in a
23
+ * correction: the control's border (or an adorned frame's) in the accent
24
+ * color, thicker than the plain hairline. It is a cue, not the record:
25
+ * say what it was in `description` ("Was 25"). `invalid` and `error`
26
+ * win. Like `invalid`, passing it, even `false` but not `undefined`,
27
+ * requires one native control child from the first render.
28
+ */
29
+ changed?: boolean;
21
30
  required?: boolean;
22
31
  /** Short text inside the control frame before the value, such as a currency sign. Not part of the value. */
23
32
  prefix?: string;
@@ -14,10 +14,10 @@ import { Stack } from './Stack.js';
14
14
  * child beside `error={undefined}` still renders.
15
15
  */
16
16
  function passesInvalid(props) {
17
- return props.invalid !== undefined;
17
+ return props.invalid !== undefined || props.changed !== undefined;
18
18
  }
19
19
  export function Field(props) {
20
- const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, required = false, prefix, suffix, } = props;
20
+ const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, changed = false, required = false, prefix, suffix, } = props;
21
21
  const controlId = useId();
22
22
  const descriptionId = useId();
23
23
  const errorId = useId();
@@ -32,6 +32,7 @@ export function Field(props) {
32
32
  'sw-field',
33
33
  size === 'xs' && 'sw-field-xs',
34
34
  marksInvalid && 'sw-field-invalid',
35
+ changed && !marksInvalid && 'sw-field-changed',
35
36
  ]
36
37
  .filter(Boolean)
37
38
  .join(' ');
@@ -0,0 +1,44 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type Breakpoint } from '../css/breakpoints.js';
3
+ export type SectionNavItem = {
4
+ id: string;
5
+ label: string;
6
+ href: string;
7
+ /**
8
+ * `page` when the item is this page, `location` (or `true`) when the page
9
+ * is inside the item's section, such as a detail page under its list.
10
+ */
11
+ current?: 'page' | 'location' | boolean;
12
+ /** A decorative glyph before the label. */
13
+ icon?: ReactNode;
14
+ };
15
+ type SectionNavLabel = {
16
+ 'aria-label': string;
17
+ 'aria-labelledby'?: never;
18
+ } | {
19
+ 'aria-label'?: never;
20
+ 'aria-labelledby': string;
21
+ };
22
+ export type SectionNavProps = SectionNavLabel & {
23
+ items: readonly SectionNavItem[];
24
+ /**
25
+ * Called for a plain press on an item (the main button, no modifier key),
26
+ * after the browser's own navigation is prevented, so a client router
27
+ * moves instead. A press with a modifier key, or a middle press, keeps the
28
+ * browser's behavior (a new tab or window). Without it the links navigate.
29
+ */
30
+ onNavigate?: (item: SectionNavItem) => void;
31
+ /**
32
+ * From this breakpoint up the items stack as a side list (the current one
33
+ * marked by a bar at its start) instead of a row (marked by an underline).
34
+ */
35
+ verticalFrom?: Breakpoint;
36
+ };
37
+ /**
38
+ * A page's secondary navigation between the sections of one area, such as
39
+ * an admin portal's pages: a labelled `nav` of links, quieter than the
40
+ * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
41
+ * a side list. The current item carries `aria-current`.
42
+ */
43
+ export declare function SectionNav({ items, onNavigate, verticalFrom, ...label }: SectionNavProps): import("react").JSX.Element;
44
+ export {};
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../class-names.js';
3
+ import { breakpoints } from '../css/breakpoints.js';
4
+ import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
+ function plainPress(event) {
6
+ return (event.button === 0 &&
7
+ !event.defaultPrevented &&
8
+ !event.metaKey &&
9
+ !event.ctrlKey &&
10
+ !event.shiftKey &&
11
+ !event.altKey);
12
+ }
13
+ function ariaCurrent(current) {
14
+ if (current === true)
15
+ return 'true';
16
+ return current || undefined;
17
+ }
18
+ function assertItems(items) {
19
+ if (items.some((item) => !item.id.trim() || !item.label.trim()))
20
+ throw new RangeError('SectionNav items need nonempty ids and labels');
21
+ if (new Set(items.map((item) => item.id)).size !== items.length)
22
+ throw new RangeError('SectionNav items need unique ids');
23
+ }
24
+ function assertBreakpoint(verticalFrom) {
25
+ if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
26
+ throw new RangeError(`SectionNav verticalFrom must be one of ${breakpoints.join(', ')}`);
27
+ }
28
+ /**
29
+ * A page's secondary navigation between the sections of one area, such as
30
+ * an admin portal's pages: a labelled `nav` of links, quieter than the
31
+ * workspace's own navigation, in a row that wraps or, from `verticalFrom`,
32
+ * a side list. The current item carries `aria-current`.
33
+ */
34
+ export function SectionNav({ items, onNavigate, verticalFrom, ...label }) {
35
+ assertItems(items);
36
+ 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,
38
+ // Only a given onNavigate attaches a handler, so the links
39
+ // render from a server component without one.
40
+ onClick: onNavigate
41
+ ? (event) => {
42
+ if (!plainPress(event))
43
+ return;
44
+ event.preventDefault();
45
+ onNavigate(item);
46
+ }
47
+ : undefined, children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-section-nav-icon", children: item.icon })) : null, item.label] }) }, item.id))) }) }));
48
+ }
@@ -1,13 +1,50 @@
1
- import { type HTMLAttributes, type TdHTMLAttributes, type ThHTMLAttributes } from 'react';
1
+ import { type HTMLAttributes } from 'react';
2
+ import { type TableLayout, type TableVerticalAlign } from './table/table-layout.js';
3
+ export { TableCell, type TableCellProps, type TableColumnWidth, type TableSort, } from './table/TableCell.js';
4
+ export type { TableLayout, TableVerticalAlign };
2
5
  export type TableDensity = 'comfortable' | 'compact';
3
6
  export type TableProps = HTMLAttributes<HTMLTableElement> & {
4
7
  density?: TableDensity;
5
8
  stickyHeader?: boolean;
9
+ /**
10
+ * `auto` (default) sizes each column by its content. `fixed` sizes the
11
+ * columns from the header row's `width`s alone (`table-layout: fixed`),
12
+ * so no column moves when the rows change, such as when a filter hides
13
+ * one. Columns without a width share what is left equally; when every
14
+ * column has one, spare width is shared in proportion to them. When the
15
+ * widths add up to more than the container, the table scrolls in its
16
+ * region and a column without a width gets no room at all, so on a table
17
+ * that can be narrower than its widths give every column one.
18
+ */
19
+ layout?: TableLayout;
20
+ /**
21
+ * Where a cell's content sits in a row taller than it: `middle` (default)
22
+ * or `top`, for rows where one cell runs to several lines and the others
23
+ * should start on its first line.
24
+ */
25
+ verticalAlign?: TableVerticalAlign;
6
26
  };
7
27
  /** A wide table scrolls inside its own `ScrollRegion`, named after the table. */
8
28
  export declare const Table: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableElement> & {
9
29
  density?: TableDensity;
10
30
  stickyHeader?: boolean;
31
+ /**
32
+ * `auto` (default) sizes each column by its content. `fixed` sizes the
33
+ * columns from the header row's `width`s alone (`table-layout: fixed`),
34
+ * so no column moves when the rows change, such as when a filter hides
35
+ * one. Columns without a width share what is left equally; when every
36
+ * column has one, spare width is shared in proportion to them. When the
37
+ * widths add up to more than the container, the table scrolls in its
38
+ * region and a column without a width gets no room at all, so on a table
39
+ * that can be narrower than its widths give every column one.
40
+ */
41
+ layout?: TableLayout;
42
+ /**
43
+ * Where a cell's content sits in a row taller than it: `middle` (default)
44
+ * or `top`, for rows where one cell runs to several lines and the others
45
+ * should start on its first line.
46
+ */
47
+ verticalAlign?: TableVerticalAlign;
11
48
  } & import("react").RefAttributes<HTMLTableElement>>;
12
49
  export type TableHeaderProps = HTMLAttributes<HTMLTableSectionElement>;
13
50
  export declare const TableHeader: import("react").ForwardRefExoticComponent<TableHeaderProps & import("react").RefAttributes<HTMLTableSectionElement>>;
@@ -19,15 +56,3 @@ export type TableRowProps = HTMLAttributes<HTMLTableRowElement> & {
19
56
  export declare const TableRow: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLTableRowElement> & {
20
57
  selected?: boolean;
21
58
  } & import("react").RefAttributes<HTMLTableRowElement>>;
22
- type TableCellAlign = 'start' | 'end';
23
- export type TableCellProps = ((TdHTMLAttributes<HTMLTableCellElement> & {
24
- as?: 'td';
25
- }) | (ThHTMLAttributes<HTMLTableCellElement> & {
26
- as: 'th';
27
- })) & {
28
- align?: TableCellAlign;
29
- numeric?: boolean;
30
- truncate?: boolean;
31
- };
32
- export declare const TableCell: import("react").ForwardRefExoticComponent<TableCellProps & import("react").RefAttributes<HTMLTableCellElement>>;
33
- export {};