@scalewing/react 1.46.0 → 1.47.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 (38) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +59 -2
  3. package/dist/app-shell.test-support.d.ts +5 -0
  4. package/dist/app-shell.test-support.js +45 -0
  5. package/dist/components/AppShell.d.ts +12 -0
  6. package/dist/components/AppShell.js +48 -0
  7. package/dist/components/NavCount.d.ts +12 -5
  8. package/dist/components/NavCount.js +9 -4
  9. package/dist/components/SectionNav.d.ts +2 -1
  10. package/dist/components/SectionNav.js +3 -19
  11. package/dist/components/app-shell/AppShellDrawer.d.ts +24 -0
  12. package/dist/components/app-shell/AppShellDrawer.js +17 -0
  13. package/dist/components/app-shell/AppShellLink.d.ts +14 -0
  14. package/dist/components/app-shell/AppShellLink.js +27 -0
  15. package/dist/components/app-shell/AppShellNav.d.ts +15 -0
  16. package/dist/components/app-shell/AppShellNav.js +29 -0
  17. package/dist/components/app-shell/AppShellSidebar.d.ts +18 -0
  18. package/dist/components/app-shell/AppShellSidebar.js +18 -0
  19. package/dist/components/app-shell/types.d.ts +87 -0
  20. package/dist/components/app-shell/types.js +1 -0
  21. package/dist/components/app-shell/validate.d.ts +6 -0
  22. package/dist/components/app-shell/validate.js +52 -0
  23. package/dist/components/nav-items.d.ts +21 -0
  24. package/dist/components/nav-items.js +26 -0
  25. package/dist/css/css-app-shell-drawer.d.ts +2 -0
  26. package/dist/css/css-app-shell-drawer.js +91 -0
  27. package/dist/css/css-app-shell-nav.d.ts +3 -0
  28. package/dist/css/css-app-shell-nav.js +167 -0
  29. package/dist/css/css-app-shell.d.ts +4 -0
  30. package/dist/css/css-app-shell.js +150 -0
  31. package/dist/css/css-components.js +3 -0
  32. package/dist/css/css-count-pill.d.ts +7 -0
  33. package/dist/css/css-count-pill.js +42 -12
  34. package/dist/css/stylesheet.js +2 -0
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.js +1 -0
  37. package/dist/styles.css +328 -7
  38. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.47.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 26fe8e5: `AppShell`: a web workspace's frame with a pinned side navigation of grouped destinations beside the page in `main`, which scrolls with the document. From 75rem a full sidebar (header, groups with optional captions, footer; glyph, label and count per destination, the current one on a quiet fill), sticky at the viewport's height with only its nav scrolling; from `lg` to 75rem an icon rail whose labels stay in each link's name and show in a Tooltip, the count a pill on the glyph, with `railHeader`/`railFooter`; below `lg` a sticky AppHeader `bar` whose menu button opens the same navigation in a modal drawer from the inline start (Escape, a press outside, its close button or choosing a destination close it, focus returning to the menu button). Items follow `SectionNav`'s rules plus a required `icon`; `onNavigate` takes a plain press for a client router; invalid groups, items or words throw a `RangeError`. New classes `sw-app-shell*` (the count joins the shared count pill as `sw-app-shell-count`); the 75rem width is internal, not a public breakpoint. `SectionNav` and `AppShell` now share one item validation (`nav-items.ts`); `SectionNav`'s behaviour and messages are unchanged. No new tokens or dependencies. Request: `docs/requests/futmas-app-shell.md`.
8
+ - 26fe8e5: `NavCount` `tone="warning"`: an amber count for work that needs the person but is not urgent, the warning tint with the warning ink (a warning Notice's, 4.5:1 in every palette) inside a warning edge; `navCountTones` is now `['danger', 'accent', 'warning']`. `AppShell` items take `countTone` (NavCount's tones, `danger` by default; needs a `count`). New classes `sw-nav-count-warning`, `sw-app-shell-count-accent` and `sw-app-shell-count-warning`. The default stays danger. No new tokens or dependencies. Request: `docs/requests/futmas-nav-count-warning.md`.
9
+
3
10
  ## 1.46.0
4
11
 
5
12
  ### Minor Changes
package/README.md CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  Accordion,
10
10
  ActionBar,
11
11
  ActionMenu,
12
+ AppShell,
12
13
  BarChart,
13
14
  ChartLegend,
14
15
  ColumnChart,
@@ -110,7 +111,7 @@ Import the CSS once at the application entry. Do not copy it into your source tr
110
111
  </Field>
