@scalewing/react 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +6 -2
  3. package/dist/components/ActionMenu.js +2 -1
  4. package/dist/components/Box.js +5 -2
  5. package/dist/components/Button.js +3 -2
  6. package/dist/components/DateField.js +2 -1
  7. package/dist/components/FilterChips.js +3 -1
  8. package/dist/components/MultiSelect.js +2 -1
  9. package/dist/components/RadioGroup.js +3 -1
  10. package/dist/components/SectionNav.js +2 -1
  11. package/dist/components/SegmentedControl.js +5 -1
  12. package/dist/components/Select.js +2 -1
  13. package/dist/components/Steps.d.ts +11 -2
  14. package/dist/components/Steps.js +5 -3
  15. package/dist/components/Switch.js +2 -1
  16. package/dist/components/checkbox/CheckboxControl.d.ts +1 -1
  17. package/dist/components/checkbox/CheckboxControl.js +3 -2
  18. package/dist/components/date-field/CalendarHeader.js +2 -1
  19. package/dist/components/field/SearchFrame.js +2 -1
  20. package/dist/components/radio-group/RadioGroupOptionLabel.d.ts +3 -1
  21. package/dist/components/radio-group/RadioGroupOptionLabel.js +3 -2
  22. package/dist/components/steps/Step.d.ts +4 -2
  23. package/dist/components/steps/Step.js +15 -7
  24. package/dist/components/steps/steps-context.d.ts +5 -3
  25. package/dist/components/table/TableCell.js +2 -1
  26. package/dist/css/css-button.js +17 -5
  27. package/dist/css/css-components.js +1 -1
  28. package/dist/css/css-steps.d.ts +3 -1
  29. package/dist/css/css-steps.js +18 -3
  30. package/dist/css/css-table-footer.js +2 -2
  31. package/dist/css/css-table-ledger.js +4 -3
  32. package/dist/css/css-table-sticky-header.d.ts +2 -0
  33. package/dist/css/css-table-sticky-header.js +52 -0
  34. package/dist/css/css-table.js +3 -10
  35. package/dist/css/stacking.js +2 -2
  36. package/dist/styles.css +51 -14
  37. package/dist/tab-stop.d.ts +22 -0
  38. package/dist/tab-stop.js +23 -0
  39. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.36.0
4
+
5
+ ### Minor Changes
6
+
7
+ - bc5d37c: A `Button` with `aria-disabled="true"` now looks disabled (`docs/requests/teisoro-button-aria-disabled.md`, Teisoro F-011 task 2210): the faded look, or with `disabledAppearance="neutral"` the neutral grey, and `GrayText` in forced colors, while it stays focusable and its `onPress` still runs, for a submit that, pressed while off, takes the person to what is missing. No new prop, token, class or dependency.
8
+ - 3d431f7: `Steps` `open` shows every step's fields at once (`docs/requests/teisoro-steps-open.md`, Teisoro F-011 task 2210): each step is a region named by its title, its marker still following its status (a number to fill, a check when done, "!" in error, a grey ring when greyed), every title at the open size, no step `aria-current` and no focus moved when a status changes. Off by default; a list without `open` is unchanged. New generated classes `sw-steps-open` and `sw-steps-region`. No new tokens, no new dependency.
9
+
10
+ ### Patch Changes
11
+
12
+ - 743a7a0: Every press control and link now renders an explicit `tabindex="0"` (Teisoro F-011 task 2205), so Safari users reach it with Tab: Safari's default leaves a `<button>`, a link, a checkbox and a radio out of the Tab order unless it carries a `tabindex`, so Tab from a field went past them to the address bar. Covered: `Button` (with `CalendarButton` and `InfoTip`), `Box as="a"` with an `href`, `SectionNav` links, the `Select`, `MultiSelect` and `ActionMenu` triggers, `SegmentedControl` items, `DateField`'s calendar button and month steps, a search `Field`'s clear button, `Table` sort and `TableCell onPress` buttons, `Checkbox` (and `MultiSelect`'s rows), `Switch`, and the checked (else first enabled) radio of a `RadioGroup` or `FilterChips`. A consumer's `tabIndex` wins; a `Stepper`'s − and + and roving items keep their `-1`. No new prop, class or token, no new dependency.
13
+
14
+ ## 1.35.1
15
+
16
+ ### Patch Changes
17
+
18
+ - 521903d: `Table` `stickyHeader` (`true` or `'page'`) now pins the whole `TableHeader` as one group (Teisoro F-011 task 2200): the `thead` is sticky, not each `thead th`, so a row under the column headings (such as a running total) stays in view right under them with all its cells, `td` as well as `th`, where before only its `th` pinned (at the same edge as the headings) and its `td` cells scrolled away. Every pinned head cell is on the glass (solid under Reduce Transparency, the system canvas in forced colours) and draws its row rule on its own `::before` instead of a collapsed bottom border, which stayed behind as the row moved and left a gap the rows under it showed through. No public API change, no new class, no new tokens, no new dependency.
19
+
3
20
  ## 1.35.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -57,7 +57,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
57
57
 
58
58
  `AppHeader` is the page's top chrome, sticky by default (`sticky={false}` leaves it in flow). Its default `appearance="glass"` is a rounded frosted card. `appearance="bar"` (`AppHeaderAppearance`, `appHeaderAppearances`) is a full-width band of the solid surface with the border hairline under it: no blur, no rounding, no side or top border, the same under Reduce Transparency, and the hairline kept in forced colors. Set it against the page's edges (no side gutter round it) and put the gutter inside it. An unknown `appearance` throws a `RangeError`.
59
59
 
60
- `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. They also take `lg` (64rem), for content that fits only from a laptop up, such as a row of labelled destinations that a tablet shows as glyphs (name each glyph-only control, for example with `Tooltip relationship="label"`); the layout props keep `md`. `breakpointQuery('below', 'lg')` returns the query a hide class uses (`not all and (min-width: 64rem)`), for an app whose script must follow the same width. An unknown breakpoint throws a `RangeError`. 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. `Box as="a" link="quiet"` (opt-in; `link="accent"`, the default, is the canvas link) is a title that opens something, such as a table row's title: it keeps the surrounding text's colour at weight 600 (`sw-link-quiet`) and is underlined on hover and keyboard focus. Its weight is the non-colour cue WCAG 1.4.1 asks for (axe's `link-in-text-block` passes it on colour, the same colour and background as its text, or on its weight; the weight is what keeps it visibly a link), so use it in text lighter than 600. In text already at 600 the quiet look has no cue: an accent link there needs at least 3:1 contrast against the text. `link` on a box that is not `as="a"` throws a `TypeError`, an unknown value a `RangeError`. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Steps` is a one-page vertical stepper: an `ol` of `Step` panels in order, numbered by their place, a line joining each marker to the next (in the success color below a done step). A `Step` takes `status` (`'upcoming' | 'current' | 'done' | 'error' | 'skipped'`) and `title` (a heading at the list's `headingLevel`, 2 by default, or the step's own). The current step is open with its `children` and has `aria-current="step"`; an error step stays open with a "!" marker. A done step folds to one line: a check, the title, its `summary` (truncated) and, with `onEdit` and `editLabel`, a ghost small `Button` at the end named by its label and the title ("Edit The site"). A step ahead is greyed with its number in a ring; a skipped step is greyed with its `skippedReason` after the title (pass your own separator, such as "· not needed"). A folded step's heading ends in its status word for assistive technology (visually hidden; English by default, `labels` on `Steps` takes your words). When a step becomes current from a folded state (upcoming, done or skipped), focus moves to its heading, a programmatic focus target; if several do in one update, the first in the list takes it. A first render, a move between current and error, and a step that turns to error leave focus alone: after a refused submit, move focus to the first invalid field yourself, as with `Field`. An error step has no `aria-current`; its heading's status word says it needs attention. Scalewing holds no order rules: the consumer sets each step's status. Pass the `Step`s as direct children (a Fragment around several would share one number). A `Step` outside `Steps`, a Fragment child, a blank title or reason, an unknown status or heading level, `onEdit` without `editLabel`, or a blank status word throws a `RangeError`.
60
+ `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. They also take `lg` (64rem), for content that fits only from a laptop up, such as a row of labelled destinations that a tablet shows as glyphs (name each glyph-only control, for example with `Tooltip relationship="label"`); the layout props keep `md`. `breakpointQuery('below', 'lg')` returns the query a hide class uses (`not all and (min-width: 64rem)`), for an app whose script must follow the same width. An unknown breakpoint throws a `RangeError`. 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. `Box as="a" link="quiet"` (opt-in; `link="accent"`, the default, is the canvas link) is a title that opens something, such as a table row's title: it keeps the surrounding text's colour at weight 600 (`sw-link-quiet`) and is underlined on hover and keyboard focus. Its weight is the non-colour cue WCAG 1.4.1 asks for (axe's `link-in-text-block` passes it on colour, the same colour and background as its text, or on its weight; the weight is what keeps it visibly a link), so use it in text lighter than 600. In text already at 600 the quiet look has no cue: an accent link there needs at least 3:1 contrast against the text. `link` on a box that is not `as="a"` throws a `TypeError`, an unknown value a `RangeError`. `Field` associates a native `<input>` or `<select>` with a label and token gap; native text controls inherit the generated document canvas. `Steps` is a one-page vertical stepper: an `ol` of `Step` panels in order, numbered by their place, a line joining each marker to the next (in the success color below a done step). A `Step` takes `status` (`'upcoming' | 'current' | 'done' | 'error' | 'skipped'`) and `title` (a heading at the list's `headingLevel`, 2 by default, or the step's own). The current step is open with its `children` and has `aria-current="step"`; an error step stays open with a "!" marker. A done step folds to one line: a check, the title, its `summary` (truncated) and, with `onEdit` and `editLabel`, a ghost small `Button` at the end named by its label and the title ("Edit The site"). A step ahead is greyed with its number in a ring; a skipped step is greyed with its `skippedReason` after the title (pass your own separator, such as "· not needed"). A folded step's heading ends in its status word for assistive technology (visually hidden; English by default, `labels` on `Steps` takes your words). When a step becomes current from a folded state (upcoming, done or skipped), focus moves to its heading, a programmatic focus target; if several do in one update, the first in the list takes it. A first render, a move between current and error, and a step that turns to error leave focus alone: after a refused submit, move focus to the first invalid field yourself, as with `Field`. An error step has no `aria-current`; its heading's status word says it needs attention. Scalewing holds no order rules: the consumer sets each step's status. `Steps open` (off by default) keeps every step open at once, for a form whose sections are all filled on one page: each step shows its `children` whatever its status, inside a `section` named by its title (a region, class `sw-steps-region`), every title at the open size; the marker still follows the status (a number to fill, a check when done, "!" in error, a grey ring and muted title when greyed, such as after a blocking step), no step is `aria-current`, and no step takes focus when its status changes. Pass the `Step`s as direct children (a Fragment around several would share one number). A `Step` outside `Steps`, a Fragment child, a blank title or reason, an unknown status or heading level, `onEdit` without `editLabel`, or a blank status word throws a `RangeError`.
61
61
 
62
62
  ```tsx
