@scalewing/react 1.60.1 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/components/AppShell.d.ts +5 -3
  3. package/dist/components/AppShell.js +9 -4
  4. package/dist/components/DescriptionList.d.ts +11 -3
  5. package/dist/components/DescriptionList.js +5 -4
  6. package/dist/components/FilterChips.d.ts +1 -1
  7. package/dist/components/FilterChips.js +3 -3
  8. package/dist/components/FixtureGroup.d.ts +7 -3
  9. package/dist/components/FixtureGroup.js +11 -4
  10. package/dist/components/Grid.d.ts +11 -3
  11. package/dist/components/Grid.js +24 -6
  12. package/dist/components/PageHeader.d.ts +40 -0
  13. package/dist/components/PageHeader.js +36 -0
  14. package/dist/components/Tabs.d.ts +19 -0
  15. package/dist/components/Tabs.js +13 -3
  16. package/dist/components/Text.d.ts +20 -0
  17. package/dist/components/Text.js +9 -2
  18. package/dist/components/app-shell/types.d.ts +15 -0
  19. package/dist/components/app-shell/validate.d.ts +3 -3
  20. package/dist/components/app-shell/validate.js +9 -3
  21. package/dist/components/description-list-layout.d.ts +9 -0
  22. package/dist/components/description-list-layout.js +15 -0
  23. package/dist/components/filter-chips/options.d.ts +10 -1
  24. package/dist/components/filter-chips/options.js +2 -0
  25. package/dist/components/filter-chips/validate.d.ts +2 -2
  26. package/dist/components/filter-chips/validate.js +6 -3
  27. package/dist/components/fixture/types.d.ts +10 -0
  28. package/dist/css/breakpoints.d.ts +2 -1
  29. package/dist/css/breakpoints.js +2 -1
  30. package/dist/css/css-app-shell.d.ts +6 -0
  31. package/dist/css/css-app-shell.js +20 -1
  32. package/dist/css/css-button.js +9 -11
  33. package/dist/css/css-components.js +6 -0
  34. package/dist/css/css-density.d.ts +9 -0
  35. package/dist/css/css-density.js +95 -0
  36. package/dist/css/css-description-list.js +21 -0
  37. package/dist/css/css-filter-chips.d.ts +8 -0
  38. package/dist/css/css-filter-chips.js +35 -1
  39. package/dist/css/css-fixture-list.d.ts +10 -0
  40. package/dist/css/css-fixture-list.js +38 -0
  41. package/dist/css/css-fixture.js +4 -1
  42. package/dist/css/css-glyph-tile.js +6 -2
  43. package/dist/css/css-grid-span.js +8 -3
  44. package/dist/css/css-grid.d.ts +8 -1
  45. package/dist/css/css-grid.js +21 -2
  46. package/dist/css/css-page-header.d.ts +2 -0
  47. package/dist/css/css-page-header.js +106 -0
  48. package/dist/css/css-table-bounded.js +3 -2
  49. package/dist/css/css-table-metrics.d.ts +5 -0
  50. package/dist/css/css-table-metrics.js +5 -0
  51. package/dist/css/css-tabs.js +8 -0
  52. package/dist/css/css-text.d.ts +5 -0
  53. package/dist/css/css-text.js +26 -1
  54. package/dist/css/stylesheet.js +4 -0
  55. package/dist/css/touch-target.d.ts +1 -0
  56. package/dist/css/touch-target.js +4 -0
  57. package/dist/index.d.ts +12 -9
  58. package/dist/index.js +8 -6
  59. package/dist/styles.css +186 -7
  60. package/dist/theme/ThemeProvider.d.ts +13 -1
  61. package/dist/theme/ThemeProvider.js +7 -2
  62. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.61.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 32040cb: Opt-in desktop primitives for a data tool (FutMas F-010-S28). Nothing changes for a consumer that does not opt in, except one fix: `FilterChips`' face lacked `box-sizing: border-box`, so a chip now measures its intended 44 px instead of 54 px.
8
+
9
+ - `ThemeProvider density="compact"`, on a fine pointer only: md controls 36/14, sm 28/10, xs 24/8, chips 32, Card corners the md radius, 15/22 reading text. Touch keeps 44 px.
10
+ - `PageHeader`: title, subtitle, back link, actions and a row below.
11
+ - `AppShell contentMaxWidth="container"` and `sidebarTone="subtle"`.
12
+ - `Grid rail`: a main column beside a fixed 20rem rail, stacking below the breakpoint it names.
13
+ - `FixtureGroup sides="fixed"`: the score at one place across runs.
14
+ - `DescriptionList termWidth`: `sm`, `md` or `lg`.
15
+ - `FilterChips selectedStyle="subtle"`.
16
+ - `TabItem` `count`, `countLabel` and `countTone`.
17
+ - `Text maxLines`, and the `bodySm` variant. Needs the matching `@scalewing/tokens`.
18
+
19
+ `Button` now reads the `--sw-control-*` variables instead of literal pixels. Their values are unchanged, so nothing moves, unless an app overrides those variables in its own CSS, which now moves its Buttons too.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [32040cb]
24
+ - @scalewing/tokens@1.8.0
25
+
3
26
  ## 1.60.1
4
27
 
5
28
  ### Patch Changes
@@ -1,4 +1,5 @@
1
1
  import { type AppShellProps } from './app-shell/types.js';
