@scalewing/react 1.14.0 → 1.16.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,24 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b64a805: `Field` takes `invalid` (`docs/requests/teisoro-field-validation.md`, 2026-09-30 follow-up, Teisoro F-007-S05 task 1375, DRW-18). `invalid?: boolean` marks the native control invalid, `aria-invalid="true"` and the same danger border as `error` (on a `prefix`/`suffix` frame too, `Mark` in forced colors), without a message of its own, for fields whose one error is shown elsewhere, such as under a group of count fields; the consumer points the control's `aria-describedby` at that message, and it is kept. The field's polite region stays empty and its `description` stays. `error` implies it. Passing `invalid`, even `invalid={false}` but not `invalid={undefined}` (so a wrapper forwarding an optional prop is safe), requires one native control child from the first render (a `TypeError` otherwise), so a form that marks a composed child learns it while its fields are valid, not on its first failed submit. `error` is unchanged: it requires the native control only while it holds a message, so `error={undefined}` beside a composed child still renders. Without it nothing changes. No new dependencies.
8
+ - 68beae9: A programmatic focus target takes the accent focus ring (`docs/requests/teisoro-focus-target.md`, Teisoro F-007-S05 task 1375, NSF-34, ENT-29, DRW-30). The generated document canvas gains `:where([data-theme] [tabindex='-1']:focus-visible)` with the same ring as links and native controls (`--sw-focus-ring-width` solid `--sw-color-accent`, `--sw-focus-ring-offset` out), so a notice or card a script focuses after a save, such as a `Box tabIndex={-1} radius="lg"` round a `Card`, no longer shows the browser's outline. The ring shows only when focus is visible, as the browser's does, and the rule has zero specificity, so a component's own ring always wins. No new prop or class and no new dependencies.
9
+
10
+ ### Patch Changes
11
+
12
+ - 849ce60: `Button` sets a glyph one token gap from its label (`docs/requests/teisoro-button.md`, 2026-09-30 follow-up, Teisoro F-007-S05 task 1375). `.sw-button` gains `gap: var(--sw-space-2)`, so a glyph and a label passed as the button's own children sit 8 px apart at every size instead of touching. An icon-only button, a visually hidden name (`CalendarButton`) and a label already wrapped in one element with its own gap (`<Inline as="span" gap={2}>`) are unchanged: each is a single flex item, so there is never a second gap, and the wrapper can be dropped. A phrase split across elements (`Save <strong>draft</strong> now`) already drew its parts touching, because each is a flex item whose edge spaces collapse; they are now 8 px apart. Put such a phrase in one element, where its word spaces are kept. No API change and no new dependencies.
13
+ - 9a84726: `DenominationGrid` keeps a row's icon beside its label (`docs/requests/teisoro-denomination-grid.md`, 2026-09-30 follow-up, Teisoro F-007-S05 task 1375, DRW-28). A row's icon and words now sit in `.sw-denomination-label-line`, a flex row that does not wrap, in both layouts, so a long label wraps its words beside the icon instead of dropping them under it. Below `md` the strip no longer puts the icon on its own line over the label: the line stays a row (its gap narrows to spacing step 1 in the strip only; a tiles row keeps step 2), and the phone total keeps its own line under it. New generated class `sw-denomination-label-line`; no API change and no new dependencies.
14
+ - b7beef1: `Toast` keeps the page gutter on a phone (`docs/requests/teisoro-toast.md`, 2026-09-30 follow-up, Teisoro F-007-S05 task 1375, DRW-29). `.sw-toast` is capped at `calc(100% - var(--sw-space-4) - var(--sw-space-4))` of the top layer and wraps a long word (`overflow-wrap: break-word`), so a message wider than the screen wraps inside a 16 px gutter on each side instead of running to the edges. A toast is still as wide as its message up to that cap and centred, so a short toast is unchanged. No API change and no new dependencies.
15
+
16
+ ## 1.15.0
17
+
18
+ ### Minor Changes
19
+
20
+ - c0a1c95: `RadioGroup` options take a `description` (`docs/requests/teisoro-radio-group.md`, 2026-09-29 follow-up, Teisoro F-007-S05 task 1365). `RadioGroupOption.description?: ReactNode` is secondary text for that option: a muted caption at the row's inline end while it fits beside the label, and a second line under the label text when it does not or below `md` (new generated classes `sw-radio-group-body` and `sw-radio-group-option-description`). It is the radio's accessible description (`aria-describedby`), the radio's name stays its `label` text (`aria-labelledby` on the label text), and a press on it chooses the option. It may hold phrasing content such as a `Badge`, nothing interactive; a taller description grows the row downward and the label stays on the radio's line. `undefined`, `null`, booleans and `''` are no description; `0` and a component that renders nothing are one. An option with a description fills the group's width. Without `description` nothing changes. No new dependencies.
21
+
3
22
  ## 1.14.0
4
23
 
5
24
  ### Minor Changes
package/README.md CHANGED
@@ -50,7 +50,23 @@ Import the CSS once at the application entry. Do not copy it into your source tr
50
50
 
