@scalewing/react 1.58.0 → 1.59.1

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,17 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.59.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 3f78164: An xs `Field`'s own text input (`input` of a typed kind, or `textarea`) now draws at the xs size: the document canvas's `[data-theme]` typed-input rule outranked `.sw-field-xs`, so the control kept the md min-height (44 px) and the inherited font size beside a 28 px xs `Select`. The xs rule now matches it under `[data-theme]` (caption type, xs min-height and padding), and on a coarse pointer the control is the 44 px touch target. Adorned and search inputs keep their frames' rules. No API change.
8
+
9
+ ## 1.59.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 9ca5393: Muted words on a row's hover fill now keep 4.5:1: while a pressable `FixtureRow`, `ListRow` or `TableRow` shows the quiet fill (hovered, a ListRow or TableRow pressed, a TableRow's action focused), the parts on it read `--sw-color-muted` as the text colour, including the meta, details, a muted `TableCell`, the chevron and a consumer's `Text color="muted"` (muted was 4.14:1 on the fill in `ink` light, 3.47:1 in `harvest` dark). No prop, nothing changes at rest, toned words keep their tone, forced colours unchanged. `LiveRegion` (new, `LiveRegionProps`, `LiveRegionPoliteness`, `liveRegionPolitenesses`): a status line whose changing words are announced, an always-mounted region that starts empty (`role="status"` polite by default, or `role="alert"` with `politeness="assertive"`, `aria-atomic`), its string `children` swapped in as a new node on each change or `announceKey` change (the first words once it has been mounted 100 ms), drawn inline in `Text`'s look (`variant`, `color`) or `visuallyHidden`; an unknown politeness throws a `RangeError`, a look with `visuallyHidden` a `TypeError`. `AppShell` `skipLink` (opt-in, `AppShellSkipLink`: `{ label }`): a skip link first in the shell, hidden until focused, then fixed at the window's top start; a press moves focus to `main`, which gets an id and `tabindex="-1"` only with it, without changing the address; a blank label throws a `RangeError`. `Select` `disabled` (opt-in): a native disabled trigger, out of the Tab order and faded (`GrayText` in forced colours), its list never opens and `onChange` never runs. New class `sw-app-shell-skip-link` and stacking layer `skipLink` (7). SiteHeader's skip link now shares its look's rules (`css/skip-link.ts`); its output is unchanged apart from rule order.
14
+
3
15
  ## 1.58.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  DateToggleCalendar,
53
53
  ListGroup,
54
54
  ListRow,
55
+ LiveRegion,
55
56
  MarkdownView,
56
57
  DenominationGrid,
57
58
  FilterChips,
@@ -319,6 +320,8 @@ On a coarse pointer every `Select` size's trigger and each option in its open li
319
320
  />
320
321
  ```
321
322
 
323
+ `Select` `disabled` (opt-in, default `false`) turns the control off, as `Button`'s `disabled` does: the trigger is a native disabled button, so it leaves the Tab order, is read as dimmed and fades to the disabled opacity (its frame, words and chevron `GrayText` in forced colours); its list cannot open, an open list closes when it is turned off, and `onChange` is never called. Say why it is off in the page's words, such as a polite `LiveRegion` beside it ("Locked while the census uploads").
324
+
322
325
  `Select` `width` is `'content'` (the default: the field is as wide as its longest option, as above) or `'full'`: the field and its trigger fill the container's inline size in a `Stack`, a `Grid` cell or any narrow column, such as a filter above full-width cards on a phone. In an `Inline` row it takes the space its siblings leave, so a `Button` beside it keeps its label on one line. The value still takes the free space and ellipsizes past it, the chevron stays at the inline end (the left, right to left), and the open list is exactly the trigger's width, so it never runs past the screen's edge; a long option wraps inside it, even a single long word.
323
326
 
324
327
  ```tsx
