@scalewing/react 1.35.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 1.35.1
4
15
 
5
16
  ### Patch 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.
@@ -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
 
@@ -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
  }
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; }
@@ -5259,10 +5266,20 @@ body {
5259
5266
  }
5260
5267
 
5261
5268
  .sw-steps-item-current,
5262
- .sw-steps-item-error {
5269
+ .sw-steps-item-error,
5270
+ .sw-steps-open > .sw-steps-item {
5263
5271
  --sw-steps-padding: var(--sw-space-4);
5264
5272
  }
5265
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
+
5266
5283
  /* The marker sits at the top of the header, so the connector always starts
5267
5284
  under it; each line of the header is centred on the marker's height. */
5268
5285
  .sw-steps-header {
@@ -5301,7 +5318,8 @@ body {
5301
5318
  }
5302
5319
 
5303
5320
  .sw-steps-item-current .sw-steps-title,
5304
- .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 {
5305
5323
  font-size: 20px;
5306
5324
  letter-spacing: -0.3px;
5307
5325
  line-height: 28px;
@@ -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.1",
3
+ "version": "1.36.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",