2
+ export { appShellContentMaxWidths, appShellSidebarTones, type AppShellContentMaxWidth, type AppShellSidebarTone, } from '../css/css-app-shell.js';
2
3
  export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps, AppShellSkipLink, } from './app-shell/types.js';
3
4
  /**
4
5
  * A web workspace's frame: a pinned side navigation of grouped
@@ -8,7 +9,8 @@ export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps,
8
9
  * `lg` a sticky top bar's menu button opens the same navigation in a modal
9
10
  * drawer from the inline start, its button optionally carrying a count.
10
11
  * `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
+ * `skipLink` a first link that moves focus to `main`. `contentMaxWidth`
13
+ * caps the page and `sidebarTone` sets the sidebar's fill. Invalid groups,
14
+ * items, counts, words or layout options throw a `RangeError`.
13
15
  */
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;
16
+ export declare function AppShell({ barEnd, barTitle, children, contentMaxWidth, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, sidebarTone, skipLink, }: AppShellProps): import("react").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useId, useRef, useState } from 'react';
4
+ import { cx } from '../class-names.js';
4
5
  import { breakpointQuery } from '../css/breakpoints.js';
5
6
  import { appShellWideQuery } from '../css/css-app-shell.js';
6
7
  import { appShellBarHeightVariable, appShellPageBarHeightVariable, } from '../css/css-app-shell-page-bar.js';
@@ -11,6 +12,7 @@ import { SkipToMain } from './app-shell/SkipToMain.js';
11
12
  import { assertAppShellProps } from './app-shell/validate.js';
12
13
  import { useElementHeight } from './use-element-height.js';
13
14
  import { useMediaQuery } from './use-media-query.js';
15
+ export { appShellContentMaxWidths, appShellSidebarTones, } from '../css/css-app-shell.js';
14
16
  const fromLg = breakpointQuery('from', 'lg');
15
17
  /**
16
18
  * A web workspace's frame: a pinned side navigation of grouped
@@ -20,17 +22,20 @@ const fromLg = breakpointQuery('from', 'lg');
20
22
  * `lg` a sticky top bar's menu button opens the same navigation in a modal
21
23
  * drawer from the inline start, its button optionally carrying a count.
22
24
  * `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`.
25
+ * `skipLink` a first link that moves focus to `main`. `contentMaxWidth`
26
+ * caps the page and `sidebarTone` sets the sidebar's fill. Invalid groups,
27
+ * items, counts, words or layout options throw a `RangeError`.
25
28
  */
26
- export function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, skipLink, }) {
29
+ export function AppShell({ barEnd, barTitle, children, contentMaxWidth = 'none', footer, groups, header, labels, menuCount, menuCountLabel, menuCountTone, menuIcon, navLabel, onNavigate, pageBar, railFooter, railHeader, sidebarTone = 'surface', skipLink, }) {
27
30
  assertAppShellProps({
31
+ contentMaxWidth,
28
32
  groups,
29
33
  labels,
30
34
  menuCount,
31
35
  menuCountLabel,
32
36
  menuCountTone,
33
37
  navLabel,
38
+ sidebarTone,
34
39
  skipLink,
35
40
  });
36
41
  const [menuOpen, setMenuOpen] = useState(false);
@@ -74,7 +79,7 @@ export function AppShell({ barEnd, barTitle, children, footer, groups, header, l
74
79
  refocusRef.current = false;
75
80
  menuRef.current?.focus();
76
81
  }, [menuOpen]);
77
- 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", { ref: pageBarRef, 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: () => {
82
+ return (_jsxs("div", { className: cx('sw-app-shell', sidebarTone === 'subtle' && 'sw-app-shell-sidebar-subtle'), 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", { ref: pageBarRef, className: "sw-app-shell-page-bar", children: pageBar })) : null, contentMaxWidth === 'container' ? (_jsx("div", { className: "sw-container", children: children })) : (children)] }), _jsx(AppShellDrawer, { closeLabel: labels.closeMenu, footer: footer, groups: groups, header: header, navLabel: navLabel, onChoose: () => setMenuOpen(false), onClose: () => {
78
83
  refocusRef.current = true;
79
84
  setMenuOpen(false);
80
85
  }, onNavigate: onNavigate, open: menuOpen })] }));
@@ -1,6 +1,6 @@
1
1
  import { type HTMLAttributes, type ReactNode } from 'react';
2
- import { type DescriptionListLayout } from './description-list-layout.js';
3
- export { descriptionListLayouts, type DescriptionListLayout, } from './description-list-layout.js';
2
+ import { type DescriptionListLayout, type DescriptionListTermWidth } from './description-list-layout.js';
3
+ export { descriptionListLayouts, descriptionListTermWidths, type DescriptionListLayout, type DescriptionListTermWidth, } from './description-list-layout.js';
4
4
  export type DescriptionListProps = Omit<HTMLAttributes<HTMLDListElement>, 'children'> & {
5
5
  /** The list's `DescriptionItem`s. */
6
6
  children: ReactNode;
@@ -11,6 +11,14 @@ export type DescriptionListProps = Omit<HTMLAttributes<HTMLDListElement>, 'child
11
11
  * by side at every width, for a short box of facts.
12
12
  */
13
13
  layout?: DescriptionListLayout;
14
+ /**
15
+ * A fixed term column in place of one as wide as the widest term, so the
16
+ * details of several lists on a page start at the same place: `sm` 8rem,
17
+ * `md` 12rem, `lg` 16rem, from `md` up and never more than half the list
18
+ * (a longer term wraps). Below `md` a term still sits over its detail.
19
+ * Only with the `columns` layout.
20
+ */
21
+ termWidth?: DescriptionListTermWidth;
14
22
  };
