@scalewing/react 1.12.0 → 1.14.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 84d5fd9: `Select` takes `width?: 'content' | 'full'` (`docs/requests/teisoro-select.md`, 2026-09-29 follow-up). `'content'`, the default, is today's field, as wide as its longest option; nothing changes for existing Selects. `'full'` adds the generated `sw-select-full` class: the field and its trigger fill the container's inline size in a `Stack`, a `Grid` cell or a narrow column, and in an `Inline` row they take the space the siblings leave (`.sw-inline > .sw-select-full { flex: 1 1 0; min-width: 0 }`), so a `Button` beside it keeps its label on one line. The value still ellipsizes, the chevron stays at the inline end (left to right and right to left), and the open list is exactly the trigger's width, so a long option wraps inside it, even a single long word (`overflow-wrap: anywhere`), instead of running past a phone's edge or scrolling sideways. New exported type `SelectWidth`. No new dependencies.
8
+
9
+ ## 1.13.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ca9350c: `RadioGroup` options take an `icon` (`docs/requests/teisoro-radio-group.md`, 2026-09-28 follow-up, Teisoro CHK-13). `RadioGroupOption.icon?: ReactNode` renders a decorative glyph between the radio and its label, `aria-hidden`, in the text color (new generated class `sw-radio-group-icon`). The option's accessible name stays its `label` text, and a press on the glyph chooses the option. Without `icon` nothing changes. No new dependencies.
14
+ - 4b0a029: `SegmentedControl` takes `error` and `required` (`docs/requests/teisoro-segmented-control.md`, 2026-09-28 follow-up, Teisoro DRW-20):
15
+
16
+ - `error?: string` renders a `sw-field-error` message under the track in the same polite live region as `Field`'s (always rendered, never an alert), links it to the `radiogroup` by `aria-describedby`, and sets `aria-invalid` and a danger outline on the group (`Mark` in forced colors). An empty string is no error.
17
+ - `required?: boolean` sets `aria-required` on the `radiogroup`; the element that labels the control shows the visible mark.
18
+ - The track now renders inside a `.sw-segmented-field` wrapper (new generated classes `sw-segmented-field` and `sw-segmented-field-filled`) that takes its place in the layout, so the track's width in a Stack, an Inline or block flow is unchanged and a `ref` still reaches the `radiogroup`. A test that measured the control against its `parentElement` should use the wrapper's parent. `error` is meant for a Stack or block layout: while a message shows, the field is at least 24ch wide (capped at its container), so in an `Inline` the row grows.
19
+
20
+ Without the new props nothing else changes. No new dependencies.
21
+
22
+ ### Patch Changes
23
+
24
+ - 6a5b226: A pressed toggle `Button` keeps its label readable in forced colors (`docs/requests/teisoro-button.md`, 2026-09-28 follow-up; a regression from 1.12.0). The `Highlight` fill with `HighlightText` is gone: Chromium paints the forced backplate behind a button's text in `Canvas`, which erased the label (1.00:1 painted). The pressed button now keeps the forced button colors, so its label, glyphs and badges stay readable, and it is marked by the same 2 px ring as outside forced colors, drawn as a `Highlight` border on an out-of-flow `::after`, with a `Highlight` border on the button itself. No `forced-color-adjust`, no API change and no new dependencies.
25
+ - d4ef0dc: `DateField`'s label row matches `Field`'s (`docs/requests/teisoro-date-field.md`, 2026-09-28 label row follow-up, Teisoro NSF-35). Its label now uses `Field`'s own label markup (the label words as a `Text` label span and the `aria-hidden` required mark, inside a `<label>` that keeps the canvas type), so a `DateField` beside a `Field` lines up at the label and the control; before, its entry sat 5 px higher. `.sw-date-field-label` no longer sets its own font size, weight or line height. No API change and no new dependencies.
26
+ - 58ea2f8: A lone `DenominationGrid` tiles row without an icon or `total`, whose `label` is the grid's own `label`, no longer names itself (`docs/requests/teisoro-denomination-grid.md`, 2026-09-28 lone-row name follow-up, Teisoro SDAY-31). It was a region with the grid's own words inside the grid's group; now the group, named by `label`, is its only name. Every other row, including a lone plain row with different words, is still a region named by its `label`. No API change and no new dependencies.
27
+ - 6f5a37c: A disabled text control looks locked (`docs/requests/teisoro-field-disabled.md`, Teisoro DRW-17). A disabled native `input`, `select` or `textarea` on the canvas, `Field`'s adorned frame and `DateField`'s entry take the subtle fill, a dashed border and `cursor: not-allowed`; the value stays in the text color at full opacity (4.5:1 or more in every palette), and forced colors keep the dashed border in `GrayText`. `DateField`'s entry no longer fades to the disabled opacity; its calendar button still does. Every typed input's placeholder is now drawn in `--sw-color-muted` (4.5:1 or more on every palette's field fill) instead of the browser's fixed gray, which fell to 2.3:1 on dark fields. No API change and no new dependencies.
28
+ - a747fc5: `Select`'s label row matches `Field`'s (`docs/requests/teisoro-select.md`, 2026-09-28 label row follow-up). Its label now uses `Field`'s own label markup (the label words as a `Text` span and the `aria-hidden` required mark, inside a plain `<label>` that keeps the canvas type), so a `Select` beside a `Field` lines up at the label and the control; before, its trigger sat 5 px higher. `labelVisuallyHidden` hides the span inside the label, as `Field` does. No API change and no new dependencies.
29
+
30
+ Consumer and migration notes:
31
+
32
+ - The `<label>` element no longer carries `sw-text-label` (or `sw-sr-only` with `labelVisuallyHidden`); a `<span>` inside it does. A selector or test that targeted `label.sw-text-label` in a `Select` should target the label element (`label[for]`, or `getByText(label)` for the span) instead.
33
+ - A visible `size="xs"` label row goes from 18 px (the caption line height on the label itself) to 25 px (the canvas body line box, as a visible `xs` `Field` label has), so an `xs` Select with a visible label sits 7 px lower. Toolbar `xs` Selects with `labelVisuallyHidden` are unchanged: the hidden label takes no row.
34
+ - A visible `md` label row goes from 20 px to 25 px, so the trigger sits 5 px lower, level with a `Field` beside it.
35
+
36
+ - 2f7e4b0: `TableRow selected` marks the row without moving a column (`docs/requests/teisoro-table.md`, 2026-09-28 follow-up, Teisoro NSF-12). The first cell no longer gains `padding-inline-start` and the 8 px dot is gone. A 4 px accent bar at the row's inline start (3:1 or more against the page and the surface) is drawn inside the first cell's padding, out of flow; it follows the writing direction. The row takes no fill, so text, muted and accent text keep their contrast. Forced colors keep the bar in `Highlight`. No API change and no new dependencies.
37
+
3
38
  ## 1.12.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -52,6 +52,8 @@ Import the CSS once at the application entry. Do not copy it into your source tr
52
52
 
53
53
  `Field` supports optional `description`, `error`, and `required` on one native input, select, or textarea child. It preserves an existing `aria-describedby` and appends a stable ID for the hint, or for the error, which replaces the hint on screen while it is supplied. `error` sets `aria-invalid` and token-owned invalid styling. The error sits in a polite live region (`aria-live="polite"`) that is always rendered, empty while there is no error, so an error that appears while the person types, or after a submit, is announced once, politely, when its text is swapped in; it is never `role="alert"`, so a submit that finds several errors does not fire several assertive alerts at once. Empty, the region takes no room in the layout. The consumer owns validation and localized messages, and after a refused submit it moves focus to the first invalid control (or to one form-level summary). `Checkbox`, `RadioGroup` and `DateField` expose their `error` the same way.
54
54
 
55
+ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the canvas, a `Field` frame with a `prefix` or `suffix`, or a `DateField` entry) takes the quiet subtle fill, a dashed border and a not-allowed cursor. Its value stays in the text color at full strength, so a locked value is still readable; forced colors keep the dashed border in the system's disabled color. A typed input's placeholder, enabled or disabled, is drawn in the muted color, which keeps 4.5:1 on the field in every palette.
56
+
55
57
  `Field` `prefix` and `suffix` put short text such as `$` or `%` inside the frame of one native `<input>` child, before or after the value. The text is not part of the value; the input is named by its label plus the adornment (`"Drop amount $"`), unless it names itself with its own `aria-label` or `aria-labelledby`, in which case the adornment joins its description. A press anywhere on the frame focuses the input. Formatting the value stays with the consumer. Any other child throws a `TypeError`.