63
63
  <Steps aria-label="Heron survey">
@@ -89,6 +89,8 @@ Import the CSS once at the application entry. Do not copy it into your source tr
89
89
  </Field>
90
90
  ```
91
91
 
92
+ Every press control and link takes an explicit `tabindex="0"`: `Button` (and the controls built on it, `CalendarButton` and `InfoTip`), `Box as="a"` with an `href`, `SectionNav` links, the `Select`, `MultiSelect` and `ActionMenu` triggers, `SegmentedControl` items (every item, as outside Safari: its arrow keys change the value, not the focus, so it is not a roving group), `DateField`'s calendar button and month steps, a search `Field`'s clear button, a `Table`'s sort and `TableCell onPress` buttons, `Checkbox`, `Switch`, and the checked radio of a `RadioGroup` or `FilterChips` (the first enabled one while none is; the others keep no `tabindex`, so each browser's arrow-key group rule stands). Safari's default (its "Press Tab to highlight each item" setting off) leaves a button, a link, a checkbox and a radio out of the Tab order unless it carries one, so a Safari user would Tab past them; the explicit `0` gives them the order every other browser already does. A `tabIndex` you pass wins, and controls that are deliberately no tab stop keep their `-1`: a `Stepper`'s − and +, and a roving item (a `Tabs` tab, a menu item, a calendar day) other than the current one. A `<summary>` is already in Safari's order and takes none.
93
+
92
94
  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.
93
95
 
94
96
  ```tsx
@@ -219,6 +221,8 @@ A `Button`'s own children are laid out in a row one token gap apart (spacing ste
219
221
 
220
222
  On the web, `variant="neutral"` is a quiet grey fill (the muted color at 16 %, the fill of an open menu row) with its words in the text color and no border: an action beside the primary that should not draw the eye, such as a sheet's Cancel. It is web only (`webButtonVariants`; the shared `buttonVariants` in `@scalewing/tokens` and React Native do not have it). `disabledAppearance="neutral"` (default `"faded"`) draws a disabled button on that grey with its words in the muted color at 48 % (about #AEAEAE on the grey) and full opacity, whatever its variant, instead of fading its own fill (a disabled accent reads as a paler accent); a `ghost` button, which has no fill, still fades. The button is still a disabled `button`; forced colors draw it as any disabled button. An unknown `disabledAppearance` throws a `RangeError`.
221
223
 
224
+ A `Button` with `aria-disabled="true"` (not `disabled`) looks disabled, faded or, with `disabledAppearance="neutral"`, on the neutral grey, with `GrayText` in forced colors, yet stays a focusable, pressable `button`: its `onPress` still runs, so a submit pressed while off can take the person to what is missing. Its look alone does not say what is missing: a press while off should show the message of the first thing to fix. Use `disabled` when a press should do nothing.
225
+
222
226
  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`.
223
227
 
224
228
  `ButtonGroup` is the action row of a form or dialog (`role="group"`, named by `aria-label`): one line from `md` up, aligned by `justify`, and full-width stacked buttons below it. `attached` makes it one joined control instead, such as Previous, Today and Next: its direct `Button` children sit side by side at one height with no gap, their borders overlapping into single dividers (a borderless variant draws its divider in its own text color at low strength), with square inner corners and `sm`-radius outer ones, mirrored right to left. It keeps its row on a phone and sizes to its buttons, so `justify` does not apply. The group is its own stacking context: a pressed toggle's ring and the focused button's usual outer ring paint over the neighbours. Give every button the same variant and size, such as `secondary`; a wrapper around a button (a tooltip's span) is not joined.
@@ -416,7 +420,7 @@ A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `
416
420
 
417
421
  A ledger read across as well as down takes `Table` `columnRules` (a hairline at the inline start of every cell after a row's first, mirrored right to left) and `TableCell` `tone` (`success`, `danger`, `muted` or `accent`: the cell's text in the theme colour, as `Text color` uses it; never the only signal, so keep the sign or the words). `TableRow` `variant="sub"` (default `"default"`) sets a row under the one above, as a batch's items: its row header, a `TableCell as="th" scope="row"`, is indented a `space-6` step with a guide bar at the indent (a sub row without a row header is not indented). `TableRow` `struck` keeps a voided row on the record: the subtle fill and the text muted and struck through, toned cells included; it works with either variant. A line-through is not read aloud, so say why in the row, such as a `Badge` "Voided": a Badge in a line of text is not struck, but one that is itself a flex item (a direct child of `Inline`) is, so put it in the title's line. A row header in the body or footer (`th scope="row"`) reads as body text. An unknown `tone` or `variant` throws a `RangeError`.
418
422
 
419
- `Table` `stickyHeader` pins the header row: `true` (default) to the top of the table's own scroll region, `false` lets it scroll away, and `'page'` pins it to the page's top as the page scrolls. With `'page'`, while the table fits its width its region stops being a scroll box (`sw-table-sticky-page`), so the header stays in view under any sticky `AppHeader` (which covers it); a table wider than its region scrolls sideways in it and pins inside it. Use it for a long table whose last row, such as a non-sticky `TableFooter` total, should scroll with the rows. An unknown value throws a `RangeError`.
423
+ `Table` `stickyHeader` pins the header: the whole `TableHeader` as one group, every row in it (the column headings and any row under them, such as a running total) with all its cells, `th` and `td`, each row under the one above, on the glass with its row and column rules. `true` (default) pins it to the top of the table's own scroll region, `false` lets it scroll away, and `'page'` pins it to the page's top as the page scrolls. With `'page'`, while the table fits its width its region stops being a scroll box (`sw-table-sticky-page`), so the header stays in view under any sticky `AppHeader` (which covers it); a table wider than its region scrolls sideways in it and pins inside it. Use it for a long table whose last row, such as a non-sticky `TableFooter` total, should scroll with the rows. An unknown value throws a `RangeError`.
420
424
 