15
23
  /**
16
24
  * Terms and what each one says, one item per row: the term in a start
@@ -19,7 +27,7 @@ export type DescriptionListProps = Omit<HTMLAttributes<HTMLDListElement>, 'child
19
27
  * the layout breakpoint (`md`) each term sits over its detail. `spread`
20
28
  * sets each detail at its row's end instead, beside its term at every width.
21
29
  */
22
- export declare function DescriptionList({ children, className, layout, ...rest }: DescriptionListProps): import("react").JSX.Element;
30
+ export declare function DescriptionList({ children, className, layout, termWidth, ...rest }: DescriptionListProps): import("react").JSX.Element;
23
31
  export type DescriptionItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
24
32
  /**
25
33
  * What the row describes, such as a name; the consumer's own `Text`. Each
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
- import { assertDescriptionListLayout, } from './description-list-layout.js';
4
- export { descriptionListLayouts, } from './description-list-layout.js';
3
+ import { assertDescriptionListLayout, assertDescriptionListTermWidth, } from './description-list-layout.js';
4
+ export { descriptionListLayouts, descriptionListTermWidths, } from './description-list-layout.js';
5
5
  /**
6
6
  * Terms and what each one says, one item per row: the term in a start
7
7
  * column as wide as the widest term (up to 40% of the list), the detail
@@ -9,9 +9,10 @@ export { descriptionListLayouts, } from './description-list-layout.js';
9
9
  * the layout breakpoint (`md`) each term sits over its detail. `spread`
10
10
  * sets each detail at its row's end instead, beside its term at every width.
11
11
  */
12
- export function DescriptionList({ children, className, layout = 'columns', ...rest }) {
12
+ export function DescriptionList({ children, className, layout = 'columns', termWidth, ...rest }) {
13
13
  assertDescriptionListLayout(layout);
14
- return (_jsx("dl", { className: cx('sw-description-list', layout === 'spread' && 'sw-description-list-spread', className), ...rest, children: children }));
14
+ assertDescriptionListTermWidth(termWidth, layout);
15
+ return (_jsx("dl", { className: cx('sw-description-list', layout === 'spread' && 'sw-description-list-spread', termWidth && `sw-description-list-term-${termWidth}`, className), ...rest, children: children }));
15
16
  }
16
17
  /** One row of a `DescriptionList`: a `dt` and its `dd` in one group. */
17
18
  export function DescriptionItem({ term, children, className, ...rest }) {
@@ -1,5 +1,5 @@
1
1
  import { type FilterChipsProps } from './filter-chips/options.js';
2
- export { filterChipCountAppearances, filterChipsOverflows, filterChipTones, type FilterChipCountAppearance, type FilterChipOption, type FilterChipsOverflow, type FilterChipsProps, type FilterChipTone, } from './filter-chips/options.js';
2
+ export { filterChipCountAppearances, filterChipsOverflows, filterChipsSelectedStyles, filterChipTones, type FilterChipCountAppearance, type FilterChipOption, type FilterChipsOverflow, type FilterChipsProps, type FilterChipsSelectedStyle, type FilterChipTone, } from './filter-chips/options.js';
3
3
  /**
4
4
  * A labelled row of chips: one choice (radios), or several at once
5
5
  * (`multiple`, checkboxes), each chip optionally counted.
@@ -7,7 +7,7 @@ import { FieldErrorRegion } from './FieldErrorRegion.js';
7
7
  import { showsRequiredMark } from './FieldLabelText.js';
8
8
  import { FilterChip } from './filter-chips/FilterChip.js';
9
9
  import { assertFilterChips } from './filter-chips/validate.js';
10
- export { filterChipCountAppearances, filterChipsOverflows, filterChipTones, } from './filter-chips/options.js';
10
+ export { filterChipCountAppearances, filterChipsOverflows, filterChipsSelectedStyles, filterChipTones, } from './filter-chips/options.js';
11
11
  /** The values a change leaves on, in the options' order. */
12
12
  function toggled(options, chosen, value, checked) {
13
13
  return options
@@ -19,7 +19,7 @@ function toggled(options, chosen, value, checked) {
19
19
  * (`multiple`, checkboxes), each chip optionally counted.
20
20
  */
21
21
  export function FilterChips(props) {
22
- const { label, options, labelVisuallyHidden = false, requiredMark = 'visible', countAppearance = 'figure', overflow = 'wrap', error, 'aria-describedby': describedByProp, } = props;
22
+ const { label, options, labelVisuallyHidden = false, requiredMark = 'visible', countAppearance = 'figure', overflow = 'wrap', selectedStyle = 'solid', error, 'aria-describedby': describedByProp, } = props;
23
23
  const name = useId();
24
24
  const errorId = useId();
25
25
  assertFilterChips(props);
@@ -39,5 +39,5 @@ export function FilterChips(props) {
39
39
  else if (checked)
40
40
  props.onChange(value);
41
41
  }
42
- return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": error ? true : undefined, className: cx('sw-filter-chips', overflow === 'scroll' && 'sw-filter-chips-scroll'), children: [_jsxs("legend", { className: cx('sw-filter-chips-legend', labelVisuallyHidden && 'sw-sr-only'), 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) => (_jsx(FilterChip, { checked: isChecked(option.value), countAppearance: countAppearance, name: name, onCheckedChange: (checked) => change(option.value, checked), option: option, required: required, tabStopHere: props.multiple ? true : option.value === entry, type: props.multiple ? 'checkbox' : 'radio' }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-filter-chips-error", id: errorId, message: error })] }));
42
+ return (_jsxs("fieldset", { "aria-describedby": describedBy || undefined, "aria-invalid": error ? true : undefined, className: cx('sw-filter-chips', overflow === 'scroll' && 'sw-filter-chips-scroll', selectedStyle === 'subtle' && 'sw-filter-chips-selected-subtle'), children: [_jsxs("legend", { className: cx('sw-filter-chips-legend', labelVisuallyHidden && 'sw-sr-only'), 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) => (_jsx(FilterChip, { checked: isChecked(option.value), countAppearance: countAppearance, name: name, onCheckedChange: (checked) => change(option.value, checked), option: option, required: required, tabStopHere: props.multiple ? true : option.value === entry, type: props.multiple ? 'checkbox' : 'radio' }, option.value))) }), _jsx(FieldErrorRegion, { className: "sw-filter-chips-error", id: errorId, message: error })] }));
43
43
  }