111
112
  ```
112
113
 
113
- Every press control and link takes an explicit `tabindex="0"`: `Button` (and the controls built on it, `CalendarButton` and `InfoTip`), `Box as="a"` with an `href`, `SectionNav` links, the `Select`, `MultiSelect` and `ActionMenu` triggers, `SegmentedControl` items (every item, as outside Safari: its arrow keys change the value, not the focus, so it is not a roving group), `DateField`'s calendar button and month steps, a search `Field`'s clear button, a `Table`'s sort and `TableCell onPress` buttons, `Checkbox`, `Switch`, and the checked radio of a `RadioGroup` or `FilterChips` (the first enabled one while none is; the others keep no `tabindex`, so each browser's arrow-key group rule stands). Safari's default (its "Press Tab to highlight each item" setting off) leaves a button, a link, a checkbox and a radio out of the Tab order unless it carries one, so a Safari user would Tab past them; the explicit `0` gives them the order every other browser already does. A `tabIndex` you pass wins, and controls that are deliberately no tab stop keep their `-1`: a `Stepper`'s − and +, and a roving item (a `Tabs` tab, a menu item, a calendar day) other than the current one. A `<summary>` is already in Safari's order and takes none.
114
+ Every press control and link takes an explicit `tabindex="0"`: `Button` (and the controls built on it, `CalendarButton` and `InfoTip`), `Box as="a"` with an `href`, `SectionNav` links, `AppShell` links and its menu and close buttons, the `Select`, `MultiSelect` and `ActionMenu` triggers, `SegmentedControl` items (every item, as outside Safari: its arrow keys change the value, not the focus, so it is not a roving group), `DateField`'s calendar button and month steps, a search `Field`'s clear button, a `Table`'s sort and `TableCell onPress` buttons, `Checkbox`, `Switch`, and the checked radio of a `RadioGroup` or `FilterChips` (the first enabled one while none is; the others keep no `tabindex`, so each browser's arrow-key group rule stands). Safari's default (its "Press Tab to highlight each item" setting off) leaves a button, a link, a checkbox and a radio out of the Tab order unless it carries one, so a Safari user would Tab past them; the explicit `0` gives them the order every other browser already does. A `tabIndex` you pass wins, and controls that are deliberately no tab stop keep their `-1`: a `Stepper`'s − and +, and a roving item (a `Tabs` tab, a menu item, a calendar day) other than the current one. A `<summary>` is already in Safari's order and takes none.
114
115
 
115
116
  An element a script focuses, such as a notice or a card announced after a save, takes the same accent focus ring as the canvas's links and native controls, 2 px past its edge (`--sw-focus-ring-width` and `--sw-focus-ring-offset`), instead of the browser's outline. Give it `tabIndex={-1}`, so it takes focus from a script but is no tab stop, and put the focus on the whole card rather than its text, so the ring goes round it clear of the words; a `Box` round the card with the card's `radius` makes the ring follow its corners. As with the browser's own outline, the ring shows only when focus is visible (`:focus-visible`): after a keyboard press, not after a mouse press. The rule has zero specificity, so a component's own ring always wins.
116
117
 
@@ -517,7 +518,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
517
518
 
518
519
  `appearance="words"` (opt-in; default `"default"`, `navAppearances`) draws every destination, a link or a `Button` of any variant, as plain words: no fill, border or underline, label type, the others in the muted color at medium weight and the current one (`aria-current`, not `"false"`) in the text color at label weight. A Button keeps its size, padding, touch target and focus ring; hover lifts a destination to the text color where the pointer can hover, never a disabled one. The current mark is color and weight, so each destination's words must be visible: a glyph-only destination has no weight to change (keep `default` for a glyph rail). Forced colors draw the current one's border in Highlight and underline its words. An unknown value throws a `RangeError`.
519
520
 
520
- `NavCount` puts a count after a destination's words, such as the tasks waiting in an area: place it inside the link or `Button`, after its words (`<a href="/tasks">Tasks<NavCount count={8} /></a>`). It is the pill `SectionNav`'s item `count` draws (the danger color with its on-color figures, an edge in forced colors), a space-2 after the words in a link or a Button, the link keeping its siblings' height, and it is read as part of the destination's name ("Tasks 8"). `count` is a whole number; 0 shows nothing. `countLabel` says what it means in your words ("8 tasks to do"): it is read in place of the bare number ("Tasks 8 tasks to do") and the pill is hidden from assistive technology. `tone="accent"` (default `"danger"`, `navCountTones`) draws the pill in the accent color with its on-color figures, for a count that is news rather than work waiting, such as what was added since the person last looked; its size, spacing and name are the same. Give an accent count a `countLabel` ("3 new") so its words, not its color, say it is news, and place either tone on a neutral or ghost destination, never a filled one of the same color. A count that is not a whole number from 0, a blank `countLabel` or an unknown `tone` throws a `RangeError`.
521
+ `NavCount` puts a count after a destination's words, such as the tasks waiting in an area: place it inside the link or `Button`, after its words (`<a href="/tasks">Tasks<NavCount count={8} /></a>`). It is the pill `SectionNav`'s item `count` draws (the danger color with its on-color figures, an edge in forced colors), a space-2 after the words in a link or a Button, the link keeping its siblings' height, and it is read as part of the destination's name ("Tasks 8"). `count` is a whole number; 0 shows nothing. `countLabel` says what it means in your words ("8 tasks to do"): it is read in place of the bare number ("Tasks 8 tasks to do") and the pill is hidden from assistive technology. `tone="accent"` (default `"danger"`, `navCountTones`) draws the pill in the accent color with its on-color figures, for a count that is news rather than work waiting, such as what was added since the person last looked; its size, spacing and name are the same. `tone="warning"` draws it in amber, for work that needs the person but is not urgent ("needs you"): the warning tint with the warning ink (a warning `Notice`'s, 4.5:1 in every palette) inside a warning edge. Give an accent or warning count a `countLabel` ("3 new", "2 to confirm") so its words, not its color, say what it is, and place either tone on a neutral or ghost destination, never a filled one of the same color. A count that is not a whole number from 0, a blank `countLabel` or an unknown `tone` throws a `RangeError`.
521
522
 
522
523
  ```tsx
523
524
  <Nav appearance="words" aria-label="Areas">
@@ -549,6 +550,62 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
549
550
  />