421
425
  ```tsx
422
426
  <Table aria-label="Site census" stickyHeader="page">
@@ -4,6 +4,7 @@ import { useId, useLayoutEffect, useRef, useState, } from 'react';
4
4
  import { cx } from '../class-names.js';
5
5
  import { actionMenuTriggerAppearances, actionMenuTriggerTextClass, } from '../css/css-action-menu-trigger.js';
6
6
  import { ActionMenuList } from './action-menu/ActionMenuList.js';
7
+ import { tabStop } from '../tab-stop.js';
7
8
  export { actionMenuTriggerAppearances };
8
9
  function nextEnabled(items, current, direction) {
9
10
  if (items.length === 0)
@@ -93,5 +94,5 @@ export function ActionMenu({ label, items, header, trigger, disabled = false, al
93
94
  close(true);
94
95
  item.onSelect();
95
96
  }
96
- return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: cx('sw-action-menu-trigger', triggerAppearance === 'text' && actionMenuTriggerTextClass), disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: triggerAppearance === 'text' ? (_jsx("span", { children: trigger ?? label })) : ((trigger ?? label)) }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, header: header, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
97
+ return (_jsxs("div", { className: "sw-action-menu", children: [_jsx("button", { "aria-controls": open ? menuId : undefined, "aria-expanded": open, "aria-haspopup": "menu", "aria-label": label, className: cx('sw-action-menu-trigger', triggerAppearance === 'text' && actionMenuTriggerTextClass), disabled: !enabled, onClick: () => (open ? close(true) : openMenu()), onKeyDown: onTriggerKeyDown, ref: triggerRef, tabIndex: tabStop, type: "button", children: triggerAppearance === 'text' ? (_jsx("span", { children: trigger ?? label })) : ((trigger ?? label)) }), open ? (_jsx(ActionMenuList, { align: align, focusIndex: focusIndex, header: header, id: menuId, items: items, label: label, onDismiss: () => close(), onFocusIndex: setFocusIndex, onKeyDown: onMenuKeyDown, onSelect: select, triggerRef: triggerRef })) : null] }));
97
98
  }
@@ -5,10 +5,13 @@ import { boxBorderClass } from '../css/css-box-border.js';
5
5
  import { assertBoxLink, boxLinkClass, } from '../css/css-box-link.js';
6
6
  import { columnSpanClassNames, } from '../column-span-classes.js';
7
7
  import { spacingClassNames } from '../spacing-classes.js';
8
+ import { tabStop } from '../tab-stop.js';
8
9
  import { visibilityClassNames, } from '../visibility-classes.js';
9
- export const Box = forwardRef(function Box({ as = 'div', background, border, className, columnSpan, hideBelow, hideFrom, link, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, style, ...rest }, ref) {
10
+ export const Box = forwardRef(function Box({ as = 'div', background, border, className, columnSpan, hideBelow, hideFrom, link, padding, paddingBottom, paddingLeft, paddingRight, paddingTop, paddingX, paddingY, radius, style, tabIndex, ...rest }, ref) {
10
11
  assertBoxLink(link, as);
11
12
  const Component = as;
13
+ // A link with a destination is a tab stop in Safari too (see tabStop).
14
+ const linkTabStop = as === 'a' && rest.href !== undefined ? tabStop : undefined;
12
15
  return (_jsx(Component, { ref: ref, className: cx(...spacingClassNames({
13
16
  padding,
14
17
  paddingBottom,
@@ -23,5 +26,5 @@ export const Box = forwardRef(function Box({ as = 'div', background, border, cla
23
26
  : undefined,
24
27
  borderRadius: radius ? `var(--sw-radius-${radius})` : undefined,
25
28
  ...style,
26
- }, ...rest }));
29
+ }, tabIndex: tabIndex ?? linkTabStop, ...rest }));
27
30
  });
@@ -2,8 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { buttonClassNames, buttonDisabledAppearances, } from '../css/css-button.js';
3
3
  import { forwardRef } from 'react';
4
4
  import { cx } from '../class-names.js';
5
- export const Button = forwardRef(function Button({ children, className, disabled = false, disabledAppearance = 'faded', onPress, size = 'md', type = 'button', variant = 'primary', ...rest }, ref) {
5
+ import { tabStop } from '../tab-stop.js';
6
+ export const Button = forwardRef(function Button({ children, className, disabled = false, disabledAppearance = 'faded', onPress, size = 'md', tabIndex, type = 'button', variant = 'primary', ...rest }, ref) {
6
7
  if (!buttonDisabledAppearances.includes(disabledAppearance))
7
8
  throw new RangeError(`Unknown Button disabledAppearance: ${String(disabledAppearance)}`);
8
- return (_jsx("button", { ref: ref, className: cx(...buttonClassNames({ size, variant, disabledAppearance }), className), disabled: disabled, type: type, ...rest, onClick: onPress, children: children }));
9
+ return (_jsx("button", { ref: ref, className: cx(...buttonClassNames({ size, variant, disabledAppearance }), className), disabled: disabled, tabIndex: tabIndex ?? tabStop, type: type, ...rest, onClick: onPress, children: children }));
9
10
  });
@@ -13,6 +13,7 @@ import { DateFieldGlyph } from './date-field/DateFieldGlyph.js';
13
13
  import { useCalendarPopup } from './date-field/use-calendar-popup.js';
14
14
  import { useDateEntry } from './date-field/use-date-entry.js';
15
15
  import { useLangLocale } from './date-field/use-lang-locale.js';
16
+ import { tabStop } from '../tab-stop.js';
16
17
  export function DateField({ label, value, onChange, min, max, today, disabled = false, required = false, requiredMark = 'visible', description, error, locale, weekStartsOn = 0, labels, }) {
17
18
  assertRequiredMark(requiredMark);
18
19
  assertDateBounds(value, min, max);
@@ -59,5 +60,5 @@ export function DateField({ label, value, onChange, min, max, today, disabled =
59
60
  day: words.dayPlaceholder,
60
61
  month: words.monthPlaceholder,
61
62
  year: words.yearPlaceholder,
62
- }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { ...trigger, "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: toggle, ref: buttonRef, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: entryErrorId, message: entry.invalid ? words.invalidEntry : undefined }), _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: errorId, message: error }), shown ? (_jsx(CalendarDialog, { ...dialog, buttonRef: buttonRef, anchorRef: controlRef, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, required: required, value: value, weekStartsOn: weekStartsOn })) : null] }));
63
+ }), ref: inputRef, required: required, spellCheck: false, type: "text", value: entry.text }), _jsxs("button", { ...trigger, "aria-describedby": labelId, className: "sw-date-field-button", disabled: disabled, onClick: toggle, ref: buttonRef, tabIndex: tabStop, type: "button", children: [_jsx("span", { className: "sw-sr-only", children: words.chooseDate }), _jsx(DateFieldGlyph, { name: "calendar" })] })] }), description ? (_jsx("span", { className: "sw-date-field-description", id: descriptionId, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: entryErrorId, message: entry.invalid ? words.invalidEntry : undefined }), _jsx(FieldErrorRegion, { className: "sw-date-field-error", id: errorId, message: error }), shown ? (_jsx(CalendarDialog, { ...dialog, buttonRef: buttonRef, anchorRef: controlRef, labelId: labelId, labels: words, locale: resolvedLocale, max: max, min: min, required: required, value: value, weekStartsOn: weekStartsOn })) : null] }));
63
64
  }
@@ -4,6 +4,7 @@ import { useId } from 'react';
4
4
  import { cx } from '../class-names.js';
5
5
  import { FieldErrorRegion } from './FieldErrorRegion.js';
6
6
  import { assertRequiredMark, showsRequiredMark, } from './FieldLabelText.js';
7
+ import { radioTabStopValue, tabStop } from '../tab-stop.js';
7
8
  function isCount(count) {
8
9
  return count === undefined || (Number.isInteger(count) && count >= 0);
9
10
  }
@@ -26,8 +27,9 @@ export function FilterChips({ label, value, onChange, options, required = false,
26
27
  throw new RangeError('value must match an option or be empty');
27
28
  if (options.some((option) => !isCount(option.count)))
28
29
  throw new RangeError('count must be a non-negative integer');
30
+ const entry = radioTabStopValue(options, value);
29
31
  return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": error ? true : undefined, className: "sw-filter-chips", children: [_jsxs("legend", { className: "sw-filter-chips-legend", children: [label, showsRequiredMark(required, requiredMark) ? (_jsxs("span", { "aria-hidden": "true", className: "sw-field-required", children: [' ', "*"] })) : null] }), _jsx("span", { className: "sw-filter-chips-options", children: options.map((option) => (_jsxs("label", { className: cx('sw-filter-chip', option.count === 0 && 'sw-filter-chip-quiet'), children: [_jsx("input", { checked: option.value === value, className: "sw-filter-chip-input", disabled: option.disabled, name: name, onChange: (event) => {
30
32
  if (event.currentTarget.checked && !option.disabled)
31
33
  onChange(option.value);
32
- }, required: required, type: "radio", value: option.value }), _jsxs("span", { className: "sw-filter-chip-face", children: [option.label, option.count === undefined ? null : (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-filter-chip-count", children: option.count })] }))] })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-filter-chips-error", id: errorId, message: error })] }));
34
+ }, required: required, tabIndex: option.value === entry ? tabStop : undefined, type: "radio", value: option.value }), _jsxs("span", { className: "sw-filter-chip-face", children: [option.label, option.count === undefined ? null : (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-filter-chip-count", children: option.count })] }))] })] }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-filter-chips-error", id: errorId, message: error })] }));
33
35
  }
@@ -3,6 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId, useRef, useState } from 'react';
4
4
  import { MultiSelectPanel } from './multi-select/MultiSelectPanel.js';
5
5
  import { assertMultiSelect } from './multi-select/validate.js';
6
+ import { tabStop } from '../tab-stop.js';
6
7
  /**
7
8
  * Several choices from a short list behind one trigger: "Show All entries ▾"
8
9
  * opens a panel with All, Clear and a checkbox per option. A disclosure (a
@@ -58,5 +59,5 @@ export function MultiSelect({ align = 'start', allLabel, clearLabel, disabled =
58
59
  close(true);
59
60
  }
60
61
  }
61
- return (_jsxs("div", { className: "sw-multi-select", children: [_jsxs("button", { "aria-controls": shown ? panelId : undefined, "aria-expanded": shown, className: "sw-multi-select-trigger", disabled: disabled, onClick: () => (shown ? close(true) : setOpen(true)), onKeyDown: onTriggerKeyDown, ref: triggerRef, type: "button", children: [_jsx("span", { className: "sw-multi-select-label", children: label }), ' ', _jsx("span", { className: "sw-multi-select-summary", children: summary })] }), shown ? (_jsx(MultiSelectPanel, { align: align, allLabel: allLabel, checked: checked, clearLabel: clearLabel, id: panelId, label: label, onAll: () => report(new Set(options.map((option) => option.value))), onClear: () => report(new Set()), onClose: close, onToggle: onToggle, options: options, triggerRef: triggerRef })) : null] }));
62
+ return (_jsxs("div", { className: "sw-multi-select", children: [_jsxs("button", { "aria-controls": shown ? panelId : undefined, "aria-expanded": shown, className: "sw-multi-select-trigger", disabled: disabled, onClick: () => (shown ? close(true) : setOpen(true)), onKeyDown: onTriggerKeyDown, ref: triggerRef, tabIndex: tabStop, type: "button", children: [_jsx("span", { className: "sw-multi-select-label", children: label }), ' ', _jsx("span", { className: "sw-multi-select-summary", children: summary })] }), shown ? (_jsx(MultiSelectPanel, { align: align, allLabel: allLabel, checked: checked, clearLabel: clearLabel, id: panelId, label: label, onAll: () => report(new Set(options.map((option) => option.value))), onClear: () => report(new Set()), onClose: close, onToggle: onToggle, options: options, triggerRef: triggerRef })) : null] }));
62
63
  }
@@ -3,6 +3,7 @@ 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
5
  import { RadioGroupOptionLabel, } from './radio-group/RadioGroupOptionLabel.js';
6
+ import { radioTabStopValue } from '../tab-stop.js';
6
7
  export function RadioGroup({ legend, value, onChange, options, required = false, disabled = false, description, error, }) {
7
8
  const name = useId();
8
9
  const descriptionId = useId();
@@ -20,8 +21,9 @@ export function RadioGroup({ legend, value, onChange, options, required = false,
20
21
  throw new RangeError('options must have unique values');
21
22
  if (value !== '' && !values.includes(value))
22
23
  throw new RangeError('value must match an option or be empty');
24
+ const entry = radioTabStopValue(options, value);
23
25
  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
26
  if (!disabled && !option.disabled)
25
27
  onChange(option.value);
26
- }, option: option, required: required }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
28
+ }, option: option, required: required, isTabStop: option.value === entry }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-radio-group-error", id: errorId, message: error })] }));
27
29
  }
@@ -3,6 +3,7 @@ import { cx } from '../class-names.js';
3
3
  import { breakpoints } from '../css/breakpoints.js';
4
4
  import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
5
  import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
6
+ import { tabStop } from '../tab-stop.js';
6
7
  export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
7
8
  function plainPress(event) {
8
9
  return (event.button === 0 &&
@@ -43,7 +44,7 @@ export function SectionNav({ appearance = 'underline', items, onNavigate, vertic
43
44
  assertItems(items);
44
45
  assertBreakpoint(verticalFrom);
45
46
  assertSectionNavAppearance(appearance);
46
- return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href,
47
+ return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href, tabIndex: tabStop,
47
48
  // Only a given onNavigate attaches a handler, so the links
48
49
  // render from a server component without one.
49
50
  onClick: onNavigate
@@ -4,6 +4,7 @@ import { forwardRef, useId } from 'react';
4
4
  import { cx } from '../class-names.js';
5
5
  import { segmentedRaisedClass } from '../css/css-segmented-raised.js';
6
6
  import { FieldErrorRegion } from './FieldErrorRegion.js';
7
+ import { tabStop } from '../tab-stop.js';
7
8
  export const SegmentedControl = forwardRef(function SegmentedControl({ disabled = false, error, items, onChange, required = false, value, variant = 'compact', ...labelProps }, ref) {
8
9
  const errorId = useId();
9
10
  const withIcons = items.some((item) => item.icon);
@@ -30,6 +31,9 @@ export const SegmentedControl = forwardRef(function SegmentedControl({ disabled
30
31
  if (!selected) {
31
32
  onChange?.(item.id);
32
33
  }
33
- }, role: "radio", type: "button", children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-segmented-item-icon", children: item.icon })) : null, item.label] }, item.id));
34
+ }, role: "radio",
35
+ // Every item stays a tab stop, as each already was outside
36
+ // Safari: the arrow keys change the value, not the focus.
37
+ tabIndex: tabStop, type: "button", children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-segmented-item-icon", children: item.icon })) : null, item.label] }, item.id));
34
38
  }) }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }));
35
39
  });
@@ -9,6 +9,7 @@ import { SelectListbox } from './select/SelectListbox.js';
9
9
  import { SelectValue } from './select/SelectValue.js';
10
10
  import { useSelectList } from './select/use-select-list.js';
11
11
  import { Stack } from './Stack.js';
12
+ import { tabStop } from '../tab-stop.js';
12
13
  export function Select({ action, label, labelVisuallyHidden = false, onChange, options, size = 'md', value, placeholder, required = false, requiredMark = 'visible', error, width = 'content', }) {
13
14
  assertRequiredMark(requiredMark);
14
15
  if (placeholder !== undefined && !placeholder.trim())
@@ -35,5 +36,5 @@ export function Select({ action, label, labelVisuallyHidden = false, onChange, o
35
36
  return;
36
37
  }
37
38
  openList();
38
- }, onKeyDown: onTriggerKeyDown, role: "combobox", type: "button", children: _jsx(SelectValue, { options: options, placeholder: placeholder, text: showsPlaceholder ? placeholder : (selected?.label ?? ''), textIsPlaceholder: showsPlaceholder }) }), open ? (_jsx(SelectListbox, { actionLabel: action?.label, highlight: highlight, id: listId, labelId: labelId, onCommit: commit, onHighlight: setHighlight, optionIdPrefix: optionIdPrefix, options: options, value: value })) : null] }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }) }));
39
+ }, onKeyDown: onTriggerKeyDown, role: "combobox", tabIndex: tabStop, type: "button", children: _jsx(SelectValue, { options: options, placeholder: placeholder, text: showsPlaceholder ? placeholder : (selected?.label ?? ''), textIsPlaceholder: showsPlaceholder }) }), open ? (_jsx(SelectListbox, { actionLabel: action?.label, highlight: highlight, id: listId, labelId: labelId, onCommit: commit, onHighlight: setHighlight, optionIdPrefix: optionIdPrefix, options: options, value: value })) : null] }), _jsx(FieldErrorRegion, { className: "sw-field-error", id: errorId, message: error })] }) }));
39
40
  }
@@ -9,10 +9,19 @@ export type StepsProps = Omit<HTMLAttributes<HTMLOListElement>, 'children'> & {
9
9
  headingLevel?: StepHeadingLevel;
10
10
  /** The status words assistive technology hears after a folded step's title. English by default. */
11
11
  labels?: Partial<StepsLabels>;
12
+ /**
13
+ * Every step open: each shows its `children` whatever its status, as a
14
+ * region named by its title, so a form's sections are all filled on one
15
+ * page. The markers still say where each stands. No step is
16
+ * `aria-current` and none takes focus when its status changes. A step's
17
+ * own `headingLevel` still overrides the list's.
18
+ */
19
+ open?: boolean;
12
20
  };
13
21
  /**
14
22
  * A one-page vertical stepper: a form's steps in order, the current one
15
23
  * open, the done ones folded to a line with their summary and Edit, the
16
- * ones ahead greyed, a line joining each marker to the next.
24
+ * ones ahead greyed, a line joining each marker to the next. With `open`,
25
+ * every step stays open and only its marker follows its status.
17
26
  */
18
- export declare function Steps({ children, className, headingLevel, labels, ...rest }: StepsProps): import("react").JSX.Element;
27
+ export declare function Steps({ children, className, headingLevel, labels, open, ...rest }: StepsProps): import("react").JSX.Element;
@@ -14,9 +14,10 @@ function assertSteps(labels, level) {
14
14
  /**
15
15
  * A one-page vertical stepper: a form's steps in order, the current one
16
16
  * open, the done ones folded to a line with their summary and Edit, the
17
- * ones ahead greyed, a line joining each marker to the next.
17
+ * ones ahead greyed, a line joining each marker to the next. With `open`,
18
+ * every step stays open and only its marker follows its status.
18
19
  */
19
- export function Steps({ children, className, headingLevel = 2, labels, ...rest }) {
20
+ export function Steps({ children, className, headingLevel = 2, labels, open = false, ...rest }) {
20
21
  const words = { ...defaultStepsLabels, ...labels };
21
22
  assertSteps(words, headingLevel);
22
23
  const steps = Children.toArray(children).filter(isValidElement);
@@ -25,10 +26,11 @@ export function Steps({ children, className, headingLevel = 2, labels, ...rest }
25
26
  throw new RangeError('Steps children must be Steps, not a Fragment');
26
27
  // The first step to open in a commit claims focus (see Step).
27
28
  const focusClaimRef = useRef(false);
28
- return (_jsx("ol", { className: cx('sw-steps', className), ...rest, children: steps.map((step, index) => (_jsx(StepPlaceContext.Provider, { value: {
29
+ return (_jsx("ol", { className: cx('sw-steps', open && 'sw-steps-open', className), ...rest, children: steps.map((step, index) => (_jsx(StepPlaceContext.Provider, { value: {
29
30
  number: index + 1,
30
31
  headingLevel,
31
32
  labels: words,
32
33
  focusClaimRef,
34
+ open,
33
35
  }, children: step }, step.key))) }));
34
36
  }
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
+ import { tabStop } from '../tab-stop.js';
4
5
  export function Switch({ label, checked, onCheckedChange, disabled = false, description, }) {
5
6
  const labelId = useId();
6
7
  const descriptionId = useId();
7
8
  return (_jsxs("label", { className: "sw-switch", children: [_jsxs("span", { className: "sw-switch-control", children: [_jsx("input", { "aria-checked": checked, "aria-describedby": description ? descriptionId : undefined, "aria-labelledby": labelId, checked: checked, className: "sw-switch-input", disabled: disabled, onChange: (event) => {
8
9
  if (!disabled)
9
10
  onCheckedChange(event.currentTarget.checked);
10
- }, role: "switch", type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-switch-track", children: _jsx("span", { className: "sw-switch-thumb" }) })] }), _jsxs("span", { className: "sw-switch-copy", children: [_jsx("span", { className: "sw-switch-label", id: labelId, children: label }), description ? (_jsx("span", { className: "sw-switch-description", id: descriptionId, children: description })) : null] })] }));
11
+ }, role: "switch", tabIndex: tabStop, type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-switch-track", children: _jsx("span", { className: "sw-switch-thumb" }) })] }), _jsxs("span", { className: "sw-switch-copy", children: [_jsx("span", { className: "sw-switch-label", id: labelId, children: label }), description ? (_jsx("span", { className: "sw-switch-description", id: descriptionId, children: description })) : null] })] }));
11
12
  }
@@ -5,5 +5,5 @@ type CheckboxControlProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'classNa
5
5
  * generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
6
6
  * and MultiSelect's rows so the two cannot drift apart.
7
7
  */
8
- export declare function CheckboxControl(props: CheckboxControlProps): import("react").JSX.Element;
8
+ export declare function CheckboxControl({ tabIndex, ...props }: CheckboxControlProps): import("react").JSX.Element;
9
9
  export {};
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { tabStop } from '../../tab-stop.js';
2
3
  /**
3
4
  * The native checkbox and its drawn mark, the one markup Checkbox's
4
5
  * generated rules style (`input + .sw-checkbox-mark`). Shared by Checkbox
5
6
  * and MultiSelect's rows so the two cannot drift apart.
6
7
  */
7
- export function CheckboxControl(props) {
8
- return (_jsxs("span", { className: "sw-checkbox-control", children: [_jsx("input", { ...props, className: "sw-checkbox-input", type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }));
8
+ export function CheckboxControl({ tabIndex, ...props }) {
9
+ return (_jsxs("span", { className: "sw-checkbox-control", children: [_jsx("input", { tabIndex: tabIndex ?? tabStop, ...props, className: "sw-checkbox-input", type: "checkbox" }), _jsx("span", { "aria-hidden": "true", className: "sw-checkbox-mark" })] }));
9
10
  }
@@ -4,13 +4,14 @@ import { monthInRange, monthsInRange, shiftMonth, } from '../../calendar-month.j
4
4
  import { calendarYearRange, yearsIn } from '../../calendar-years.js';
5
5
  import { Select } from '../Select.js';
6
6
  import { DateFieldGlyph } from './DateFieldGlyph.js';
7
+ import { tabStop } from '../../tab-stop.js';
7
8
  function MonthStep({ delta, label, max, min, month, onMonthChange, }) {
8
9
  const target = shiftMonth(month, delta);
9
10
  const unavailable = target.year < 1 || target.year > 9999 || !monthInRange(target, min, max);
10
11
  return (_jsx("button", { "aria-disabled": unavailable || undefined, "aria-label": label, className: "sw-date-field-step", onClick: () => {
11
12
  if (!unavailable)
12
13
  onMonthChange(target);
13
- }, type: "button", children: _jsx(DateFieldGlyph, { name: delta < 0 ? 'previous' : 'next' }) }));
14
+ }, tabIndex: tabStop, type: "button", children: _jsx(DateFieldGlyph, { name: delta < 0 ? 'previous' : 'next' }) }));
14
15
  }
15
16
  /**
16
17
  * Month stepping plus month and year selectors for jumping far, both cut to
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { tabStop } from '../../tab-stop.js';
2
3
  /*
3
4
  * A press on the frame's chrome (the glyph or the padding) focuses the
4
5
  * input, as on an adorned field; a press on the input keeps the browser's
@@ -28,5 +29,5 @@ export function SearchFrame({ children, clearLabel, leading, onClear, showClear,
28
29
  ?.querySelector('input')
29
30
  ?.focus();
30
31
  onClear();
31
- }, type: "button", children: _jsx("span", { "aria-hidden": "true", className: "sw-field-search-cross" }) })) : null] }));
32
+ }, tabIndex: tabStop, type: "button", children: _jsx("span", { "aria-hidden": "true", className: "sw-field-search-cross" }) })) : null] }));
32
33
  }
@@ -27,6 +27,8 @@ type RadioGroupOptionLabelProps = {
27
27
  name: string;
28
28
  checked: boolean;
29
29
  required: boolean;
30
+ /** Whether this radio is its group's explicit Tab stop (`radioTabStopValue`). */
31
+ isTabStop: boolean;
30
32
  onSelect: () => void;
31
33
  };
32
34
  /**
@@ -35,5 +37,5 @@ type RadioGroupOptionLabelProps = {
35
37
  * share a wrapping body, and the radio is named by the label text alone and
36
38
  * described by the description.
37
39
  */
38
- export declare function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }: RadioGroupOptionLabelProps): import("react").JSX.Element;
40
+ export declare function RadioGroupOptionLabel({ option, name, checked, required, isTabStop, onSelect, }: RadioGroupOptionLabelProps): import("react").JSX.Element;
39
41
  export {};
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useId } from 'react';
3
+ import { tabStop } from '../../tab-stop.js';
3
4
  /** Whether React renders something for a description: not nothing, a boolean or ''. */
4
5
  function hasDescription(description) {
5
6
  return (description !== undefined &&
@@ -13,7 +14,7 @@ function hasDescription(description) {
13
14
  * share a wrapping body, and the radio is named by the label text alone and
14
15
  * described by the description.
15
16
  */
16
- export function RadioGroupOptionLabel({ option, name, checked, required, onSelect, }) {
17
+ export function RadioGroupOptionLabel({ option, name, checked, required, isTabStop, onSelect, }) {
17
18
  const id = useId();
18
19
  const textId = `${id}-text`;
19
20
  const descriptionId = `${id}-description`;
@@ -22,5 +23,5 @@ export function RadioGroupOptionLabel({ option, name, checked, required, onSelec
22
23
  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
24
  if (event.currentTarget.checked)
24
25
  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
+ }, required: required, tabIndex: isTabStop ? tabStop : undefined, 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
27
  }
@@ -13,12 +13,14 @@ export type StepProps = {
13
13
  skippedReason?: string;
14
14
  /** Overrides the list's heading level for this step's title. */
15
15
  headingLevel?: StepHeadingLevel;
16
- /** The step's fields, shown while it is current or in error. */
16
+ /** The step's fields, shown while it is current or in error, or always in an open list. */
17
17
  children?: ReactNode;
18
18
  };
19
19
  /**
20
20
  * One step of a `Steps` list: a numbered marker, the title as a heading,
21
21
  * and, by status, its fields (current, error) or one folded line (a done
22
- * step's summary and Edit; an upcoming or skipped step's title alone).
22
+ * step's summary and Edit; an upcoming or skipped step's title alone). In
23
+ * an open list every step shows its fields, inside a region named by its
24
+ * title.
23
25
  */
24
26
  export declare function Step(props: StepProps): import("react").JSX.Element;
@@ -21,12 +21,15 @@ function assertStep(props, level) {
21
21
  * Moves focus to the heading when the step becomes current from a folded
22
22
  * state, unless an earlier step of the list already took it in this commit
23
23
  * (effects run in list order, so the first one wins; the claim is released
24
- * after the flush).
24
+ * after the flush). In an open list nothing folds or opens, so focus stays
25
+ * where the person put it.
25
26
  */
26
- function useFocusWhenOpened(status, heading, focusClaimRef) {
27
+ function useFocusWhenOpened(status, heading, focusClaimRef, open) {
27
28
  const previous = useRef(undefined);
28
29
  useEffect(() => {
29
- if (stepTakesFocus(previous.current, status) && !focusClaimRef.current) {
30
+ if (!open &&
31
+ stepTakesFocus(previous.current, status) &&
32
+ !focusClaimRef.current) {
30
33
  focusClaimRef.current = true;
31
34
  heading.current?.focus();
32
35
  // One commit's effects run in one flush: release the claim after it,
@@ -36,12 +39,14 @@ function useFocusWhenOpened(status, heading, focusClaimRef) {
36
39
  });
37
40
  }
38
41
  previous.current = status;
39
- }, [status, heading, focusClaimRef]);
42
+ }, [status, heading, focusClaimRef, open]);
40
43
  }
41
44
  /**
42
45
  * One step of a `Steps` list: a numbered marker, the title as a heading,
43
46
  * and, by status, its fields (current, error) or one folded line (a done
44
- * step's summary and Edit; an upcoming or skipped step's title alone).
47
+ * step's summary and Edit; an upcoming or skipped step's title alone). In
48
+ * an open list every step shows its fields, inside a region named by its
49
+ * title.
45
50
  */
46
51
  export function Step(props) {
47
52
  const place = useContext(StepPlaceContext);
@@ -53,10 +58,13 @@ export function Step(props) {
53
58
  const heading = useRef(null);
54
59
  const titleId = useId();
55
60
  const editId = useId();
56
- useFocusWhenOpened(status, heading, place.focusClaimRef);
61
+ useFocusWhenOpened(status, heading, place.focusClaimRef, place.open);
57
62
  const Heading = `h${level}`;
58
63
  const reason = status === 'skipped' ? skippedReason : undefined;
59
64
  const word = stepStatusWord(status, place.labels, reason !== undefined);
60
65
  const done = status === 'done';
61
- return (_jsxs("li", { "aria-current": status === 'current' ? 'step' : undefined, className: cx('sw-steps-item', `sw-steps-item-${status}`), children: [_jsxs("div", { className: "sw-steps-header", children: [_jsx("span", { "aria-hidden": "true", className: "sw-steps-marker", children: status === 'error' ? '!' : done ? null : place.number }), _jsxs(Heading, { ref: heading, className: "sw-steps-title", tabIndex: -1, children: [_jsx("span", { id: titleId, children: title }), reason ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-steps-reason", children: reason })] })) : null, word ? _jsxs("span", { className: "sw-sr-only", children: [", ", word] }) : null] }), done && summary ? (_jsx("span", { className: "sw-steps-summary", children: summary })) : null, done && onEdit ? (_jsx(Button, { "aria-labelledby": `${editId} ${titleId}`, className: "sw-steps-edit", id: editId, onPress: onEdit, size: "sm", variant: "ghost", children: editLabel })) : null] }), isOpenStep(status) && Children.toArray(children).length > 0 ? (_jsx("div", { className: "sw-steps-body", children: children })) : null] }));
66
+ const header = (_jsxs("div", { className: "sw-steps-header", children: [_jsx("span", { "aria-hidden": "true", className: "sw-steps-marker", children: status === 'error' ? '!' : done ? null : place.number }), _jsxs(Heading, { ref: heading, className: "sw-steps-title", tabIndex: -1, children: [_jsx("span", { id: titleId, children: title }), reason ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-steps-reason", children: reason })] })) : null, word ? _jsxs("span", { className: "sw-sr-only", children: [", ", word] }) : null] }), done && summary ? (_jsx("span", { className: "sw-steps-summary", children: summary })) : null, done && onEdit ? (_jsx(Button, { "aria-labelledby": `${editId} ${titleId}`, className: "sw-steps-edit", id: editId, onPress: onEdit, size: "sm", variant: "ghost", children: editLabel })) : null] }));
67
+ const shown = (place.open || isOpenStep(status)) && Children.toArray(children).length > 0;
68
+ const content = (_jsxs(_Fragment, { children: [header, shown ? _jsx("div", { className: "sw-steps-body", children: children }) : null] }));
69
+ return (_jsx("li", { "aria-current": !place.open && status === 'current' ? 'step' : undefined, className: cx('sw-steps-item', `sw-steps-item-${status}`), children: place.open ? (_jsx("section", { "aria-labelledby": titleId, className: "sw-steps-region", children: content })) : (content) }));
62
70
  }
@@ -2,15 +2,17 @@ import { type RefObject } from 'react';
2
2
  import { type StepHeadingLevel, type StepsLabels } from './step-status.js';
3
3
  /**
4
4
  * What a `Step` learns from its `Steps`: its number, its heading level, the
5
- * status words, and the list's focus claim for this commit (the first step
6
- * to open in a commit takes focus and releases the claim after that
7
- * commit's effects).
5
+ * status words, the list's focus claim for this commit (the first step to
6
+ * open in a commit takes focus and releases the claim after that commit's
7
+ * effects), and whether every step is open.
8
8
  */
9
9
  export type StepPlace = {
10
10
  number: number;
11
11
  headingLevel: StepHeadingLevel;
12
12
  labels: StepsLabels;
13
13
  focusClaimRef: RefObject<boolean>;
14
+ /** Every step of the list is open (`Steps open`). */
15
+ open: boolean;
14
16
  };
15
17
  /** Null outside `Steps`: a `Step` must be one of a list's steps. */
16
18
  export declare const StepPlaceContext: import("react").Context<StepPlace | null>;
@@ -3,6 +3,7 @@ import { forwardRef, } from 'react';
3
3
  import { cx } from '../../class-names.js';
4
4
  import { assertTableCellTone, } from '../../css/css-table-ledger.js';
5
5
  import { assertTableColumnWidth, tableColumnWidthClass, } from './table-layout.js';
6
+ import { tabStop } from '../../tab-stop.js';
6
7
  const tableSorts = ['ascending', 'descending', 'none'];
7
8
  /** A cell's press is a function, and never on a sortable header. */
8
9
  function assertPress(onPress, sort) {
@@ -35,5 +36,5 @@ export const TableCell = forwardRef(function TableCell({ align = 'start', as = '
35
36
  : align === 'end'
36
37
  ? 'sw-table-end'
37
38
  : undefined;
38
- return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : onPress ? (_jsx("button", { className: "sw-table-action", onClick: onPress, type: "button", children: children })) : (children) }));
39
+ return (_jsx(Component, { ref: ref, "aria-sort": sort === undefined || sort === 'none' ? undefined : sort, className: cx(alignmentClass, truncate && 'sw-table-clip', tone && `sw-table-tone-${tone}`, width && tableColumnWidthClass(width), className), ...rest, children: sort !== undefined && onSort ? (_jsxs("button", { className: "sw-table-sort", onClick: onSort, tabIndex: tabStop, type: "button", children: [children, _jsx("span", { "aria-hidden": "true", className: cx('sw-table-sort-glyph', `sw-table-sort-${sort}`) })] })) : onPress ? (_jsx("button", { className: "sw-table-action", onClick: onPress, tabIndex: tabStop, type: "button", children: children })) : (children) }));
39
40
  });
@@ -35,11 +35,11 @@ function variantRules() {
35
35
  * A neutral disabled button: the quiet fill with its words in the muted
36
36
  * color at 48 %, about #AEAEAE on the grey (a disabled control needs no
37
37
  * contrast, and the faded words say it cannot be pressed), at full
38
- * opacity so no accent shows through.
39
- * Forced colors draw it as any disabled button.
38
+ * opacity so no accent shows through. The same for one that is only
39
+ * \`aria-disabled\`. Forced colors draw it as any disabled button.
40
40
  */
41
41
  function disabledNeutralRules() {
42
- return `.sw-button-disabled-neutral:disabled:not(.sw-button-ghost) {
42
+ return `.sw-button-disabled-neutral:is(:disabled, [aria-disabled='true']):not(.sw-button-ghost) {
43
43
  background: ${quietFill};
44
44
  border-color: transparent;
45
45
  color: color-mix(in srgb, var(--sw-color-muted) 48%, transparent);
@@ -73,6 +73,14 @@ function pressedRules() {
73
73
  .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; }
74
74
  }`;
75
75
  }