@@ -1,10 +1,14 @@
1
+ import { fixtureGroupSides } from '../css/css-fixture-list.js';
1
2
  import { type FixtureGroupProps } from './fixture/types.js';
2
3
  export type { FixtureGroupProps } from './fixture/types.js';
4
+ export { fixtureGroupSides };
5
+ export type { FixtureGroupSides } from '../css/css-fixture-list.js';
3
6
  /**
4
7
  * A run of fixtures under a caption, such as a match day at a venue: the
5
8
  * caption names a list of FixtureRows, whose columns line up across the
6
9
  * run (one grid, each row a subgrid of it). `titleHidden` keeps the
7
- * caption as the list's name without drawing it. A blank title throws a
8
- * `RangeError`.
10
+ * caption as the list's name without drawing it. `sides="fixed"` keeps the
11
+ * centre at one place across the runs of a page. A blank title or an unknown
12
+ * `sides` throws a `RangeError`.
9
13
  */
10
- export declare function FixtureGroup({ children, title, titleHidden, }: FixtureGroupProps): import("react").JSX.Element;
14
+ export declare function FixtureGroup({ children, sides, title, titleHidden, }: FixtureGroupProps): import("react").JSX.Element;
@@ -1,17 +1,24 @@
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 { cx } from '../class-names.js';
5
+ import { fixtureGroupSides } from '../css/css-fixture-list.js';
6
+ import { isOneOf } from '../is-one-of.js';
4
7
  import { FixtureListContext } from './fixture/fixture-list-context.js';
8
+ export { fixtureGroupSides };
5
9
  /**
6
10
  * A run of fixtures under a caption, such as a match day at a venue: the
7
11
  * caption names a list of FixtureRows, whose columns line up across the
8
12
  * run (one grid, each row a subgrid of it). `titleHidden` keeps the
9
- * caption as the list's name without drawing it. A blank title throws a
10
- * `RangeError`.
13
+ * caption as the list's name without drawing it. `sides="fixed"` keeps the
14
+ * centre at one place across the runs of a page. A blank title or an unknown
15
+ * `sides` throws a `RangeError`.
11
16
  */