550
551
  ```
551
552
 
553
+ `AppShell` is a web workspace's frame: a pinned side navigation of grouped destinations beside the page, which it renders in `main` and which scrolls with the document (`main` is never its own scroll box). `groups` is a list of `{ id, label?, items }`; each item is `{ id, label, href, icon, count?, countLabel?, countTone?, current? }` with `SectionNav`'s rules for `current`, `count` and `countLabel` (the same shared count pill, read in the link's name), `countTone` one of `NavCount`'s tones (`danger` by default, `warning` for amber, `accent`; it needs a `count`), and `icon`, the consumer's decorative glyph, is required because the rail shows nothing else. `navLabel` names the navigation (and the drawer). `onNavigate` takes a plain press for a client router, as `SectionNav`'s does: the item, after the browser's navigation is prevented; a modifier or middle press keeps the browser's behaviour.
554
+
555
+ The layout follows the window, with generated media queries only. From 75rem (1200 px, an internal width of the component, not a public breakpoint) the sidebar is 15rem on the solid surface with a hairline at its end, sticky at the top at the viewport's height: `header` (a workspace switcher such as an `ActionMenu`), the groups (a group's `label` is a small muted caption naming its list), `footer` (the account menu) under a hairline; only the nav scrolls, and only when it is taller than the window. Each destination shows its glyph, label and count; the current one takes `SectionNav filled`'s quiet fill at the label weight; a coarse pointer gets 44 px rows. From `lg` (64rem) to 75rem it is a rail of glyphs, the 44 px target wide: labels are visually hidden but stay in each link's name and show in a `Tooltip` on hover or keyboard focus, a count is the pill on the glyph's corner, group captions become hairlines, and `railHeader`/`railFooter` replace `header`/`footer` (none is drawn without them). Below `lg` there is no sidebar: a sticky `AppHeader appearance="bar"` holds an icon-only ghost menu button (`menuIcon`, named `labels.openMenu`), `barTitle` and `barEnd`. The menu opens a modal drawer on the top layer from the inline start (the end in right-to-left), sliding in unless reduced motion is asked for, with a close button named `labels.closeMenu` where the menu button was, then `header`, the nav and `footer`. Escape, a press outside, the close button or a plain press on a destination close it; after the first three focus returns to the menu button, while a chosen destination leaves focus to the page it moves to. A window widened past `lg` closes it too. `header` and `footer` render in the sidebar and again in the open drawer (and `railHeader`/`railFooter` beside them in the hidden sidebar), so a slot's content can be mounted twice at once with separate state: give it no fixed DOM `id` (use `useId`), and keep state that must survive opening the menu, such as a selection, in your own component. Forced colours draw the links as `LinkText`, the current one with a `Highlight` edge. A blank `navLabel` or menu label, no groups, an empty or duplicate group id, a blank group label, an empty group, an empty or duplicate item id (across groups), an empty label, a missing icon (or one that draws nothing: a boolean, `''` or `0`), a `count` that is not a whole number from 0, a blank `countLabel` or one without a `count`, and an unknown `countTone` or one without a `count` throw a `RangeError`.
556
+
557
+ ```tsx
558
+ <AppShell
559
+ navLabel="Field station"
560
+ groups={[
561
+ {
562
+ id: 'field',
563
+ items: [
564
+ {
565
+ id: 'today',
566
+ label: 'Today',
567
+ href: '/today',
568
+ icon: <CalendarDays />,
569
+ current: 'page',
570
+ },
571
+ ],
572
+ },
573
+ {
574
+ id: 'reserves',
575
+ label: 'Reserves',
576
+ items: [
577
+ {
578
+ id: 'wetlands',
579
+ label: 'Wetlands',
580
+ href: '/wetlands',
581
+ icon: <Waves />,
582
+ count: 3,
583
+ countLabel: '3 sightings to check',
584
+ },
585
+ {
586
+ id: 'forests',
587
+ label: 'Forests',
588
+ href: '/forests',
589
+ icon: <Trees />,
590
+ count: 2,
591
+ countLabel: '2 to confirm',
592
+ countTone: 'warning',
593
+ },
594
+ ],
595
+ },
596
+ ]}
597
+ onNavigate={(item) => navigate(item.href)}
598
+ header={<StationMenu />}
599
+ railHeader={<StationTile />}
600
+ footer={<AccountMenu />}
601
+ barTitle={<Text variant="label">Heron Marsh</Text>}
602
+ menuIcon={<Menu />}
603
+ labels={{ openMenu: 'Open menu', closeMenu: 'Close menu' }}
604
+ >
605
+ <Box padding={5}>{page}</Box>
606
+ </AppShell>
607
+ ```
608
+
552
609
  ```tsx
553
610
  <Stack gap={3}>
554
611
  <Box paddingX={4}>