76
+ /* The system greys a disabled button in forced colors, not one that is
77
+ only aria-disabled: grey it the same, over the neutral look's words. */
78
+ function ariaDisabledForcedRules() {
79
+ return `@media (forced-colors: active) {
80
+ .sw-button[aria-disabled='true'],
81
+ .sw-button-disabled-neutral[aria-disabled='true']:not(.sw-button-ghost) { border-color: GrayText; color: GrayText; }
82
+ }`;
83
+ }
76
84
  function sizeRules() {
77
85
  return buttonSizes
78
86
  .map((size) => {
@@ -114,14 +122,18 @@ export function cssButtonClasses() {
114
122
  outline-offset: var(--sw-focus-ring-offset);
115
123
  }
116
124
 
117
- .sw-button:disabled {
125
+ /* A button that is off but still pressed and focused (\`aria-disabled\`,
126
+ for a submit that, pressed while off, shows what to fix) looks as a
127
+ disabled one; forced colors grey it too, after the pressed rules. */
128
+ .sw-button:disabled,
129
+ .sw-button[aria-disabled='true'] {
118
130
  cursor: not-allowed;
119
131
  opacity: var(--sw-disabled-opacity);
120
132
  }
121
-
122
133
  ${variantRules()}
123
134
  ${disabledNeutralRules()}
124
135
  ${pressedRules()}
136
+ ${ariaDisabledForcedRules()}
125
137
  ${sizeRules()}
126
138
  ${groupRules()}
127
139
 
@@ -56,7 +56,7 @@ function reducedTransparencyRules() {
56
56
  .sw-steps-item,
57
57
  .sw-badge-neutral,
58
58
  .sw-segmented,
59
- .sw-table-sticky thead th,
59
+ .sw-table-sticky thead :is(th, td),
60
60
  .sw-table .sw-table-footer-sticky :is(th, td),
61
61
  .sw-denomination-strip .sw-denomination-label,
62
62
  .sw-denomination-strip thead .sw-denomination-corner:first-child,
@@ -3,7 +3,9 @@
3
3
  * gap apart. Each has a header line (marker, title, and on a done step its
4
4
  * summary and Edit) and, while open (current or error), a body indented to
5
5
  * the title. An open step's title is the title variant; a folded one's is
6
- * body size at label weight, muted ahead of the form or skipped.
6
+ * body size at label weight, muted ahead of the form or skipped. In an
7
+ * open list (`sw-steps-open`) every step is open: each panel takes the open
8
+ * padding and title, its header and body in one region.
7
9
  */
8
10
  export declare function cssStepsClasses(): string;
9
11
  export declare function stepsClassCatalog(): string[];
@@ -72,7 +72,9 @@ function forcedColorRules() {
72
72
  * gap apart. Each has a header line (marker, title, and on a done step its
73
73
  * summary and Edit) and, while open (current or error), a body indented to
74
74
  * the title. An open step's title is the title variant; a folded one's is
75
- * body size at label weight, muted ahead of the form or skipped.
75
+ * body size at label weight, muted ahead of the form or skipped. In an
76
+ * open list (`sw-steps-open`) every step is open: each panel takes the open
77
+ * padding and title, its header and body in one region.
76
78
  */
77
79
  export function cssStepsClasses() {
78
80
  return `.sw-steps {
@@ -99,10 +101,20 @@ export function cssStepsClasses() {
99
101
  }
100
102
 
101
103
  .sw-steps-item-current,
102
- .sw-steps-item-error {
104
+ .sw-steps-item-error,
105
+ .sw-steps-open > .sw-steps-item {
103
106
  --sw-steps-padding: var(--sw-space-4);
104
107
  }
105
108
 
109
+ /* An open list's step is a region holding its header and body, spaced as
110
+ the item spaces them in a stepped list. */
111
+ .sw-steps-region {
112
+ display: flex;
113
+ flex-direction: column;
114
+ gap: var(--sw-space-4);
115
+ min-width: 0;
116
+ }
117
+
106
118
  /* The marker sits at the top of the header, so the connector always starts
107
119
  under it; each line of the header is centred on the marker's height. */
108
120
  .sw-steps-header {
@@ -141,7 +153,8 @@ export function cssStepsClasses() {
141
153
  }
142
154
 
143
155
  .sw-steps-item-current .sw-steps-title,
144
- .sw-steps-item-error .sw-steps-title {
156
+ .sw-steps-item-error .sw-steps-title,
157
+ .sw-steps-open > .sw-steps-item .sw-steps-title {
145
158
  font-size: ${title.fontSize}px;
146
159
  letter-spacing: ${title.letterSpacing}px;
147
160
  line-height: ${title.lineHeight}px;
@@ -186,6 +199,7 @@ ${forcedColorRules()}`;
186
199
  export function stepsClassCatalog() {
187
200
  return [
188
201
  'sw-steps',
202
+ 'sw-steps-open',
189
203
  'sw-steps-item',
190
204
  'sw-steps-item-upcoming',
191
205
  'sw-steps-item-current',
@@ -199,5 +213,6 @@ export function stepsClassCatalog() {
199
213
  'sw-steps-summary',
200
214
  'sw-steps-edit',
201
215
  'sw-steps-body',
216
+ 'sw-steps-region',
202
217
  ];
203
218
  }
@@ -9,7 +9,7 @@ import { zIndex } from './stacking.js';
9
9
  * A sticky footer pins its cells to the bottom edge on the header's glass
10
10
  * (solid surface under reduced transparency, the system canvas in forced
11
11
  * colours, as the pinned header cells), on the `stickyFooter` layer: over
12
- * the cells and the pinned header cells, under the ActionBar, which covers
12
+ * the cells and the pinned head group, under the ActionBar, which covers
13
13
  * it, so a page does not pair the two. Where it pins (the page or the
14
14
  * table's own region) is `css-table-page-pin.ts`.
15
15
  */
@@ -41,7 +41,7 @@ export function cssTableFooterClasses() {
41
41
  @media (forced-colors: active) {
42
42
  .sw-table tfoot > tr:first-child > :is(th, td)::before { border-color: CanvasText; }
43
43
  .sw-table .sw-table-footer-sticky :is(th, td),
44
- .sw-table-sticky thead th { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
44
+ .sw-table-sticky thead :is(th, td) { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
45
45
  }`;
46
46
  }
47
47
  export function tableFooterClassCatalog() {
@@ -54,9 +54,10 @@ export function cssTableLedgerClasses() {
54
54
  border-inline-start: 1px solid var(--sw-color-border);
55
55
  }
56
56
 
57
- /* A sticky header cell is positioned, so the next cell's fill covers a
58
- collapsed border; its rule is a border on its own ::after instead, so
59
- forced colours keep it and the logical inset mirrors it. */
57
+ /* A pinned head cell (in a sticky header group) is positioned, so the
58
+ next cell's fill covers a collapsed border; its rule is a border on its
59
+ own ::after instead, so forced colours keep it and the logical inset
60
+ mirrors it. */
60
61
  .sw-table-sticky.sw-table-column-rules thead :is(th, td) + :is(th, td)::after {
61
62
  border-inline-start: 1px solid var(--sw-color-border);
62
63
  content: '';
@@ -0,0 +1,2 @@
1
+ export declare function cssTableStickyHeaderClasses(): string;
2
+ export declare function tableStickyHeaderClassCatalog(): string[];
@@ -0,0 +1,52 @@
1
+ import { zIndex } from './stacking.js';
2
+ /*
3
+ * A sticky header pins the table's whole head group, not cell by cell: the
4
+ * `thead` itself is sticky at the top edge, so every row in it (the column
5
+ * headings and any row under them, such as a running total) and every cell
6
+ * in those rows, `th` and `td` alike, stays in view together, each row at
7
+ * its own offset under the one above. A cell-by-cell pin would leave a
8
+ * second row's `td` cells to scroll away and stack every row's `th` at the
9
+ * same edge. Where the group pins (the page or the table's own region) is
10
+ * `css-table-page-pin.ts`.
11
+ *
12
+ * Each pinned cell is on the glass (solid surface under Reduce
13
+ * Transparency, the system canvas in forced colours), so the rows that
14
+ * scroll under the group never show through, and positioned, so its rules
15
+ * can draw on it. A collapsed border stays where the cell was as the cell
16
+ * moves and leaves a gap the rows under it show through, so a pinned
17
+ * cell has none at its bottom and draws its row rule as a border on its
18
+ * own ::before (forced colours keep it), as its column rule is on its
19
+ * ::after (`css-table-ledger.ts`). The group is on the `stickyCell`
20
+ * layer, over the body cells and under a pinned footer.
21
+ */
22
+ export function cssTableStickyHeaderClasses() {
23
+ return `.sw-table-sticky thead {
24
+ position: sticky;
25
+ top: 0;
26
+ ${zIndex('stickyCell')}
27
+ }
28
+
29
+ .sw-table-sticky thead :is(th, td) {
30
+ background: var(--sw-glass-fill);
31
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
32
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
33
+ border-bottom: 0;
34
+ position: relative;
35
+ }
36
+
37
+ .sw-table-sticky thead :is(th, td)::before {
38
+ border-bottom: 1px solid var(--sw-color-border);
39
+ content: '';
40
+ inset-block-end: 0;
41
+ inset-inline: 0;
42
+ pointer-events: none;
43
+ position: absolute;
44
+ }
45
+
46
+ @media (forced-colors: active) {
47
+ .sw-table-sticky thead :is(th, td)::before { border-color: CanvasText; }
48
+ }`;
49
+ }
50
+ export function tableStickyHeaderClassCatalog() {
51
+ return ['sw-table-sticky'];
52
+ }
@@ -5,8 +5,8 @@ import { cssTableLedgerClasses, tableLedgerClassCatalog, } from './css-table-led
5
5
  import { cssTableFooterClasses, tableFooterClassCatalog, } from './css-table-footer.js';
6
6
  import { cssTablePagePinClasses, tablePagePinClassCatalog, } from './css-table-page-pin.js';
7
7
  import { cssTablePressClasses, tablePressClassCatalog, } from './css-table-press.js';
8
+ import { cssTableStickyHeaderClasses, tableStickyHeaderClassCatalog, } from './css-table-sticky-header.js';
8
9
  import { cssTableSortClasses, tableSortClassCatalog, } from './css-table-sort.js';
9
- import { zIndex } from './stacking.js';
10
10
  const caption = typographyVariants.caption;
11
11
  /*
12
12
  * A selected row is marked by an accent bar at its inline start and nothing
@@ -75,14 +75,7 @@ export function cssTableClasses() {
75
75
  overflow: hidden;
76
76
  }
77
77
 
78
- .sw-table-sticky thead th {
79
- background: var(--sw-glass-fill);
80
- backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
81
- -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
82
- position: sticky;
83
- top: 0;
84
- ${zIndex('stickyCell')}
85
- }
78
+ ${cssTableStickyHeaderClasses()}
86
79
 
87
80
  .sw-table-compact th,
88
81
  .sw-table-compact td {
@@ -110,7 +103,7 @@ export function tableClassCatalog() {
110
103
  'sw-table-end',
111
104
  'sw-table-numeric',
112
105
  'sw-table-clip',
113
- 'sw-table-sticky',
106
+ ...tableStickyHeaderClassCatalog(),
114
107
  'sw-table-compact',
115
108
  'sw-table-row-selected',
116
109
  ...tableLayoutClassCatalog(),
@@ -2,8 +2,8 @@
2
2
  * The one stacking order of the generated stylesheet. Every generated
3
3
  * `z-index` reads a layer from here, lowest first:
4
4
  *
5
- * 1. `stickyCell`: a sticky table header cell or row label over the cells
6
- * that scroll under it.
5
+ * 1. `stickyCell`: a sticky table head group (every row in a sticky
6
+ * `thead`) or row label over the cells that scroll under it.
7
7
  * 2. `stickyFooter`: a sticky table footer (a pinned total row) over the
8
8
  * cells and pinned header cells that scroll under it, and under the
9
9
  * ActionBar, which stays the page's last word at the bottom edge.
package/dist/styles.css CHANGED
@@ -914,18 +914,21 @@ body {
914
914
  outline-offset: var(--sw-focus-ring-offset);
915
915
  }
916
916
 
917
- .sw-button:disabled {
917
+ /* A button that is off but still pressed and focused (`aria-disabled`,
918
+ for a submit that, pressed while off, shows what to fix) looks as a
919
+ disabled one; forced colors grey it too, after the pressed rules. */
920
+ .sw-button:disabled,
921
+ .sw-button[aria-disabled='true'] {
918
922
  cursor: not-allowed;
919
923
  opacity: var(--sw-disabled-opacity);
920
924
  }
921
-
922
925
  .sw-button-primary { background: var(--sw-color-accent); border-color: transparent; color: var(--sw-color-onAccent); }
923
926
  .sw-button-secondary { background: var(--sw-color-secondary); border-color: var(--sw-button-secondary-border); color: var(--sw-color-onSecondary); }
924
927
  .sw-button-tertiary { background: var(--sw-color-tertiary); border-color: transparent; color: var(--sw-color-onTertiary); }
925
928
  .sw-button-ghost { background: transparent; border-color: transparent; color: var(--sw-color-accent); }
926
929
  .sw-button-danger { background: var(--sw-color-danger); border-color: transparent; color: var(--sw-color-onDanger); }
927
930
  .sw-button-neutral { background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent); border-color: transparent; color: var(--sw-color-text); }
928
- .sw-button-disabled-neutral:disabled:not(.sw-button-ghost) {
931
+ .sw-button-disabled-neutral:is(:disabled, [aria-disabled='true']):not(.sw-button-ghost) {
929
932
  background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
930
933
  border-color: transparent;
931
934
  color: color-mix(in srgb, var(--sw-color-muted) 48%, transparent);
@@ -937,6 +940,10 @@ body {
937
940
  .sw-button[aria-pressed='true'] { border-color: Highlight; position: relative; }
938
941
  .sw-button[aria-pressed='true']::after { border: 2px solid Highlight; border-radius: inherit; content: ''; inset: calc(-1px - 4px); pointer-events: none; position: absolute; }
939
942
  }
943
+ @media (forced-colors: active) {
944
+ .sw-button[aria-disabled='true'],
945
+ .sw-button-disabled-neutral[aria-disabled='true']:not(.sw-button-ghost) { border-color: GrayText; color: GrayText; }
946
+ }
940
947
  .sw-button-xs { min-height: 28px; padding-inline: 10px; }
941
948
  .sw-button-sm { min-height: 32px; padding-inline: 12px; }
942
949
  .sw-button-md { min-height: 44px; padding-inline: 16px; }
@@ -1600,15 +1607,33 @@ body {
1600
1607
  overflow: hidden;
1601
1608
  }
1602
1609
 
1603
- .sw-table-sticky thead th {
1604
- background: var(--sw-glass-fill);
1605
- backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
1606
- -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
1610
+ .sw-table-sticky thead {
1607
1611
  position: sticky;
1608
1612
  top: 0;
1609
1613
  z-index: 1;
1610
1614
  }
1611
1615
 
1616
+ .sw-table-sticky thead :is(th, td) {
1617
+ background: var(--sw-glass-fill);
1618
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
1619
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
1620
+ border-bottom: 0;
1621
+ position: relative;
1622
+ }
1623
+
1624
+ .sw-table-sticky thead :is(th, td)::before {
1625
+ border-bottom: 1px solid var(--sw-color-border);
1626
+ content: '';
1627
+ inset-block-end: 0;
1628
+ inset-inline: 0;
1629
+ pointer-events: none;
1630
+ position: absolute;
1631
+ }
1632
+
1633
+ @media (forced-colors: active) {
1634
+ .sw-table-sticky thead :is(th, td)::before { border-color: CanvasText; }
1635
+ }
1636
+
1612
1637
  .sw-table-compact th,
1613
1638
  .sw-table-compact td {
1614
1639
  padding: var(--sw-space-1) var(--sw-space-2);
@@ -1749,9 +1774,10 @@ body {
1749
1774
  border-inline-start: 1px solid var(--sw-color-border);
1750
1775
  }
1751
1776
 
1752
- /* A sticky header cell is positioned, so the next cell's fill covers a
1753
- collapsed border; its rule is a border on its own ::after instead, so
1754
- forced colours keep it and the logical inset mirrors it. */
1777
+ /* A pinned head cell (in a sticky header group) is positioned, so the
1778
+ next cell's fill covers a collapsed border; its rule is a border on its
1779
+ own ::after instead, so forced colours keep it and the logical inset
1780
+ mirrors it. */
1755
1781
  .sw-table-sticky.sw-table-column-rules thead :is(th, td) + :is(th, td)::after {
1756
1782
  border-inline-start: 1px solid var(--sw-color-border);
1757
1783
  content: '';
@@ -1834,7 +1860,7 @@ body {
1834
1860
  @media (forced-colors: active) {
1835
1861
  .sw-table tfoot > tr:first-child > :is(th, td)::before { border-color: CanvasText; }
1836
1862
  .sw-table .sw-table-footer-sticky :is(th, td),
1837
- .sw-table-sticky thead th { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
1863
+ .sw-table-sticky thead :is(th, td) { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
1838
1864
  }
1839
1865
 
1840
1866
  .sw-table-wrap.sw-scroll-fits:has(> .sw-table > .sw-table-footer-sticky),
@@ -5240,10 +5266,20 @@ body {
5240
5266
  }
5241
5267
 
5242
5268
  .sw-steps-item-current,
5243
- .sw-steps-item-error {
5269
+ .sw-steps-item-error,
5270
+ .sw-steps-open > .sw-steps-item {
5244
5271
  --sw-steps-padding: var(--sw-space-4);
5245
5272
  }
5246
5273
 
5274
+ /* An open list's step is a region holding its header and body, spaced as
5275
+ the item spaces them in a stepped list. */
5276
+ .sw-steps-region {
5277
+ display: flex;
5278
+ flex-direction: column;
5279
+ gap: var(--sw-space-4);
5280
+ min-width: 0;
5281
+ }
5282
+
5247
5283
  /* The marker sits at the top of the header, so the connector always starts
5248
5284
  under it; each line of the header is centred on the marker's height. */
5249
5285
  .sw-steps-header {
@@ -5282,7 +5318,8 @@ body {
5282
5318
  }
5283
5319
 
5284
5320
  .sw-steps-item-current .sw-steps-title,
5285
- .sw-steps-item-error .sw-steps-title {
5321
+ .sw-steps-item-error .sw-steps-title,
5322
+ .sw-steps-open > .sw-steps-item .sw-steps-title {
5286
5323
  font-size: 20px;
5287
5324
  letter-spacing: -0.3px;
5288
5325
  line-height: 28px;
@@ -5671,7 +5708,7 @@ body {
5671
5708
  .sw-steps-item,
5672
5709
  .sw-badge-neutral,
5673
5710
  .sw-segmented,
5674
- .sw-table-sticky thead th,
5711
+ .sw-table-sticky thead :is(th, td),
5675
5712
  .sw-table .sw-table-footer-sticky :is(th, td),
5676
5713
  .sw-denomination-strip .sw-denomination-label,
5677
5714
  .sw-denomination-strip thead .sw-denomination-corner:first-child,
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `tabIndex` every Scalewing press control and link renders by default.
3
+ *
4
+ * Safari leaves a `<button>`, a link, a checkbox and a radio out of the Tab
5
+ * order unless the element carries an explicit `tabindex` (its "Press Tab to
6
+ * highlight each item" setting is off by default), so a keyboard user there
7
+ * Tabs past them to the address bar. An explicit `0` puts them in the same
8
+ * order every other browser already gives them. A control that is
9
+ * deliberately not a tab stop (a Stepper's − and +, a roving item) keeps its
10
+ * own `-1`, and a consumer's `tabIndex` always wins.
11
+ */
12
+ export declare const tabStop = 0;
13
+ /**
14
+ * Which radio of a group carries the explicit tab stop: the checked one, or
15
+ * the first enabled one while none is. Browsers move between a group's
16
+ * radios with the arrow keys and Tab only into the group, so the others keep
17
+ * no `tabindex` and each browser's own group rule stands.
18
+ */
19
+ export declare function radioTabStopValue(options: readonly {
20
+ value: string;
21
+ disabled?: boolean;
22
+ }[], value: string | undefined): string | undefined;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The `tabIndex` every Scalewing press control and link renders by default.
3
+ *
4
+ * Safari leaves a `<button>`, a link, a checkbox and a radio out of the Tab
5
+ * order unless the element carries an explicit `tabindex` (its "Press Tab to
6
+ * highlight each item" setting is off by default), so a keyboard user there
7
+ * Tabs past them to the address bar. An explicit `0` puts them in the same
8
+ * order every other browser already gives them. A control that is
9
+ * deliberately not a tab stop (a Stepper's − and +, a roving item) keeps its
10
+ * own `-1`, and a consumer's `tabIndex` always wins.
11
+ */
12
+ export const tabStop = 0;
13
+ /**
14
+ * Which radio of a group carries the explicit tab stop: the checked one, or
15
+ * the first enabled one while none is. Browsers move between a group's
16
+ * radios with the arrow keys and Tab only into the group, so the others keep
17
+ * no `tabindex` and each browser's own group rule stands.
18
+ */
19
+ export function radioTabStopValue(options, value) {
20
+ const enabled = options.filter((option) => !option.disabled);
21
+ return (enabled.find((option) => option.value === value) ?? enabled[0])
22
+ ?.value;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",