12
- export function FixtureGroup({ children, title, titleHidden = false, }) {
17
+ export function FixtureGroup({ children, sides = 'fluid', title, titleHidden = false, }) {
13
18
  if (typeof title !== 'string' || !title.trim())
14
19
  throw new RangeError('FixtureGroup title must be nonempty');
20
+ if (!isOneOf(fixtureGroupSides, sides))
21
+ throw new RangeError(`Unknown FixtureGroup sides: ${String(sides)}`);
15
22
  const titleId = useId();
16
- return (_jsxs("div", { className: "sw-fixture-group", children: [_jsx("div", { className: titleHidden ? 'sw-sr-only' : 'sw-fixture-group-title', id: titleId, children: title }), _jsx("ul", { "aria-labelledby": titleId, className: "sw-fixture-list", role: "list", children: _jsx(FixtureListContext.Provider, { value: true, children: children }) })] }));
23
+ return (_jsxs("div", { className: "sw-fixture-group", children: [_jsx("div", { className: titleHidden ? 'sw-sr-only' : 'sw-fixture-group-title', id: titleId, children: title }), _jsx("ul", { "aria-labelledby": titleId, className: cx('sw-fixture-list', sides === 'fixed' && 'sw-fixture-list-sides-fixed'), role: "list", children: _jsx(FixtureListContext.Provider, { value: true, children: children }) })] }));
17
24
  }
@@ -1,9 +1,9 @@
1
1
  import { type SpacingStep } from '@scalewing/tokens';
2
2
  import { type Breakpoint } from '../css/breakpoints.js';
3
- import { type GridColumns, type GridFit, gridFits } from '../css/css-grid.js';
3
+ import { type GridColumns, type GridFit, gridFits, type GridRail, gridRails } from '../css/css-grid.js';
4
4
  import { type BoxProps } from './Box.js';
5
5
  import { type Align } from './Stack.js';
6
- export { gridFits, type GridColumns, type GridFit };
6
+ export { gridFits, gridRails, type GridColumns, type GridFit, type GridRail };
7
7
  export type GridProps = BoxProps & {
8
8
  /** Where each child sits in the height of its row; unset, children stretch. */
9
9
  align?: Align;
@@ -18,5 +18,13 @@ export type GridProps = BoxProps & {
18
18
  */
19
19
  fit?: GridFit;
20
20
  gap?: SpacingStep;
21
+ /**
22
+ * A main column beside a fixed 20rem side rail, the main column taking the
23
+ * free space: the first child is the main column, the second the rail.
24
+ * The value is the breakpoint from which they sit side by side (`md`
25
+ * 48rem, `lg` 64rem); below it they stack, the main column first. Not with
26
+ * `columns`, `columnsBelow` or `fit`; a child's `columnSpan` is ignored.
27
+ */
28
+ rail?: GridRail;
21
29
  };
22
- export declare function Grid({ align, className, columns, columnsBelow, fit, gap, ...rest }: GridProps): import("react").JSX.Element;
30
+ export declare function Grid({ align, className, columns, columnsBelow, fit, gap, rail, ...rest }: GridProps): import("react").JSX.Element;
@@ -1,21 +1,39 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
3
  import { breakpoints } from '../css/breakpoints.js';
4
- import { gridColumnsBelowClass, gridColumnsClass, gridFitClass, gridFits, } from '../css/css-grid.js';
4
+ import { gridColumnsBelowClass, gridColumnsClass, gridFitClass, gridFits, gridRailClass, gridRails, } from '../css/css-grid.js';
5
5
  import { spacingClass } from '../css/spacing-classes.js';
6
6
  import { assertGridCount } from '../grid-counts.js';
7
7
  import { isOneOf } from '../is-one-of.js';
8
8
  import { Box } from './Box.js';
9
- export { gridFits };
10
- export function Grid({ align, className, columns, columnsBelow, fit, gap = 0, ...rest }) {
9
+ export { gridFits, gridRails };
10
+ /** Fails closed on a layout that asks for two ways to set the columns. */
11
+ function assertGridRail({ columns, columnsBelow, fit, rail, }) {
12
+ if (rail === undefined)
13
+ return;
14
+ if (!isOneOf(gridRails, rail))
15
+ throw new RangeError(`Unknown Grid rail: ${String(rail)}`);
16
+ if (columns !== undefined || columnsBelow !== undefined || fit !== undefined)
17
+ throw new RangeError('A rail Grid takes no columns, columnsBelow or fit; rail sets them');
18
+ }
19
+ /** The classes that set a grid's columns, from the one way it was asked for. */
20
+ function columnClasses({ columns, fit, rail, }) {
21
+ if (rail)
22
+ return gridRailClass(rail);
23
+ if (fit)
24
+ return gridFitClass(fit);
25
+ const fixedColumns = columns ?? 1;
26
+ assertGridCount('columns', fixedColumns);
27
+ return gridColumnsClass(fixedColumns);
28
+ }
29
+ export function Grid({ align, className, columns, columnsBelow, fit, gap = 0, rail, ...rest }) {
30
+ assertGridRail({ columns, columnsBelow, fit, rail });
11
31
  if (fit !== undefined) {
12
32
  if (!isOneOf(gridFits, fit))
13
33
  throw new RangeError(`Unknown Grid fit: ${String(fit)}`);
14
34
  if (columns !== undefined || columnsBelow !== undefined)
15
35
  throw new RangeError('A fitted Grid takes no columns or columnsBelow; fit sets them');
16
36
  }
17
- const fixedColumns = columns ?? 1;
18
- assertGridCount('columns', fixedColumns);
19
37
  const belowClasses = breakpoints.flatMap((breakpoint) => {
20
38
  const count = columnsBelow?.[breakpoint];
21
39
  if (count === undefined)
@@ -23,5 +41,5 @@ export function Grid({ align, className, columns, columnsBelow, fit, gap = 0, ..
23
41
  assertGridCount('columnsBelow', count);
24
42
  return [gridColumnsBelowClass(breakpoint, count)];
25
43
  });
26
- return (_jsx(Box, { className: cx('sw-grid', fit ? gridFitClass(fit) : gridColumnsClass(fixedColumns), ...belowClasses, spacingClass('gap', 'all', gap), align && `sw-align-${align}`, className), ...rest }));
44
+ return (_jsx(Box, { className: cx('sw-grid', columnClasses({ columns, fit, rail }), ...belowClasses, spacingClass('gap', 'all', gap), align && `sw-align-${align}`, className), ...rest }));
27
45
  }
@@ -0,0 +1,40 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ /** The link back to where the page was reached from, above the title. */
3
+ export type PageHeaderBack = {
4
+ /** Its words, such as the parent page's name. */
5
+ label: string;
6
+ href: string;
7
+ /**
8
+ * Called for a plain press (the main button, no modifier key), after the
9
+ * browser's own navigation is prevented, so a client router moves instead.
10
+ * Without it the link navigates.
11
+ */
12
+ onNavigate?: () => void;
13
+ };
14
+ export type PageHeaderProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
15
+ /** The page's title, its one `h1`, in the `heading` size (28/34). */
16
+ title: ReactNode;
17
+ /** A short muted line under the title, in the `bodySm` size (15/22). */
18
+ subtitle?: ReactNode;
19
+ /** A link back above the title. */
20
+ back?: PageHeaderBack;
21
+ /**
22
+ * The page's actions, at the title row's end; they move under the words
23
+ * when they do not fit beside them. Leave it out rather than passing an
24
+ * empty fragment, which keeps the slot.
25
+ */
26
+ actions?: ReactNode;
27
+ /**
28
+ * A row under the title, such as Tabs or FilterChips that switch the page.
29
+ * Leave it out rather than passing an empty fragment, which keeps the slot.
30
+ */
31
+ below?: ReactNode;
32
+ };
33
+ /**
34
+ * A workspace page's opening: an optional link back, the title (`h1`,
35
+ * `heading` size) with an optional muted subtitle under it and the actions at
36
+ * its end, and an optional row below. It wraps cleanly when narrow. Place it
37
+ * inside `main`, where a `header` is no banner landmark. A blank title or
38
+ * back link throws a `RangeError`.
39
+ */
40
+ export declare function PageHeader({ actions, back, below, className, subtitle, title, ...rest }: PageHeaderProps): import("react").JSX.Element;
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cx } from '../class-names.js';
4
+ import { tabStop } from '../tab-stop.js';
5
+ import { plainPress } from './link-press.js';
6
+ import { Text } from './Text.js';
7
+ function isBlank(node) {
8
+ return (node === undefined ||
9
+ node === null ||
10
+ node === false ||
11
+ (typeof node === 'string' && node.trim() === ''));
12
+ }
13
+ /** Fails closed on a header with no title or a back link with nothing to say. */
14
+ function assertPageHeader({ back, title, }) {
15
+ if (isBlank(title))
16
+ throw new RangeError('A PageHeader needs a title');
17
+ if (back !== undefined && (!back.label?.trim() || !back.href?.trim()))
18
+ throw new RangeError('A PageHeader back link needs a label and an href');
19
+ }
20
+ /**
21
+ * A workspace page's opening: an optional link back, the title (`h1`,
22
+ * `heading` size) with an optional muted subtitle under it and the actions at
23
+ * its end, and an optional row below. It wraps cleanly when narrow. Place it
24
+ * inside `main`, where a `header` is no banner landmark. A blank title or
25
+ * back link throws a `RangeError`.
26
+ */
27
+ export function PageHeader({ actions, back, below, className, subtitle, title, ...rest }) {
28
+ assertPageHeader({ back, title });
29
+ function onBack(event) {
30
+ if (!back?.onNavigate || !plainPress(event))
31
+ return;
32
+ event.preventDefault();
33
+ back.onNavigate();
34
+ }
35
+ return (_jsxs("header", { ...rest, className: cx('sw-page-header', className), children: [back ? (_jsx("a", { className: "sw-page-header-back", href: back.href, onClick: onBack, tabIndex: tabStop, children: back.label })) : null, _jsxs("div", { className: "sw-page-header-row", children: [_jsxs("div", { className: "sw-page-header-words", children: [_jsx(Text, { as: "h1", variant: "heading", children: title }), isBlank(subtitle) ? null : (_jsx(Text, { as: "p", color: "muted", variant: "bodySm", children: subtitle }))] }), isBlank(actions) ? null : (_jsx("div", { className: "sw-page-header-actions", children: actions }))] }), isBlank(below) ? null : (_jsx("div", { className: "sw-page-header-below", children: below }))] }));
36
+ }
@@ -1,8 +1,27 @@
1
1
  import { type HTMLAttributes, type ReactNode } from 'react';
2
2
  import { type AccessibleName } from '../accessible-name.js';
3
+ import { type NavCountTone } from './NavCount.js';
3
4
  export type TabItem = {
4
5
  id: string;
5
6
  label: ReactNode;
7
+ /**
8
+ * How many things wait behind the tab: the shared count pill after its
9
+ * label, read in its name ("Postponed 4"). A whole number; 0 (or none)
10
+ * shows no pill. For a count that only says how many, with nothing to do,
11
+ * write it in the `label` ("Postponed (4)").
12
+ */
13
+ count?: number;
14
+ /**
15
+ * What the count means, in the consumer's words ("4 need a time"): read
16
+ * in the name in place of the bare number. Needs `count`.
17
+ */
18
+ countLabel?: string;
19
+ /**
20
+ * The pill's colour, NavCount's tones: `danger` (the default), `warning`
21
+ * (amber) for work that needs the person but is not urgent, `accent` for
22
+ * news. Needs `count`.
23
+ */
24
+ countTone?: NavCountTone;
6
25
  };
7
26
  export type TabsProps = AccessibleName & {
8
27
  /** Prefix for the tab and panel ids, so a `TabPanel` can point back at its tab. */
@@ -1,7 +1,8 @@
1
1
  'use client';
2
- import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useRef, } from 'react';
4
4
  import { cx } from '../class-names.js';
5
+ import { NavCount } from './NavCount.js';
5
6
  import { useScrollOverflow } from './use-scroll-overflow.js';
6
7
  export function tabId(tabsId, id) {
7
8
  return `${tabsId}-tab-${id}`;
@@ -9,6 +10,14 @@ export function tabId(tabsId, id) {
9
10
  export function tabPanelId(tabsId, id) {
10
11
  return `${tabsId}-panel-${id}`;
11
12
  }
13
+ /** Fails closed on a count's words or colour with no count to go with them. */
14
+ function assertTabItems(items) {
15
+ for (const item of items) {
16
+ if (item.count === undefined &&
17
+ (item.countLabel !== undefined || item.countTone !== undefined))
18
+ throw new RangeError('A tab countLabel and countTone come with a count');
19
+ }
20
+ }
12
21
  /**
13
22
  * A tab strip with `tablist` semantics for the sections of one page. Arrow
14
23
  * keys, Home and End move the focus and select at once; the strip scrolls
@@ -18,6 +27,7 @@ export function tabPanelId(tabsId, id) {
18
27
  * keeps it at the top of the viewport over a long panel.
19
28
  */
20
29
  export function Tabs({ id, items, onChange, sticky = false, value, ...labelProps }) {
30
+ assertTabItems(items);
21
31
  const tabs = useRef(new Map());
22
32
  const strip = useRef(null);
23
33
  const overflow = useScrollOverflow(strip);
@@ -51,14 +61,14 @@ export function Tabs({ id, items, onChange, sticky = false, value, ...labelProps
51
61
  }
52
62
  return (_jsx("div", { className: cx('sw-tabs', sticky && 'sw-tabs-sticky', overflow.start && 'sw-scroll-more-start', overflow.end && 'sw-scroll-more-end'), onKeyDown: onKeyDown, ref: strip, role: "tablist", ...labelProps, children: items.map((item) => {
53
63
  const selected = item.id === value;
54
- return (_jsx("button", { "aria-controls": tabPanelId(id, item.id), "aria-selected": selected, className: cx('sw-tab', selected && 'sw-tab-selected'), id: tabId(id, item.id), onClick: () => select(item), ref: (node) => {
64
+ return (_jsxs("button", { "aria-controls": tabPanelId(id, item.id), "aria-selected": selected, className: cx('sw-tab', selected && 'sw-tab-selected', Boolean(item.count) && 'sw-tab-counted'), id: tabId(id, item.id), onClick: () => select(item), ref: (node) => {
55
65
  if (node) {
56
66
  tabs.current.set(item.id, node);
57
67
  }
58
68
  else {
59
69
  tabs.current.delete(item.id);
60
70
  }
61
- }, role: "tab", tabIndex: selected ? 0 : -1, type: "button", children: item.label }, item.id));
71
+ }, role: "tab", tabIndex: selected ? 0 : -1, type: "button", children: [item.label, item.count !== undefined ? (_jsx(NavCount, { count: item.count, countLabel: item.countLabel, tone: item.countTone })) : null] }, item.id));
62
72
  }) }));
63
73
  }
64
74
  /** The content of one tab, labelled by its tab and hidden while another tab is current. */
@@ -1,10 +1,12 @@
1
1
  import { type SemanticColorKey, type TypographyVariant } from '@scalewing/tokens';
2
2
  import { type HTMLAttributes, type LabelHTMLAttributes } from 'react';
3
+ import { textMaxLines, type TextMaxLines } from '../css/css-text.js';
3
4
  export type TextElement = 'p' | 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'label' | 'strong';
4
5
  export type TextAlign = 'start' | 'center' | 'end';
5
6
  /** The faces Text sets its words in: the theme's sans or the platform's monospace. */
6
7
  export declare const textFonts: readonly ["sans", "mono"];
7
8
  export type TextFont = (typeof textFonts)[number];
9
+ export { textMaxLines, type TextMaxLines };
8
10
  export type TextProps = HTMLAttributes<HTMLElement> & Pick<LabelHTMLAttributes<HTMLLabelElement>, 'htmlFor'> & {
9
11
  align?: TextAlign;
10
12
  as?: TextElement;
@@ -18,6 +20,15 @@ export type TextProps = HTMLAttributes<HTMLElement> & Pick<LabelHTMLAttributes<H
18
20
  */
19
21
  font?: TextFont;
20
22
  truncate?: boolean;
23
+ /**
24
+ * Cuts the words with "…" after this many lines, such as a long name in
25
+ * a ContextSwitcher's trigger, so a long one cannot push what follows
26
+ * it down. Only the drawing is cut: assistive technology still reads
27
+ * all the words, so show them in full somewhere a sighted person can
28
+ * reach too. Not with `truncate`; anything but 2 or 3 throws a
29
+ * `RangeError`.
30
+ */
31
+ maxLines?: TextMaxLines;
21
32
  variant?: TypographyVariant;
22
33
  };
23
34
  export declare const Text: import("react").ForwardRefExoticComponent<HTMLAttributes<HTMLElement> & Pick<LabelHTMLAttributes<HTMLLabelElement>, "htmlFor"> & {
@@ -33,5 +44,14 @@ export declare const Text: import("react").ForwardRefExoticComponent<HTMLAttribu
33
44
  */
34
45
  font?: TextFont;
35
46
  truncate?: boolean;
47
+ /**
48
+ * Cuts the words with "…" after this many lines, such as a long name in
49
+ * a ContextSwitcher's trigger, so a long one cannot push what follows
50
+ * it down. Only the drawing is cut: assistive technology still reads
51
+ * all the words, so show them in full somewhere a sighted person can
52
+ * reach too. Not with `truncate`; anything but 2 or 3 throws a
53
+ * `RangeError`.
54
+ */
55
+ maxLines?: TextMaxLines;
36
56
  variant?: TypographyVariant;
37
57
  } & import("react").RefAttributes<HTMLElement>>;
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { forwardRef, } from 'react';
3
3
  import { cx } from '../class-names.js';
4
+ import { textClampClass, textMaxLines, } from '../css/css-text.js';
4
5
  /** The faces Text sets its words in: the theme's sans or the platform's monospace. */
5
6
  export const textFonts = ['sans', 'mono'];
7
+ export { textMaxLines };
6
8
  const defaultElement = {
7
9
  body: 'p',
10
+ bodySm: 'p',
8
11
  caption: 'span',
9
12
  data: 'span',
10
13
  display: 'h1',
@@ -15,9 +18,13 @@ const defaultElement = {
15
18
  lead: 'p',
16
19
  title: 'h3',
17
20
  };
18
- export const Text = forwardRef(function Text({ align, as, className, color = 'text', font = 'sans', style, truncate = false, variant = 'body', ...rest }, ref) {
21
+ export const Text = forwardRef(function Text({ align, as, className, color = 'text', font = 'sans', maxLines, style, truncate = false, variant = 'body', ...rest }, ref) {
19
22
  if (!textFonts.includes(font))
20
23
  throw new RangeError(`Unknown Text font: ${String(font)}`);
24
+ if (maxLines !== undefined && !textMaxLines.includes(maxLines))
25
+ throw new RangeError(`Unknown Text maxLines: ${String(maxLines)}`);
26
+ if (maxLines !== undefined && truncate)
27
+ throw new RangeError('Text takes truncate or maxLines, not both');
21
28
  const Component = as ?? defaultElement[variant];
22
- return (_jsx(Component, { ref: ref, className: cx(`sw-text-${variant}`, align && `sw-text-align-${align}`, font === 'mono' && 'sw-font-mono', truncate && 'sw-truncate', className), style: { color: `var(--sw-color-${color})`, ...style }, ...rest }));
29
+ return (_jsx(Component, { ref: ref, className: cx(`sw-text-${variant}`, align && `sw-text-align-${align}`, font === 'mono' && 'sw-font-mono', truncate && 'sw-truncate', maxLines && textClampClass(maxLines), className), style: { color: `var(--sw-color-${color})`, ...style }, ...rest }));
23
30
  });
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode } from 'react';
2
+ import { type AppShellContentMaxWidth, type AppShellSidebarTone } from '../../css/css-app-shell.js';
2
3
  import { type NavCountTone } from '../NavCount.js';
3
4
  import { type NavItemCurrent } from '../nav-items.js';
4
5
  export type AppShellNavItem = {
@@ -134,6 +135,20 @@ export type AppShellProps = {
134
135
  * shell draws none.
135
136
  */
136
137
  skipLink?: AppShellSkipLink;
138
+ /**
139
+ * Caps the page beside the sidebar: `none` (the default) lets it fill
140
+ * `main`; `container` centres it at the container width (72rem), so a
141
+ * sparse table does not stretch on a wide window. `pageBar` stays the full
142
+ * width of `main`, and a sticky bar in the page keeps sticking, at the
143
+ * page's width.
144
+ */
145
+ contentMaxWidth?: AppShellContentMaxWidth;
146
+ /**
147
+ * The sidebar's and the drawer's fill: `surface` (the default) or `subtle`,
148
+ * a quiet grey that sets the navigation apart from a white page. The words
149
+ * on it and the current destination keep 4.5:1 in every palette.
150
+ */
151
+ sidebarTone?: AppShellSidebarTone;
137
152
  /** The page, rendered in `main`. */
138
153
  children: ReactNode;
139
154
  };
@@ -1,7 +1,7 @@
1
1
  import { type AppShellProps } from './types.js';
2
2
  /**
3
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).
4
+ * its layout options, its groups and their items (ids unique across every
5
+ * group, each with an icon, which is all the rail shows).
6
6
  */
7
- export declare function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, skipLink, }: Pick<AppShellProps, 'groups' | 'labels' | 'menuCount' | 'menuCountLabel' | 'menuCountTone' | 'navLabel' | 'skipLink'>): void;
7
+ export declare function assertAppShellProps({ contentMaxWidth, groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, sidebarTone, skipLink, }: Pick<AppShellProps, 'contentMaxWidth' | 'groups' | 'labels' | 'menuCount' | 'menuCountLabel' | 'menuCountTone' | 'navLabel' | 'sidebarTone' | 'skipLink'>): void;
@@ -1,3 +1,4 @@
1
+ import { appShellContentMaxWidths, appShellSidebarTones, } from '../../css/css-app-shell.js';
1
2
  import { isOneOf } from '../../is-one-of.js';
2
3
  import { navCountTones } from '../NavCount.js';
3
4
  import { assertNavItems } from '../nav-items.js';
@@ -45,10 +46,15 @@ function assertMenuCount({ menuCount, menuCountLabel, menuCountTone, }) {
45
46
  }
46
47
  /**
47
48
  * 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).
49
+ * its layout options, its groups and their items (ids unique across every
50
+ * group, each with an icon, which is all the rail shows).
50
51
  */
51
- export function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, skipLink, }) {
52
+ export function assertAppShellProps({ contentMaxWidth, groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, sidebarTone, skipLink, }) {
53
+ if (contentMaxWidth !== undefined &&
54
+ !isOneOf(appShellContentMaxWidths, contentMaxWidth))
55
+ throw new RangeError(`AppShell contentMaxWidth must be one of ${appShellContentMaxWidths.join(', ')}`);
56
+ if (sidebarTone !== undefined && !isOneOf(appShellSidebarTones, sidebarTone))
57
+ throw new RangeError(`AppShell sidebarTone must be one of ${appShellSidebarTones.join(', ')}`);
52
58
  if (isBlank(navLabel))
53
59
  throw new RangeError('AppShell navLabel must be nonempty');
54
60
  if (isBlank(labels?.openMenu) || isBlank(labels?.closeMenu))