@scalewing/react 1.39.0 → 1.41.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 (181) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +255 -5
  3. package/dist/calendar-labels.d.ts +2 -0
  4. package/dist/calendar-labels.js +7 -0
  5. package/dist/calendar-month.d.ts +6 -0
  6. package/dist/calendar-month.js +15 -0
  7. package/dist/components/BarChart.d.ts +20 -4
  8. package/dist/components/BarChart.js +13 -6
  9. package/dist/components/ButtonGroup.d.ts +8 -1
  10. package/dist/components/ButtonGroup.js +11 -2
  11. package/dist/components/DateField.d.ts +14 -2
  12. package/dist/components/DateField.js +14 -11
  13. package/dist/components/DateRangeButton.d.ts +65 -0
  14. package/dist/components/DateRangeButton.js +54 -0
  15. package/dist/components/DenominationGrid.d.ts +3 -2
  16. package/dist/components/DenominationGrid.js +21 -3
  17. package/dist/components/Dialog.d.ts +29 -13
  18. package/dist/components/Dialog.js +22 -20
  19. package/dist/components/Field.d.ts +13 -2
  20. package/dist/components/Field.js +14 -38
  21. package/dist/components/FilterChips.d.ts +7 -37
  22. package/dist/components/FilterChips.js +32 -24
  23. package/dist/components/ListGroup.d.ts +33 -0
  24. package/dist/components/ListGroup.js +19 -0
  25. package/dist/components/ListRow.d.ts +60 -0
  26. package/dist/components/ListRow.js +60 -0
  27. package/dist/components/NavCount.d.ts +21 -0
  28. package/dist/components/NavCount.js +23 -0
  29. package/dist/components/SectionNav.js +1 -8
  30. package/dist/components/SegmentedControl.d.ts +17 -1
  31. package/dist/components/SegmentedControl.js +11 -3
  32. package/dist/components/StatTile.d.ts +11 -4
  33. package/dist/components/StatTile.js +11 -4
  34. package/dist/components/Switch.d.ts +16 -1
  35. package/dist/components/Switch.js +17 -6
  36. package/dist/components/action-bar/ActionBarSummary.d.ts +10 -2
  37. package/dist/components/action-bar/ActionBarSummary.js +11 -2
  38. package/dist/components/{date-field → calendar}/CalendarGrid.d.ts +17 -2
  39. package/dist/components/{date-field → calendar}/CalendarGrid.js +11 -3
  40. package/dist/components/calendar/CalendarPopover.d.ts +25 -0
  41. package/dist/components/calendar/CalendarPopover.js +22 -0
  42. package/dist/components/calendar/MonthStep.d.ts +16 -0
  43. package/dist/components/calendar/MonthStep.js +17 -0
  44. package/dist/components/calendar/use-grid-focus.d.ts +18 -0
  45. package/dist/components/calendar/use-grid-focus.js +35 -0
  46. package/dist/components/calendar/wrap-tab.d.ts +7 -0
  47. package/dist/components/calendar/wrap-tab.js +29 -0
  48. package/dist/components/date-field/CalendarDialog.js +9 -67
  49. package/dist/components/date-field/CalendarEntryButton.d.ts +24 -0
  50. package/dist/components/date-field/CalendarEntryButton.js +16 -0
  51. package/dist/components/date-field/CalendarHeader.js +2 -11
  52. package/dist/components/date-field/TypedEntry.d.ts +26 -0
  53. package/dist/components/date-field/TypedEntry.js +13 -0
  54. package/dist/components/date-range/DateRangePopover.d.ts +7 -0
  55. package/dist/components/date-range/DateRangePopover.js +17 -0
  56. package/dist/components/date-range/DateRangeSheet.d.ts +7 -0
  57. package/dist/components/date-range/DateRangeSheet.js +15 -0
  58. package/dist/components/date-range/RangeMonths.d.ts +28 -0
  59. package/dist/components/date-range/RangeMonths.js +50 -0
  60. package/dist/components/date-range/RangePresets.d.ts +16 -0
  61. package/dist/components/date-range/RangePresets.js +10 -0
  62. package/dist/components/date-range/RangeSummary.d.ts +15 -0
  63. package/dist/components/date-range/RangeSummary.js +11 -0
  64. package/dist/components/date-range/range-panel-props.d.ts +31 -0
  65. package/dist/components/date-range/range-panel-props.js +1 -0
  66. package/dist/components/date-range/use-range-popup.d.ts +21 -0
  67. package/dist/components/date-range/use-range-popup.js +53 -0
  68. package/dist/components/denomination-grid/CountCell.d.ts +16 -0
  69. package/dist/components/denomination-grid/CountCell.js +14 -0
  70. package/dist/components/denomination-grid/DenominationStrip.d.ts +4 -1
  71. package/dist/components/denomination-grid/DenominationStrip.js +2 -2
  72. package/dist/components/denomination-grid/EntryCell.d.ts +3 -1
  73. package/dist/components/denomination-grid/EntryCell.js +2 -2
  74. package/dist/components/denomination-grid/StripRow.js +1 -10
  75. package/dist/components/denomination-grid/TransposedStrip.d.ts +17 -0
  76. package/dist/components/denomination-grid/TransposedStrip.js +43 -0
  77. package/dist/components/denomination-grid/types.d.ts +31 -0
  78. package/dist/components/denomination-grid/types.js +7 -1
  79. package/dist/components/denomination-grid/validate-options.d.ts +7 -0
  80. package/dist/components/denomination-grid/validate-options.js +27 -0
  81. package/dist/components/dialog/DialogFooter.d.ts +6 -5
  82. package/dist/components/dialog/DialogFooter.js +6 -6
  83. package/dist/components/dialog/validate.d.ts +24 -0
  84. package/dist/components/dialog/validate.js +23 -0
  85. package/dist/components/field/field-checks.d.ts +34 -0
  86. package/dist/components/field/field-checks.js +42 -0
  87. package/dist/components/filter-chips/FilterChip.d.ts +16 -0
  88. package/dist/components/filter-chips/FilterChip.js +20 -0
  89. package/dist/components/filter-chips/options.d.ts +75 -0
  90. package/dist/components/filter-chips/options.js +11 -0
  91. package/dist/components/filter-chips/validate.d.ts +9 -0
  92. package/dist/components/filter-chips/validate.js +48 -0
  93. package/dist/components/link-press.d.ts +7 -0
  94. package/dist/components/link-press.js +13 -0
  95. package/dist/components/list/ListRowAccessoryMark.d.ts +5 -0
  96. package/dist/components/list/ListRowAccessoryMark.js +7 -0
  97. package/dist/components/list/ListRowContent.d.ts +22 -0
  98. package/dist/components/list/ListRowContent.js +11 -0
  99. package/dist/components/list/list-row-container.d.ts +5 -0
  100. package/dist/components/list/list-row-container.js +6 -0
  101. package/dist/components/use-media-query.d.ts +5 -0
  102. package/dist/components/use-media-query.js +18 -0
  103. package/dist/css/chart-tones.d.ts +2 -0
  104. package/dist/css/chart-tones.js +5 -1
  105. package/dist/css/chevron.d.ts +6 -0
  106. package/dist/css/chevron.js +14 -0
  107. package/dist/css/css-bar-chart.d.ts +17 -2
  108. package/dist/css/css-bar-chart.js +38 -7
  109. package/dist/css/css-button-group-attached.d.ts +3 -0
  110. package/dist/css/css-button-group-attached.js +26 -5
  111. package/dist/css/css-button-group-track.js +1 -1
  112. package/dist/css/css-canvas-grouped.d.ts +17 -0
  113. package/dist/css/css-canvas-grouped.js +40 -0
  114. package/dist/css/css-checkbox.js +2 -2
  115. package/dist/css/css-components.js +15 -0
  116. package/dist/css/css-count-pill.d.ts +2 -0
  117. package/dist/css/css-count-pill.js +54 -0
  118. package/dist/css/css-date-field-calendar-entry.d.ts +11 -0
  119. package/dist/css/css-date-field-calendar-entry.js +50 -0
  120. package/dist/css/css-date-field.js +7 -2
  121. package/dist/css/css-date-range-presets.d.ts +2 -0
  122. package/dist/css/css-date-range-presets.js +109 -0
  123. package/dist/css/css-date-range.d.ts +2 -0
  124. package/dist/css/css-date-range.js +197 -0
  125. package/dist/css/css-denomination-grid.js +4 -0
  126. package/dist/css/css-denomination-transposed.d.ts +4 -0
  127. package/dist/css/css-denomination-transposed.js +178 -0
  128. package/dist/css/css-dialog-canvas.d.ts +3 -0
  129. package/dist/css/css-dialog-canvas.js +20 -0
  130. package/dist/css/css-dialog-frame.js +19 -2
  131. package/dist/css/css-dialog-grabber.d.ts +4 -0
  132. package/dist/css/css-dialog-grabber.js +56 -0
  133. package/dist/css/css-dialog.js +3 -2
  134. package/dist/css/css-document.js +3 -0
  135. package/dist/css/css-field-width.d.ts +12 -0
  136. package/dist/css/css-field-width.js +49 -0
  137. package/dist/css/css-field.js +4 -0
  138. package/dist/css/css-filter-chips-layout.d.ts +2 -0
  139. package/dist/css/css-filter-chips-layout.js +42 -0
  140. package/dist/css/css-filter-chips.js +4 -0
  141. package/dist/css/css-list-row.d.ts +8 -0
  142. package/dist/css/css-list-row.js +209 -0
  143. package/dist/css/css-list.d.ts +3 -0
  144. package/dist/css/css-list.js +85 -0
  145. package/dist/css/css-scroll-region.d.ts +18 -0
  146. package/dist/css/css-scroll-region.js +41 -0
  147. package/dist/css/css-section-nav-filled.js +2 -25
  148. package/dist/css/css-segmented-sizing.d.ts +13 -0
  149. package/dist/css/css-segmented-sizing.js +66 -0
  150. package/dist/css/css-segmented.js +4 -0
  151. package/dist/css/css-stat-tile.d.ts +7 -0
  152. package/dist/css/css-stat-tile.js +16 -0
  153. package/dist/css/css-steps.js +2 -2
  154. package/dist/css/css-switch.d.ts +6 -0
  155. package/dist/css/css-switch.js +39 -0
  156. package/dist/css/css-table-bounded-edges.js +4 -27
  157. package/dist/css/range-band.d.ts +2 -0
  158. package/dist/css/range-band.js +7 -0
  159. package/dist/css/stacking.d.ts +1 -0
  160. package/dist/css/stacking.js +6 -3
  161. package/dist/css/stylesheet.js +10 -0
  162. package/dist/date-field-labels.d.ts +5 -0
  163. package/dist/date-field-labels.js +1 -0
  164. package/dist/date-range-draft.d.ts +26 -0
  165. package/dist/date-range-draft.js +36 -0
  166. package/dist/date-range-input.d.ts +26 -0
  167. package/dist/date-range-input.js +58 -0
  168. package/dist/date-range-labels.d.ts +22 -0
  169. package/dist/date-range-labels.js +26 -0
  170. package/dist/date-range-view.d.ts +12 -0
  171. package/dist/date-range-view.js +29 -0
  172. package/dist/date-range-words.d.ts +11 -0
  173. package/dist/date-range-words.js +40 -0
  174. package/dist/index.d.ts +16 -9
  175. package/dist/index.js +14 -7
  176. package/dist/list-options.d.ts +71 -0
  177. package/dist/list-options.js +84 -0
  178. package/dist/styles.css +863 -41
  179. package/dist/theme/ThemeProvider.d.ts +11 -1
  180. package/dist/theme/ThemeProvider.js +4 -2
  181. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f6bce98: `BarChart`: an item `tone="neutral"` (the muted grey, 3:1 against the track and the surface in every palette; `barChartTones` gains `neutral`) and `appearance="bare"` (`BarChartAppearance`, `barChartAppearances`; default `track`), the bars alone with no track and no value axis. An unknown appearance throws a `RangeError`. New class `sw-bar-chart-bare`; no new tokens or dependencies. Request: `docs/requests/teisoro-bar-chart-bare.md`.