56
58
 
57
59
  ```tsx
@@ -60,7 +62,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
60
62
  </Field>
61
63
  ```
62
64
 
63
- `Select` is a labeled listbox menu: supply `label`, `options` (`value` and `label`), a controlled `value` and `onChange`; `size="xs"` and `labelVisuallyHidden` are for toolbar chrome, and `action` is a last listbox command that never becomes the value. The closed trigger matches `Field`: its text is centred in the control, it ends in the `Accordion` chevron, and it is as wide as its longest option (as a native select is), so it keeps its width when the value changes; past the available width the label ellipsizes. The option labels that size it are drawn by CSS from `data-label`, so the trigger's text content and accessible value are only the current label.
65
+ `Select` is a labeled listbox menu: supply `label`, `options` (`value` and `label`), a controlled `value` and `onChange`; `size="xs"` and `labelVisuallyHidden` are for toolbar chrome, and `action` is a last listbox command that never becomes the value. The closed trigger matches `Field`: its text is centred in the control, it ends in the `Accordion` chevron, and it is as wide as its longest option (as a native select is), so it keeps its width when the value changes; past the available width the label ellipsizes. The option labels that size it are drawn by CSS from `data-label`, so the trigger's text content and accessible value are only the current label. Its label is drawn as `Field`'s (the same label words and mark inside a `<label>` that keeps the canvas type), so a `Select` beside a `Field` lines up at the label and the control.
64
66
 