@@ -0,0 +1,5 @@
1
+ import { type AppShellNavGroup, type AppShellProps } from './components/AppShell.js';
2
+ /** A field station's destinations: one bare group and one labelled. */
3
+ export declare const habitats: AppShellNavGroup[];
4
+ /** The shell with its required words, a page, and any prop overridden. */
5
+ export declare function renderShell(props?: Partial<AppShellProps>): import("@testing-library/react").RenderResult<typeof import("@testing-library/dom/types/queries.js"), HTMLElement, HTMLElement>;
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from '@testing-library/react';
3
+ import { AppShell, } from './components/AppShell.js';
4
+ /** A field station's destinations: one bare group and one labelled. */
5
+ export const habitats = [
6
+ {
7
+ id: 'field',
8
+ items: [
9
+ {
10
+ id: 'today',
11
+ label: 'Today',
12
+ href: '/today',
13
+ icon: _jsx("svg", { "data-glyph": "today" }),
14
+ current: 'page',
15
+ },
16
+ ],
17
+ },
18
+ {
19
+ id: 'reserves',
20
+ label: 'Reserves',
21
+ items: [
22
+ {
23
+ id: 'wetlands',
24
+ label: 'Wetlands',
25
+ href: '/wetlands',
26
+ icon: _jsx("svg", {}),
27
+ count: 3,
28
+ countLabel: '3 sightings to check',
29
+ },
30
+ {
31
+ id: 'forests',
32
+ label: 'Forests',
33
+ href: '/forests',
34
+ icon: _jsx("svg", {}),
35
+ count: 2,
36
+ countTone: 'warning',
37
+ },
38
+ { id: 'dunes', label: 'Dunes', href: '/dunes', icon: _jsx("svg", {}), count: 0 },
39
+ ],
40
+ },
41
+ ];
42
+ /** The shell with its required words, a page, and any prop overridden. */
43
+ export function renderShell(props = {}) {
44
+ return render(_jsx(AppShell, { barTitle: "Heron Marsh", groups: habitats, labels: { openMenu: 'Open menu', closeMenu: 'Close menu' }, menuIcon: _jsx("svg", { "data-glyph": "menu" }), navLabel: "Field station", ...props, children: _jsx("p", { children: "Otters seen at dawn." }) }));
45
+ }
@@ -0,0 +1,12 @@
1
+ import { type AppShellProps } from './app-shell/types.js';
2
+ export type { AppShellLabels, AppShellNavGroup, AppShellNavItem, AppShellProps, } from './app-shell/types.js';
3
+ /**
4
+ * A web workspace's frame: a pinned side navigation of grouped
5
+ * destinations beside the page (`main`), which scrolls with the document.
6
+ * From 75rem the sidebar is full (glyphs, labels, counts and group
7
+ * captions); from `lg` to 75rem it is a rail of glyphs with tooltips; below
8
+ * `lg` a sticky top bar's menu button opens the same navigation in a modal
9
+ * drawer from the inline start. Invalid groups, items or words throw a
10
+ * `RangeError`.
11
+ */
12
+ export declare function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuIcon, navLabel, onNavigate, railFooter, railHeader, }: AppShellProps): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { breakpointQuery } from '../css/breakpoints.js';
5
+ import { appShellWideQuery } from '../css/css-app-shell.js';
6
+ import { AppHeader } from './AppHeader.js';
7
+ import { AppShellDrawer } from './app-shell/AppShellDrawer.js';
8
+ import { AppShellSidebar } from './app-shell/AppShellSidebar.js';
9
+ import { assertAppShellProps } from './app-shell/validate.js';
10
+ import { Button } from './Button.js';
11
+ import { useMediaQuery } from './use-media-query.js';
12
+ const fromLg = breakpointQuery('from', 'lg');
13
+ /**
14
+ * A web workspace's frame: a pinned side navigation of grouped
15
+ * destinations beside the page (`main`), which scrolls with the document.
16
+ * From 75rem the sidebar is full (glyphs, labels, counts and group
17
+ * captions); from `lg` to 75rem it is a rail of glyphs with tooltips; below
18
+ * `lg` a sticky top bar's menu button opens the same navigation in a modal
19
+ * drawer from the inline start. Invalid groups, items or words throw a
20
+ * `RangeError`.
21
+ */
22
+ export function AppShell({ barEnd, barTitle, children, footer, groups, header, labels, menuIcon, navLabel, onNavigate, railFooter, railHeader, }) {
23
+ assertAppShellProps({ groups, labels, navLabel });
24
+ const [menuOpen, setMenuOpen] = useState(false);
25
+ const menuRef = useRef(null);
26
+ // Set by a dismissal (Escape, a press outside, the close button): only
27
+ // that hands focus back to the menu button.
28
+ const refocusRef = useRef(false);
29
+ const sidebarShown = useMediaQuery(fromLg, false);
30
+ const wide = useMediaQuery(appShellWideQuery, false);
31
+ // The drawer belongs to the bar, which is gone from `lg` up: a window
32
+ // widened while it is open closes it.
33
+ if (sidebarShown && menuOpen)
34
+ setMenuOpen(false);
35
+ // After a dismissal (the drawer's own effect closes it first), focus
36
+ // returns to the menu button. A chosen destination leaves focus to the
37
+ // page it moves to, and a widened window has no menu button to focus.
38
+ useEffect(() => {
39
+ if (menuOpen || !refocusRef.current)
40
+ return;
41
+ refocusRef.current = false;
42
+ menuRef.current?.focus();
43
+ }, [menuOpen]);
44
+ return (_jsxs("div", { className: "sw-app-shell", children: [_jsxs(AppHeader, { appearance: "bar", className: "sw-app-shell-bar", padding: 2, children: [_jsx(Button, { ref: menuRef, "aria-expanded": menuOpen, "aria-haspopup": "dialog", "aria-label": labels.openMenu, className: "sw-app-shell-menu", onPress: () => setMenuOpen(true), size: "sm", variant: "ghost", children: _jsx("span", { "aria-hidden": "true", className: "sw-app-shell-icon", children: menuIcon }) }), _jsx("div", { className: "sw-app-shell-bar-title", children: barTitle }), barEnd ? _jsx("div", { className: "sw-app-shell-bar-end", children: barEnd }) : null] }), _jsx(AppShellSidebar, { footer: footer, groups: groups, header: header, navLabel: navLabel, onNavigate: onNavigate, rail: sidebarShown && !wide, railFooter: railFooter, railHeader: railHeader }), _jsx("main", { className: "sw-app-shell-main", children: children }), _jsx(AppShellDrawer, { closeLabel: labels.closeMenu, footer: footer, groups: groups, header: header, navLabel: navLabel, onChoose: () => setMenuOpen(false), onClose: () => {
45
+ refocusRef.current = true;
46
+ setMenuOpen(false);
47
+ }, onNavigate: onNavigate, open: menuOpen })] }));
48
+ }
@@ -1,5 +1,8 @@
1
- /** The pill's colours: `danger` for work waiting, `accent` for news. */
2
- export declare const navCountTones: readonly ["danger", "accent"];
1
+ /**
2
+ * The pill's colours: `danger` for work waiting, `accent` for news,
3
+ * `warning` for work that needs the person but is not urgent.
4
+ */
5
+ export declare const navCountTones: readonly ["danger", "accent", "warning"];
3
6
  export type NavCountTone = (typeof navCountTones)[number];