51
51
  `Box hideBelow="md"` hides a region under 48rem and `hideFrom="md"` hides it at 48rem and wider, so a product can swap a wide layout for a narrow one without its own stylesheet. Hidden regions leave the accessibility tree; do not use this to protect data. `Box as="a"` is a layout link. Use `Button` for press actions. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Select` is a labeled listbox menu when the open list must match the canvas.
52
52
 
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.
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. `invalid` marks the control invalid the same way (`aria-invalid` and the danger border, `Mark` in forced colors) without a message of its own, for fields whose one error is shown elsewhere, such as a count that adds up to nothing under a group of count fields: point each control's `aria-describedby` at that message so it is read with the field. The field's own polite region stays empty and its `description` stays on screen; `error` implies `invalid`. Passing `invalid`, even `invalid={false}` but not `invalid={undefined}`, needs one native control child and throws a `TypeError` on the first render otherwise, so a form learns it while the fields are valid, not on its first failed submit. `error` keeps its rule: it needs the native control only while it holds a message. `invalid` is `Field`'s only: `DateField`, `Select` and the other controls with `error` do not have it yet.
54
+
55
+ ```tsx
56
+ <Field invalid={countIsZero} label="$1 bills">
57
+ <input aria-describedby={countIsZero ? countErrorId : undefined} />
58
+ </Field>
59
+ ```
60
+
61
+ An element a script focuses, such as a notice or a card announced after a save, takes the same accent focus ring as the canvas's links and native controls, 2 px past its edge (`--sw-focus-ring-width` and `--sw-focus-ring-offset`), instead of the browser's outline. Give it `tabIndex={-1}`, so it takes focus from a script but is no tab stop, and put the focus on the whole card rather than its text, so the ring goes round it clear of the words; a `Box` round the card with the card's `radius` makes the ring follow its corners. As with the browser's own outline, the ring shows only when focus is visible (`:focus-visible`): after a keyboard press, not after a mouse press. The rule has zero specificity, so a component's own ring always wins.
62
+
63
+ ```tsx
64
+ <Box ref={notice} tabIndex={-1} radius="lg" role="status">
65
+ <Card padding={4} variant="outlined">
66
+ <Text as="p">Survey saved.</Text>
67
+ </Card>
68
+ </Box>
69
+ ```
54
70
 
55
71
  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
72
 
@@ -124,6 +140,15 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
124
140
  </Stack>
125
141
  ```
126
142
 
143
+ A `Button`'s own children are laid out in a row one token gap apart (spacing step 2, 8 px), so a glyph passed beside the label never touches it. Pass the glyph and the label as direct children. An icon-only button has one child and is unchanged, and a visually hidden name (`sw-sr-only`) is out of the flow, so it adds no gap. A label already wrapped in one element with its own gap, such as `<Inline as="span" gap={2}>`, is a single child and gets no second gap; it can drop the wrapper. A phrase split across elements, such as `Save <strong>draft</strong> now`, is several flex items: the spaces at their edges collapse, so its parts touched before and are a gap apart now. Put such a phrase in one element (`<span>Save <strong>draft</strong> now</span>`), where its word spaces are kept.
144
+
145
+ ```tsx
146
+ <Button onPress={logSighting}>
147
+ <Plus aria-hidden />
148
+ Log sighting
149
+ </Button>
150
+ ```
151
+
127
152
  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`.
128
153
 
129
154
  `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.
@@ -132,7 +157,7 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
132
157
 
133
158
  `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.
134
159
 
135
- `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.
160
+ `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.
136
161
 
137
162
  `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`.
138
163
 
@@ -152,6 +177,30 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
152
177
  />
153
178
  ```
154
179
 