65
67
  `Select` `placeholder` shows in the closed trigger, in the muted color, while `value` matches no option (such as `''`); it is not an option, is never selected in the list and never becomes the value, and it counts toward the trigger's width. `required` marks the label with `Field`'s `aria-hidden` asterisk and sets `aria-required` on the trigger. `error` renders a message under the control (`sw-field-error`), links it by `aria-describedby`, sets `aria-invalid` and the danger border; like `Field`'s it is announced politely from a live region that is always there, never as an alert, and an empty string is no error. A blank `placeholder` throws a `RangeError`.
66
68
 
@@ -76,6 +78,18 @@ Import the CSS once at the application entry. Do not copy it into your source tr
76
78
  />
77
79
  ```
78
80
 
81
+ `Select` `width` is `'content'` (the default: the field is as wide as its longest option, as above) or `'full'`: the field and its trigger fill the container's inline size in a `Stack`, a `Grid` cell or any narrow column, such as a filter above full-width cards on a phone. In an `Inline` row it takes the space its siblings leave, so a `Button` beside it keeps its label on one line. The value still takes the free space and ellipsizes past it, the chevron stays at the inline end (the left, right to left), and the open list is exactly the trigger's width, so it never runs past the screen's edge; a long option wraps inside it, even a single long word.
82
+
83
+ ```tsx
84
+ <Select
85
+ label="Show"
86
+ onChange={setFilter}
87
+ options={filters}
88
+ value={filter}
89
+ width="full"
90
+ />
91
+ ```
92
+
79
93
  `Accordion` is a native `<details>` disclosure controlled by `open` and `onOpenChange`. A token chevron at the end of the header replaces the browser triangle and turns when it opens (it holds still under reduced motion). `subtitle` adds one muted caption line under the title, such as a summary of what the section holds; it wraps rather than truncating and is read after the title. `size="sm"` is a quieter disclosure nested inside other content: a label-size title, tighter padding, smaller corners and a header that is an `sm` control (at least 32px).
80
94
 
81
95
  ```tsx
@@ -110,7 +124,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
110
124
  </Stack>
111
125
  ```
112
126
 
113
- A `Button` that toggles something passes `aria-pressed`. The pressed button gets a 2 px accent ring outside its fill, past a 2 px gap in `--sw-color-background`, so the ring meets only the page and keeps the accent's contrast on the canvas (4.5:1 or more in every palette, at least the 3:1 a state indicator needs) whatever its `variant` or fill. A focused pressed button moves its focus outline out past the ring. In forced colors, which drop shadows, the pressed button is filled with the system highlight. An unpressed button is drawn at full strength, so its label keeps its contrast. To make the choice stand out further, give the pressed button `primary` and the others `secondary`.
127
+ A `Button` that toggles something passes `aria-pressed`. The pressed button gets a 2 px accent ring outside its fill, past a 2 px gap in `--sw-color-background`, so the ring meets only the page and keeps the accent's contrast on the canvas (4.5:1 or more in every palette, at least the 3:1 a state indicator needs) whatever its `variant` or fill. A focused pressed button moves its focus outline out past the ring. In forced colors, which drop shadows, the pressed button keeps the forced button colors, so its label and any glyph or badge inside it stay readable, and draws the same ring and its own border in the system highlight (`Highlight`); nothing opts out of forced colors. An unpressed button is drawn at full strength, so its label keeps its contrast. To make the choice stand out further, give the pressed button `primary` and the others `secondary`.
114
128
 
115
129
  `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.