4
7
  export type NavCountProps = {
5
8
  /**
@@ -17,8 +20,11 @@ export type NavCountProps = {
17
20
  /**
18
21
  * The pill's colour. `danger` (the default) is work waiting there, such
19
22
  * as tasks to do; `accent` is news, such as what was added since the
20
- * person last looked, where nothing is overdue. Give an accent count a
21
- * `countLabel` ("3 new"), so its words, not its colour, say it is news.
23
+ * person last looked, where nothing is overdue; `warning` is amber, work
24
+ * that needs the person but is not urgent ("needs you"): the warning
25
+ * tint with the warning ink (4.5:1 in every palette) inside a warning
26
+ * edge. Give an accent or warning count a `countLabel` ("3 new", "2 to
27
+ * confirm"), so its words, not its colour, say what it is.
22
28
  * Place either tone on a neutral or ghost destination: a filled one of
23
29
  * the same colour would hide it.
24
30
  */
@@ -29,6 +35,7 @@ export type NavCountProps = {
29
35
  * Button: `<a href="/tasks">Tasks<NavCount count={8} /></a>`. It is the
30
36
  * pill SectionNav's item count draws, and it is part of the destination's
31
37
  * accessible name, as there. `tone="accent"` draws it in the accent colour,
32
- * for news rather than work.
38
+ * for news rather than work; `tone="warning"` in amber, for work that needs
39
+ * the person but is not urgent.
33
40
  */
34
41
  export declare function NavCount({ count, countLabel, tone, }: NavCountProps): import("react").JSX.Element | null;
@@ -1,8 +1,12 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cx } from '../class-names.js';
3
+ import { countToneClass } from '../css/css-count-pill.js';
3
4
  import { isOneOf } from '../is-one-of.js';
4
- /** The pill's colours: `danger` for work waiting, `accent` for news. */
5
- export const navCountTones = ['danger', 'accent'];
5
+ /**
6
+ * The pill's colours: `danger` for work waiting, `accent` for news,
7
+ * `warning` for work that needs the person but is not urgent.
8
+ */
9
+ export const navCountTones = ['danger', 'accent', 'warning'];
6
10
  /**
7
11
  * Fails closed on a count that is not a whole number, an empty label or an
8
12
  * unknown tone.
@@ -21,7 +25,8 @@ function assertNavCount({ count, countLabel, tone }) {
21
25
  * Button: `<a href="/tasks">Tasks<NavCount count={8} /></a>`. It is the
22
26
  * pill SectionNav's item count draws, and it is part of the destination's
23
27
  * accessible name, as there. `tone="accent"` draws it in the accent colour,
24
- * for news rather than work.
28
+ * for news rather than work; `tone="warning"` in amber, for work that needs
29
+ * the person but is not urgent.
25
30
  */
26
31
  export function NavCount({ count, countLabel, tone = 'danger', }) {
27
32
  assertNavCount({ count, countLabel, tone });
@@ -29,5 +34,5 @@ export function NavCount({ count, countLabel, tone = 'danger', }) {
29
34
  return null;
30
35
  // The space is its own text node, so every browser and the name's
31
36
  // computation keep it between the words and the count.
32
- return (_jsxs(_Fragment, { children: [' ', countLabel ? _jsx("span", { className: "sw-sr-only", children: countLabel }) : null, _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-nav-count', tone === 'accent' && 'sw-nav-count-accent'), children: count })] }));
37
+ return (_jsxs(_Fragment, { children: [' ', countLabel ? _jsx("span", { className: "sw-sr-only", children: countLabel }) : null, _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-nav-count', tone !== 'danger' && countToneClass('sw-nav-count', tone)), children: count })] }));
33
38
  }
@@ -2,6 +2,7 @@ import { type ReactNode } from 'react';
2
2
  import { type AccessibleName } from '../accessible-name.js';
3
3
  import { type Breakpoint } from '../css/breakpoints.js';
4
4
  import { type SectionNavAppearance } from '../css/css-section-nav-filled.js';
5
+ import { type NavItemCurrent } from './nav-items.js';
5
6
  export type { SectionNavAppearance };
6
7
  export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
7
8
  export type SectionNavItem = {
@@ -12,7 +13,7 @@ export type SectionNavItem = {
12
13
  * `page` when the item is this page, `location` (or `true`) when the page
13
14
  * is inside the item's section, such as a detail page under its list.
14
15
  */
15
- current?: 'page' | 'location' | boolean;
16
+ current?: NavItemCurrent;
16
17
  /** A decorative glyph before the label. */
17
18
  icon?: ReactNode;
18
19
  /**
@@ -5,24 +5,8 @@ import { sectionNavVerticalClass } from '../css/css-section-nav.js';
5
5
  import { assertSectionNavAppearance, } from '../css/css-section-nav-filled.js';
6
6
  import { tabStop } from '../tab-stop.js';
7
7
  import { plainPress } from './link-press.js';
8
+ import { assertNavItems, navItemAriaCurrent, } from './nav-items.js';
8
9
  export { sectionNavAppearances } from '../css/css-section-nav-filled.js';
9
- function ariaCurrent(current) {
10
- if (current === true)
11
- return 'true';
12
- return current || undefined;
13
- }
14
- function assertItems(items) {
15
- if (items.some((item) => !item.id.trim() || !item.label.trim()))
16
- throw new RangeError('SectionNav items need nonempty ids and labels');
17
- if (new Set(items.map((item) => item.id)).size !== items.length)
18
- throw new RangeError('SectionNav items need unique ids');
19
- if (items.some((item) => item.count !== undefined &&
20
- (!Number.isInteger(item.count) || item.count < 0)))
21
- throw new RangeError('SectionNav item counts are whole numbers from 0');
22
- if (items.some((item) => item.countLabel !== undefined &&
23
- (!item.countLabel.trim() || item.count === undefined)))
24
- throw new RangeError('SectionNav item countLabel is nonempty and comes with a count');
25
- }
26
10
  function assertBreakpoint(verticalFrom) {
27
11
  if (verticalFrom !== undefined && !breakpoints.includes(verticalFrom))
28
12
  throw new RangeError(`SectionNav verticalFrom must be one of ${breakpoints.join(', ')}`);
@@ -34,10 +18,10 @@ function assertBreakpoint(verticalFrom) {
34
18
  * a side list. The current item carries `aria-current`.
35
19
  */
36
20
  export function SectionNav({ appearance = 'underline', items, onNavigate, verticalFrom, ...label }) {
37
- assertItems(items);
21
+ assertNavItems('SectionNav', items);
38
22
  assertBreakpoint(verticalFrom);
39
23
  assertSectionNavAppearance(appearance);
40
- return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": ariaCurrent(item.current), className: "sw-section-nav-link", href: item.href, tabIndex: tabStop,
24
+ return (_jsx("nav", { className: cx('sw-section-nav', verticalFrom && sectionNavVerticalClass(verticalFrom), appearance === 'filled' && 'sw-section-nav-filled'), ...label, children: _jsx("ul", { className: "sw-section-nav-list", children: items.map((item) => (_jsx("li", { children: _jsxs("a", { "aria-current": navItemAriaCurrent(item.current), className: "sw-section-nav-link", href: item.href, tabIndex: tabStop,
41
25
  // Only a given onNavigate attaches a handler, so the links
42
26
  // render from a server component without one.
43
27
  onClick: onNavigate
@@ -0,0 +1,24 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
3
+ export type AppShellDrawerProps = {
4
+ open: boolean;
5
+ /** Asked on every close request: Escape, a press outside, the close button. */
6
+ onClose: () => void;
7
+ /** Asked after a plain press on a destination, which also closes it. */
8
+ onChoose: () => void;
9
+ closeLabel: string;
10
+ navLabel: string;
11
+ groups: readonly AppShellNavGroup[];
12
+ onNavigate?: (item: AppShellNavItem) => void;
13
+ header?: ReactNode;
14
+ footer?: ReactNode;
15
+ };
16
+ /**
17
+ * The menu below `lg`: a modal `dialog` on the top layer, full height at
18
+ * the inline start, named by the navigation's label. Dialog's close-request
19
+ * mechanics make Escape, a backdrop press and a platform close request one
20
+ * `onClose`, after which focus returns to the menu button. Its close button
21
+ * sits where the menu button was, so it is the first control. The content
22
+ * is drawn only while open, so the page holds one navigation at a time.
23
+ */
24
+ export declare function AppShellDrawer({ closeLabel, footer, groups, header, navLabel, onChoose, onClose, onNavigate, open, }: AppShellDrawerProps): import("react").JSX.Element;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button } from '../Button.js';
4
+ import { useDialogCloseRequests } from '../dialog-close-requests.js';
5
+ import { AppShellNav } from './AppShellNav.js';
6
+ /**
7
+ * The menu below `lg`: a modal `dialog` on the top layer, full height at
8
+ * the inline start, named by the navigation's label. Dialog's close-request
9
+ * mechanics make Escape, a backdrop press and a platform close request one
10
+ * `onClose`, after which focus returns to the menu button. Its close button
11
+ * sits where the menu button was, so it is the first control. The content
12
+ * is drawn only while open, so the page holds one navigation at a time.
13
+ */
14
+ export function AppShellDrawer({ closeLabel, footer, groups, header, navLabel, onChoose, onClose, onNavigate, open, }) {
15
+ const { nodeRef, handlers } = useDialogCloseRequests({ open, onClose });
16
+ return (_jsx("dialog", { ref: nodeRef, "aria-label": navLabel, className: "sw-app-shell-drawer", ...handlers, children: open ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "sw-app-shell-drawer-top", children: [_jsxs(Button, { className: "sw-app-shell-close", onPress: onClose, size: "sm", variant: "ghost", children: [_jsx("span", { className: "sw-sr-only", children: closeLabel }), _jsx("span", { "aria-hidden": "true", className: "sw-dialog-close-glyph" })] }), header ? (_jsx("div", { className: "sw-app-shell-header", children: header })) : null] }), _jsx(AppShellNav, { groups: groups, navLabel: navLabel, onChoose: onChoose, onNavigate: onNavigate }), footer ? _jsx("div", { className: "sw-app-shell-footer", children: footer }) : null] })) : null }));
17
+ }
@@ -0,0 +1,14 @@
1
+ import { type MouseEvent } from 'react';
2
+ import { type AppShellNavItem } from './types.js';
3
+ export type AppShellLinkProps = {
4
+ item: AppShellNavItem;
5
+ onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
6
+ /**
7
+ * The sidebar's links sit in a Tooltip with their label, shown only while
8
+ * `tooltip` is true (the rail, where the label is hidden). Left out (the
9
+ * drawer) there is no Tooltip at all.
10
+ */
11
+ tooltip?: boolean;
12
+ };
13
+ /** One destination in its list item: the glyph, the label and the count. */
14
+ export declare function AppShellLink({ item, onClick, tooltip }: AppShellLinkProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../../class-names.js';
3
+ import { countToneClass } from '../../css/css-count-pill.js';
4
+ import { tabStop } from '../../tab-stop.js';
5
+ import { navItemAriaCurrent } from '../nav-items.js';
6
+ import { Tooltip } from '../Tooltip.js';
7
+ /*
8
+ * The destination's link. A Tooltip around it passes its references in;
9
+ * only `aria-labelledby` is kept. Its `aria-describedby` is left out on
10
+ * purpose: the tooltip's words are the label the link is already named by,
11
+ * so they would be read twice.
12
+ */
13
+ function AppShellAnchor({ 'aria-labelledby': labelledBy, children, item, onClick, }) {
14
+ return (_jsx("a", { "aria-current": navItemAriaCurrent(item.current), "aria-labelledby": labelledBy, className: "sw-app-shell-link", href: item.href, onClick: onClick, tabIndex: tabStop, children: children }));
15
+ }
16
+ /** The count after the label, read in the link's name as SectionNav's is. */
17
+ function AppShellCount({ count, countLabel, countTone = 'danger', }) {
18
+ if (!count)
19
+ return null;
20
+ return (_jsxs(_Fragment, { children: [countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${countLabel}` })) : (' '), _jsx("span", { "aria-hidden": countLabel ? true : undefined, className: cx('sw-app-shell-count', countTone !== 'danger' &&
21
+ countToneClass('sw-app-shell-count', countTone)), children: count })] }));
22
+ }
23
+ /** One destination in its list item: the glyph, the label and the count. */
24
+ export function AppShellLink({ item, onClick, tooltip }) {
25
+ const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "sw-app-shell-icon", children: item.icon }), _jsx("span", { className: "sw-app-shell-label", children: item.label }), _jsx(AppShellCount, { count: item.count, countLabel: item.countLabel, countTone: item.countTone })] }));
26
+ return (_jsx("li", { className: "sw-app-shell-item", children: tooltip === undefined ? (_jsx(AppShellAnchor, { item: item, onClick: onClick, children: content })) : (_jsx(Tooltip, { content: item.label, disabled: !tooltip, trigger: _jsx(AppShellAnchor, { item: item, onClick: onClick, children: content }) })) }));
27
+ }
@@ -0,0 +1,15 @@
1
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
2
+ export type AppShellNavProps = {
3
+ navLabel: string;
4
+ groups: readonly AppShellNavGroup[];
5
+ onNavigate?: (item: AppShellNavItem) => void;
6
+ /** The drawer's: a plain press on a destination also closes it. */
7
+ onChoose?: () => void;
8
+ /** The sidebar's: whether the rail's tooltips show (see AppShellLink). */
9
+ tooltip?: boolean;
10
+ };
11
+ /**
12
+ * The shell's navigation landmark: each group a list named by its label,
13
+ * the same in the sidebar and the drawer.
14
+ */
15
+ export declare function AppShellNav({ groups, navLabel, onChoose, onNavigate, tooltip, }: AppShellNavProps): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId } from 'react';
3
+ import { plainPress } from '../link-press.js';
4
+ import { AppShellLink } from './AppShellLink.js';
5
+ /**
6
+ * The shell's navigation landmark: each group a list named by its label,
7
+ * the same in the sidebar and the drawer.
8
+ */
9
+ export function AppShellNav({ groups, navLabel, onChoose, onNavigate, tooltip, }) {
10
+ const idPrefix = useId();
11
+ // Only a given onNavigate or onChoose attaches a handler.
12
+ function pressHandler(item) {
13
+ if (!onNavigate && !onChoose)
14
+ return undefined;
15
+ return (event) => {
16
+ if (!plainPress(event))
17
+ return;
18
+ if (onNavigate) {
19
+ event.preventDefault();
20
+ onNavigate(item);
21
+ }
22
+ onChoose?.();
23
+ };
24
+ }
25
+ return (_jsx("nav", { "aria-label": navLabel, className: "sw-app-shell-nav", children: groups.map((group, index) => {
26
+ const labelId = `${idPrefix}-group-${index}`;
27
+ return (_jsxs("div", { className: "sw-app-shell-group", children: [group.label ? (_jsx("p", { className: "sw-app-shell-group-label", id: labelId, children: group.label })) : null, _jsx("ul", { "aria-labelledby": group.label ? labelId : undefined, className: "sw-app-shell-list", children: group.items.map((item) => (_jsx(AppShellLink, { item: item, onClick: pressHandler(item), tooltip: tooltip }, item.id))) })] }, group.id));
28
+ }) }));
29
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type AppShellNavGroup, type AppShellNavItem } from './types.js';
3
+ export type AppShellSidebarProps = {
4
+ navLabel: string;
5
+ groups: readonly AppShellNavGroup[];
6
+ onNavigate?: (item: AppShellNavItem) => void;
7
+ header?: ReactNode;
8
+ footer?: ReactNode;
9
+ railHeader?: ReactNode;
10
+ railFooter?: ReactNode;
11
+ /** Whether the window is in the rail's range, where tooltips show. */
12
+ rail: boolean;
13
+ };
14
+ /**
15
+ * The pinned column from `lg` up: a rail to 75rem, the full sidebar from
16
+ * there. Hidden below `lg`, where the drawer holds the same parts.
17
+ */
18
+ export declare function AppShellSidebar({ footer, groups, header, navLabel, onNavigate, rail, railFooter, railHeader, }: AppShellSidebarProps): import("react").JSX.Element;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AppShellNav } from './AppShellNav.js';
3
+ /*
4
+ * A slot drawn in the full sidebar (`sw-app-shell-slot`) or only in the
5
+ * rail (`sw-app-shell-rail-slot`); the generated media queries pick one.
6
+ */
7
+ function Slot({ children, part, rail, }) {
8
+ if (!children)
9
+ return null;
10
+ return (_jsx("div", { className: `sw-app-shell-${part} ${rail ? 'sw-app-shell-rail-slot' : 'sw-app-shell-slot'}`, children: children }));
11
+ }
12
+ /**
13
+ * The pinned column from `lg` up: a rail to 75rem, the full sidebar from
14
+ * there. Hidden below `lg`, where the drawer holds the same parts.
15
+ */
16
+ export function AppShellSidebar({ footer, groups, header, navLabel, onNavigate, rail, railFooter, railHeader, }) {
17
+ return (_jsxs("div", { className: "sw-app-shell-sidebar", children: [_jsx(Slot, { part: "header", rail: false, children: header }), _jsx(Slot, { part: "header", rail: true, children: railHeader }), _jsx(AppShellNav, { groups: groups, navLabel: navLabel, onNavigate: onNavigate, tooltip: rail }), _jsx(Slot, { part: "footer", rail: false, children: footer }), _jsx(Slot, { part: "footer", rail: true, children: railFooter })] }));
18
+ }
@@ -0,0 +1,87 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type NavCountTone } from '../NavCount.js';
3
+ import { type NavItemCurrent } from '../nav-items.js';
4
+ export type AppShellNavItem = {
5
+ id: string;
6
+ label: string;
7
+ href: string;
8
+ /**
9
+ * The destination's glyph (a consumer Lucide icon or mark), hidden from
10
+ * assistive technology. Required: in the rail it is all the item shows.
11
+ */
12
+ icon: ReactNode;
13
+ /**
14
+ * How many things wait there, such as work to do: the shared count pill
15
+ * (danger) after the label, or on the glyph in the rail, read in the
16
+ * link's name. A whole number; 0 (or none) shows no pill.
17
+ */
18
+ count?: number;
19
+ /**
20
+ * What the count means, in the consumer's words ("3 to confirm"): read in
21
+ * the name in place of the bare number, the pill then hidden from
22
+ * assistive technology. Needs `count`.
23
+ */
24
+ countLabel?: string;
25
+ /**
26
+ * The pill's colour, as NavCount's `tone`: `danger` (the default) for
27
+ * work waiting, `warning` (amber) for work that needs the person but is
28
+ * not urgent, `accent` for news. Needs `count`; say what it is in a
29
+ * `countLabel`, never by its colour alone.
30
+ */
31
+ countTone?: NavCountTone;
32
+ /**
33
+ * `page` when the item is this page, `location` (or `true`) when the page
34
+ * is inside the item's area, such as a detail page under its list.
35
+ */
36
+ current?: NavItemCurrent;
37
+ };
38
+ export type AppShellNavGroup = {
39
+ id: string;
40
+ /**
41
+ * A small muted caption over the group's destinations that names their
42
+ * list. In the rail it is hidden (still naming the list) and the group
43
+ * starts under a hairline.
44
+ */
45
+ label?: string;
46
+ items: readonly AppShellNavItem[];
47
+ };
48
+ /** The shell's own words: the menu button's name and the drawer's close. */
49
+ export type AppShellLabels = {
50
+ openMenu: string;
51
+ closeMenu: string;
52
+ };
53
+ export type AppShellProps = {
54
+ /** The navigation landmark's name, also the drawer's. */
55
+ navLabel: string;
56
+ groups: readonly AppShellNavGroup[];
57
+ /**
58
+ * Called for a plain press on a destination (the main button, no
59
+ * modifier key), after the browser's own navigation is prevented, so a
60
+ * client router moves instead. A press with a modifier key, or a middle
61
+ * press, keeps the browser's behaviour. Without it the links navigate.
62
+ */
63
+ onNavigate?: (item: AppShellNavItem) => void;
64
+ /**
65
+ * The sidebar's top, such as a workspace switcher; also the drawer's.
66
+ * While the drawer is open it is mounted twice (the sidebar's copy is
67
+ * hidden), so each copy keeps its own state: put no fixed DOM `id` in it
68
+ * (use `useId`), and keep state that must be shared in the consumer.
69
+ * The same holds for `footer`, `railHeader` and `railFooter`.
70
+ */
71
+ header?: ReactNode;
72
+ /** The sidebar's bottom, such as the account menu; also the drawer's (see `header`). */
73
+ footer?: ReactNode;
74
+ /** A compact `header` for the rail. Left out, the rail shows none. */
75
+ railHeader?: ReactNode;
76
+ /** A compact `footer` for the rail. Left out, the rail shows none. */
77
+ railFooter?: ReactNode;
78
+ /** The compact top bar's title, below `lg`. */
79
+ barTitle: ReactNode;
80
+ /** Controls at the top bar's end, below `lg`. */
81
+ barEnd?: ReactNode;
82
+ /** The consumer's glyph for the menu button (Scalewing has no icon catalog). */
83
+ menuIcon: ReactNode;
84
+ labels: AppShellLabels;
85
+ /** The page, rendered in `main`. */
86
+ children: ReactNode;
87
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import { type AppShellProps } from './types.js';
2
+ /**
3
+ * Fails closed on the shell's words, its groups and their items (ids unique
4
+ * across every group, each with an icon, which is all the rail shows).
5
+ */
6
+ export declare function assertAppShellProps({ groups, labels, navLabel, }: Pick<AppShellProps, 'groups' | 'labels' | 'navLabel'>): void;