@@ -755,6 +758,14 @@ The layout follows the window, with generated media queries only. From 75rem (12
755
758
  </AppShell>
756
759
  ```
757
760
 
761
+ `AppShell` `skipLink` (opt-in, `AppShellSkipLink`: `{ label }`) puts a "skip to main content" link first in the shell, before the top bar and the sidebar, in the consumer's words (`label`, such as "Skip to main content" or "Saltar al contenido principal"), so a keyboard user passes every destination on each page (WCAG 2.4.1). It is visually hidden until it has focus, then drawn as `SiteHeader`'s skip link (a pill on the surface in the text colour with the accent ring, the control height) fixed at the window's top start, over the sidebar, the compact bar and a page bar, however far the page has scrolled. The shell gives its `main` an id and `tabindex="-1"` only when the link is there, so no `href` is needed: a press (Enter included) moves focus to `main` without following the fragment, so a hash router's address and the history are unchanged, and the next Tab reaches the page's first control. `main` shows no ring while it holds that focus (it is not a control). Forced colours draw the link as a link on the system canvas. A blank `label` throws a `RangeError`.
762
+
763
+ ```tsx
764
+ <AppShell skipLink={{ label: t('skipToMain') }} {...shellProps}>
765
+ {page}
766
+ </AppShell>
767
+ ```
768
+
758
769
  ```tsx
759
770
  <Stack gap={3}>
760
771
  <Box paddingX={4}>
@@ -778,7 +789,7 @@ The layout follows the window, with generated media queries only. From 75rem (12
778
789
 
779
790
  `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. While the table is wider than the region, each inline edge with columns scrolled out past it draws a soft inset shade (`sw-scroll-more-start`, `sw-scroll-more-end`, mirrored right to left), so a phone, whose scrollbars stay hidden, still shows that the table goes on; a table that fits draws none, and in forced colors the system scrollbar is the cue. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
780
791
 
781
- `TableRow pressable` (opt-in) makes the whole row the pointer target of its one action, such as a search result that is picked: a plain primary press anywhere on it calls `click()` on the row's first link or button, so the action's own handler runs. Left alone: a modifier key or non-primary button, a press already handled, a press on another control, a press that ends a text selection (text stays selectable), and a disabled or missing action. Keep one control in the row: a control placed before the action (an icon button, say) would take the row's press. The row is not a control (no role, no tab stop), so keyboard and screen reader users use the action itself. It takes the pointer cursor and the quiet fill while hovered, pressed (a brief flash on touch) or while its action has keyboard focus (a `Highlight` outline in forced colours), and underlines its action on hover (`sw-table-row-press`). A consumer `onClick` runs once per press. Each click of a double click presses the action, as a native button's would, so keep it safe to repeat. `TableCell onPress` (a body cell or a row header in `TableBody`) makes the cell's children that action: a button in the cell's own text style at weight 600, underlined on hover and focus, with the accent focus ring (`sw-table-action`); a non-function value or one on a sortable header throws a `TypeError`.
792
+ `TableRow pressable` (opt-in) makes the whole row the pointer target of its one action, such as a search result that is picked: a plain primary press anywhere on it calls `click()` on the row's first link or button, so the action's own handler runs. Left alone: a modifier key or non-primary button, a press already handled, a press on another control, a press that ends a text selection (text stays selectable), and a disabled or missing action. Keep one control in the row: a control placed before the action (an icon button, say) would take the row's press. The row is not a control (no role, no tab stop), so keyboard and screen reader users use the action itself. It takes the pointer cursor and the quiet fill while hovered, pressed (a brief flash on touch) or while its action has keyboard focus (a `Highlight` outline in forced colours), and underlines its action on hover (`sw-table-row-press`). On that fill a muted cell and muted words in the cells take the text colour (see "Muted words on a row's quiet fill" under `FixtureRow`). A consumer `onClick` runs once per press. Each click of a double click presses the action, as a native button's would, so keep it safe to repeat. `TableCell onPress` (a body cell or a row header in `TableBody`) makes the cell's children that action: a button in the cell's own text style at weight 600, underlined on hover and focus, with the accent focus ring (`sw-table-action`); a non-function value or one on a sortable header throws a `TypeError`.
782
793
 
783
794
  `TableSelectCell` is a table's select column: a cell (`as="td"`, the default, in a body row, or `as="th"` in a `TableHeader` row, which gets `scope="col"`) holding one `Checkbox` with `labelVisuallyHidden`. Its props are `label` (the box's name, not drawn: "Select all", "Select Mallard"), `checked`, `indeterminate`, `onCheckedChange` and `disabled`. The column is as wide as the box (`sw-table-select`), and 44 px wide on a coarse pointer, where the box is the 44 × 44 px target; a fixed table sizes columns from its header row, so put one in the header too. Mark a chosen row with `TableRow selected`; a press on the box is left alone by `TableRow pressable`. The consumer keeps the chosen set and works out "Select all"'s `checked` and `indeterminate`. A blank label or an unknown `as` throws a `RangeError`.
784
795
 
@@ -935,6 +946,23 @@ The consumer needs no PDF.js dependency of its own. With another bundler, build
935
946
  <Skeleton label="Loading rangers" variant="rows" lines={4} />
936
947
  ```
937
948
 
949
+ `LiveRegion` is a status line whose words change and are read out. It renders a region that is always mounted and starts empty (`role="status"` with `aria-live="polite"`, or `role="alert"` with `aria-live="assertive"`, both `aria-atomic="true"`), and swaps its words in as a new keyed child each time `children` (a string, the words in the consumer's language) or `announceKey` changes, so new words are read, and the same words again when only the key changes (a second "Copied"). Empty `children` clears it and announces nothing. Screen readers miss words added the moment a region appears, so its first words wait until it has been mounted for 100 ms; later changes show at once (a re-render with the same words and key changes nothing, so nothing is re-read). `politeness` (`liveRegionPolitenesses`) is `'polite'` (default), read when the reader is idle, or `'assertive'`, read at once: keep that for an error the person must hear before going on. By default the words are drawn inline in a `span` with `Text`'s look (`variant`, default `body`, and `color`, default `text`); `visuallyHidden` keeps them for assistive technology only (`sw-sr-only`), for a change the page already shows another way, such as a button's label turning to "Copied". `id` names the region, for example for a control's `aria-describedby`. An unknown politeness throws a `RangeError`; `variant` or `color` with `visuallyHidden` throws a `TypeError`.
950
+
951
+ Use `LiveRegion` rather than your own `role="status"` or `aria-live` whenever words change on a region already in the page: a save line ("Saving…", "Saved"), a count after a filter, a copy confirmation, a step's result. A `role="status"` element mounted with its words already inside is often not read, text replaced in place is read inconsistently across screen readers, and the same words twice are not read again; `LiveRegion` handles all three. Put it on the status line itself, never on a heading or a whole card, and keep one per message. Components that own their announcements keep them: `Toast` (`status` / `alert`), `Notice`, `Skeleton`, `Field`'s and `Select`'s error region, `Pagination`'s range, `ActionBar`'s and `Dialog`'s status lines, `ScheduleGrid`'s steps; do not wrap them in another region.
952
+
953
+ ```tsx
954
+ <LiveRegion color="muted" variant="caption">
955
+ {saving ? 'Saving…' : 'Saved'}
956
+ </LiveRegion>
957
+
958
+ <Button onPress={copy}>{copied ? 'Copied' : 'Copy code'}</Button>
959
+ <LiveRegion announceKey={copies} visuallyHidden>
960
+ {copies > 0 ? 'Code copied' : ''}
961
+ </LiveRegion>
962
+
963
+ <LiveRegion color="danger" politeness="assertive">{error}</LiveRegion>
964
+ ```
965
+
938
966
  `EmptyState` is where a list or a page has nothing yet: a centred panel on the surface with a dashed hairline edge (the md radius) that fills its column, so it sits in a page column or inside a `Card`. `title` (required) says what is empty ("No teams in Spring 2027 yet"), a heading at `titleAs` (`emptyStateTitleElements`: `"h2"` by default in a page, `"h3"` inside a card under its own heading) in title type. `description` is a muted line under it, usually what to do next. `icon` is a decorative glyph (Lucide in your app) in a quiet subtle-fill square over the title, hidden from assistive technology. `actions` are the ways out, such as a primary and a neutral `Button`, in a centred row that wraps (a 44 px target on a touch screen). The panel is a plain `div`, not a landmark or a live region: if it replaces a list after a person's action, say so yourself. An empty slot (nothing, an empty Fragment or array, or blank words) draws no box. Inside a bordered `Card` (the default glass or `outlined`) the dashed edge sits just inside the card's own hairline and reads as a double edge: where the empty panel is the card's whole content, use it in place of the card, or in an `elevated` or `filled` Card, which draw no hairline. Forced colours keep the dashed edge. A blank `title` or `description` or an unknown `titleAs` throws a `RangeError`.
939
967
 
940
968
  ```tsx
@@ -1265,6 +1293,8 @@ const { headings, omissions } = inspectMarkdown(page.body, {
1265
1293
  </FixtureGroup>
1266
1294
  ```
1267
1295
 
1296
+ Muted words on a row's quiet fill: a pressable `FixtureRow` (and a detail under one with an `action` or `select`, which the fill spans), a pressable `ListRow` (hovered or pressed) and a `TableRow pressable` (hovered, pressed or with its action focused) show the quiet grey fill, on which the muted colour falls under 4.5:1 in most palettes (4.14:1 in `ink` light, 3.47:1 in `harvest` dark). While the fill shows, the parts on it read the muted token as the text colour, which keeps 4.5:1 there in every palette and scheme: the details, the meta, a muted `TableCell`, the chevron, and a consumer's `Text color="muted"` inside the row, with no prop and no change at rest. Toned words (a value's or a cell's success, danger or accent, a danger title, a Badge) keep their tone. Forced colours replace both colours and outline the hovered row in `Highlight`, as before.
1297
+
1268
1298
  `FixtureRow` `flag` (opt-in, `FixtureFlag`: `{ label, tone? }`) marks an issue at the row's start, such as a clash: a round mark a space-4 across with a "!", in the tone's tint and ink (`warning`, amber, the default, for something that needs the person; `danger`, red), its edge keeping 3:1 on the surface in every palette and the text colour in forced colors. The mark is `aria-hidden` with `label` as its `title` tooltip, and `label` is read first in the row from a visually hidden span ("Can't-play clash Heron Marsh 8:00 Otter Pool"); with `aria-label` it is put before your words with a comma ("Can't-play clash, Heron Marsh vs Otter Pool, 8:00"), so do not repeat it there. From `md` the mark sits on the first line at the row's start, the home side kept clear of it; below `md` it leads the second line, before the details, so it never squeezes the home name. A row with nothing on that line below `md` (no `status`, and no `meta` or one hidden below `md` by `metaHideBelow`) keeps the mark inline on its first line instead, as from `md` (class `sw-fixture-row-flag-inline`), rather than gaining a line that holds only the flag. A blank `label` or an unknown `tone` throws a `RangeError`. `action` (opt-in) is a control after the row, such as a small neutral `Button` ("Move", named in your words: `aria-label="Move Heron Marsh vs Otter Pool"`): it is the row's sibling in its list item, outside a link or button row's press area and after it in the Tab order, a press on it never opens the row, and it stays centred on the row's height in the run's last column at every width (from `md` the row's one line, below `md` both lines), so every row's action lines up. Where `:has` is supported the hover fill spans the row and its action. An empty slot draws nothing. The run has a column for it at the end, and one for `select` at the start (`sw-fixture-list` is `auto minmax(0, 1fr) auto minmax(0, 1fr) auto auto auto`, six below `md`); a row spans every column but those two (`grid-column: 2 / -2`), and an `auto` column no row uses takes no space. New classes `sw-fixture-row-flagged`, `sw-fixture-flag`, `sw-fixture-flag-mark`, `sw-fixture-flag-warning`, `sw-fixture-flag-danger`, `sw-fixture-item-action` and `sw-fixture-action`.
1269
1299
 
1270
1300
  ```tsx
@@ -1,5 +1,5 @@
1
1
  import { type AppShellProps } from './app-shell/types.js';
2
- export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps, } from './app-shell/types.js';
2
+ export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps, AppShellSkipLink, } from './app-shell/types.js';
3
3
  /**
4
4
  * A web workspace's frame: a pinned side navigation of grouped
5
5
  * destinations beside the page (`main`), which scrolls with the document.
@@ -7,7 +7,8 @@ export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps,
7
7
  * captions); from `lg` to 75rem it is a rail of glyphs with tooltips; below
8
8
  * `lg` a sticky top bar's menu button opens the same navigation in a modal
9
9
  * drawer from the inline start, its button optionally carrying a count.
10
- * `pageBar` is a sticky band at the top of `main` on every layout. Invalid
11
- * groups, items, counts or words throw a `RangeError`.
10
+ * `pageBar` is a sticky band at the top of `main` on every layout, and
11
+ * `skipLink` a first link that moves focus to `main`. Invalid groups,
12
+ * items, counts or words throw a `RangeError`.
12
13
  */
13
- export declare function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, }: AppShellProps): import("react").JSX.Element;
14
+ export declare function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, skipLink, }: AppShellProps): import("react").JSX.Element;
@@ -1,12 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
4
  import { breakpointQuery } from '../css/breakpoints.js';
5
5
  import { appShellWideQuery } from '../css/css-app-shell.js';
6
6
  import { appShellBarHeightVariable } from '../css/css-app-shell-page-bar.js';
7
7
  import { AppShellBar } from './app-shell/AppShellBar.js';
8
8
  import { AppShellDrawer } from './app-shell/AppShellDrawer.js';
9
9
  import { AppShellSidebar } from './app-shell/AppShellSidebar.js';
10
+ import { SkipToMain } from './app-shell/SkipToMain.js';
10
11
  import { assertAppShellProps } from './app-shell/validate.js';
11
12
  import { useElementHeight } from './use-element-height.js';
12
13
  import { useMediaQuery } from './use-media-query.js';
@@ -18,10 +19,11 @@ const fromLg = breakpointQuery('from', 'lg');
18
19
  * captions); from `lg` to 75rem it is a rail of glyphs with tooltips; below
19
20
  * `lg` a sticky top bar's menu button opens the same navigation in a modal
20
21
  * drawer from the inline start, its button optionally carrying a count.
21
- * `pageBar` is a sticky band at the top of `main` on every layout. Invalid
22
- * groups, items, counts or words throw a `RangeError`.
22
+ * `pageBar` is a sticky band at the top of `main` on every layout, and
23
+ * `skipLink` a first link that moves focus to `main`. Invalid groups,
24
+ * items, counts or words throw a `RangeError`.
23
25
  */
24
- export function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, }) {
26
+ export function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, skipLink, }) {
25
27
  assertAppShellProps({
26
28
  groups,
27
29
  labels,
@@ -29,10 +31,13 @@ export function AppShell({ barEnd, barTitle, children, footer, groups, header, l
29
31
  menuCountLabel,
30
32
  menuCountTone,
31
33
  navLabel,
34
+ skipLink,
32
35
  });
33
36
  const [menuOpen, setMenuOpen] = useState(false);
34
37
  const menuRef = useRef(null);
35
38
  const barRef = useRef(null);
39
+ const mainRef = useRef(null);
40
+ const mainId = useId();
36
41
  // Set by a dismissal (Escape, a press outside, the close button): only
37
42
  // that hands focus back to the menu button.
38
43
  const refocusRef = useRef(false);
@@ -58,7 +63,7 @@ export function AppShell({ barEnd, barTitle, children, footer, groups, header, l
58
63
  refocusRef.current = false;
59
64
  menuRef.current?.focus();
60
65
  }, [menuOpen]);
61
- return (_jsxs("div", { className: "sw-app-shell", style: shellStyle, children: [_jsx(AppShellBar, { barEnd: barEnd, barRef: barRef, barTitle: barTitle, menuCount: menuCount, menuCountLabel: menuCountLabel, menuCountTone: menuCountTone, menuIcon: menuIcon, menuOpen: menuOpen, menuRef: menuRef, onOpenMenu: () => setMenuOpen(true), openMenuLabel: labels.openMenu }), _jsx(AppShellSidebar, { footer: footer, groups: groups, header: header, navLabel: navLabel, onNavigate: onNavigate, rail: sidebarShown && !wide, railFooter: railFooter, railHeader: railHeader }), _jsxs("main", { className: "sw-app-shell-main", children: [hasPageBar ? (_jsx("div", { className: "sw-app-shell-page-bar", children: pageBar })) : null, children] }), _jsx(AppShellDrawer, { closeLabel: labels.closeMenu, footer: footer, groups: groups, header: header, navLabel: navLabel, onChoose: () => setMenuOpen(false), onClose: () => {
66
+ return (_jsxs("div", { className: "sw-app-shell", style: shellStyle, children: [skipLink ? (_jsx(SkipToMain, { label: skipLink.label, mainId: mainId, mainRef: mainRef })) : null, _jsx(AppShellBar, { barEnd: barEnd, barRef: barRef, barTitle: barTitle, menuCount: menuCount, menuCountLabel: menuCountLabel, menuCountTone: menuCountTone, menuIcon: menuIcon, menuOpen: menuOpen, menuRef: menuRef, onOpenMenu: () => setMenuOpen(true), openMenuLabel: labels.openMenu }), _jsx(AppShellSidebar, { footer: footer, groups: groups, header: header, navLabel: navLabel, onNavigate: onNavigate, rail: sidebarShown && !wide, railFooter: railFooter, railHeader: railHeader }), _jsxs("main", { ref: mainRef, className: "sw-app-shell-main", id: skipLink ? mainId : undefined, tabIndex: skipLink ? -1 : undefined, children: [hasPageBar ? (_jsx("div", { className: "sw-app-shell-page-bar", children: pageBar })) : null, children] }), _jsx(AppShellDrawer, { closeLabel: labels.closeMenu, footer: footer, groups: groups, header: header, navLabel: navLabel, onChoose: () => setMenuOpen(false), onClose: () => {
62
67
  refocusRef.current = true;
63
68
  setMenuOpen(false);
64
69
  }, onNavigate: onNavigate, open: menuOpen })] }));
@@ -0,0 +1,44 @@
1
+ import { type SemanticColorKey, type TypographyVariant } from '@scalewing/tokens';
2
+ /** `polite` waits for the reader to finish; `assertive` interrupts it. */
3
+ export declare const liveRegionPolitenesses: readonly ["polite", "assertive"];
4
+ export type LiveRegionPoliteness = (typeof liveRegionPolitenesses)[number];
5
+ export type LiveRegionProps = {
6
+ /**
7
+ * The words to announce now, in the consumer's language. Empty (or none)
8
+ * clears the region and announces nothing.
9
+ */
10
+ children?: string;
11
+ /**
12
+ * `polite` (the default) is a `status`, read when the reader is idle;
13
+ * `assertive` is an `alert`, read at once: keep it for an error the
14
+ * person must hear before they go on.
15
+ */
16
+ politeness?: LiveRegionPoliteness;
17
+ /**
18
+ * Changed to announce the same words again, such as a second "Copied".
19
+ * New words are announced without it.
20
+ */
21
+ announceKey?: string | number;
22
+ /**
23
+ * Keeps the words for assistive technology only (`sw-sr-only`), for a
24
+ * change the page already shows another way, such as a button's label
25
+ * turning to "Copied". Default `false`: the words are drawn inline as
26
+ * `Text` draws them.
27
+ */
28
+ visuallyHidden?: boolean;
29
+ /** The drawn words' type, as `Text`'s (default `body`). Not with `visuallyHidden`. */
30
+ variant?: TypographyVariant;
31
+ /** The drawn words' colour, as `Text`'s (default `text`). Not with `visuallyHidden`. */
32
+ color?: SemanticColorKey;
33
+ /** The region's id, such as for a control's `aria-describedby`. */
34
+ id?: string;
35
+ };
36
+ /**
37
+ * A status line whose words change and are announced: a region that is
38
+ * always mounted and starts empty (`role="status"`, or `"alert"` when
39
+ * assertive, with `aria-live` and `aria-atomic`), its words a keyed child
40
+ * swapped in on each change of `children` or `announceKey`, so new words,
41
+ * and the same words again, are read. Drawn inline in `Text`'s look, or
42
+ * visually hidden.
43
+ */
44
+ export declare function LiveRegion({ announceKey, children, color, id, politeness, variant, visuallyHidden, }: LiveRegionProps): import("react").JSX.Element;
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ import { Text } from './Text.js';
5
+ /** `polite` waits for the reader to finish; `assertive` interrupts it. */
6
+ export const liveRegionPolitenesses = ['polite', 'assertive'];
7
+ /*
8
+ * Screen readers miss words put into a live region in the same moment the
9
+ * region itself appears, so the first words wait until it has been in the
10
+ * page this long, as common announcers do. Later changes show at once.
11
+ */
12
+ const regionSettleMs = 100;
13
+ function assertLiveRegionProps({ color, politeness, variant, visuallyHidden, }) {
14
+ if (!liveRegionPolitenesses.includes(politeness))
15
+ throw new RangeError(`Unknown LiveRegion politeness: ${String(politeness)}`);
16
+ if (visuallyHidden && (variant !== undefined || color !== undefined))
17
+ throw new TypeError('LiveRegion variant and color draw words; a visuallyHidden region draws none');
18
+ }
19
+ /**
20
+ * A status line whose words change and are announced: a region that is
21
+ * always mounted and starts empty (`role="status"`, or `"alert"` when
22
+ * assertive, with `aria-live` and `aria-atomic`), its words a keyed child
23
+ * swapped in on each change of `children` or `announceKey`, so new words,
24
+ * and the same words again, are read. Drawn inline in `Text`'s look, or
25
+ * visually hidden.
26
+ */
27
+ export function LiveRegion({ announceKey, children = '', color, id, politeness = 'polite', variant, visuallyHidden = false, }) {
28
+ assertLiveRegionProps({ color, politeness, variant, visuallyHidden });
29
+ const [settled, setSettled] = useState(false);
30
+ const [shown, setShown] = useState({
31
+ words: '',
32
+ announceKey,
33
+ key: 0,
34
+ });
35
+ // Words or a key that changed since the last swap are swapped in while
36
+ // rendering (React's state from the previous render), once settled.
37
+ if (settled &&
38
+ (shown.words !== children || shown.announceKey !== announceKey))
39
+ setShown({ words: children, announceKey, key: shown.key + 1 });
40
+ useEffect(() => {
41
+ const timer = window.setTimeout(() => setSettled(true), regionSettleMs);
42
+ return () => window.clearTimeout(timer);
43
+ }, []);
44
+ const region = {
45
+ 'aria-atomic': true,
46
+ 'aria-live': politeness,
47
+ id,
48
+ role: politeness === 'assertive' ? 'alert' : 'status',
49
+ };
50
+ const words = shown.words === '' ? null : _jsx("span", { children: shown.words }, shown.key);
51
+ if (visuallyHidden)
52
+ return (_jsx("span", { className: "sw-sr-only", ...region, children: words }));
53
+ return (_jsx(Text, { as: "span", color: color, variant: variant, ...region, children: words }));
54
+ }
@@ -39,6 +39,13 @@ export type SelectProps = SelectNaming & {
39
39
  requiredMark?: RequiredMark;
40
40
  /** A validation message under the control, wired as Field's error is. */
41
41
  error?: string;
42
+ /**
43
+ * Turns the control off, as `Button`'s `disabled`: the trigger is a
44
+ * native disabled button (out of the Tab order, read as dimmed, faded),
45
+ * its list cannot open (an open one closes) and `onChange` is never
46
+ * called. Say why it is off in the page's words. Default `false`.
47
+ */
48
+ disabled?: boolean;
42
49
  /**
43
50
  * `full` makes the field, its trigger and its open list fill the
44
51
  * container's inline size; the chosen label still ellipsizes past it.
@@ -46,4 +53,4 @@ export type SelectProps = SelectNaming & {
46
53
  */
47
54
  width?: SelectWidth;
48
55
  };
49
- export declare function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
56
+ export declare function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, disabled, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
@@ -11,7 +11,7 @@ import { SelectValue } from './select/SelectValue.js';
11
11
  import { useSelectList } from './select/use-select-list.js';
12
12
  import { Stack } from './Stack.js';
13
13
  import { tabStop } from '../tab-stop.js';
14
- export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size = 'md', value, placeholder, required = false, requiredMark, error, width = 'content', }) {
14
+ export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, disabled = false, onChange, options, size = 'md', value, placeholder, required = false, requiredMark, error, width = 'content', }) {
15
15
  assertSelectNaming({ label, labelVisuallyHidden, labelledBy, requiredMark });
16
16
  assertRequiredMark(requiredMark ?? 'visible');
17
17
  if (placeholder !== undefined && !placeholder.trim())
@@ -27,7 +27,10 @@ export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby':
27
27
  const showsPlaceholder = placeholder !== undefined &&
28
28
  !options.some((option) => option.value === value);
29
29
  const { commit, highlight, onTriggerKeyDown, open, openList, setHighlight, setOpen, } = useSelectList({ action, onChange, options, rootRef, value });
30
- return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: label === undefined || labelVisuallyHidden ? 0 : 1, children: [label === undefined ? null : (_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark ?? 'visible', size: size, visuallyHidden: labelVisuallyHidden ?? false }) })), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
30
+ // A control turned off while its list is open closes it.
31
+ if (disabled && open)
32
+ setOpen(false);
33
+ return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: label === undefined || labelVisuallyHidden ? 0 : 1, children: [label === undefined ? null : (_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark ?? 'visible', size: size, visuallyHidden: labelVisuallyHidden ?? false }) })), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", disabled: disabled, id: triggerId, onBlur: (event) => {
31
34
  const next = event.relatedTarget;
32
35
  if (next instanceof Node && rootRef.current?.contains(next)) {
33
36
  return;
@@ -0,0 +1,14 @@
1
+ import { type RefObject } from 'react';
2
+ type SkipToMainProps = {
3
+ label: string;
4
+ mainId: string;
5
+ mainRef: RefObject<HTMLElement | null>;
6
+ };
7
+ /**
8
+ * The shell's first focusable element. A press (Enter included) moves
9
+ * focus to `main` itself rather than following the fragment, so a hash
10
+ * router's address and the history stay as they were; the `href` keeps it
11
+ * a real link to the same place.
12
+ */
13
+ export declare function SkipToMain({ label, mainId, mainRef }: SkipToMainProps): import("react").JSX.Element;
14
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { tabStop } from '../../tab-stop.js';
3
+ /**
4
+ * The shell's first focusable element. A press (Enter included) moves
5
+ * focus to `main` itself rather than following the fragment, so a hash
6
+ * router's address and the history stay as they were; the `href` keeps it
7
+ * a real link to the same place.
8
+ */
9
+ export function SkipToMain({ label, mainId, mainRef }) {
10
+ return (_jsx("a", { className: "sw-app-shell-skip-link", href: `#${mainId}`, onClick: (event) => {
11
+ event.preventDefault();
12
+ mainRef.current?.focus();
13
+ }, tabIndex: tabStop, children: label }));
14
+ }
@@ -50,6 +50,15 @@ export type AppShellLabels = {
50
50
  openMenu: string;
51
51
  closeMenu: string;
52
52
  };