116
130
 
@@ -124,7 +138,38 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
124
138
 
125
139
  `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.
126
140
 
127
- `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.
141
+ `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.
142
+
143
+ ```tsx
144
+ <RadioGroup
145
+ legend="Check type"
146
+ onChange={setCheckType}
147
+ options={[
148
+ { value: 'personal', label: 'Personal check', icon: <User /> },
149
+ { value: 'company', label: 'Company check', icon: <Building2 /> },
150
+ ]}
151
+ value={checkType}
152
+ />
153
+ ```
154
+
155
+ `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.
156
+
157
+ ```tsx
158
+ <SegmentedControl
159
+ aria-labelledby={directionLabelId}
160
+ error={directionError}
161
+ items={[
162
+ { id: 'add', label: 'Add' },
163
+ { id: 'remove', label: 'Remove' },
164
+ ]}
165
+ onChange={setDirection}
166
+ required
167
+ value={direction}
168
+ variant="filled"
169
+ />
170
+ ```
171
+
172
+ `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
128
173
 
129
174
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
130
175
 
@@ -168,11 +213,11 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
168
213
  </Grid>
169
214
  ```
170
215
 
171
- `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start; the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
216
+ `DenominationGrid` shows integer counts per unit across a fixed set of columns. The `strip` layout is a captioned table: each row has a toned label with an icon slot, muted zero cells, optional signed deltas toned by sign, and an optional consumer-formatted `total` that shows under the label on a phone. Pass `totalLabel` (for example "Total") to name the total column for a screen reader: it renders a visually hidden `th scope="col"`, and without it the corner stays an empty cell as before (either way the column takes no width on a phone). On a phone the total cell stays in the table, visually hidden, so a screen reader still reads each total in its row; the copy under the label is `aria-hidden`. A strip wider than its container scrolls sideways inside its own keyboard-focusable region, named after `label`, with the row labels pinned at the start; the page never scrolls sideways. The `tiles` layout stacks the column label, the count, and an optional `subtotal` string per column. Each tiles row shows its label line (icon, label and `total`) when the grid has several rows, or when a lone row has an icon or a `total`; a lone plain row is named by the grid's `label` alone. Every row is a region named by its `label`, except a lone plain row whose `label` is the grid's own: it has no name of its own, so assistive technology hears the grid's group once rather than a region repeating it. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
172
217
 
173
218
  `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.
174
219
 
175
- `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`. For a blank required field, supply `error` when form validation runs to show the message and invalid styling.
220
+ `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.
176
221
 
177
222
  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`.
178
223
 
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useMemo, useRef } from 'react';
4
4
  import { assertWeekStart } from '../calendar-month.js';
5
5
  import { resolveDateFieldLabels, } from '../date-field-labels.js';
@@ -7,6 +7,7 @@ import { dateEntryPattern, dateEntryPlaceholder } from '../date-entry.js';
7
7
  import { dateEntryValidity } from '../date-entry-validity.js';
8
8
  import { assertDateBounds, isOutsideDateRange } from '../date-only.js';
9
9
  import { FieldErrorRegion } from './FieldErrorRegion.js';
10
+ import { FieldLabelText } from './FieldLabelText.js';
10
11
  import { CalendarDialog } from './date-field/CalendarDialog.js';
11
12
  import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
12
13
  import { useCalendarPopup } from './date-field/use-calendar-popup.js';