180
+ An option's optional `description` (a `ReactNode`) is secondary text for that option alone, such as its history. It is a muted caption after the label, at the row's inline end, while the two fit on one line; when they do not, and always below the `md` breakpoint, it wraps to a second line that starts under the label text, not under the radio. The label keeps the first line. An option with a description fills the group's width. The description is the radio's accessible description (`aria-describedby` on that option's input), while its name stays the `label` text, and a press on it chooses the option as the label does. A disabled option fades its description with the rest of the option. It may hold phrasing content such as a small `Badge`, but nothing interactive, because it sits inside the option's `<label>`. A description taller than the label, such as a default-size `Badge`, grows the row downward; the label stays on the radio's line. `undefined`, `null`, `false`, `true` and `''` are no description. Any other node is one, including `0` and a component that renders nothing, so pass `undefined` when there is nothing to say. The group's own `description` still describes the whole group.
181
+
182
+ ```tsx
183
+ <RadioGroup
184
+ legend="Sighting source"
185
+ onChange={setSource}
186
+ options={[
187
+ {
188
+ value: 'observer',
189
+ label: 'Field observer',
190
+ icon: <User />,
191
+ description: <Badge size="sm">Most recent</Badge>,
192
+ },
193
+ {
194
+ value: 'camera',
195
+ label: 'Camera trap',
196
+ icon: <Camera />,
197
+ description: '2 sightings · last Sep 13, 2026',
198
+ },
199
+ ]}
200
+ value={source}
201
+ />
202
+ ```
203
+
155
204
  `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
205
 
157
206
  ```tsx
@@ -213,7 +262,7 @@ Values outside the catalog throw a `RangeError`. There is no per-breakpoint span
213
262
  </Grid>
214
263
  ```
215
264
 
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.
265
+ `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. 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. The primitive does no arithmetic and no currency formatting; tones reuse the Badge vocabulary. Invalid columns, rows, or cells throw.
217
266
 
218
267
  `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.
219
268
 
@@ -9,10 +9,19 @@ export type FieldProps = {
9
9
  size?: FieldSize;
10
10
  description?: string;
11
11
  error?: string;
12
+ /**
13
+ * Marks the control invalid (`aria-invalid` and the danger border) without
14
+ * a message of its own, for a field whose error is shown elsewhere, such
15
+ * as one message under a group of fields. Point the control's
16
+ * `aria-describedby` at that message. `error` implies it. Passing
17
+ * `invalid`, even `false` but not `undefined`, requires one native control
18
+ * child from the first render.
19
+ */
20
+ invalid?: boolean;
12
21
  required?: boolean;
13
22
  /** Short text inside the control frame before the value, such as a currency sign. Not part of the value. */
14
23
  prefix?: string;
15
24
  /** Short text inside the control frame after the value, such as a unit. Not part of the value. */
16
25
  suffix?: string;
17
26
  };
18
- export declare function Field({ children, gap, label, labelVisuallyHidden, size, description, error, required, prefix, suffix, }: FieldProps): import("react").JSX.Element;
27
+ export declare function Field(props: FieldProps): import("react").JSX.Element;
@@ -5,7 +5,19 @@ import { adornedControlAria, FieldAdornment, } from './FieldAdornment.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
6
  import { FieldLabelText } from './FieldLabelText.js';
7
7
  import { Stack } from './Stack.js';
8
- export function Field({ children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, required = false, prefix, suffix, }) {
8
+ /**
9
+ * `invalid` was passed, even as `false`, but not as `undefined` (a wrapper
10
+ * forwarding an optional prop passes nothing): such a field needs one native
11
+ * control from its first render, so a form learns it on the happy path
12
+ * instead of on its first failed submit. `error` keeps its older rule (it
13
+ * requires the control only while it holds a message), so a composed
14
+ * child beside `error={undefined}` still renders.
15
+ */
16
+ function passesInvalid(props) {
17
+ return props.invalid !== undefined;
18
+ }
19
+ export function Field(props) {
20
+ const { children, gap = 1, label, labelVisuallyHidden = false, size = 'md', description, error, invalid = false, required = false, prefix, suffix, } = props;
9
21
  const controlId = useId();
10
22
  const descriptionId = useId();
11
23
  const errorId = useId();
@@ -13,12 +25,13 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
13
25
  const prefixId = useId();
14
26
  const suffixId = useId();
15
27
  const message = error || description;
28
+ const marksInvalid = Boolean(error) || invalid;
16
29
  const adorned = Boolean(prefix || suffix);
17
- const needsControl = Boolean(message || required || adorned);
30
+ const needsControl = Boolean(message || marksInvalid || passesInvalid(props) || required || adorned);
18
31
  const className = [
19
32
  'sw-field',
20
33
  size === 'xs' && 'sw-field-xs',
21
- error && 'sw-field-invalid',
34
+ marksInvalid && 'sw-field-invalid',
22
35
  ]
23
36
  .filter(Boolean)
24
37
  .join(' ');
@@ -46,7 +59,7 @@ export function Field({ children, gap = 1, label, labelVisuallyHidden = false, s
46
59
  const control = cloneElement(children, {
47
60
  id: children.props.id || controlId,
48
61
  'aria-describedby': describedBy || undefined,
49
- 'aria-invalid': error ? true : children.props['aria-invalid'],
62
+ 'aria-invalid': marksInvalid ? true : children.props['aria-invalid'],
50
63
  'aria-labelledby': adornment.labelledBy,
51
64
  required: required || children.props.required,
52
65
  });
@@ -1,15 +1,5 @@
1
- import { type ReactNode } from 'react';
2
- export type RadioGroupOption = {
3
- value: string;
4
- label: string;
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;
12
- };
1
+ import { type RadioGroupOption } from './radio-group/RadioGroupOptionLabel.js';
2
+ export type { RadioGroupOption };
13
3
  export type RadioGroupProps = {
14
4
  legend: string;
15
5
  value: string;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
4
  import { FieldErrorRegion } from './FieldErrorRegion.js';
5
+ import { RadioGroupOptionLabel, } from './radio-group/RadioGroupOptionLabel.js';
5
6
  export function RadioGroup({ legend, value, onChange, options, required = false, disabled = false, description, error, }) {
6
7
  const name = useId();
7
8
  const descriptionId = useId();
@@ -19,10 +20,8 @@ export function RadioGroup({ legend, value, onChange, options, required = false,
19
20
  throw new RangeError('options must have unique values');
20
21
  if (value !== '' && !values.includes(value))
21
22
  throw new RangeError('value must match an option or be empty');
22
- return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), className: "sw-radio-group", disabled: disabled, children: [_jsx("legend", { className: "sw-radio-group-legend", children: legend }), description ? (_jsx("span", { className: "sw-radio-group-description", id: descriptionId, children: description })) : null, _jsx("span", { className: "sw-radio-group-options", children: options.map((option) => (_jsxs("label", { className: "sw-radio-group-option", children: [_jsxs("span", { className: "sw-radio-group-control", children: [_jsx("input", { checked: value === option.value, className: "sw-radio-group-input", disabled: option.disabled, name: name, onChange: (event) => {
23
- if (!disabled &&
24
- !option.disabled &&
25
- event.currentTarget.checked)
26
- onChange(option.value);
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 })] }));
23
+ return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": Boolean(error), className: "sw-radio-group", disabled: disabled, children: [_jsx("legend", { className: "sw-radio-group-legend", children: legend }), description ? (_jsx("span", { className: "sw-radio-group-description", id: descriptionId, children: description })) : null, _jsx("span", { className: "sw-radio-group-options", children: options.map((option) => (_jsx(RadioGroupOptionLabel, { checked: value === option.value, name: name, onSelect: () => {
24
+ if (!disabled && !option.disabled)
25
+ onChange(option.value);
26
+ }, option: option, required: required }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
28
27
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../../class-names.js';
3
3
  import { denominationCellView } from '../../denomination-cells.js';
4
4
  import { ScrollRegion } from '../ScrollRegion.js';
5
+ import { RowLabelLine } from './RowLabelLine.js';
5
6
  /**
6
7
  * The total column's header: a visually hidden `th` when the consumer names
7
8
  * the column, otherwise the empty corner it has always been. Both carry
@@ -14,7 +15,7 @@ function TotalHead({ totalLabel }) {
14
15
  }
15
16
  export function DenominationStrip({ label, columns, rows, zeroLabel, totalLabel, }) {
16
17
  const hasTotals = rows.some((row) => row.total !== undefined);
17
- return (_jsx(ScrollRegion, { "aria-label": label, className: "sw-denomination-scroll", children: _jsxs("table", { className: "sw-denomination-grid sw-denomination-strip", children: [_jsx("caption", { className: "sw-sr-only", children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", { className: "sw-denomination-corner" }), columns.map((column) => (_jsx("th", { className: "sw-denomination-head", scope: "col", children: column.label }, column.key))), hasTotals ? _jsx(TotalHead, { totalLabel: totalLabel }) : null] }) }), _jsx("tbody", { children: rows.map((row) => (_jsxs("tr", { className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`, row.signed && 'sw-denomination-row-signed'), children: [_jsx("th", { className: "sw-denomination-label", scope: "row", children: _jsxs("span", { className: "sw-denomination-label-body", 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", { "aria-hidden": "true", className: "sw-denomination-total-inline", children: row.total })) : null] }) }), columns.map((column, index) => {
18
+ return (_jsx(ScrollRegion, { "aria-label": label, className: "sw-denomination-scroll", children: _jsxs("table", { className: "sw-denomination-grid sw-denomination-strip", children: [_jsx("caption", { className: "sw-sr-only", children: label }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("td", { className: "sw-denomination-corner" }), columns.map((column) => (_jsx("th", { className: "sw-denomination-head", scope: "col", children: column.label }, column.key))), hasTotals ? _jsx(TotalHead, { totalLabel: totalLabel }) : null] }) }), _jsx("tbody", { children: rows.map((row) => (_jsxs("tr", { className: cx('sw-denomination-row', `sw-denomination-row-${row.tone ?? 'neutral'}`, row.signed && 'sw-denomination-row-signed'), children: [_jsx("th", { className: "sw-denomination-label", scope: "row", children: _jsxs("span", { className: "sw-denomination-label-body", children: [_jsx(RowLabelLine, { row: row }), row.total !== undefined ? (_jsx("span", { "aria-hidden": "true", className: "sw-denomination-total-inline", children: row.total })) : null] }) }), columns.map((column, index) => {
18
19
  const view = denominationCellView(row.cells[index] ?? null, row.signed ?? false, zeroLabel);
19
20
  return (_jsx("td", { className: `sw-denomination-cell sw-denomination-cell-${view.state}`, children: view.text }, column.key));
20
21
  }), hasTotals ? (_jsx("td", { className: "sw-denomination-total", children: _jsx("span", { className: "sw-denomination-total-value", children: row.total ?? '' }) })) : null] }, row.id))) })] }) }));
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../../class-names.js';
3
3
  import { denominationCellView } from '../../denomination-cells.js';
4
+ import { RowLabelLine } from './RowLabelLine.js';
4
5
  /**
5
6
  * A lone row is named by the grid's own label, so its label line shows only
6
7
  * when it adds something: an icon or a total. Several rows always show it.
@@ -20,7 +21,7 @@ function namesRow(rowCount, row, gridLabel) {
20
21
  export function DenominationTiles({ label, columns, rows, subtotal, zeroLabel, }) {
21
22
  return (_jsx("div", { "aria-label": label, className: "sw-denomination-grid sw-denomination-tiles", role: "group", children: rows.map((row) => {
22
23
  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
+ 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: [_jsx(RowLabelLine, { row: row }), 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
25
  const cell = row.cells[index] ?? null;
25
26
  const view = denominationCellView(cell, row.signed ?? false, zeroLabel);
26
27
  const amount = subtotal && cell !== null && cell !== 0
@@ -0,0 +1,9 @@
1
+ import { type DenominationGridRow } from './types.js';
2
+ /**
3
+ * A row label's glyph and words, kept on one line: when the words wrap,
4
+ * they wrap beside the glyph, never under it (Teisoro DRW-28). Both the
5
+ * strip and the tiles layouts draw a row label with it.
6
+ */
7
+ export declare function RowLabelLine({ row }: {
8
+ row: DenominationGridRow;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A row label's glyph and words, kept on one line: when the words wrap,
4
+ * they wrap beside the glyph, never under it (Teisoro DRW-28). Both the
5
+ * strip and the tiles layouts draw a row label with it.
6
+ */
7
+ export function RowLabelLine({ row }) {
8
+ return (_jsxs("span", { className: "sw-denomination-label-line", 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 })] }));
9
+ }
@@ -0,0 +1,39 @@
1
+ import { type ReactNode } from 'react';
2
+ export type RadioGroupOption = {
3
+ value: string;
4
+ label: string;
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;
12
+ /**
13
+ * Secondary text for this option, such as its history: a muted caption at
14
+ * the row's inline end, or on a second line under the label when the row
15
+ * is narrower than md or the two do not fit. It is the radio's accessible
16
+ * description (`aria-describedby`), never part of its name, and a press on
17
+ * it chooses the option. It may hold phrasing content such as a `Badge`,
18
+ * but nothing interactive: it sits inside the option's `<label>`.
19
+ * `undefined`, `null`, `false`, `true` and `''` are no description; any
20
+ * other node, `0` or a component that renders nothing included, is one,
21
+ * so pass `undefined` when there is nothing to say.
22
+ */
23
+ description?: ReactNode;
24
+ };
25
+ type RadioGroupOptionLabelProps = {
26
+ option: RadioGroupOption;
27
+ name: string;
28
+ checked: boolean;
29
+ required: boolean;
30
+ onSelect: () => void;
31
+ };
32
+ /**
33
+ * One option of a RadioGroup: the native radio and its mark, the optional
34
+ * glyph, and the label. With a description, the label and the description
35
+ * share a wrapping body, and the radio is named by the label text alone and
36
+ * described by the description.
37
+ */
38
+ export declare function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }: RadioGroupOptionLabelProps): import("react").JSX.Element;
39
+ export {};
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ /** Whether React renders something for a description: not nothing, a boolean or ''. */
4
+ function hasDescription(description) {
5
+ return (description !== undefined &&
6
+ description !== null &&
7
+ typeof description !== 'boolean' &&
8
+ description !== '');
9
+ }
10
+ /**
11
+ * One option of a RadioGroup: the native radio and its mark, the optional
12
+ * glyph, and the label. With a description, the label and the description
13
+ * share a wrapping body, and the radio is named by the label text alone and
14
+ * described by the description.
15
+ */
16
+ export function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }) {
17
+ const id = useId();
18
+ const textId = `${id}-text`;
19
+ const descriptionId = `${id}-description`;
20
+ const described = hasDescription(option.description);
21
+ const text = (_jsx("span", { className: "sw-radio-group-text", id: described ? textId : undefined, children: option.label }));
22
+ return (_jsxs("label", { className: "sw-radio-group-option", children: [_jsxs("span", { className: "sw-radio-group-control", children: [_jsx("input", { "aria-describedby": described ? descriptionId : undefined, "aria-labelledby": described ? textId : undefined, checked: checked, className: "sw-radio-group-input", disabled: option.disabled, name: name, onChange: (event) => {
23
+ if (event.currentTarget.checked)
24
+ onSelect();
25
+ }, 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, described ? (_jsxs("span", { className: "sw-radio-group-body", children: [text, _jsx("span", { className: "sw-radio-group-option-description", id: descriptionId, children: option.description })] })) : (text)] }));
26
+ }
@@ -5,6 +5,7 @@ export declare function buttonClassNames(options: {
5
5
  }): string[];
6
6
  export declare const pressedRingGap = 2;
7
7
  export declare const pressedRingWidth = 4;
8
+ export declare const buttonContentGap = "var(--sw-space-2)";
8
9
  export declare function cssButtonClasses(): string;
9
10
  export declare const buttonGroupJustifies: readonly ["start", "end", "between"];
10
11
  export declare function buttonClassCatalog(): string[];
@@ -52,6 +52,14 @@ function sizeRules() {
52
52
  })
53
53
  .join('\n');
54
54
  }
55
+ /*
56
+ * The button's own children are its flex items, so a glyph and its label
57
+ * sit one token gap apart. An icon-only button has one item, and a consumer
58
+ * that wraps its glyph and label in one element (an Inline span) has one
59
+ * item too, so neither gets a second gap. A visually hidden name is
60
+ * absolutely positioned, out of the flex flow, so it adds none either.
61
+ */
62
+ export const buttonContentGap = 'var(--sw-space-2)';
55
63
  export function cssButtonClasses() {
56
64
  return `.sw-button {
57
65
  appearance: none;
@@ -65,6 +73,7 @@ export function cssButtonClasses() {
65
73
  font-family: var(--sw-font-sans);
66
74
  font-size: ${label.fontSize}px;
67
75
  font-weight: ${label.fontWeight};
76
+ gap: ${buttonContentGap};
68
77
  justify-content: center;
69
78
  letter-spacing: ${label.letterSpacing}px;
70
79
  line-height: ${label.lineHeight}px;
@@ -72,6 +72,17 @@ ${scrollRegionRules('.sw-denomination-scroll')}
72
72
  gap: var(--sw-space-2);
73
73
  }
74
74
 
75
+ /*
76
+ * A row label's glyph and words are one line: the line does not wrap, so its
77
+ * narrowest width is the glyph, the gap and the longest word, and a long
78
+ * label wraps its words beside the glyph instead of dropping them under it.
79
+ */
80
+ .sw-denomination-label-line {
81
+ align-items: center;
82
+ display: flex;
83
+ gap: var(--sw-space-2);
84
+ }
85
+
75
86
  /*
76
87
  * The row labels and the header corner above them stay in view while a wide
77
88
  * strip scrolls under them, so each column head stays over its counts.
@@ -221,6 +232,7 @@ ${scrollRegionRules('.sw-denomination-scroll')}
221
232
  .sw-denomination-total-value { ${visuallyHiddenDeclarations} }
222
233
  .sw-denomination-total-inline { display: block; }
223
234
  .sw-denomination-label-body { flex-direction: column; align-items: flex-start; gap: var(--sw-space-1); }
235
+ .sw-denomination-strip .sw-denomination-label-line { gap: var(--sw-space-1); }
224
236
  .sw-denomination-strip th,
225
237
  .sw-denomination-strip td { padding: var(--sw-space-1); }
226
238
  .sw-denomination-strip .sw-denomination-label { padding-inline-start: calc(var(--sw-space-1) * 2); }
@@ -249,6 +261,7 @@ export function denominationGridClassCatalog() {
249
261
  'sw-denomination-row-signed',
250
262
  'sw-denomination-label',
251
263
  'sw-denomination-label-body',
264
+ 'sw-denomination-label-line',
252
265
  'sw-denomination-label-text',
253
266
  'sw-denomination-icon',
254
267
  'sw-denomination-cell',
@@ -2,4 +2,6 @@
2
2
  export declare const controlSurface = "background-color: var(--sw-glass-fill);\n border: 1px solid var(--sw-color-border);\n border-radius: var(--sw-radius-sm);\n color: inherit;\n font-family: inherit;\n font-size: inherit;\n padding-inline: var(--sw-control-md-padding-inline);";
3
3
  export declare const disabledControlSurface = "background-color: var(--sw-color-subtle);\n border-style: dashed;\n cursor: not-allowed;\n opacity: 1;\n -webkit-text-fill-color: currentColor;";
4
4
  export declare const disabledControlForcedColors = "border-color: GrayText;\n color: GrayText;\n -webkit-text-fill-color: GrayText;";
5
+ /** A script-only focus target in the canvas, at zero specificity. */
6
+ export declare const focusTargetSelector = ":where([data-theme] [tabindex='-1']:focus-visible)";
5
7
  export declare function cssDocumentCanvas(): string;
@@ -34,6 +34,8 @@ export const disabledControlSurface = `background-color: var(--sw-color-subtle);
34
34
  export const disabledControlForcedColors = `border-color: GrayText;
35
35
  color: GrayText;
36
36
  -webkit-text-fill-color: GrayText;`;
37
+ /** A script-only focus target in the canvas, at zero specificity. */
38
+ export const focusTargetSelector = ":where([data-theme] [tabindex='-1']:focus-visible)";
37
39
  export function cssDocumentCanvas() {
38
40
  return `html,
39
41
  body {
@@ -79,6 +81,20 @@ body {
79
81
  outline-offset: var(--sw-focus-ring-offset);
80
82
  }
81
83
 
84
+ /*
85
+ * A programmatic focus target (tabindex="-1": a notice, a card or a heading
86
+ * that a script focuses after a save) takes the accent ring past the ring
87
+ * offset, as links and native controls do, instead of the browser's outline.
88
+ * It shows when the browser would show its own (:focus-visible), so a target
89
+ * focused after a mouse press stays quiet. :where() keeps the rule at zero
90
+ * specificity: it still beats the user-agent outline, and every component's
91
+ * own ring (a roving tabindex="-1" item's included) wins over it.
92
+ */
93
+ ${focusTargetSelector} {
94
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
95
+ outline-offset: var(--sw-focus-ring-offset);
96
+ }
97
+
82
98
  [data-theme] :is(${typedInputs}) {
83
99
  ${controlSurface}
84
100
  box-sizing: border-box;
@@ -1,4 +1,5 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
+ import { breakpointQuery } from './breakpoints.js';
2
3
  const label = typographyVariants.label;
3
4
  const caption = typographyVariants.caption;
4
5
  export function cssRadioGroupClasses() {
@@ -118,6 +119,8 @@ export function cssRadioGroupClasses() {
118
119
  min-width: 0;
119
120
  }
120
121
 
122
+ ${optionDescriptionRules()}
123
+
121
124
  .sw-radio-group-description,
122
125
  .sw-radio-group-error {
123
126
  font-family: var(--sw-font-sans);
@@ -141,6 +144,57 @@ export function cssRadioGroupClasses() {
141
144
  .sw-radio-group[aria-invalid='true'] .sw-radio-group-mark { border-color: CanvasText; }
142
145
  }`;
143
146
  }
147
+ /**
148
+ * An option with a description fills the group's width. Its label and
149
+ * description share a wrapping body: the label grows, so the description sits
150
+ * at the row's inline end while both fit, and wraps to its own line, starting
151
+ * under the label text, when they do not. The label keeps the row first.
152
+ * Below md the description always takes its own line. The radio and glyph
153
+ * sit on the label's line, which starts where a one-line option centers it.
154
+ * Items align to the line's start, not a shared baseline, so a description
155
+ * taller than the label (a default-size Badge) grows the row downward and
156
+ * never pushes the label off the radio's line; a caption line is centred
157
+ * on the label's line box.
158
+ */
159
+ function optionDescriptionRules() {
160
+ return `.sw-radio-group-option:has(> .sw-radio-group-body) {
161
+ align-items: flex-start;
162
+ box-sizing: border-box;
163
+ padding-block-start: calc((var(--sw-control-md-min-height) - var(--sw-space-5)) / 2);
164
+ width: 100%;
165
+ }
166
+
167
+ .sw-radio-group-option:has(> .sw-radio-group-body) > .sw-radio-group-icon {
168
+ min-height: var(--sw-space-5);
169
+ }
170
+
171
+ .sw-radio-group-body {
172
+ align-items: flex-start;
173
+ column-gap: var(--sw-space-3);
174
+ display: flex;
175
+ flex: 1 1 0;
176
+ flex-wrap: wrap;
177
+ min-width: 0;
178
+ padding-block-start: calc((var(--sw-space-5) - ${label.lineHeight}px) / 2);
179
+ }
180
+
181
+ .sw-radio-group-body > .sw-radio-group-text { flex: 1 1 auto; }
182
+
183
+ .sw-radio-group-option-description {
184
+ color: var(--sw-color-muted);
185
+ flex: 0 1 auto;
186
+ font-family: var(--sw-font-sans);
187
+ font-size: ${caption.fontSize}px;
188
+ font-weight: ${caption.fontWeight};
189
+ line-height: ${caption.lineHeight}px;
190
+ margin-block-start: calc((${label.lineHeight}px - ${caption.lineHeight}px) / 2);
191
+ min-width: 0;
192
+ }
193
+
194
+ @media ${breakpointQuery('below', 'md')} {
195
+ .sw-radio-group-option-description { flex-basis: 100%; }
196
+ }`;
197
+ }
144
198
  export function radioGroupClassCatalog() {
145
199
  return [
146
200
  'sw-radio-group',
@@ -152,6 +206,8 @@ export function radioGroupClassCatalog() {
152
206
  'sw-radio-group-mark',
153
207
  'sw-radio-group-icon',
154
208
  'sw-radio-group-text',
209
+ 'sw-radio-group-body',
210
+ 'sw-radio-group-option-description',
155
211
  'sw-radio-group-description',
156
212
  'sw-radio-group-error',
157
213
  ];
@@ -1,4 +1,5 @@
1
1
  export declare const toastTones: readonly ["neutral", "success", "warning", "danger"];
2
2
  export type ToastTone = (typeof toastTones)[number];
3
+ export declare const toastMaxWidth = "calc(100% - var(--sw-space-4) - var(--sw-space-4))";
3
4
  export declare function cssToastClasses(): string;
4
5
  export declare function toastClassCatalog(): string[];
@@ -9,6 +9,13 @@ function toneRules() {
9
9
  .map((tone) => `.sw-toast-${tone} { --sw-toast-tone: var(--sw-color-${tone}); border-color: var(--sw-toast-tone); }`)
10
10
  .join('\n');
11
11
  }
12
+ /*
13
+ * The toast keeps the page's side gutter (spacing step 4 on each side): a
14
+ * long message wraps inside it instead of running to the screen's edges.
15
+ * 100% of the top layer's containing block leaves out a classic scrollbar;
16
+ * 100vw would not.
17
+ */
18
+ export const toastMaxWidth = 'calc(100% - var(--sw-space-4) - var(--sw-space-4))';
12
19
  export function cssToastClasses() {
13
20
  return `.sw-toast {
14
21
  background: var(--sw-glass-fill);
@@ -23,7 +30,9 @@ export function cssToastClasses() {
23
30
  bottom: var(--sw-space-6);
24
31
  left: 50%;
25
32
  margin: 0;
33
+ max-width: ${toastMaxWidth};
26
34
  overflow: visible;
35
+ overflow-wrap: break-word;
27
36
  pointer-events: none;
28
37
  position: fixed;
29
38
  translate: -50% 0;
package/dist/styles.css CHANGED
@@ -610,6 +610,20 @@ body {
610
610
  outline-offset: var(--sw-focus-ring-offset);
611
611
  }
612
612
 
613
+ /*
614
+ * A programmatic focus target (tabindex="-1": a notice, a card or a heading
615
+ * that a script focuses after a save) takes the accent ring past the ring
616
+ * offset, as links and native controls do, instead of the browser's outline.
617
+ * It shows when the browser would show its own (:focus-visible), so a target
618
+ * focused after a mouse press stays quiet. :where() keeps the rule at zero
619
+ * specificity: it still beats the user-agent outline, and every component's
620
+ * own ring (a roving tabindex="-1" item's included) wins over it.
621
+ */
622
+ :where([data-theme] [tabindex='-1']:focus-visible) {
623
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
624
+ outline-offset: var(--sw-focus-ring-offset);
625
+ }
626
+
613
627
  [data-theme] :is(input[type='text'], input[type='email'], input[type='number'], input[type='search'], input[type='url'], input[type='password'], input:not([type]), textarea) {
614
628
  background-color: var(--sw-glass-fill);
615
629
  border: 1px solid var(--sw-color-border);
@@ -858,6 +872,7 @@ body {
858
872
  font-family: var(--sw-font-sans);
859
873
  font-size: 15px;
860
874
  font-weight: 600;
875
+ gap: var(--sw-space-2);
861
876
  justify-content: center;
862
877
  letter-spacing: -0.2px;
863
878
  line-height: 20px;
@@ -1125,6 +1140,44 @@ body {
1125
1140
  min-width: 0;
1126
1141
  }
1127
1142
 
1143
+ .sw-radio-group-option:has(> .sw-radio-group-body) {
1144
+ align-items: flex-start;
1145
+ box-sizing: border-box;
1146
+ padding-block-start: calc((var(--sw-control-md-min-height) - var(--sw-space-5)) / 2);
1147
+ width: 100%;
1148
+ }
1149
+
1150
+ .sw-radio-group-option:has(> .sw-radio-group-body) > .sw-radio-group-icon {
1151
+ min-height: var(--sw-space-5);
1152
+ }
1153
+
1154
+ .sw-radio-group-body {
1155
+ align-items: flex-start;
1156
+ column-gap: var(--sw-space-3);
1157
+ display: flex;
1158
+ flex: 1 1 0;
1159
+ flex-wrap: wrap;
1160
+ min-width: 0;
1161
+ padding-block-start: calc((var(--sw-space-5) - 20px) / 2);
1162
+ }
1163
+
1164
+ .sw-radio-group-body > .sw-radio-group-text { flex: 1 1 auto; }
1165
+
1166
+ .sw-radio-group-option-description {
1167
+ color: var(--sw-color-muted);
1168
+ flex: 0 1 auto;
1169
+ font-family: var(--sw-font-sans);
1170
+ font-size: 13px;
1171
+ font-weight: 400;
1172
+ line-height: 18px;
1173
+ margin-block-start: calc((20px - 18px) / 2);
1174
+ min-width: 0;
1175
+ }
1176
+
1177
+ @media not all and (min-width: 48rem) {
1178
+ .sw-radio-group-option-description { flex-basis: 100%; }
1179
+ }
1180
+
1128
1181
  .sw-radio-group-description,
1129
1182
  .sw-radio-group-error {
1130
1183
  font-family: var(--sw-font-sans);
@@ -2848,6 +2901,17 @@ body {
2848
2901
  gap: var(--sw-space-2);
2849
2902
  }
2850
2903
 
2904
+ /*
2905
+ * A row label's glyph and words are one line: the line does not wrap, so its
2906
+ * narrowest width is the glyph, the gap and the longest word, and a long
2907
+ * label wraps its words beside the glyph instead of dropping them under it.
2908
+ */
2909
+ .sw-denomination-label-line {
2910
+ align-items: center;
2911
+ display: flex;
2912
+ gap: var(--sw-space-2);
2913
+ }
2914
+
2851
2915
  /*
2852
2916
  * The row labels and the header corner above them stay in view while a wide
2853
2917
  * strip scrolls under them, so each column head stays over its counts.
@@ -2997,6 +3061,7 @@ body {
2997
3061
  .sw-denomination-total-value { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
2998
3062
  .sw-denomination-total-inline { display: block; }
2999
3063
  .sw-denomination-label-body { flex-direction: column; align-items: flex-start; gap: var(--sw-space-1); }
3064
+ .sw-denomination-strip .sw-denomination-label-line { gap: var(--sw-space-1); }
3000
3065
  .sw-denomination-strip th,
3001
3066
  .sw-denomination-strip td { padding: var(--sw-space-1); }
3002
3067
  .sw-denomination-strip .sw-denomination-label { padding-inline-start: calc(var(--sw-space-1) * 2); }
@@ -3183,7 +3248,9 @@ body {
3183
3248
  bottom: var(--sw-space-6);
3184
3249
  left: 50%;
3185
3250
  margin: 0;
3251
+ max-width: calc(100% - var(--sw-space-4) - var(--sw-space-4));
3186
3252
  overflow: visible;
3253
+ overflow-wrap: break-word;
3187
3254
  pointer-events: none;
3188
3255
  position: fixed;
3189
3256
  translate: -50% 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",