53
+ /**
54
+ * A "skip to main content" link, the shell's first focusable element:
55
+ * hidden until it has focus, it moves focus to the shell's `main` (which
56
+ * the shell names with its own id), past the navigation.
57
+ */
58
+ export type AppShellSkipLink = {
59
+ /** Its words, in the page's language ("Skip to main content"). */
60
+ label: string;
61
+ };
53
62
  export type AppShellProps = {
54
63
  /** The navigation landmark's name, also the drawer's. */
55
64
  navLabel: string;
@@ -117,6 +126,14 @@ export type AppShellProps = {
117
126
  */
118
127
  pageBar?: ReactNode;
119
128
  labels: AppShellLabels;
129
+ /**
130
+ * A skip link before everything else in the shell: shown at the window's
131
+ * top start while it has focus, a press moves focus to `main` (given an
132
+ * id and `tabindex="-1"`, without changing the address), so a keyboard
133
+ * user passes the navigation's destinations (WCAG 2.4.1). Left out, the
134
+ * shell draws none.
135
+ */
136
+ skipLink?: AppShellSkipLink;
120
137
  /** The page, rendered in `main`. */
121
138
  children: ReactNode;
122
139
  };
@@ -1,7 +1,7 @@
1
1
  import { type AppShellProps } from './types.js';
2
2
  /**
3
- * Fails closed on the shell's words, the menu's count, its groups and
4
- * their items (ids unique across every group, each with an icon, which is
5
- * all the rail shows).
3
+ * Fails closed on the shell's words (a skip link's too), the menu's count,
4
+ * its groups and their items (ids unique across every group, each with an
5
+ * icon, which is all the rail shows).
6
6
  */
7
- export declare function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, }: Pick<AppShellProps, 'groups' | 'labels' | 'menuCount' | 'menuCountLabel' | 'menuCountTone' | 'navLabel'>): void;
7
+ export declare function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, skipLink, }: Pick<AppShellProps, 'groups' | 'labels' | 'menuCount' | 'menuCountLabel' | 'menuCountTone' | 'navLabel' | 'skipLink'>): void;
@@ -44,15 +44,17 @@ function assertMenuCount({ menuCount, menuCountLabel, menuCountTone, }) {
44
44
  throw new RangeError('AppShell menuCountLabel and menuCountTone come with a menuCount');
45
45
  }