@@ -49,7 +50,7 @@ export function DateField({ label, value, onChange, min, max, disabled = false,
49
50
  .filter((id) => id !== null)
50
51
  .join(' ');
51
52
  const invalid = Boolean(error) || entry.invalid || isOutsideDateRange(value, min, max);
52
- return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsxs("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
53
+ return (_jsxs("div", { className: "sw-date-field", ref: rootRef, children: [_jsx("label", { className: "sw-date-field-label", htmlFor: inputId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required }) }), _jsxs("div", { className: "sw-date-field-control", ref: controlRef, children: [_jsx("input", { "aria-describedby": describedBy || undefined, "aria-invalid": invalid, autoComplete: "off", className: "sw-date-field-input", disabled: disabled, id: inputId, onBlur: entry.check, onChange: (event) => entry.change(event.currentTarget.value), onKeyDown: (event) => {
53
54
  if (event.key === 'Enter')
54
55
  entry.check();
55
56
  }, placeholder: dateEntryPlaceholder(pattern, {
@@ -1,10 +1,10 @@
1
1
  'use client';
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cloneElement, isValidElement, useId } from 'react';
4
4
  import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
+ import { FieldLabelText } from './FieldLabelText.js';
6
7
  import { Stack } from './Stack.js';
7
- import { Text } from './Text.js';
8
8
  export function Field({ children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, required = false, prefix, suffix, }) {
9
9
  const controlId = useId();
10
10
  const descriptionId = useId();
@@ -22,7 +22,7 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
22
22
  ]
23
23
  .filter(Boolean)
24
24
  .join(' ');
25
- const labelText = (_jsxs(Text, { as: "span", className: labelVisuallyHidden ? 'sw-sr-only' : undefined, variant: size === 'xs' ? 'caption' : 'label', children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }));
25
+ const labelText = (_jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelVisuallyHidden }));
26
26
  if (!isValidElement(children) ||
27
27
  typeof children.type !== 'string' ||
28
28
  !['input', 'select', 'textarea'].includes(children.type)) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A form control's label words and its required mark, as `Field` draws
3
+ * them. The `<label>` around it inherits the canvas's body type, so its
4
+ * line box, and the gap to the control below, is the same for every field
5
+ * that uses it (`Field`, `DateField` and `Select`). The mark is `aria-hidden`: the
6
+ * control's own required state is what assistive technology reads.
7
+ */
8
+ export declare function FieldLabelText({ label, required, size, visuallyHidden, }: {
9
+ label: string;
10
+ required?: boolean;
11
+ size?: 'xs' | 'md';
12
+ visuallyHidden?: boolean;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './Text.js';
3
+ /**
4
+ * A form control's label words and its required mark, as `Field` draws
5
+ * them. The `<label>` around it inherits the canvas's body type, so its
6
+ * line box, and the gap to the control below, is the same for every field
7
+ * that uses it (`Field`, `DateField` and `Select`). The mark is `aria-hidden`: the
8
+ * control's own required state is what assistive technology reads.
9
+ */
10
+ export function FieldLabelText({ label, required = false, size = 'md', visuallyHidden = false, }) {
11
+ return (_jsxs(Text, { as: "span", className: visuallyHidden ? 'sw-sr-only' : undefined, variant: size === 'xs' ? 'caption' : 'label', children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }));
12
+ }
@@ -1,7 +1,14 @@
1
+ import { type ReactNode } from 'react';
1
2
  export type RadioGroupOption = {
2
3
  value: string;
3
4
  label: string;
4
5
  disabled?: boolean;
6
+ /**
7
+ * A decorative glyph between the radio and the label, such as a person or
8
+ * a building for the kind of check. It is `aria-hidden` and drawn in the
9
+ * text color: the option's accessible name stays its `label` text.
10
+ */
11
+ icon?: ReactNode;
5
12
  };
6
13
  export type RadioGroupProps = {
7
14
  legend: string;
@@ -24,5 +24,5 @@ export function RadioGroup({ legend, value, onChange, options, required = false,
24
24
  !option.disabled &&
25
25
  event.currentTarget.checked)
26
26
  onChange(option.value);
27
- }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), _jsx("span", { className: "sw-radio-group-text", children: option.label })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
27
+ }, required: required, type: "radio", value: option.value }), _jsx("span", { "aria-hidden": "true", className: "sw-radio-group-mark" })] }), option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-radio-group-icon", children: option.icon })) : null, _jsx("span", { className: "sw-radio-group-text", children: option.label })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
28
28
  }
@@ -33,6 +33,14 @@ export type SegmentedControlProps = SegmentedLabel & SegmentedChange & {
33
33
  items: readonly SegmentedItem[];
34
34
  value: string;
35
35
  variant?: SegmentedControlVariant;
36
+ /**
37
+ * A validation message under the control, as `Field`'s: announced from a
38
+ * polite live region, linked by `aria-describedby`, with `aria-invalid`
39
+ * and a danger outline on the group. An empty string is no error.
40
+ */
41
+ error?: string;
42
+ /** Sets `aria-required` on the group; the visible mark belongs to the element that labels it. */
43
+ required?: boolean;
36
44
  };
37
45
  export declare const SegmentedControl: import("react").ForwardRefExoticComponent<SegmentedControlProps & import("react").RefAttributes<HTMLDivElement>>;
38
46
  export {};
@@ -1,7 +1,10 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { forwardRef } from 'react';
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { forwardRef, useId } from 'react';
3
4
  import { cx } from '../class-names.js';
4
- export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, items, onChange, value, variant = 'compact', ...labelProps }, ref) {
5
+ import { FieldErrorRegion } from './FieldErrorRegion.js';
6
+ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, error, items, onChange, required = false, value, variant = 'compact', ...labelProps }, ref) {
7
+ const errorId = useId();
5
8
  function onKeyDown(event) {
6
9
  if (disabled || (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft')) {
7
10
  return;
@@ -17,12 +20,14 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled
17
20
  onChange?.(next.id);
18
21
  }
19
22
  }
20
- return (_jsx("div", { ref: ref, "aria-disabled": disabled || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
21
- const selected = item.id === value;
22
- return (_jsx("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
23
- if (!selected) {
24
- onChange?.(item.id);
25
- }
26
- }, role: "radio", type: "button", children: item.label }, item.id));
27
- }) }));
23
+ // The wrapper is always rendered, so an error that appears later swaps
24
+ // text into a live region that already exists and the group keeps focus.
25
+ return (_jsxs("div", { className: cx('sw-segmented-field', variant === 'filled' && 'sw-segmented-field-filled'), children: [_jsx("div", { ref: ref, "aria-describedby": error ? errorId : undefined, "aria-disabled": disabled || undefined, "aria-invalid": error ? true : undefined, "aria-required": required || undefined, className: cx('sw-segmented', variant === 'filled' && 'sw-segmented-filled', disabled && 'sw-segmented-disabled'), onKeyDown: onKeyDown, role: "radiogroup", ...labelProps, children: items.map((item) => {
26
+ const selected = item.id === value;
27
+ return (_jsx("button", { "aria-checked": selected, className: cx('sw-segmented-item', selected && 'sw-segmented-item-selected'), disabled: disabled, onClick: () => {
28
+ if (!selected) {
29
+ onChange?.(item.id);
30
+ }
31
+ }, role: "radio", type: "button", children: item.label }, item.id));
32
+ }) }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
28
33
  });
@@ -1,6 +1,12 @@
1
1
  import { type SelectOption } from '../select-list.js';
2
2
  import { type FieldSize } from './Field.js';
3
3
  export type { SelectOption };
4
+ /**
5
+ * `content` (the default) sizes the field to its longest option, as a native
6
+ * select is; `full` fills the container's inline size (in an Inline row, the
7
+ * space its siblings leave).
8
+ */
9
+ export type SelectWidth = 'content' | 'full';
4
10
  export type SelectAction = {
5
11
  label: string;
6
12
  onPress: () => void;
@@ -22,5 +28,11 @@ export type SelectProps = {
22
28
  required?: boolean;
23
29
  /** A validation message under the control, wired as Field's error is. */
24
30
  error?: string;
31
+ /**
32
+ * `full` makes the field, its trigger and its open list fill the
33
+ * container's inline size; the chosen label still ellipsizes past it.
34
+ * Default `content`.
35
+ */
36
+ width?: SelectWidth;
25
37
  };
26
- export declare function Select({ action, label, labelVisuallyHidden, onChange, options, size, value, placeholder, required, error, }: SelectProps): import("react").JSX.Element;
38
+ export declare function Select({ action, label, labelVisuallyHidden, onChange, options, size, value, placeholder, required, error, width, }: SelectProps): import("react").JSX.Element;
@@ -1,15 +1,15 @@
1
1
  'use client';
2
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef } from 'react';
4
4
  import { cx } from '../class-names.js';
5
5
  import { selectedSelectIndex } from '../select-list.js';
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
+ import { FieldLabelText } from './FieldLabelText.js';
7
8
  import { SelectListbox } from './select/SelectListbox.js';
8
9
  import { SelectValue } from './select/SelectValue.js';
9
10
  import { useSelectList } from './select/use-select-list.js';
10
11
  import { Stack } from './Stack.js';
11
- import { Text } from './Text.js';
12
- export function Select({ action, label, labelVisuallyHidden = false, onChange, options, size = 'md', value, placeholder, required = false, error, }) {
12
+ export function Select({ action, label, labelVisuallyHidden = false, onChange, options, size = 'md', value, placeholder, required = false, error, width = 'content', }) {
13
13
  if (placeholder !== undefined && !placeholder.trim())
14
14
  throw new RangeError('placeholder must not be empty');
15
15
  const labelId = useId();
@@ -22,7 +22,7 @@ export function Select({ action, label, labelVisuallyHidden = false, onChange, o
22
22
  const showsPlaceholder = placeholder !== undefined &&
23
23
  !options.some((option) => option.value === value);
24
24
  const { commit, highlight, onTriggerKeyDown, open, openList, setHighlight, setOpen, } = useSelectList({ action, onChange, options, rootRef, value });
25
- return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: labelVisuallyHidden ? 0 : 1, children: [_jsxs(Text, { as: "label", className: labelVisuallyHidden ? 'sw-sr-only' : undefined, htmlFor: triggerId, id: labelId, variant: size === 'xs' ? 'caption' : 'label', children: [label, required ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
25
+ return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: labelVisuallyHidden ? 0 : 1, children: [_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, size: size, visuallyHidden: labelVisuallyHidden }) }), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
26
26
  const next = event.relatedTarget;
27
27
  if (next instanceof Node && rootRef.current?.contains(next)) {
28
28
  return;
@@ -8,13 +8,25 @@ import { denominationCellView } from '../../denomination-cells.js';
8
8
  function showsRowLabel(rowCount, row) {
9
9
  return rowCount > 1 || Boolean(row.icon) || row.total !== undefined;
10
10
  }
11
+ /**
12
+ * A row is a region named by its label, except a lone row without its label
13
+ * line whose label repeats the grid's: that region would only say the
14
+ * group's name a second time (Teisoro SDAY-31). A lone row with its own
15
+ * words keeps them, since nothing else on screen says them.
16
+ */
17
+ function namesRow(rowCount, row, gridLabel) {
18
+ return showsRowLabel(rowCount, row) || row.label !== gridLabel;
19
+ }
11
20
  export function DenominationTiles({ label, columns, rows, subtotal, zeroLabel, }) {
12
- return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => (_jsxs("section", { "aria-label": row.label, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), children: [showsRowLabel(rows.length, row) ? (_jsxs("span", { className: "sw-denomination-label", children: [row.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-icon", children: row.icon })) : null, _jsx("span", { className: "sw-denomination-label-text", children: row.label }), row.total !== undefined ? (_jsx("span", { className: "sw-denomination-total", children: row.total })) : null] })) : null, _jsx("ul", { className: "sw-denomination-tile-list", children: columns.map((column, index) => {
13
- const cell = row.cells[index] ?? null;
14
- const view = denominationCellView(cell, row.signed ?? false, zeroLabel);
15
- const amount = subtotal && cell !== null && cell !== 0
16
- ? subtotal(cell, column)
17
- : null;
18
- return (_jsxs("li", { className: "sw-denomination-tile", children: [_jsx("span", { className: "sw-denomination-head", children: column.label }), _jsx("span", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }), subtotal ? (_jsx("span", { className: "sw-denomination-subtotal", children: amount ?? zeroLabel })) : null] }, column.key));
19
- }) })] }, row.id))) }));
21
+ return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => {
22
+ const labelled = showsRowLabel(rows.length, row);
23
+ return (_jsxs("section", { "aria-label": namesRow(rows.length, row, label) ? row.label : undefined, className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`), children: [labelled ? (_jsxs("span", { className: "sw-denomination-label", children: [row.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-icon", children: row.icon })) : null, _jsx("span", { className: "sw-denomination-label-text", children: row.label }), row.total !== undefined ? (_jsx("span", { className: "sw-denomination-total", children: row.total })) : null] })) : null, _jsx("ul", { className: "sw-denomination-tile-list", children: columns.map((column, index) => {
24
+ const cell = row.cells[index] ?? null;
25
+ const view = denominationCellView(cell, row.signed ?? false, zeroLabel);
26
+ const amount = subtotal && cell !== null && cell !== 0
27
+ ? subtotal(cell, column)
28
+ : null;
29
+ return (_jsxs("li", { className: "sw-denomination-tile", children: [_jsx("span", { className: "sw-denomination-head", children: column.label }), _jsx("span", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }), subtotal ? (_jsx("span", { className: "sw-denomination-subtotal", children: amount ?? zeroLabel })) : null] }, column.key));
30
+ }) })] }, row.id));
31
+ }) }));
20
32
  }
@@ -3,6 +3,7 @@ export declare function buttonClassNames(options: {
3
3
  size: ButtonSize;
4
4
  variant: ButtonVariant;
5
5
  }): string[];
6
+ export declare const pressedRingGap = 2;
6
7
  export declare const pressedRingWidth = 4;
7
8
  export declare function cssButtonClasses(): string;
8
9
  export declare const buttonGroupJustifies: readonly ["start", "end", "between"];
@@ -24,15 +24,24 @@ function variantRules() {
24
24
  * in every palette, above WCAG 1.4.11's 3:1) whatever the variant's fill.
25
25
  * A focused pressed button moves its focus outline out past the ring, so the
26
26
  * two stay apart. The unpressed button keeps its full label contrast: no
27
- * opacity. Forced colors, which drop box shadows, fill the pressed button
28
- * with the system highlight, as a checked FilterChips chip or Checkbox is.
27
+ * opacity.
28
+ *
29
+ * Forced colors drop box shadows, so there the same ring is a Highlight
30
+ * border on an out-of-flow ::after, and the button's own border turns
31
+ * Highlight. The button keeps the forced ButtonFace and ButtonText of every
32
+ * other button: a Highlight fill cannot be used, because Chromium paints
33
+ * the forced backplate behind a button's text in Canvas, which erased a
34
+ * HighlightText label (1.00:1, Teisoro's pressed toggles in 1.12.0), and
35
+ * forced-color-adjust: none would let every descendant keep author colors.
29
36
  */
37
+ export const pressedRingGap = 2;
30
38
  export const pressedRingWidth = 4;
31
39
  function pressedRules() {
32
- return `.sw-button[aria-pressed='true'] { box-shadow: 0 0 0 2px var(--sw-color-background), 0 0 0 ${pressedRingWidth}px var(--sw-color-accent); }
40
+ return `.sw-button[aria-pressed='true'] { box-shadow: 0 0 0 ${pressedRingGap}px var(--sw-color-background), 0 0 0 ${pressedRingWidth}px var(--sw-color-accent); }
33
41
  .sw-button[aria-pressed='true']:focus-visible { outline-offset: calc(var(--sw-focus-ring-offset) + ${pressedRingWidth}px); }
34
42
  @media (forced-colors: active) {
35
- .sw-button[aria-pressed='true'] { background: Highlight; border-color: Highlight; color: HighlightText; }
43
+ .sw-button[aria-pressed='true'] { border-color: Highlight; position: relative; }
44
+ .sw-button[aria-pressed='true']::after { border: ${pressedRingWidth - pressedRingGap}px solid Highlight; border-radius: inherit; content: ''; inset: calc(-1px - ${pressedRingWidth}px); pointer-events: none; position: absolute; }
36
45
  }`;
37
46
  }
38
47
  function sizeRules() {
@@ -8,6 +8,8 @@ import { cssCheckboxClasses } from './css-checkbox.js';
8
8
  import { cssRadioGroupClasses } from './css-radio-group.js';
9
9
  import { cssChromeClasses } from './css-chrome.js';
10
10
  import { cssDataClasses } from './css-data.js';
11
+ import { cssSegmentedClasses } from './css-segmented.js';
12
+ import { cssTableClasses } from './css-table.js';
11
13
  import { cssDateFieldClasses } from './css-date-field.js';
12
14
  import { cssDialogClasses } from './css-dialog.js';
13
15
  import { cssSelectClasses } from './css-select.js';
@@ -109,6 +111,10 @@ ${cssRadioGroupClasses()}
109
111
 
110
112
  ${cssDataClasses()}
111
113
 
114
+ ${cssSegmentedClasses()}
115
+
116
+ ${cssTableClasses()}
117
+
112
118
  ${cssDateFieldClasses()}
113
119
 
114
120
  ${cssCalendarButtonClasses()}