8
+ - 2f34b46: `ThemeProvider` `canvas="grouped"` (`ThemeCanvas`, `themeCanvases`; default `default`, unchanged): an opt-in grey page tone so white cards stand apart, written as `data-canvas="grouped"` on the themed node. The generated canvas declares `--sw-canvas-grouped` (light: the background with 7 % of the muted colour; dark: the background) and paints it only where a canvas asks for it. The text and muted colours keep 4.5:1 on it in every palette; keep links and accent words in the cards. An unknown value throws a `RangeError`. New public CSS hooks: the `data-canvas` attribute and the `--sw-canvas-grouped` custom property. No new tokens, classes or dependencies. Request: `docs/requests/teisoro-canvas-grouped.md`.
9
+ - 9c6078b: `DateField` `entry="calendar"` (`DateFieldEntry`, `dateFieldEntries`; default `typed`, unchanged): a day-picker field, one neutral field-shaped `Button` showing the calendar glyph and the chosen day in the locale's medium style, or `labels.chooseDate` while empty, that opens the same calendar. It is named by the label and its words and described by `description` and `error`; a required one is described by the new `labels.required` ("Required"). The consumer's `error` reports a missing day. An unknown entry throws a `RangeError`. New classes `sw-date-field-calendar-entry`, `sw-date-field-calendar-entry-text` and `sw-date-field-calendar-entry-invalid`; no new tokens or dependencies. Request: `docs/requests/teisoro-date-field-calendar-entry.md`.
10
+ - 995cb76: `DenominationGrid`: a row's `negativeAppearance="plain"` (`DenominationGridNegativeAppearance`, `denominationNegativeAppearances`; default `danger`) sets its negative counts in the text colour with their minus, for counts going out by design; `transposeBelow` (`'md' | 'lg'`, `DenominationTransposeBreakpoint`, `denominationTransposeBreakpoints`; strip only) draws the strip below that width with a row per denomination and a column per grid row, entry, value lines and totals kept; `countDigits` (3 to 6, entry rows only) keeps that many digits' room in entry counts. A signed count's cell also takes `sw-denomination-cell-signed`. Unknown values, `transposeBelow` on tiles and `countDigits` out of range or without entry rows throw a `RangeError`. New classes `sw-denomination-cell-signed`, `sw-denomination-cell-plain`, `sw-denomination-count-digits` and the `sw-denomination-transposed*` family; no new tokens or dependencies. Request: `docs/requests/teisoro-denomination-grid-board.md`.
11
+ - 9ad24d3: `Dialog` gets three options:
12
+
13
+ - `status` (a line beside the actions, in the footer's polite live region);
14
+ - `canvas="grouped"` (`ThemeCanvas`; the grouped canvas grey fill, solid, so cards stand apart);
15
+ - `sheetGrabber` (a decorative grabber on the bottom sheet; needs `sheetBelow`).
16
+
17
+ An `ActionBar` or `Dialog` `summary` whose `value` is empty no longer throws. It means there is no figure yet: the line is left out and the live region stays, so the first figure is still announced.
18
+
19
+ New classes: `sw-dialog-footer-note`, `sw-dialog-canvas-grouped`, `sw-dialog-grabber` and `sw-dialog-has-grabber`. New stacking layer `sheetGrabber`. No new tokens or dependencies. Request: `docs/requests/teisoro-dialog-board.md`.
20
+
21
+ - 594a9c7: `Field` `width` (`FieldWidth`, `fieldWidths`: `xs` 4rem, `sm` 6rem, `md` 8rem, `lg` 12rem, `xl` 16rem, the Table column sizes): caps the control (or an adorned or search field's frame) at that width at the column's start, never wider than the column, so a short value does not stretch across a form; the label and messages keep the column. Left out, nothing changes. It needs one native text control or select child (a `TypeError` otherwise); an unknown width throws a `RangeError`. New classes `sw-field-sized` and `sw-field-width-*`; no new tokens or dependencies. Request: `docs/requests/teisoro-field-width.md`.
22
+ - 74d6db2: `FilterChips` gains four opt-in options: `labelVisuallyHidden` (the label stays the group's name, not drawn); `countAppearance="inline"` (`FilterChipCountAppearance`, `filterChipCountAppearances`; default `figure`), the count in the chip's words after a hidden middle dot; `overflow="scroll"` (`FilterChipsOverflow`, `filterChipsOverflows`; default `wrap`), one row that scrolls sideways inside its own box; and `multiple` with a `value` list and `onChange(values)`, each chip a checkbox and its own Tab stop (a multiple group cannot be `required`). Unknown values, a multiple value that is not a list or holds a value that is no option, and `required` with `multiple` throw a `RangeError`. New classes `sw-filter-chip-count-inline` and `sw-filter-chips-scroll`; no new tokens or dependencies. Request: `docs/requests/teisoro-filter-chips-board.md`.
23
+ - 7cd729f: `SegmentedControl` `fullWidthBelow` and an attached `ButtonGroup` `fullWidthBelow` (the layout `Breakpoint`, `md`): below it the control fills its column, its segments or buttons sharing the width equally; from it up nothing changes. `SegmentedControl` `size="sm"` (`SegmentedSize`, `segmentedSizes`; default `md`) pads each segment a step less and keeps its label on one line with an ellipsis past its share. Unknown values, and `fullWidthBelow` on a group that is not attached, throw a `RangeError`. New classes `sw-segmented-field-full-below-md`, `sw-segmented-sm` and `sw-button-group-full-below-md`; no new tokens or dependencies. Request: `docs/requests/teisoro-full-width-controls.md`.
24
+ - a044de3: `ListRow` `valueTone="warning"` (the warning colour's ink, halfway to the text colour, 4.5:1 on the surface in every palette; `listRowValueTones` gains `warning`) and `valueBadge` (a `BadgeTone`: the value drawn as a Badge at the row's end, still in the row's name; needs a value, never with `valueTone`). New class `sw-list-row-value-warning`; no new tokens or dependencies. The refusal for a value option without a value now reads "ListRow valueDetail, valueTone and valueBadge need a value". Request: `docs/requests/teisoro-list-row-value.md`.
25
+ - 0d0b1bd: `Switch` `layout="trailing"` (`SwitchLayout`, `switchLayouts`; default `leading`): the words first and the control at the end of a full-width row; and `checkedTone="success"` (`SwitchCheckedTone`, `switchCheckedTones`; default `accent`): the success track while on, `Highlight` in forced colours. Unknown values throw a `RangeError`. New classes `sw-switch-trailing` and `sw-switch-success`; no new tokens or dependencies. Request: `docs/requests/teisoro-switch-trailing.md`.
26
+
27
+ ## 1.40.0
28
+
29
+ ### Minor Changes
30
+
31
+ - 935ff2a: `DateRangeButton`: a button showing a range of days (`value` `{ start, end }`, `YYYY-MM-DD`) that opens a calendar to pick a first and a last day. From `md` up it is a popover with the `presets` (`DateRangePreset`: `id`, `label`, `range`; a press applies it at once) beside two months (above them below `lg`), the range being picked in a polite status, and Cancel and Apply; below `md` it is a Dialog bottom sheet with the presets in a row and one month. The first press sets the start and the second the end; days after `max` are disabled, and once a start is pressed so are the days past `maxDays`. Apply calls `onChange` with a changed range; Cancel, Escape, a press outside and the sheet's close keep the value. Its name is `label`, `labels.nameSeparator` and the range it shows. `labels` (`DateRangeButtonLabels`) holds every word, `dayCount` included; `min`, `today`, `weekStartsOn`, `locale`, `disabled`, `id`, `size` and `variant` (default `neutral`) as on CalendarButton. Input it cannot draw throws a `RangeError`. New exports `DateRangeButton`, `DateRangeButtonProps`, `DateRangeButtonValue`, `DateRangePreset`, `DateRangeButtonLabels`; new `sw-date-range-*` classes. It shares CalendarButton's private calendar grid, popover and month steps, whose public behaviour is unchanged. `ButtonGroup attached` now finds its buttons by type, so an open calendar beside one keeps the group's dividers and corners. No new tokens, no new dependency. Request: `docs/requests/teisoro-date-range-button.md`.
32
+ - 3192e17: Web `ListGroup` and `ListRow`, the counterpart of react-native's. `ListGroup` is a list (`ul`, role kept) of rows with hairlines between them, a bordered `panel` or `plain` rows inside a surface, named by `aria-label` or `aria-labelledby`; `maxHeight` (`{ rows }`) bounds it to rows of a title and one detail line that scroll inside a named, focusable region shaded at an edge with more past it, and `footer` stays pinned under the rows, such as a total. `ListRow` has a `title` (`titleAppearance` `strong` or `plain`, `tone` `danger`), a muted `detail`, a `value` with `valueDetail` and `valueTone` (a table cell's tones) and a hidden `leading` mark; `onPress` or `href` makes the whole row a button or link with a chevron, named by its title and value and described by its details (`href` with `onPress` takes a plain press for a client router); `selected` makes it a choice with a check (`aria-pressed` or `aria-current`); `accessory="none"` hides the chevron; `disabled` blocks presses. Input it cannot draw throws a `RangeError`. New exports `ListGroup`, `ListGroupProps`, `ListRow`, `ListRowProps`, `listGroupVariants`, `listRowTones`, `listRowTitleAppearances`, `listRowValueTones`, `ListGroupMaxHeight`, `ListGroupVariant`, `ListRowTone`, `ListRowTitleAppearance`, `ListRowValueTone`; new `sw-list*` classes; no new tokens, no new dependency. Request: `docs/requests/teisoro-list-group.md`.
33
+ - 1d3fafd: `NavCount`: a count after a Nav destination's words, placed inside its link or Button (`<a href="/tasks">Tasks<NavCount count={8} /></a>`), such as the tasks waiting in an area. It is SectionNav's count pill, now one shared rule, and it is read in the destination's name ("Tasks 8"); `countLabel` is read in place of the bare number ("Tasks 8 tasks to do") with the pill hidden from assistive technology; a `count` of 0 shows nothing. A count that is not a whole number from 0 or a blank `countLabel` throws a `RangeError`. New exports `NavCount`, `NavCountProps`; new class `sw-nav-count`; no new tokens, no new dependency. Request: `docs/requests/teisoro-nav-count.md`.
34
+ - 62c33b1: `StatTile` `appearance="plain"` (`StatTileAppearance`, `statTileAppearances`; default `card`): the figure without the tile's surface, edge, radius or padding, top-aligned, for figures inside a card, such as four answers in a row divided by a composed `Separator`. A plain tile cannot be `emphasis="primary"`; that or an unknown appearance throws a `RangeError`. New class `sw-stat-tile-plain`; no new tokens, no new dependency. Request: `docs/requests/teisoro-stat-tile-plain.md`.
35
+
3
36
  ## 1.39.0
4
37
 
5
38
  ### Minor Changes
package/README.md CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  Box,
18
18
  Button,
19
19
  CalendarButton,
20
+ DateRangeButton,
20
21
  Card,
21
22
  Checkbox,
22
23
  RadioGroup,
@@ -29,6 +30,8 @@ import {
29
30
  Separator,
30
31
  DescriptionList,
31
32
  DescriptionItem,
33
+ ListGroup,
34
+ ListRow,
32
35
  DenominationGrid,
33
36
  FilterChips,
34
37
  Grid,
@@ -52,6 +55,16 @@ import {
52
55
 
53
56
  `colorScheme` is `"light"`, `"dark"`, or `"system"` so product users can switch. Each named palette already has a light pair and a dark pair. Brand overlays may be a flat `colors` map or `{ light, dark }` so both schemes are declared once.
54
57
 
58
+ `canvas="grouped"` (opt-in; `themeCanvases`, default `"default"`) sets the page on a quiet grey so white cards stand apart, as a report or a settings page that groups its content in cards: in the light scheme the background with a little of the muted colour, in the dark scheme the background itself, which is already darker than the surface. It writes `data-canvas="grouped"` on the themed node (set the attribute yourself on a `data-theme` node without `ThemeProvider`); a nested `ThemeProvider canvas="grouped"` paints a grouped region. The text and muted colours keep 4.5:1 or more on it in every palette.
59
+
60
+ The accent does not keep 4.5:1 on the grey in every palette, so keep links and accent words in the cards. An unknown value throws a `RangeError`.
61
+
62
+ ```tsx
63
+ <ThemeProvider canvas="grouped" colorScheme="system">
64
+ <Card padding={4}>North colony</Card>
65
+ </ThemeProvider>
66
+ ```
67
+
55
68
  ```html
56
69
  <div class="sw-padding-top-4 sw-padding-x-4"></div>
57
70
  ```
@@ -84,7 +97,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
84
97
 
85
98
  `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.
86
99
 
87
- `requiredMark="hidden"` (default `"visible"`; `requiredMarks`, `RequiredMark`) leaves out the label's `aria-hidden` " *" on a required `Field`, `Select`, `DateField`, `Combobox` or `FilterChips`, for a form that marks nothing as required (or marks the optional fields instead). The control stays required (native `required` or `aria-required`), so assistive technology still hears it; say in the form's words which fields must be filled. An unknown value throws a `RangeError`.
100
+ `requiredMark="hidden"` (default `"visible"`; `requiredMarks`, `RequiredMark`) leaves out the label's `aria-hidden` " *" on a required `Field`, `Select`, `DateField`, `Combobox` or `FilterChips`, for a form that marks nothing as required (or marks the optional fields instead). The control stays required (native `required` or `aria-required`, or, on a `DateField` with `entry="calendar"`, its `labels.required` word), so assistive technology still hears it; say in the form's words which fields must be filled. An unknown value throws a `RangeError`.
88
101
 
89
102
  `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), and its fill takes a 12 % accent tint over its own background, so a changed field reads apart from the focused one, whose ring never tints. The value keeps 4.5:1 or more on the tint in every palette; the tint is on text controls and selects (not a range, color or file input), a select's chevron stays where its size puts it, while a placeholder shows the fill stays plain, on the tint an adorned field's prefix and suffix take the text color, and forced colors drop the tint. 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).
90
103
 
@@ -110,6 +123,14 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
110
123
 
111
124
  `Field` `prefix` and `suffix` put short text such as `$` or `%` inside the frame of one native `<input>` child, before or after the value. The text is not part of the value; the input is named by its label plus the adornment (`"Drop amount $"`), unless it names itself with its own `aria-label` or `aria-labelledby`, in which case the adornment joins its description. A press anywhere on the frame focuses the input. Formatting the value stays with the consumer. Any other child throws a `TypeError`.
112
125
 
126
+ `Field` `width` (opt-in; `fieldWidths`) caps the control at a Table column size, `xs` 4rem, `sm` 6rem, `md` 8rem, `lg` 12rem or `xl` 16rem, so a short value such as a rate, a count or a code sits in a box its own size instead of stretching across the form's column. The cap is on the control alone (an adorned or search field's frame), placed at the column's start and never wider than the column on a narrow screen; the label, description and error keep the column's width. It caps a text input (the canvas's typed inputs: text, email, number, search, url, password or no type), a select or a textarea (a checkbox, a range or another input type keeps its own size), and a sized select keeps its control height. It needs one native control child, as `prefix` does (a `TypeError` otherwise); an unknown width throws a `RangeError`.
127
+
128
+ ```tsx
129
+ <Field label="Body length" suffix="cm" width="sm">
130
+ <input inputMode="decimal" />
131
+ </Field>
132
+ ```
133
+
113
134
  `Field` `variant="search"` (default `"outlined"`; `fieldVariants`) is a search box over one native `<input>` child with a `value`: the `label` is not drawn but still names the input, which becomes `type="search"` (a `searchbox`); `leading` is your glyph at the frame's start, such as a magnifier, hidden from assistive technology; and while the input holds text and is enabled and not read-only, a clear button named `clearLabel` ("Clear search") ends the frame, puts focus back in the input and calls `onClear` (set the value to `''`); Escape with text calls `onClear` too. The browser's own clear button is hidden, so there is one. The input gets `enterKeyHint="search"` unless you pass one. The frame is the adorned field's, so focus, `error`, `description`, `invalid` and the disabled look work as on any field; a press on the glyph focuses the input; the clear button is the 44 px target on a coarse pointer. A search field without a nonempty `clearLabel` or `onClear`, with `prefix` or `suffix`, or with a child that is not an `<input>` with a `value` throws; `leading`, `clearLabel` and `onClear` on an outlined field, or an unknown `variant`, throw too.
114
135
 
115
136
  ```tsx
@@ -183,7 +204,7 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
183
204
 
184
205
  `ActionBar` keeps the actions of a long page on a glass bar stuck to the bottom of the viewport, with an optional short `status` (such as when the work was last saved): a string, or a node such as a `Badge` and then a short line. Put it last in the content it acts on: it stays stuck while that content scrolls by and then rests in its own place at the end. `stickyBelow="md"` sticks only on a phone and leaves the bar in page flow from `md` up. The bar clears `env(safe-area-inset-bottom)` (set `viewport-fit=cover` in the page's viewport meta for the inset to apply). Its children are the actions: on a phone they share one row under the status. The status is a polite live region (`role="status"`, kept in the page even while empty), so a new status such as "Draft saved at 5:00 PM" is announced; do not announce the same save a second time with your own notice. The status is a `div` whose children sit on one row a `space-2` gap apart and wrap under one another when the row is full, so a line that does not fit beside its badge drops under it on a phone; put a phrase that mixes text and elements in one `span` so it wraps as a sentence. `undefined`, `null` or `false` leaves the status empty. An open `Select`, `ActionMenu` or `Tooltip` paints over the bar, including inside a glass `Card` or `Accordion`.
185
206
 
186
- `ActionBar` `summary` (`{ label, value, detail?, tone? }`, type `ActionBarSummary`) puts a labelled figure first in the same polite status region, before any `status`: the label and the detail in muted label-size words, the value between them in the `heading` variant (24 below `md`) at tabular figures, all on one baseline and wrapping as one phrase, such as "Counted **$33,790.00** short $4,000.00". `tone` (`'default' | 'danger' | 'success'`, such as danger while short or over and success once it matches) colors the value only, so say why in the detail. Format the value yourself; each new figure is announced politely. For a guided form, the actions are a secondary Cancel and one primary that stays `disabled` until the form is valid. A blank `label`, `value` or `detail`, or an unknown `tone`, throws a `RangeError`.
207
+ `ActionBar` `summary` (`{ label, value, detail?, tone? }`, type `ActionBarSummary`) puts a labelled figure first in the same polite status region, before any `status`: the label and the detail in muted label-size words, the value between them in the `heading` variant (24 below `md`) at tabular figures, all on one baseline and wrapping as one phrase, such as "Counted **$33,790.00** short $4,000.00". `tone` (`'default' | 'danger' | 'success'`, such as danger while short or over and success once it matches) colors the value only, so say why in the detail. Format the value yourself; each new figure is announced politely. For a guided form, the actions are a secondary Cancel and one primary that stays `disabled` until the form is valid. An empty `value` (`''` or only spaces) means there is no figure yet, such as before anything is counted: the summary line is left out and the live region stays, so the first figure is announced when it arrives. Leaving `summary` out is the same; format the value before you pass it, since an empty one hides its detail and tone too. A blank `label` or `detail`, a `value` that is not a string, or an unknown `tone` throws a `RangeError`.
187
208
 
188
209
  ```tsx
189
210
  <ActionBar
@@ -234,6 +255,8 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
234
255
 
235
256
  `ButtonGroup` `appearance="track"` (an `attached` group only; default `"default"`, `buttonGroupAppearances`) sets the joined buttons as words in the text color on one quiet grey track (the muted fill at 16 %, the sm radius) with the `border` token's hairline between them, whatever their variant, such as Previous, This week and Next above a period. It is for equal-weight navigation: every variant's meaning goes, so keep a primary or destructive action out of it. A pressed button keeps its ring, a focused one its outline and a disabled one fades (`disabledAppearance="neutral"` too); forced colors outline the track, keep every border and draw a pressed button's in Highlight. An unknown value, or `track` without `attached`, throws a `RangeError`.
236
257
 
258
+ `ButtonGroup` `fullWidthBelow="md"` (an `attached` group only; the layout `Breakpoint`) makes the joined control fill its column below that breakpoint, its buttons sharing the width equally (each may shrink, so keep their words short), such as earlier, a period and later across a phone; from `md` up it sizes to its buttons as before. On a group that is not attached it throws (that row already stacks full width on a phone), as does an unknown breakpoint.
259
+
237
260
  `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.
238
261
 
239
262
  `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.
@@ -263,7 +286,7 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
263
286
 
264
287
  `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.
265
288
 
266
- A sheet over a page takes a fuller frame. `icon` is a consumer glyph or tile before the title (hidden from assistive technology) and `description` a muted line under the title that also describes the dialog (unless you pass `aria-describedby`). `footer` holds the actions, pinned to the dialog's bottom edge under a hairline while the content scrolls, and `summary` (ActionBar's `{ label, value, detail?, tone? }`) puts a labelled figure at the footer's start in a polite live region. `size="xl"` (65rem) holds a six-column count grid with its labels and total. `role="alertdialog"` is for a warning that interrupts, such as an idle sign-out: give its safe action `autoFocus` (the browser otherwise focuses the first footer button), and point `aria-describedby` at the warning in its body when that is not the `description`. `appearance="banded"` (default `"glass"`) draws the dialog on the solid surface with the medium elevation instead of the frosted panel: its title row is a full-width band over a hairline (a lone title too), the footer the band it already is, and the close button a circle on the quiet muted fill in the text color, keeping its touch target; forced colors outline the dialog and keep the hairlines. An unknown `size`, `role` or `appearance`, a blank `description` or an invalid `summary` throws a `RangeError`.
289
+ A sheet over a page takes a fuller frame. `icon` is a consumer glyph or tile before the title (hidden from assistive technology) and `description` a muted line under the title that also describes the dialog (unless you pass `aria-describedby`). `footer` holds the actions, pinned to the dialog's bottom edge under a hairline while the content scrolls, and `summary` (ActionBar's `{ label, value, detail?, tone? }`) puts a labelled figure at the footer's start in a polite live region. `size="xl"` (65rem) holds a six-column count grid with its labels and total. `role="alertdialog"` is for a warning that interrupts, such as an idle sign-out: give its safe action `autoFocus` (the browser otherwise focuses the first footer button), and point `aria-describedby` at the warning in its body when that is not the `description`. `appearance="banded"` (default `"glass"`) draws the dialog on the solid surface with the medium elevation instead of the frosted panel: its title row is a full-width band over a hairline (a lone title too), the footer the band it already is, and the close button a circle on the quiet muted fill in the text color, keeping its touch target; forced colors outline the dialog and keep the hairlines. `status` is one short line in the footer after the summary and before the actions, in the same polite live region: muted caption words, or a `Badge` and a short line in one `span` (a status alone makes a footer; blank words or `false` leave it out). `canvas="grouped"` (default `"default"`) fills the dialog with the grouped canvas grey that `ThemeProvider canvas="grouped"` paints, solid with no blur, so `Card`s inside stand apart; a banded title row and the footer keep the surface. `sheetGrabber` (with `sheetBelow`, which it needs) draws a grabber, a short rounded bar centered at the top of the bottom sheet, hidden from assistive technology; it stays at the top while the content scrolls, does not drag, and is not drawn from the breakpoint up. An unknown `size`, `role`, `appearance` or `canvas`, a blank `description`, an invalid `summary`, or `sheetGrabber` without `sheetBelow` throws a `RangeError`.
267
290
 
268
291
  ```tsx
269
292
  <Dialog
@@ -288,6 +311,26 @@ A sheet over a page takes a fuller frame. `icon` is a consumer glyph or tile bef
288
311
  </Dialog>
289
312
  ```
290
313
 
314
+ ```tsx
315
+ <Dialog
316
+ open={open}
317
+ onClose={close}
318
+ sheetBelow="md"
319
+ sheetGrabber
320
+ canvas="grouped"
321
+ title="Tally the roosts"
322
+ summary={{
323
+ label: 'Bats',
324
+ value: counted ? String(counted) : '',
325
+ detail: 'in all roosts',
326
+ }}
327
+ status="Nothing counted yet"
328
+ footer={footerActions}
329
+ >
330
+ <Card padding={4}>…</Card>
331
+ </Dialog>
332
+ ```
333
+
291
334
  `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.
292
335
 
293
336
  A toast can also be a corner card that confirms a save: `placement="top-end"` puts it `space-4` from the viewport's top and end corner, `title` is a bold first line with the children as a muted detail under it (pass plain text, not a `Text`), `action` (`{ label, onPress }`) is one ghost small Button at the end that runs `onPress` and then closes the toast, and `showRemaining` adds a thin line that runs down with the time left. A toast with an action or the line takes the pointer, and its time (and the line) is held while the pointer is over it or focus is inside it; each opening starts with the whole time. Pass the time you want, such as `timeoutMs={10000}`, and close it yourself when it no longer applies (for example when the next sale starts). It is never modal and never takes focus; when it closes with focus inside (after its action), focus goes back to where it came from. Keyboard users reach the action in the Toast's place in the DOM, so render it right after the control that saves. A travelling toast ignores `placement` and `showRemaining`.
@@ -310,6 +353,19 @@ A toast can also be a corner card that confirms a save: `placement="top-end"` pu
310
353
 
311
354
  `Switch` is a controlled on/off input. It keeps native checkbox keyboard behavior and exposes switch semantics. Provide the current `checked` value and update it in `onCheckedChange`.
312
355
 
356
+ `Switch` `layout="trailing"` (`switchLayouts`, default `"leading"`) puts the label and description first and the control at the end of a row as wide as its column, lined up with the label's first line, as a settings list draws it; the reading order follows. `checkedTone="success"` (`switchCheckedTones`, default `"accent"`) fills the track with the success colour while on, for a setting whose on state is the healthy one (3:1 or more on the surface in every palette; `Highlight` in forced colors); the thumb's place says the state either way. An unknown layout or tone throws a `RangeError`.
357
+
358
+ ```tsx
359
+ <Switch
360
+ label="Number the observers"
361
+ description="Names stay out of the file"
362
+ layout="trailing"
363
+ checkedTone="success"
364
+ checked={numbered}
365
+ onCheckedChange={setNumbered}
366
+ />
367
+ ```
368
+
313
369
  `Checkbox` is a controlled native checkbox for one independent form or confirmation choice. `checked` and `onCheckedChange` own its state. The consumer supplies `error` after validation to describe and style an invalid choice (associated by `aria-describedby` with `aria-invalid` and announced from a polite live region, as `Field`'s error is, never an alert); `required` retains native form semantics.
314
370
 
315
371
  `RadioGroup` is a controlled fieldset of native radio choices. Supply a unique nonempty `value` for each option, one selected `value` or an empty value for no selection, a `legend`, and `onChange`. Long labels wrap in a vertical group. The consumer provides localized option labels and validation `error`; `required` keeps native form semantics. An option's optional `icon` (a `ReactNode`, such as a Lucide glyph) sits between the radio and the label, in the text color and the option's gap; it is `aria-hidden`, so the radio's accessible name stays the `label` text, and a press on it chooses the option as the label does.
@@ -352,6 +408,23 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
352
408
 
353
409
  `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.
354
410
 
411
+ `SegmentedControl` `fullWidthBelow="md"` makes the control fill its column below that breakpoint, its segments sharing the width equally whatever the variant (a raised track too); from `md` up it keeps its own width. `size="sm"` (`segmentedSizes`, default `"md"`) pads every segment a step less and keeps each label on one line, ending in an ellipsis past its share while its name keeps the whole label, so three or four segments with counts fit a phone's card (a segment with a mark keeps its padding, and the label truncates on any variant). The ellipsis is a last resort that sighted people cannot expand: keep `sm` labels short enough to fit at the widths you expect, at 200 % zoom too, and never let the hidden part carry meaning. An unknown breakpoint or size throws a `RangeError`.
412
+
413
+ ```tsx
414
+ <SegmentedControl
415
+ aria-label="Field log"
416
+ variant="raised"
417
+ size="sm"
418
+ fullWidthBelow="md"
419
+ items={[
420
+ { id: 'changes', label: 'Changes 57' },
421
+ { id: 'people', label: 'People 12' },
422
+ ]}
423
+ value={log}
424
+ onChange={setLog}
425
+ />
426
+ ```
427
+
355
428
  `variant="raised"` (opt-in; default `"compact"`) is a grey track (the quiet muted fill, no border, corners concentric with the segments' small radius) as wide as its segments even in a `Stack`; every segment is the `md` control height in label type with its words in the text color at medium weight, and the selected one is the surface raised by the small elevation at label weight; in the dark scheme, where a shadow does not show, it draws a hairline ring in the muted color instead (3:1 or more on the track in every dark palette). Item `icon`s keep that look, and `error` and `disabled` behave as on the other tracks. It is the same radiogroup with the same keys; forced colors outline the track and the selected segment.
356
429
 
357
430
  ```tsx
@@ -375,6 +448,20 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
375
448
 
376
449
  `appearance="words"` (opt-in; default `"default"`, `navAppearances`) draws every destination, a link or a `Button` of any variant, as plain words: no fill, border or underline, label type, the others in the muted color at medium weight and the current one (`aria-current`, not `"false"`) in the text color at label weight. A Button keeps its size, padding, touch target and focus ring; hover lifts a destination to the text color where the pointer can hover, never a disabled one. The current mark is color and weight, so each destination's words must be visible: a glyph-only destination has no weight to change (keep `default` for a glyph rail). Forced colors draw the current one's border in Highlight and underline its words. An unknown value throws a `RangeError`.
377
450
 
451
+ `NavCount` puts a count after a destination's words, such as the tasks waiting in an area: place it inside the link or `Button`, after its words (`<a href="/tasks">Tasks<NavCount count={8} /></a>`). It is the pill `SectionNav`'s item `count` draws (the danger color with its on-color figures, an edge in forced colors), a space-2 after the words in a link or a Button, the link keeping its siblings' height, and it is read as part of the destination's name ("Tasks 8"). `count` is a whole number; 0 shows nothing. `countLabel` says what it means in your words ("8 tasks to do"): it is read in place of the bare number ("Tasks 8 tasks to do") and the pill is hidden from assistive technology. A count that is not a whole number from 0, or a blank `countLabel`, throws a `RangeError`.
452
+
453
+ ```tsx
454
+ <Nav appearance="words" aria-label="Areas">
455
+ <Box as="a" href="/sightings">
456
+ Sightings
457
+ </Box>
458
+ <Box as="a" href="/tasks" aria-current="page">
459
+ Tasks
460
+ <NavCount count={8} countLabel="8 tasks to do" />
461
+ </Box>
462
+ </Nav>
463
+ ```
464
+
378
465
  `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.
379
466
 
380
467
  ```tsx
@@ -445,7 +532,18 @@ A ledger read across as well as down takes `Table` `columnRules` (a hairline at
445
532
  </Table>
446
533
  ```
447
534
 
448
- `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.
535
+ `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'`, `'danger'` or `'neutral'`, 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'`, or the usual days in `'neutral'` (the muted grey) beside the busiest ones in the accent; 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. `appearance="bare"` (`barChartAppearances`, default `"track"`) draws the bars alone, with no track and no value axis, for bars whose values are all written beside them; the track's room is kept, so labels and values line up as before, a diverging bare chart keeps its zero hairline, and every tone keeps 3:1 against the surface it sits on; in forced colors the track's edge is the canvas color. A bar's tone is the charts' stroke color of that tone (`chart-tones.ts`); a `ChartLegend` `bar` swatch takes the lighter fill of a tone, so key a neutral or warning bar with a `marker` or `line` swatch. An unknown appearance throws a `RangeError`.
536
+
537
+ ```tsx
538
+ <BarChart
539
+ appearance="bare"
540
+ aria-label="Owls heard by night"
541
+ items={[
542
+ { label: 'Thu', value: 3, tone: 'neutral' },
543
+ { label: 'Fri', value: 9 },
544
+ ]}
545
+ />
546
+ ```
449
547
 
450
548
  `DataBar` is a horizontal range bar for a table cell or a list row: a bar from `from` to `to` (whichever is larger is its far end) on a track whose domain runs from `min` to `max`, such as where a statement's line leaves the running total. It fills its container's width, a spacing step 4 tall with no track fill, and its ends follow the writing direction. It is decorative (`aria-hidden`): the figure is the cell's own text, so keep the value beside it, and a cell holding both is named by the figure alone. `tone` (`DataBarTone`, listed in `dataBarTones`; default `'neutral'`) says what the bar is: `'neutral'` for where it started, `'success'` for what came in, `'muted'` for what went out (never danger: an outflow is not an error), `'strong'` for a subtotal, `'accent'` for what was counted or recorded, and `'danger'` for a shortage, never for cash leaving (a light red, the danger colour at 40 %). Each is a semantic colour mixed into the surface, 1.5:1 or more on it, the three greys 1.5:1 or more apart, the shortage's red apart from the grey of what went out, in every palette and scheme. A domain from below zero to above it (`min < 0 < max`) draws a hairline at zero, so a column of differences diverges around it (`from={0} to={value}`), and the bar's end at zero is square; a short bar in `'danger'` and an over bar in `'success'` differ by hue alone, so the bar's side of zero and the signed figure carry the meaning. Values past the domain are clamped to it; non-finite numbers, a `min` not under `max`, or an unknown tone throw a `RangeError`. Forced colors draw the bar and the zero line in the system text color.
451
549
 
@@ -620,6 +718,65 @@ A bar in its own column reads as empty cells to assistive technology, which is f
620
718
  </DescriptionList>
621
719
  ```
622
720
 
721
+ `StatTile` is one prominent figure with its label: a `label`, a consumer-formatted `value` (tabular figures; `tone` `accent`, `success`, `danger` or `warning` colours it), an optional `caption` under it and an optional `glyph` in a tinted circle. `emphasis="primary"` fills the one figure a page leads with in the accent. `appearance="plain"` (default `"card"`, `statTileAppearances`) drops the tile's surface, edge, radius and padding, top-aligned, for figures inside a card that already has them, such as four answers in a row divided by hairlines: compose a `Grid` and a vertical `Separator` before every tile but the first, hiding the one that would start a row below `md` (`Inline hideBelow="md"` around it). A plain tile cannot be `primary` (there is no surface to fill), and an unknown appearance throws a `RangeError`.
722
+
723
+ ```tsx
724
+ <Card padding={5}>
725
+ <Grid columns={4} columnsBelow={{ md: 2 }} gap={4}>
726
+ {answers.map((answer, index) => (
727
+ <Inline align="stretch" gap={4} key={answer.label}>
728
+ {index > 0 ? (
729
+ // 2 is columnsBelow md: an answer that starts a row there has no hairline.
730
+ <Inline
731
+ align="stretch"
732
+ hideBelow={index % 2 === 0 ? 'md' : undefined}
733
+ >
734
+ <Separator orientation="vertical" decorative />
735
+ </Inline>
736
+ ) : null}
737
+ <StatTile appearance="plain" {...answer} />
738
+ </Inline>
739
+ ))}
740
+ </Grid>
741
+ </Card>
742
+ ```
743
+
744
+ `ListGroup` is rows in one list with a hairline between each pair, the web twin of react-native's: a `ul` (its list role kept) named by `aria-label` or `aria-labelledby`. `variant="panel"` (the default) is one bordered panel; `plain` draws no panel and a hairline above the first row too, for rows inside a `Card`. Each `ListRow` has a `title` (weight 600, or the body weight with `titleAppearance="plain"`; `tone="danger"` for a row that ends something), an optional muted `detail` line, an optional `value` at its end (weight 600, tabular figures; `valueTone` `success`, `danger`, `muted`, `accent` or `warning`, the warning colour's ink moved halfway to the text colour so it keeps 4.5:1 on the surface in every palette; `valueBadge` draws the value as a `Badge` in that tone instead, such as a status, still read in the row's name, never with `valueTone`; `valueDetail` a muted line under it) and an optional `leading` mark such as a status dot, hidden from assistive technology, so the words say what it means. With `onPress` the whole row is a button, and with `href` a link (`onPress` then takes a plain press for a client router); either adds a chevron, a quiet fill under the pointer and 44 px on a coarse pointer, and the row is named by its title and value and described by its detail lines (`aria-label` replaces the name). `selected` makes a pressable row a choice: a check while true, `aria-pressed` on a button or `aria-current` on a link (on a row with neither `onPress` nor `href` it throws, since nothing could read the state). `accessory="none"` hides the chevron of a row that acts in place; `disabled` blocks presses. A row with neither `onPress` nor `href` only states something. `maxHeight={{ rows }}` bounds the list to that many rows of a title and one detail line, the rest scrolling inside its own named region, which shades an edge with more rows past it; a bounded list must be named. `footer` stays pinned under the rows, outside the scroll, such as a `ListRow` with the count and the total (a row there is not a list item). An empty title or value, an unknown variant, tone or badge tone, a `valueDetail`, `valueTone` or `valueBadge` without a value, a value both toned and a badge, or a bad row count throws a `RangeError`.
745
+
746
+ ```tsx
747
+ <Card padding={4}>
748
+ <Text as="h3" id="look" variant="label">
749
+ Worth a look
750
+ </Text>
751
+ <ListGroup aria-labelledby="look" variant="plain">
752
+ <ListRow
753
+ leading={<Circle size={8} fill="currentColor" />}
754
+ title="Wed, Oct 7 · North marsh counted 6 fewer herons"
755
+ detail="Recounted Sat, Oct 10 by Field team A"
756
+ href="/nights/2026-10-07"
757
+ onPress={() => router.push('/nights/2026-10-07')}
758
+ />
759
+ </ListGroup>
760
+ </Card>
761
+
762
+ <ListGroup
763
+ aria-label="Night counts"
764
+ maxHeight={{ rows: 5 }}
765
+ footer={<ListRow title="8 nights" value="+0" />}
766
+ >
767
+ {nights.map((night) => (
768
+ <ListRow
769
+ key={night.id}
770
+ title={night.name}
771
+ detail={night.detail}
772
+ value={night.change}
773
+ valueTone={night.tone}
774
+ onPress={() => openNight(night)}
775
+ />
776
+ ))}
777
+ </ListGroup>
778
+ ```
779
+
623
780
  `Card` defaults to `glass`; `outlined` and `elevated` are solid surfaces, and `filled` is a quiet `--sw-color-subtle` fill with no border for plain information apart from pressable rows.
624
781
 
625
782
  `--sw-color-accentSubtle` is the accent tint from `@scalewing/tokens`: a quiet fill behind accent text or glyphs that keeps 4.5:1. The stylesheet and every palette file set it, and a palette that moves the accent or surface retints it.
@@ -656,6 +813,33 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
656
813
 
657
814
  `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. `labelAppearance="plain"` (default `"tone"`) sets every row label in the text color at label weight with no tone stripe, in both layouts, for a grid whose rows need no color to tell apart; a row's `tone` still colors its total, and an unknown value throws. Three more grid-level options match a framed count sheet, each off by default: `frame="table"` (default `"none"`) draws a strip as a framed table, its scroll region a surface inside a border hairline at the `sm` radius, the heading row on the subtle fill, a rule between columns, the counts at label size centred under their heads and the cells padded a space-3 step across (the tiles layout has no frame and ignores it); `totalEmphasis="strong"` (default `"default"`) sets the row totals in bold label type, a neutral row's in the text color and a toned row's in its tone, under the label on a phone too; and `cellToneAppearance="pill"` (default `"text"`) sets a `cellTones` count of a strip's read-only row on a rounded tint of its tone in the tone's ink (the `Notice` fill and words, 4.5:1 on the tint except where a palette's own `danger` is already under 4.5:1, harvest and mocha dark), outlined in forced colors; entry counts and tiles keep their tone border. A strip row's `valueRow` (`{ label, cells }`, opt-in) adds a line right under it that gives each column's value, such as the money its count adds up to: its own table row on the subtle fill, `label` (such as "Value") in the label column (named with the row's label, "Counted Value", for table navigation), each of `cells` (one consumer-formatted string or `null` per column; `null` or `''` leaves it blank) centred under its count at label size in tabular figures, and the row's `total` moved onto it (bold with `totalEmphasis="strong"`, under the line's name on a phone); the counts' own total cell stays empty. It keeps the row's tone and `disabled` (a disabled line fades its values). Each entry count with a value is described by it (`aria-describedby`, before an error), and the value is no tab stop. The grid still does no arithmetic. A `valueRow` in the tiles layout, a blank label, or cells that are not one string or `null` per column throw a `RangeError`. With `zeroLabel=""` a toned zero or empty count shows nothing in either appearance, so tone only counts that are there. An unknown value of any of them throws a `RangeError`. `zeroLabel` (default `"—"`) is what a zero or empty read-only count shows; an empty string leaves those counts blank, so only the counts that are there stand out (it suits the strip, where the column header names each blank cell, better than tiles, where a blank tile is only its column's name), and a `zeroLabel` that is not a string throws. 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.
658
815
 
816
+ `DenominationGrid` board options, all opt-in.
817
+
818
+ - A row's `negativeAppearance="plain"` (`denominationNegativeAppearances`, default `"danger"`) sets its negative counts in the text colour with their minus. Use it for counts that go out by design, such as bills paid out: going out is not an error, so never red. `"danger"` stays for a shortage.
819
+ - `transposeBelow="md"` (or `"lg"`; `denominationTransposeBreakpoints`; the strip layout only) draws the strip transposed below that width, for a phone. Each denomination is a row (its label and caption, pinned while the columns scroll), each grid row a column (its label over its counts, entry steppers or value line), and the totals a last row named by `totalLabel`. Names, entry, cell tones, the zero label and quiet and disabled rows are the strip's; a disabled row says so on its column heads. It reads the width with the breakpoint's media query, so a server render draws the strip, and crossing the breakpoint (turning a phone) remounts the grid, so a count being typed loses focus.
820
+ - `countDigits` (3 to 6, entry rows only) keeps that many digits' room in every entry count before the strip scrolls, such as 4 for counts in the thousands in seven columns. The default room is 3.
821
+ - An unknown appearance or breakpoint, `transposeBelow` on tiles, or a `countDigits` out of range or without entry rows throws a `RangeError`.
822
+
823
+ ```tsx
824
+ <DenominationGrid
825
+ label="Bird rings"
826
+ columns={ringColumns}
827
+ countDigits={4}
828
+ transposeBelow="md"
829
+ totalLabel="Total"
830
+ rows={[
831
+ { id: 'kept', label: 'Keep on hand', cells: kept, entry, valueRow },
832
+ {
833
+ id: 'given',
834
+ label: 'Given out',
835
+ cells: given,
836
+ signed: true,
837
+ negativeAppearance: 'plain',
838
+ },
839
+ ]}
840
+ />
841
+ ```
842
+
659
843
  A strip row with `entry` makes its counts editable: each column holds a `Stepper` (`width="full"`, its label hidden and named by the row label and the column's label and caption as its header reads, such as "Received $20" or "Counted $1 × 100") whose count is the row's `cells` entry (`null` is an empty box). `entry.onChange(columnKey, value)` reports a change; `entry.decrementLabel` and `entry.incrementLabel` name the buttons; `entry.max` caps each column (`null` for none), `entry.errors` puts a message under a column's count (`null` for none), and `entry.placeholder` fills an empty box. `entry.limitAppearance` is the Steppers' `limitAppearance`: `"plain"` keeps − at 0 (and + at a cap) unfaded. The minimum is 0. The grid still does no arithmetic, so recompute the row's `total` in `onChange`. A strip with an entry row centres its heads and read-only counts over the steppers, and each count keeps spacing step 8 of room, so a narrow strip scrolls in its region instead of crushing it. A `cellTones` tone on an entry count draws its border in the tone (danger for a difference); an `accent` tone, for a suggestion or a change, is the `Stepper`'s `changed` look (border and tint); a message in `entry.errors` wins. A row `caption` is a muted line under its label ("from the customer"), and a column `caption` a muted note after its label ("× 100"). Captions show in both layouts. In the strip, `emphasis="quiet"` sets a row on the subtle fill, for a row to compare against, and `disabled` fades a row that does not apply, disables its entry and marks it `aria-disabled`. Entry rows, quiet rows and disabled rows need the `strip` layout. An entry row's counts are zero or more and it is not `signed`; its label is its own among the entry rows, as each column's label and caption are, so every count has its own name. Breaking any of these, a cap or message list that is not one per column, a cap that is not a whole count of zero or more, blank button names or captions, an unknown `emphasis`, or a `disabled` that is not a boolean throws a `RangeError`.
660
844
 
661
845
  ```tsx
@@ -688,11 +872,51 @@ A strip row with `entry` makes its counts editable: each column holds a `Stepper
688
872
 
689
873
  `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`.
690
874
 
875
+ `FilterChips` options for chips that sit above a list, all opt-in: `labelVisuallyHidden` keeps `label` as the group's name without drawing it, for chips whose words and place already say what they filter; `countAppearance="inline"` (`filterChipCountAppearances`, default `"figure"`) writes the count in the chip's own words after a middle dot ("Open · 3"), the dot hidden so the name reads "Open 3"; `overflow="scroll"` (`filterChipsOverflows`, default `"wrap"`) keeps the chips on one row that scrolls sideways inside its own box, never the page, each chip on one line, a focused chip scrolled into view and the focus ring unclipped. `multiple` turns several chips on at once, such as the days of a week: each chip is a native checkbox and its own Tab stop, `value` is a list of the chosen values (empty for none) and `onChange` receives every chosen value in the options' order. A multiple group cannot be `required` (nothing makes a checkbox group required); say what is needed in `error`. A value that is no option, a multiple `value` that is not a list, `required` with `multiple`, or an unknown count appearance or overflow throws a `RangeError`.
876
+
877
+ ```tsx
878
+ <FilterChips
879
+ label="Nocturnal surveys"
880
+ labelVisuallyHidden
881
+ countAppearance="inline"
882
+ overflow="scroll"
883
+ value={nights}
884
+ onChange={setNights}
885
+ options={[
886
+ { value: 'all', label: 'All', count: 7 },
887
+ { value: 'owls', label: 'Owls heard', count: 3 },
888
+ ]}
889
+ />
890
+
891
+ <FilterChips
892
+ label="Watch days"
893
+ multiple
894
+ value={watchDays}
895
+ onChange={setWatchDays}
896
+ options={[
897
+ { value: 'fri', label: 'Fri' },
898
+ { value: 'sat', label: 'Sat' },
899
+ ]}
900
+ />
901
+ ```
902
+
691
903
  `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.
692
904
 
905
+ `DateField` `entry="calendar"` (opt-in; `dateFieldEntries`, default `"typed"`) is a day picked, never typed, such as a hire date: one neutral `Button` shaped as a field's control (the small radius, sized to its words on one line at the column's start) holding the calendar glyph and the chosen day in the locale's medium style ("Oct 6, 2026"), or `labels.chooseDate` while the field is empty (pass "Choose a day"). It opens the same calendar, which picks, steps by month and year, picks today and, on an optional field, clears. It is named by the label and its words ("Hire date Choose a day"), described by the `description` and `error`, and a label press opens it too. A button has no required state or text to check, so `required` draws the label's mark and says `labels.required` ("Required") after its name, the form's own check supplies `error`, and the danger edge marks it while `error` is set or the value is outside `min`/`max`. A button cannot be `aria-invalid`, so pass an `error` for a value outside the bounds too: the edge alone is colour only. A long day or `chooseDate` in a narrow column ends in an ellipsis; its name keeps the whole words. An unknown `entry` throws a `RangeError`.
906
+
907
+ ```tsx
908
+ <DateField
909
+ entry="calendar"
910
+ label="Banding day"
911
+ labels={{ chooseDate: 'Choose a day' }}
912
+ value={bandingDay}
913
+ onChange={setBandingDay}
914
+ />
915
+ ```
916
+
693
917
  People type the date into a text entry in the locale's numeric order (`MM/DD/YYYY` for en-US, `DD/MM/YYYY` for es), with any non-digit separator, eight bare digits, or ISO `YYYY-MM-DD`. A keystroke calls `onChange` once the entry is complete, meaning its last field is at full width (four year digits, or two digits for a trailing day or month, as in ISO); otherwise the date commits when the person leaves the field or presses Enter, so typing `2024-03-10` never sends `2024-03-01` on the way. If the parent keeps the old value, the typed text stays on screen; any other outside change replaces it. Text that is not a date keeps the last value and, once the person leaves the field, sets `aria-invalid` and shows `labels.invalidEntry`. Like the native date input, the entry blocks form submission through `setCustomValidity`: `labels.invalidEntry` while its text is not a date, and `labels.outOfRange` while its date, typed or given, is outside `min`/`max`. The calendar button beside it opens a WAI-ARIA date picker dialog anchored under the field on the popover layer: the month grid shows today, the selected day, and days outside `min`/`max` as `aria-disabled` (padding before `0001-01-01` or after `9999-12-31` is blank and inert); arrows move by day and week, Home and End to the week's edges, PageUp and PageDown by month, Shift with them by year, Enter or Space selects and closes, and Escape closes and returns focus to the button. Month and year selectors in the header jump decades (the year list spans 120 years back and 20 ahead of today and the shown month, cut to `min` and `max`; the month list offers only months with a day inside them). A new `value`, `min`, or `max` while the calendar is open moves it to the new value or back inside the bounds. **Today** selects today; **Clear** appears only when the field is not `required`. Today is the device's local date unless `today` (a `YYYY-MM-DD` date) is given: pass the business's own day when it keeps a time zone the device may not share, so a phone in another zone after the business's midnight does not mark tomorrow as today, open on it, or pick it while `max` is the business's day. `today` moves the today mark, the empty field's starting day, **Today**, and the year list's span; it may lie outside `min`/`max`, where **Today** is disabled as before. An empty or malformed `today` throws a `RangeError`.
694
918
 
695
- `locale` (BCP 47) sets month and weekday names, spoken dates, and the typed order; it defaults to the nearest `lang` attribute when the field mounts, then `en-US`, and a malformed tag throws. `weekStartsOn` is `0` (Sunday, the default) or `1` (Monday). `labels` overrides the control's own words, English by default: `chooseDate`, `previousMonth`, `nextMonth`, `month`, `year`, `today`, `clear`, `invalidEntry`, `outOfRange`, and the placeholder letters `dayPlaceholder`, `monthPlaceholder`, `yearPlaceholder`. The field label names only the text entry; the calendar button is named `labels.chooseDate` and described by the field label, so tests find the entry with `getByLabel` or `getByRole('textbox', { name })`.
919
+ `locale` (BCP 47) sets month and weekday names, spoken dates, and the typed order; it defaults to the nearest `lang` attribute when the field mounts, then `en-US`, and a malformed tag throws. `weekStartsOn` is `0` (Sunday, the default) or `1` (Monday). `labels` overrides the control's own words, English by default: `chooseDate`, `previousMonth`, `nextMonth`, `month`, `year`, `today`, `clear`, `invalidEntry`, `outOfRange`, `required` (said after a required calendar entry's name; `entry="calendar"` only), and the placeholder letters `dayPlaceholder`, `monthPlaceholder`, `yearPlaceholder`. With the typed entry (the default) the field label names only the text entry; the calendar button is named `labels.chooseDate` and described by the field label, so tests find the entry with `getByLabel` or `getByRole('textbox', { name })`. With `entry="calendar"` the one button is named by the label and its words (`getByRole('button', { name })`), and `chooseDate` is its empty text.
696
920
 
697
921
  ```tsx
698
922
  <DateField
@@ -736,6 +960,32 @@ People type the date into a text entry in the locale's numeric order (`MM/DD/YYY
736
960
  </Inline>
737
961
  ```
738
962
 
963
+ `DateRangeButton` shows a range of days and opens a calendar to pick a first and a last day, such as a report's period between its own ‹ and › steps. Supply `label` (what pressing it does, such as "Choose survey period"), `value` (`{ start, end }`, both `YYYY-MM-DD` and included) and `onChange`; optional `min`, `max`, `today`, `disabled`, `locale`, `weekStartsOn`, `id` and a `ref` behave as on `CalendarButton`. The button shows a calendar glyph and the range in the locale, sharing what both ends have ("Oct 6 – 12, 2026"; one day as one date); its name is `label`, `labels.nameSeparator`, then that range. The first press on a day sets the start and the second the end; a press before the start starts over from it, and Apply with only a start applies that one day. Days after `max` are disabled, and once a start is pressed so are the days past `maxDays` (a whole number of days, both ends counted, such as 366). The range being picked and its length (`labels.dayCount`) are a polite status. **Apply** calls `onChange` when the range changed, closes and returns focus to the button; **Cancel**, Escape and a press outside close without a change. `presets` (`{ id, label, range }[]`) are ranges chosen in one press: a press applies its range at once and closes, and the one matching the range shown is pressed; a preset outside `min`/`max` or longer than `maxDays` throws, so compute presets from the same bounds and leave out one that does not fit (such as Last year for a station opened this spring). From `md` up the calendar is a popover with the presets beside two months (above them below `lg`, where both do not fit side by side), the range and the hint, and Cancel and Apply; below `md` it is a `Dialog` bottom sheet titled by `label` with the range under the title, the presets in a row that scrolls sideways, one month and the hint over Cancel and Apply. The keyboard is the calendar's: arrows, Home, End, PageUp and PageDown (with Shift by year) move one focus day across both months, and Enter or Space presses it. `labels` takes `previousMonth`, `nextMonth`, `nameSeparator`, `presets` (the presets group's name), `apply`, `cancel`, `close`, `hint` and `dayCount(days)`, in English by default. `size` (default `md`, at least 44 px tall on a coarse pointer) and `variant` (default `neutral`) follow `Button`. A value outside `min`/`max` or longer than `maxDays` is kept; an empty or malformed date, a start after its end, a `maxDays` under 1 or fractional, repeated preset ids, or an empty word throws a `RangeError`.
964
+
965
+ ```tsx
966
+ <ButtonGroup aria-label="Survey period" attached>
967
+ <Button aria-label="The period before" variant="neutral" onPress={previous}>
968
+ ‹
969
+ </Button>
970
+ <DateRangeButton
971
+ label="Choose survey period"
972
+ value={period}
973
+ onChange={setPeriod}
974
+ presets={[
975
+ { id: 'week', label: 'Last 7 days', range: lastSevenDays },
976
+ { id: 'month', label: 'This month', range: thisMonth },
977
+ ]}
978
+ max={stationDay}
979
+ maxDays={366}
980
+ today={stationDay}
981
+ labels={{ apply: 'Show' }}
982
+ />
983
+ <Button aria-label="The period after" variant="neutral" onPress={next}>
984
+ ›
985
+ </Button>
986
+ </ButtonGroup>
987
+ ```
988
+
739
989
  ```tsx
740
990
  <Switch
741
991
  label="Active habitats only"
@@ -10,6 +10,8 @@ export declare function monthNames(locale: string): string[];
10
10
  export declare function monthTitle(month: CalendarMonth, locale: string): string;
11
11
  /** A spoken date such as "Sunday, March 10, 2024"; empty for no date. */
12
12
  export declare function formatDateLabel(value: string, locale: string): string;
13
+ /** A date as a field shows it, such as "Mar 10, 2024"; empty for no date. */
14
+ export declare function formatDateMedium(value: string, locale: string): string;
13
15
  /**
14
16
  * An icon-only calendar trigger's name: its purpose, a separator, then the
15
17
  * date it holds spoken in full, such as "Choose survey day, Tuesday,
@@ -30,6 +30,13 @@ export function formatDateLabel(value, locale) {
30
30
  return '';
31
31
  return dateFormatter(locale, { dateStyle: 'full' }).format(formatterDate(parts));
32
32
  }
33
+ /** A date as a field shows it, such as "Mar 10, 2024"; empty for no date. */
34
+ export function formatDateMedium(value, locale) {
35
+ const parts = parseDateOnly(value);
36
+ if (!parts)
37
+ return '';
38
+ return dateFormatter(locale, { dateStyle: 'medium' }).format(formatterDate(parts));
39
+ }
33
40
  /**
34
41
  * An icon-only calendar trigger's name: its purpose, a separator, then the
35
42
  * date it holds spoken in full, such as "Choose survey day, Tuesday,
@@ -17,8 +17,14 @@ export declare function weekdayOf(value: string): number;
17
17
  export declare function addDays(value: string, delta: number): string;
18
18
  /** Same day of the month `delta` months away, capped at that month's length. */
19
19
  export declare function addMonths(value: string, delta: number): string;
20
+ /** Days from `start` to `end`: 0 for the same day, negative when `end` is earlier. */
21
+ export declare function daysBetween(start: string, end: string): number;
20
22
  export declare function monthOf(value: string): CalendarMonth;
21
23
  export declare function shiftMonth(month: CalendarMonth, delta: number): CalendarMonth;
24
+ /** Months from `from` to `to`: 0 for the same month, negative when `to` is earlier. */
25
+ export declare function monthsBetween(from: CalendarMonth, to: CalendarMonth): number;
26
+ /** `value`'s day of the month in `month`, capped at that month's length. */
27
+ export declare function sameDayIn(month: CalendarMonth, value: string): string;
22
28
  export declare function firstOfMonth(month: CalendarMonth): string;
23
29
  export declare function lastOfMonth(month: CalendarMonth): string;
24
30
  /** Whether any day of the month falls inside `[min, max]`. */
@@ -61,6 +61,10 @@ export function addMonths(value, delta) {
61
61
  day: Math.min(parts.day, daysInMonth(month.year, month.month)),
62
62
  });
63
63
  }
64
+ /** Days from `start` to `end`: 0 for the same day, negative when `end` is earlier. */
65
+ export function daysBetween(start, end) {
66
+ return toDayNumber(requireDate(end)) - toDayNumber(requireDate(start));
67
+ }
64
68
  export function monthOf(value) {
65
69
  const parts = requireDate(value);
66
70
  return { year: parts.year, month: parts.month };
@@ -69,6 +73,17 @@ export function shiftMonth(month, delta) {
69
73
  const index = month.year * 12 + (month.month - 1) + delta;
70
74
  return { year: Math.floor(index / 12), month: (index % 12) + 1 };
71
75
  }
76
+ /** Months from `from` to `to`: 0 for the same month, negative when `to` is earlier. */
77
+ export function monthsBetween(from, to) {
78
+ return to.year * 12 + to.month - (from.year * 12 + from.month);
79
+ }
80
+ /** `value`'s day of the month in `month`, capped at that month's length. */
81
+ export function sameDayIn(month, value) {
82
+ return formatDateOnly({
83
+ ...month,
84
+ day: Math.min(requireDate(value).day, daysInMonth(month.year, month.month)),
85
+ });
86
+ }
72
87
  export function firstOfMonth(month) {
73
88
  return formatDateOnly({ ...month, day: 1 });
74
89
  }