46
46
  /**
47
- * Fails closed on the shell's words, the menu's count, its groups and
48
- * their items (ids unique across every group, each with an icon, which is
49
- * all the rail shows).
47
+ * Fails closed on the shell's words (a skip link's too), the menu's count,
48
+ * its groups and their items (ids unique across every group, each with an
49
+ * icon, which is all the rail shows).
50
50
  */
51
- export function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, }) {
51
+ export function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, skipLink, }) {
52
52
  if (isBlank(navLabel))
53
53
  throw new RangeError('AppShell navLabel must be nonempty');
54
54
  if (isBlank(labels?.openMenu) || isBlank(labels?.closeMenu))
55
55
  throw new RangeError('AppShell labels openMenu and closeMenu are nonempty');
56
+ if (skipLink != null && isBlank(skipLink.label))
57
+ throw new RangeError('AppShell skipLink needs a nonempty label');
56
58
  assertMenuCount({ menuCount, menuCountLabel, menuCountTone });
57
59
  assertGroups(groups);
58
60
  const items = groups.flatMap((group) => group.items);
@@ -0,0 +1,2 @@
1
+ export declare function cssAppShellSkipLinkRules(): string;
2
+ export declare function appShellSkipLinkClassCatalog(): string[];
@@ -0,0 +1,26 @@
1
+ import { skipLinkRules } from './skip-link.js';
2
+ import { zIndex } from './stacking.js';
3
+ const link = '.sw-app-shell-skip-link';
4
+ /*
5
+ * The shell's skip link, its first focusable element, in the shared skip
6
+ * link look: while it has focus it is fixed at the window's top start,
7
+ * over the sidebar, the compact bar and a page bar on its own layer, so it
8
+ * shows however far the page has scrolled. Once used, `main` holds focus
9
+ * (`tabindex="-1"`) without a ring: it is not a control, and the next Tab
10
+ * reaches the page's first one.
11
+ */
12
+ export function cssAppShellSkipLinkRules() {
13
+ return `${skipLinkRules(link)}
14
+
15
+ ${link} {
16
+ inset-block-start: var(--sw-space-2);
17
+ inset-inline-start: var(--sw-space-2);
18
+ position: fixed;
19
+ ${zIndex('skipLink')}
20
+ }
21
+
22
+ .sw-app-shell-main:focus { outline: none; }`;
23
+ }
24
+ export function appShellSkipLinkClassCatalog() {
25
+ return ['sw-app-shell-skip-link'];
26
+ }
@@ -2,6 +2,7 @@ import { breakpointQuery } from './breakpoints.js';
2
2
  import { appShellDrawerClassCatalog, cssAppShellDrawerClasses, } from './css-app-shell-drawer.js';
3
3
  import { appShellNavClassCatalog, cssAppShellNavClasses, cssAppShellRailNavRules, } from './css-app-shell-nav.js';
4
4
  import { appShellPageBarClassCatalog, cssAppShellPageBarClasses, } from './css-app-shell-page-bar.js';
5
+ import { appShellSkipLinkClassCatalog, cssAppShellSkipLinkRules, } from './css-app-shell-skip-link.js';
5
6
  import { iconButtonRules } from './css-icon-button.js';
6
7
  /*
7
8
  * The width from which AppShell's sidebar is full rather than a rail: an
@@ -112,7 +113,8 @@ ${cssAppShellRailNavRules(' ')}
112
113
  * AppShell: a workspace frame with a pinned side navigation (see
113
114
  * components/AppShell.tsx). The links, groups and the rail's nav are
114
115
  * `css-app-shell-nav.ts`; the page bar and the menu's count are
115
- * `css-app-shell-page-bar.ts`; the menu drawer is `css-app-shell-drawer.ts`.
116
+ * `css-app-shell-page-bar.ts`; the menu drawer is `css-app-shell-drawer.ts`;
117
+ * the skip link is `css-app-shell-skip-link.ts`.
116
118
  */
117
119
  export function cssAppShellClasses() {
118
120
  return `${barRules()}
@@ -133,7 +135,9 @@ ${cssAppShellPageBarClasses()}
133
135
 
134
136
  ${sidebarRules()}
135
137
 
136
- ${cssAppShellDrawerClasses()}`;
138
+ ${cssAppShellDrawerClasses()}
139
+
140
+ ${cssAppShellSkipLinkRules()}`;
137
141
  }
138
142
  export function appShellClassCatalog() {
139
143
  return [
@@ -151,5 +155,6 @@ export function appShellClassCatalog() {
151
155
  ...appShellNavClassCatalog(),
152
156
  ...appShellPageBarClassCatalog(),
153
157
  ...appShellDrawerClassCatalog(),
158
+ ...appShellSkipLinkClassCatalog(),
154
159
  ];
155
160
  }
@@ -1,5 +1,6 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { appHeaderBarClassCatalog, cssAppHeaderBarClasses, } from './css-app-header-bar.js';
3
+ import { typedInputs } from './css-document.js';
3
4
  import { cssNavWordsClasses, navWordsClassCatalog } from './css-nav-words.js';
4
5
  import { zIndex } from './stacking.js';
5
6
  import { coarsePointerQuery, touchTarget } from './touch-target.js';
@@ -71,6 +72,26 @@ ${cssNavWordsClasses()}
71
72
  padding-inline-end: calc(
72
73
  var(--sw-control-xs-padding-inline) + var(--sw-space-5)
73
74
  );
75
+ }
76
+
77
+ /*
78
+ * The document canvas sizes every typed input under [data-theme] (0,2,1),
79
+ * which outranks the xs rule above, so an xs field's own text control
80
+ * matches it there. Adorned and search inputs sit one level deeper and keep
81
+ * their frames' rules. On a coarse pointer it is the 44 px touch target.
82
+ */
83
+ [data-theme] .sw-field-xs > :is(${typedInputs}) {
84
+ font-size: ${caption.fontSize}px;
85
+ letter-spacing: ${caption.letterSpacing}px;
86
+ line-height: ${caption.lineHeight}px;
87
+ min-height: var(--sw-control-xs-min-height);
88
+ padding-inline: var(--sw-control-xs-padding-inline);
89
+ }
90
+
91
+ @media ${coarsePointerQuery} {
92
+ [data-theme] .sw-field-xs > :is(${typedInputs}) {
93
+ min-height: ${touchTarget};
94
+ }
74
95
  }`;
75
96
  }
76
97
  export function chromeClassCatalog() {
@@ -1,9 +1,10 @@
1
- import { quietFill } from './quiet-fill.js';
1
+ import { mutedReadsAsText, quietFill } from './quiet-fill.js';
2
2
  /*
3
3
  * The action sits after the row in its own column, centred on the row's
4
4
  * height, outside the row's press area, keeping the row's end padding
5
5
  * after it. Where :has() is supported the hover fill spans the whole item,
6
- * so the row, its select and its action read as one line.
6
+ * so the row, its select and its action read as one line; a detail under
7
+ * the row is on that fill too, so its muted words take the text colour.
7
8
  */
8
9
  function actionRules() {
9
10
  return `.sw-fixture-action {
@@ -24,6 +25,8 @@ function actionRules() {
24
25
  :is(.sw-fixture-item-action, .sw-fixture-item-select) > .sw-fixture-row-pressable:hover {
25
26
  background: transparent;
26
27
  }
28
+
29
+ ${mutedReadsAsText([':is(.sw-fixture-item-action, .sw-fixture-item-select):has(> .sw-fixture-row-pressable:hover) > .sw-fixture-detail'])}
27
30
  }
28
31
  }`;
29
32
  }
@@ -1,7 +1,7 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
2
  import { breakpointQuery } from './breakpoints.js';
3
3
  import { chevronStrokeIn } from './chevron.js';
4
- import { quietFill } from './quiet-fill.js';
4
+ import { mutedReadsAsText, quietFill } from './quiet-fill.js';
5
5
  import { cssFixtureActionClasses, fixtureActionClassCatalog, } from './css-fixture-action.js';
6
6
  import { cssFixtureListRules } from './css-fixture-list.js';
7
7
  import { cssFixtureDetailClasses, fixtureDetailClassCatalog, } from './css-fixture-detail.js';
@@ -20,7 +20,8 @@ const belowMd = breakpointQuery('below', 'md');
20
20
  * colour, the quiet fill on hover and an inset accent ring, with the
21
21
  * shared chevron at the end. The sides' names are label type at weight 500
22
22
  * and wrap; the home side ends at the centre (its mark nearest it), the
23
- * away side starts from it, mirrored in right-to-left.
23
+ * away side starts from it, mirrored in right-to-left. On the hover fill
24
+ * its muted parts take the text colour (`mutedReadsAsText`).
24
25
  */
25
26
  function rowRules() {
26
27
  return `.sw-fixture-row {
@@ -47,6 +48,8 @@ function rowRules() {
47
48
 
48
49
  @media (hover: hover) {
49
50
  .sw-fixture-row-pressable:hover { background: ${quietFill}; }
51
+
52
+ ${mutedReadsAsText(['.sw-fixture-row-pressable:hover *'])}
50
53
  }
51
54
 
52
55
  .sw-fixture-row-pressable:focus { outline: none; }
@@ -2,7 +2,7 @@ import { typographyVariants } from '@scalewing/tokens';
2
2
  import { checkStrokeIn, chevronStrokeIn } from './chevron.js';
3
3
  import { chartColorMix, chartInkMixes } from './chart-tones.js';
4
4
  import { tableCellTones } from './css-table-ledger.js';
5
- import { quietFill } from './quiet-fill.js';
5
+ import { mutedReadsAsText, quietFill } from './quiet-fill.js';
6
6
  import { coarsePointerQuery, touchTarget } from './touch-target.js';
7
7
  const label = typographyVariants.label;
8
8
  const body = typographyVariants.body;
@@ -16,7 +16,8 @@ function onTitleLine(size) {
16
16
  * detail at the end, then the chevron or check, each lined up with the
17
17
  * title's first line. A pressable row is the whole button or link: a
18
18
  * quiet fill under the pointer, the focus ring inside its edge, 44 px on a
19
- * coarse pointer. Plain rows (inside a Card) have no inline padding.
19
+ * coarse pointer. Plain rows (inside a Card) have no inline padding. On
20
+ * the fill its muted parts take the text colour (`mutedReadsAsText`).
20
21
  */
21
22
  function rowRules() {
22
23
  return `.sw-list-row {
@@ -44,10 +45,14 @@ function rowRules() {
44
45
 
45
46
  @media (hover: hover) {
46
47
  .sw-list-row-pressable:hover:not(.sw-list-row-disabled) { background: ${quietFill}; }
48
+
49
+ ${mutedReadsAsText(['.sw-list-row-pressable:hover:not(.sw-list-row-disabled) *'])}
47
50
  }
48
51
 
49
52
  .sw-list-row-pressable:active:not(.sw-list-row-disabled) { background: ${quietFill}; }
50
53
 
54
+ ${mutedReadsAsText(['.sw-list-row-pressable:active:not(.sw-list-row-disabled) *'])}
55
+
51
56
  .sw-list-row-pressable:focus { outline: none; }
52
57
 
53
58
  .sw-list-row-pressable:focus-visible {
@@ -125,6 +125,17 @@ export function cssSelectClasses() {
125
125
  .sw-select-invalid .sw-select-trigger { border-color: Mark; }
126
126
  }
127
127
 
128
+ /* Off: faded as a disabled Button is; forced colours grey its frame, words and chevron. */
129
+ .sw-select-trigger:disabled {
130
+ cursor: not-allowed;
131
+ opacity: var(--sw-disabled-opacity);
132
+ }
133
+
134
+ @media (forced-colors: active) {
135
+ .sw-select-trigger:disabled { border-color: GrayText; color: GrayText; }
136
+ .sw-select-trigger:disabled::after { border-color: GrayText; }
137
+ }
138
+
128
139
  .sw-select-list {
129
140
  background: var(--sw-glass-fill);
130
141
  border: 1px solid var(--sw-glass-border);
@@ -1,49 +1,23 @@
1
- import { typographyVariants } from '@scalewing/tokens';
2
- import { visuallyHiddenDeclarations } from './css-utilities.js';
3
1
  import { siteHeaderContentStart, siteHeaderGutter, siteHeaderGutterQuery, } from './site-header-geometry.js';
4
- const label = typographyVariants.label;
2
+ import { skipLinkRules } from './skip-link.js';
3
+ const link = '.sw-site-header-skip-link';
5
4
  /*
6
- * The header's skip link, its first focusable element: visually hidden
7
- * until it has focus, then a pill on the surface in the text colour with
8
- * the accent ring, laid over the bar's start (centred in its height and
9
- * lined up with the brand), the control height tall only then. Positioned,
10
- * it paints over the bar's content, which is not, so it needs no layer of
11
- * its own.
5
+ * The header's skip link, its first focusable element, in the shared skip
6
+ * link look, laid over the bar's start (centred in its height and lined up
7
+ * with the brand) while it has focus. Positioned, it paints over the bar's
8
+ * content, which is not, so it needs no layer of its own.
12
9
  */
13
10
  export function cssSiteHeaderSkipLinkRules() {
14
- return `.sw-site-header-skip-link {
15
- align-items: center;
16
- background: var(--sw-color-surface);
17
- border-radius: var(--sw-radius-pill);
18
- box-sizing: border-box;
19
- display: inline-flex;
20
- font-family: var(--sw-font-sans);
21
- font-size: ${label.fontSize}px;
22
- font-weight: ${label.fontWeight};
11
+ return `${skipLinkRules(link)}
12
+
13
+ ${link} {
23
14
  inset-block-start: 50%;
24
15
  inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.compact)};
25
- letter-spacing: ${label.letterSpacing}px;
26
- line-height: ${label.lineHeight}px;
27
- padding-inline: var(--sw-space-4);
28
16
  position: absolute;
29
17
  transform: translateY(-50%);
30
- white-space: nowrap;
31
- }
32
-
33
- [data-theme] .sw-site-header-skip-link {
34
- color: var(--sw-color-text);
35
- text-decoration: none;
36
18
  }
37
19
 
38
- [data-theme] .sw-site-header-skip-link:focus {
39
- min-height: var(--sw-control-md-min-height);
40
- outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
41
- outline-offset: calc(-1 * var(--sw-focus-ring-width));
42
- }
43
-
44
- .sw-site-header-skip-link:not(:focus) { ${visuallyHiddenDeclarations} }
45
-
46
20
  @media ${siteHeaderGutterQuery} {
47
- .sw-site-header-skip-link { inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.wide)}; }
21
+ ${link} { inset-inline-start: ${siteHeaderContentStart(siteHeaderGutter.wide)}; }
48
22
  }`;
49
23
  }
@@ -1,17 +1,28 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
- import { quietFill } from './quiet-fill.js';
2
+ import { mutedReadsAsText, quietFill } from './quiet-fill.js';
3
3
  const row = '.sw-table-row-press';
4
4
  const action = '.sw-table-action';
5
5
  /*
6
6
  * A pressable row: the pointer cursor over all of it, and the quiet fill
7
7
  * on its cells while it is hovered (on a pointer that hovers), pressed, or
8
8
  * while its action has keyboard focus, so a keyboard user sees the row the
9
- * focused name belongs to. The fill sits under the cells' text, so text,
10
- * muted text and toned figures keep their contrast as on any quiet-filled
11
- * row (an open menu's). Its action is underlined with it, so the row reads
12
- * as that one action. Forced colours drop fills, so the row is outlined in
13
- * the system highlight instead.
9
+ * focused name belongs to. Muted words fall under 4.5:1 on that fill, so
10
+ * a muted cell and the muted parts inside every cell take the text colour
11
+ * while it shows (`mutedReadsAsText`; the cell paints the fill, so its own
12
+ * colour is set, not the token). Its action is underlined with it, so the
13
+ * row reads as that one action. Forced colours drop fills, so the row is
14
+ * outlined in the system highlight instead.
14
15
  */
16
+ const hovered = `${row}:hover`;
17
+ const held = [`${row}:active`, `${row}:has(:focus-visible)`];
18
+ /* A filled row's muted cells and the muted parts inside its cells read as text. */
19
+ function mutedCellRules(rows) {
20
+ return `${mutedReadsAsText(rows.map((filled) => `${filled} > :is(td, th) *`))}
21
+
22
+ ${rows.map((filled) => `${filled} > .sw-table-tone-muted`).join(',\n')} {
23
+ color: var(--sw-color-text);
24
+ }`;
25
+ }
15
26
  function pressableRowRules() {
16
27
  return `${row} {
17
28
  cursor: pointer;
@@ -25,6 +36,8 @@ function pressableRowRules() {
25
36
  ${row}:hover ${action} {
26
37
  text-decoration-line: underline;
27
38
  }
39
+
40
+ ${mutedCellRules([hovered])}
28
41
  }
29
42
 
30
43
  ${row}:active > :is(td, th),
@@ -32,6 +45,8 @@ ${row}:has(:focus-visible) > :is(td, th) {
32
45
  background: ${quietFill};
33
46
  }
34
47
 
48
+ ${mutedCellRules(held)}
49
+
35
50
  @media (forced-colors: active) {
36
51
  ${row}:hover,
37
52
  ${row}:has(:focus-visible) {
@@ -5,3 +5,14 @@
5
5
  * button all take it, so they read as one grey.
6
6
  */
7
7
  export declare const quietFill = "color-mix(in srgb, var(--sw-color-muted) 16%, transparent)";
8
+ /**
9
+ * Muted words keep 4.5:1 on the surface but not on the quiet fill (4.14:1
10
+ * in most light palettes, 3.47:1 in harvest dark), while the text colour
11
+ * keeps it in every palette and scheme. So while a row shows the fill, its
12
+ * parts read the muted token as the text colour: the details, a consumer's
13
+ * `Text color="muted"`, the chevron. `scopes` are selectors for what sits
14
+ * on the fill (`<row>:hover *`), never the element that paints it, whose
15
+ * fill reads the muted token too. Toned words keep their tone. Forced
16
+ * colours replace both colours, so this changes nothing there.
17
+ */
18
+ export declare function mutedReadsAsText(scopes: readonly string[]): string;
@@ -5,3 +5,18 @@
5
5
  * button all take it, so they read as one grey.
6
6
  */
7
7
  export const quietFill = 'color-mix(in srgb, var(--sw-color-muted) 16%, transparent)';
8
+ /**
9
+ * Muted words keep 4.5:1 on the surface but not on the quiet fill (4.14:1
10
+ * in most light palettes, 3.47:1 in harvest dark), while the text colour
11
+ * keeps it in every palette and scheme. So while a row shows the fill, its
12
+ * parts read the muted token as the text colour: the details, a consumer's
13
+ * `Text color="muted"`, the chevron. `scopes` are selectors for what sits
14
+ * on the fill (`<row>:hover *`), never the element that paints it, whose
15
+ * fill reads the muted token too. Toned words keep their tone. Forced
16
+ * colours replace both colours, so this changes nothing there.
17
+ */
18
+ export function mutedReadsAsText(scopes) {
19
+ return `${scopes.join(',\n')} {
20
+ --sw-color-muted: var(--sw-color-text);
21
+ }`;
22
+ }
@@ -0,0 +1 @@
1
+ export declare function skipLinkRules(selector: string): string;
@@ -0,0 +1,39 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { visuallyHiddenDeclarations } from './css-utilities.js';
3
+ const label = typographyVariants.label;
4
+ /*
5
+ * A skip link's look, shared by SiteHeader's and AppShell's: visually
6
+ * hidden until it has focus, then a pill on the surface in the text colour
7
+ * with the accent ring inside its edge, the control height tall. Each
8
+ * component positions its own; forced colours draw it as a link on the
9
+ * system canvas with the ring in the system colour.
10
+ */
11
+ export function skipLinkRules(selector) {
12
+ return `${selector} {
13
+ align-items: center;
14
+ background: var(--sw-color-surface);
15
+ border-radius: var(--sw-radius-pill);
16
+ box-sizing: border-box;
17
+ display: inline-flex;
18
+ font-family: var(--sw-font-sans);
19
+ font-size: ${label.fontSize}px;
20
+ font-weight: ${label.fontWeight};
21
+ letter-spacing: ${label.letterSpacing}px;
22
+ line-height: ${label.lineHeight}px;
23
+ padding-inline: var(--sw-space-4);
24
+ white-space: nowrap;
25
+ }
26
+
27
+ [data-theme] ${selector} {
28
+ color: var(--sw-color-text);
29
+ text-decoration: none;
30
+ }
31
+
32
+ [data-theme] ${selector}:focus {
33
+ min-height: var(--sw-control-md-min-height);
34
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
35
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
36
+ }
37
+
38
+ ${selector}:not(:focus) { ${visuallyHiddenDeclarations} }`;
39
+ }
@@ -5,6 +5,7 @@ export declare const stackingOrder: {
5
5
  readonly popupHost: 4;
6
6
  readonly topChrome: 5;
7
7
  readonly sheetGrabber: 6;
8
+ readonly skipLink: 7;
8
9
  readonly popup: 10;
9
10
  };
10
11
  export type StackingLayer = keyof typeof stackingOrder;
@@ -19,7 +19,9 @@
19
19
  * strip its way between sections, and a popup opens downward).
20
20
  * 6. `sheetGrabber`: a bottom sheet's grabber, inside its dialog, over the
21
21
  * banded title band that sticks under it.
22
- * 7. `popup`: a popup outside any lifted surface (Select list, ActionMenu
22
+ * 7. `skipLink`: AppShell's skip link while it has focus, fixed over the
23
+ * top chrome and the sidebar (a popup is never open while it has focus).
24
+ * 8. `popup`: a popup outside any lifted surface (Select list, ActionMenu
23
25
  * list, ContextSwitcher popup, MultiSelect panel, Combobox list, Tooltip,
24
26
  * the DateField and CalendarButton calendar) over everything in the page.
25
27
  *
@@ -32,6 +34,7 @@ export const stackingOrder = {
32
34
  popupHost: 4,
33
35
  topChrome: 5,
34
36
  sheetGrabber: 6,
37
+ skipLink: 7,
35
38
  popup: 10,
36
39
  };
37
40
  /*
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export { Checkbox, type CheckboxProps } from './components/Checkbox.js';
18
18
  export { PasteRows, type PasteRowsColumn, type PasteRowsProps, } from './components/PasteRows.js';
19
19
  export { parsePastedRows, type PastedRows, type PastedRowsDelimiter, pastedRowsDelimiters, } from './components/paste-rows/parse-pasted-rows.js';
20
20
  export { Spinner, type SpinnerProps, type SpinnerSize, } from './components/Spinner.js';
21
+ export { LiveRegion, liveRegionPolitenesses, type LiveRegionPoliteness, type LiveRegionProps, } from './components/LiveRegion.js';
21
22
  export { Skeleton, skeletonVariants, type SkeletonProps, type SkeletonVariant, } from './components/Skeleton.js';
22
23
  export { EmptyState, emptyStateTitleElements, type EmptyStateProps, type EmptyStateTitleElement, } from './components/EmptyState.js';
23
24
  export { GlyphTile, glyphTileSizes, type GlyphTileHue, type GlyphTileProps, type GlyphTileSize, } from './components/GlyphTile.js';
@@ -65,7 +66,7 @@ export { DateToggleCalendar, type DateToggleCalendarProps, } from './components/
65
66
  export { CommandPalette, commandPaletteShortcuts, type CommandPaletteGroup, type CommandPaletteItem, type CommandPaletteProps, type CommandPaletteShortcut, } from './components/CommandPalette.js';
66
67
  export { CommandPaletteTrigger, type CommandPaletteTriggerProps, } from './components/CommandPaletteTrigger.js';
67
68
  export { ContextSwitcher, contextSwitcherTriggerAppearances, contextSwitcherWidths, type ContextSwitcherAction, type ContextSwitcherBadge, type ContextSwitcherFilter, type ContextSwitcherGroup, type ContextSwitcherOption, type ContextSwitcherProps, type ContextSwitcherTriggerAppearance, type ContextSwitcherWidth, } from './components/ContextSwitcher.js';
68
- export { AppShell, type AppShellLabels, type AppShellNavGroup, type AppShellNavItem, type AppShellProps, } from './components/AppShell.js';
69
+ export { AppShell, type AppShellLabels, type AppShellNavGroup, type AppShellNavItem, type AppShellProps, type AppShellSkipLink, } from './components/AppShell.js';
69
70
  export { SectionNav, sectionNavAppearances, type SectionNavAppearance, type SectionNavItem, type SectionNavProps, } from './components/SectionNav.js';
70
71
  export { SegmentedControl, type SegmentedControlProps, type SegmentedControlVariant, segmentedSizes, type SegmentedSize, type SegmentedItem, } from './components/SegmentedControl.js';
71
72
  export { Combobox, comboboxSizes, type ComboboxAction, type ComboboxBadge, type ComboboxGroup, type ComboboxOption, type ComboboxProps, type ComboboxSize, } from './components/Combobox.js';
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export { Checkbox } from './components/Checkbox.js';
17
17
  export { PasteRows, } from './components/PasteRows.js';
18
18
  export { parsePastedRows, pastedRowsDelimiters, } from './components/paste-rows/parse-pasted-rows.js';
19
19
  export { Spinner, } from './components/Spinner.js';
20
+ export { LiveRegion, liveRegionPolitenesses, } from './components/LiveRegion.js';
20
21
  export { Skeleton, skeletonVariants, } from './components/Skeleton.js';
21
22
  export { EmptyState, emptyStateTitleElements, } from './components/EmptyState.js';
22
23
  export { GlyphTile, glyphTileSizes, } from './components/GlyphTile.js';
package/dist/styles.css CHANGED
@@ -2194,6 +2194,14 @@ body {
2194
2194
  .sw-table-row-press:hover .sw-table-action {
2195
2195
  text-decoration-line: underline;
2196
2196
  }
2197
+
2198
+ .sw-table-row-press:hover > :is(td, th) * {
2199
+ --sw-color-muted: var(--sw-color-text);
2200
+ }
2201
+
2202
+ .sw-table-row-press:hover > .sw-table-tone-muted {
2203
+ color: var(--sw-color-text);
2204
+ }
2197
2205
  }
2198
2206
 
2199
2207
  .sw-table-row-press:active > :is(td, th),
@@ -2201,6 +2209,16 @@ body {
2201
2209
  background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent);
2202
2210
  }
2203
2211
 
2212
+ .sw-table-row-press:active > :is(td, th) *,
2213
+ .sw-table-row-press:has(:focus-visible) > :is(td, th) * {
2214
+ --sw-color-muted: var(--sw-color-text);
2215
+ }
2216
+
2217
+ .sw-table-row-press:active > .sw-table-tone-muted,
2218
+ .sw-table-row-press:has(:focus-visible) > .sw-table-tone-muted {
2219
+ color: var(--sw-color-text);
2220
+ }
2221
+
2204
2222
  @media (forced-colors: active) {
2205
2223
  .sw-table-row-press:hover,
2206
2224
  .sw-table-row-press:has(:focus-visible) {
@@ -3077,10 +3095,18 @@ button.sw-date-toggle-day[aria-disabled='true'] {
3077
3095
 
3078
3096
  @media (hover: hover) {
3079
3097
  .sw-list-row-pressable:hover:not(.sw-list-row-disabled) { background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent); }
3098
+
3099
+ .sw-list-row-pressable:hover:not(.sw-list-row-disabled) * {
3100
+ --sw-color-muted: var(--sw-color-text);
3101
+ }
3080
3102
  }
3081
3103
 
3082
3104
  .sw-list-row-pressable:active:not(.sw-list-row-disabled) { background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent); }
3083
3105
 
3106
+ .sw-list-row-pressable:active:not(.sw-list-row-disabled) * {
3107
+ --sw-color-muted: var(--sw-color-text);
3108
+ }
3109
+
3084
3110
  .sw-list-row-pressable:focus { outline: none; }
3085
3111
 
3086
3112
  .sw-list-row-pressable:focus-visible {
@@ -3355,6 +3381,10 @@ button.sw-date-toggle-day[aria-disabled='true'] {
3355
3381
 
3356
3382
  @media (hover: hover) {
3357
3383
  .sw-fixture-row-pressable:hover { background: color-mix(in srgb, var(--sw-color-muted) 16%, transparent); }
3384
+
3385
+ .sw-fixture-row-pressable:hover * {
3386
+ --sw-color-muted: var(--sw-color-text);
3387
+ }
3358
3388
  }
3359
3389
 
3360
3390
  .sw-fixture-row-pressable:focus { outline: none; }
@@ -3598,6 +3628,10 @@ button.sw-date-toggle-day[aria-disabled='true'] {
3598
3628
  :is(.sw-fixture-item-action, .sw-fixture-item-select) > .sw-fixture-row-pressable:hover {
3599
3629
  background: transparent;
3600
3630
  }
3631
+
3632
+ :is(.sw-fixture-item-action, .sw-fixture-item-select):has(> .sw-fixture-row-pressable:hover) > .sw-fixture-detail {
3633
+ --sw-color-muted: var(--sw-color-text);
3634
+ }
3601
3635
  }
3602
3636
  }
3603
3637
 
@@ -5011,6 +5045,26 @@ button.sw-date-toggle-day[aria-disabled='true'] {
5011
5045
  );
5012
5046
  }
5013
5047
 
5048
+ /*
5049
+ * The document canvas sizes every typed input under [data-theme] (0,2,1),
5050
+ * which outranks the xs rule above, so an xs field's own text control
5051
+ * matches it there. Adorned and search inputs sit one level deeper and keep
5052
+ * their frames' rules. On a coarse pointer it is the 44 px touch target.
5053
+ */
5054
+ [data-theme] .sw-field-xs > :is(input[type='text'], input[type='email'], input[type='number'], input[type='search'], input[type='url'], input[type='password'], input:not([type]), textarea) {
5055
+ font-size: 13px;
5056
+ letter-spacing: -0.08px;
5057
+ line-height: 18px;
5058
+ min-height: var(--sw-control-xs-min-height);
5059
+ padding-inline: var(--sw-control-xs-padding-inline);
5060
+ }
5061
+
5062
+ @media (pointer: coarse) {
5063
+ [data-theme] .sw-field-xs > :is(input[type='text'], input[type='email'], input[type='number'], input[type='search'], input[type='url'], input[type='password'], input:not([type]), textarea) {
5064
+ min-height: var(--sw-control-md-min-height);
5065
+ }
5066
+ }
5067
+
5014
5068
  .sw-section-nav-list {
5015
5069
  border-bottom: 1px solid var(--sw-color-border);
5016
5070
  display: flex;
@@ -5724,13 +5778,9 @@ button.sw-date-toggle-day[aria-disabled='true'] {
5724
5778
  font-family: var(--sw-font-sans);
5725
5779
  font-size: 15px;
5726
5780
  font-weight: 600;
5727
- inset-block-start: 50%;
5728
- inset-inline-start: calc(max(0px, (100% - calc(var(--sw-container-max) + 2 * var(--sw-space-5))) / 2) + var(--sw-space-4));
5729
5781
  letter-spacing: -0.2px;
5730
5782
  line-height: 20px;
5731
5783
  padding-inline: var(--sw-space-4);
5732
- position: absolute;
5733
- transform: translateY(-50%);
5734
5784
  white-space: nowrap;
5735
5785
  }
5736
5786
 
@@ -5747,6 +5797,13 @@ button.sw-date-toggle-day[aria-disabled='true'] {
5747
5797
 
5748
5798
  .sw-site-header-skip-link:not(:focus) { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
5749
5799
 
5800
+ .sw-site-header-skip-link {
5801
+ inset-block-start: 50%;
5802
+ inset-inline-start: calc(max(0px, (100% - calc(var(--sw-container-max) + 2 * var(--sw-space-5))) / 2) + var(--sw-space-4));
5803
+ position: absolute;
5804
+ transform: translateY(-50%);
5805
+ }
5806
+
5750
5807
  @media (min-width: 48rem) {
5751
5808
  .sw-site-header-skip-link { inset-inline-start: calc(max(0px, (100% - calc(var(--sw-container-max) + 2 * var(--sw-space-5))) / 2) + var(--sw-space-5)); }
5752
5809
  }
@@ -6132,6 +6189,43 @@ html:has(.sw-site-header-sticky) {
6132
6189
  .sw-app-shell-drawer { border-inline-end-color: CanvasText; }
6133
6190
  }
6134
6191
 
6192
+ .sw-app-shell-skip-link {
6193
+ align-items: center;
6194
+ background: var(--sw-color-surface);
6195
+ border-radius: var(--sw-radius-pill);
6196
+ box-sizing: border-box;
6197
+ display: inline-flex;
6198
+ font-family: var(--sw-font-sans);
6199
+ font-size: 15px;
6200
+ font-weight: 600;
6201
+ letter-spacing: -0.2px;
6202
+ line-height: 20px;
6203
+ padding-inline: var(--sw-space-4);
6204
+ white-space: nowrap;
6205
+ }
6206
+
6207
+ [data-theme] .sw-app-shell-skip-link {
6208
+ color: var(--sw-color-text);
6209
+ text-decoration: none;
6210
+ }
6211
+
6212
+ [data-theme] .sw-app-shell-skip-link:focus {
6213
+ min-height: var(--sw-control-md-min-height);
6214
+ outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
6215
+ outline-offset: calc(-1 * var(--sw-focus-ring-width));
6216
+ }
6217
+
6218
+ .sw-app-shell-skip-link:not(:focus) { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
6219
+
6220
+ .sw-app-shell-skip-link {
6221
+ inset-block-start: var(--sw-space-2);
6222
+ inset-inline-start: var(--sw-space-2);
6223
+ position: fixed;
6224
+ z-index: 7;
6225
+ }
6226
+
6227
+ .sw-app-shell-main:focus { outline: none; }
6228
+
6135
6229
  .sw-dialog {
6136
6230
  background: var(--sw-glass-fill);
6137
6231
  border: 1px solid var(--sw-glass-border);
@@ -6637,6 +6731,17 @@ html:has(.sw-site-header-sticky) {
6637
6731
  .sw-select-invalid .sw-select-trigger { border-color: Mark; }
6638
6732
  }
6639
6733
 
6734
+ /* Off: faded as a disabled Button is; forced colours grey its frame, words and chevron. */
6735
+ .sw-select-trigger:disabled {
6736
+ cursor: not-allowed;
6737
+ opacity: var(--sw-disabled-opacity);
6738
+ }
6739
+
6740
+ @media (forced-colors: active) {
6741
+ .sw-select-trigger:disabled { border-color: GrayText; color: GrayText; }
6742
+ .sw-select-trigger:disabled::after { border-color: GrayText; }
6743
+ }
6744
+
6640
6745
  .sw-select-list {
6641
6746
  background: var(--sw-glass-fill);
6642
6747
  border: 1px solid var(--sw-glass-border);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.58.0",
3
+ "version": "1.59.1",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",