@scalewing/react 1.47.0 → 1.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -0
- package/README.md +171 -4
- package/dist/command-palette.test-support.d.ts +21 -0
- package/dist/command-palette.test-support.js +55 -0
- package/dist/components/AppHeader.d.ts +3 -0
- package/dist/components/AppShell.d.ts +4 -3
- package/dist/components/AppShell.js +24 -7
- package/dist/components/Band.d.ts +30 -0
- package/dist/components/Band.js +23 -0
- package/dist/components/ButtonLink.d.ts +33 -0
- package/dist/components/ButtonLink.js +27 -0
- package/dist/components/CommandPalette.d.ts +12 -0
- package/dist/components/CommandPalette.js +143 -0
- package/dist/components/CommandPaletteTrigger.d.ts +9 -0
- package/dist/components/CommandPaletteTrigger.js +16 -0
- package/dist/components/ContextSwitcher.d.ts +12 -0
- package/dist/components/ContextSwitcher.js +62 -0
- package/dist/components/FixtureGroup.d.ts +9 -0
- package/dist/components/FixtureGroup.js +16 -0
- package/dist/components/FixtureRow.d.ts +10 -0
- package/dist/components/FixtureRow.js +46 -0
- package/dist/components/GlyphTile.d.ts +9 -3
- package/dist/components/GlyphTile.js +15 -4
- package/dist/components/SiteFooter.d.ts +12 -0
- package/dist/components/SiteFooter.js +23 -0
- package/dist/components/SiteHeader.d.ts +14 -0
- package/dist/components/SiteHeader.js +24 -0
- package/dist/components/Skeleton.d.ts +22 -0
- package/dist/components/Skeleton.js +30 -0
- package/dist/components/app-shell/AppShellBar.d.ts +23 -0
- package/dist/components/app-shell/AppShellBar.js +25 -0
- package/dist/components/app-shell/types.d.ts +36 -1
- package/dist/components/app-shell/validate.d.ts +4 -3
- package/dist/components/app-shell/validate.js +21 -3
- package/dist/components/command-palette/PaletteList.d.ts +20 -0
- package/dist/components/command-palette/PaletteList.js +21 -0
- package/dist/components/command-palette/shortcut.d.ts +19 -0
- package/dist/components/command-palette/shortcut.js +75 -0
- package/dist/components/command-palette/types.d.ts +72 -0
- package/dist/components/command-palette/types.js +2 -0
- package/dist/components/command-palette/validate.d.ts +10 -0
- package/dist/components/command-palette/validate.js +50 -0
- package/dist/components/context-switcher/SwitcherOption.d.ts +17 -0
- package/dist/components/context-switcher/SwitcherOption.js +20 -0
- package/dist/components/context-switcher/SwitcherPopup.d.ts +31 -0
- package/dist/components/context-switcher/SwitcherPopup.js +111 -0
- package/dist/components/context-switcher/options.d.ts +33 -0
- package/dist/components/context-switcher/options.js +60 -0
- package/dist/components/context-switcher/types.d.ts +96 -0
- package/dist/components/context-switcher/types.js +1 -0
- package/dist/components/context-switcher/use-switcher-list.d.ts +29 -0
- package/dist/components/context-switcher/use-switcher-list.js +93 -0
- package/dist/components/context-switcher/validate.d.ts +8 -0
- package/dist/components/context-switcher/validate.js +94 -0
- package/dist/components/fixture/fixture-list-context.d.ts +4 -0
- package/dist/components/fixture/fixture-list-context.js +9 -0
- package/dist/components/fixture/types.d.ts +50 -0
- package/dist/components/fixture/types.js +1 -0
- package/dist/components/fixture/validate.d.ts +8 -0
- package/dist/components/fixture/validate.js +36 -0
- package/dist/components/site-footer/types.d.ts +31 -0
- package/dist/components/site-footer/types.js +1 -0
- package/dist/components/site-footer/validate.d.ts +7 -0
- package/dist/components/site-footer/validate.js +22 -0
- package/dist/components/site-header/SiteHeaderLinks.d.ts +7 -0
- package/dist/components/site-header/SiteHeaderLinks.js +7 -0
- package/dist/components/site-header/types.d.ts +38 -0
- package/dist/components/site-header/types.js +1 -0
- package/dist/components/site-header/use-menu-dismiss.d.ts +8 -0
- package/dist/components/site-header/use-menu-dismiss.js +62 -0
- package/dist/components/site-header/validate.d.ts +7 -0
- package/dist/components/site-header/validate.js +17 -0
- package/dist/components/use-element-height.d.ts +10 -0
- package/dist/components/use-element-height.js +35 -0
- package/dist/context-switcher.test-support.d.ts +9 -0
- package/dist/context-switcher.test-support.js +41 -0
- package/dist/css/css-app-shell-page-bar.d.ts +3 -0
- package/dist/css/css-app-shell-page-bar.js +81 -0
- package/dist/css/css-app-shell.js +6 -1
- package/dist/css/css-band.d.ts +10 -0
- package/dist/css/css-band.js +101 -0
- package/dist/css/css-button-link.d.ts +3 -0
- package/dist/css/css-button-link.js +48 -0
- package/dist/css/css-command-palette-key.d.ts +1 -0
- package/dist/css/css-command-palette-key.js +33 -0
- package/dist/css/css-command-palette-trigger.d.ts +2 -0
- package/dist/css/css-command-palette-trigger.js +88 -0
- package/dist/css/css-command-palette.d.ts +2 -0
- package/dist/css/css-command-palette.js +209 -0
- package/dist/css/css-components.js +24 -0
- package/dist/css/css-context-switcher-trigger.d.ts +15 -0
- package/dist/css/css-context-switcher-trigger.js +175 -0
- package/dist/css/css-context-switcher.d.ts +4 -0
- package/dist/css/css-context-switcher.js +219 -0
- package/dist/css/css-count-pill.d.ts +2 -2
- package/dist/css/css-count-pill.js +11 -3
- package/dist/css/css-fixture.d.ts +5 -0
- package/dist/css/css-fixture.js +260 -0
- package/dist/css/css-glyph-tile.d.ts +2 -0
- package/dist/css/css-glyph-tile.js +29 -1
- package/dist/css/css-site-footer.d.ts +2 -0
- package/dist/css/css-site-footer.js +152 -0
- package/dist/css/css-site-header.d.ts +2 -0
- package/dist/css/css-site-header.js +262 -0
- package/dist/css/css-skeleton.d.ts +10 -0
- package/dist/css/css-skeleton.js +110 -0
- package/dist/css/stacking.js +7 -4
- package/dist/css/stylesheet.js +16 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/styles.css +2008 -719
- package/package.json +2 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** One link in a footer group. */
|
|
3
|
+
export type SiteFooterLink = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
href: string;
|
|
7
|
+
/** `true` when the link is the page shown: `aria-current="page"`. */
|
|
8
|
+
current?: boolean;
|
|
9
|
+
/** The language of the link's words, when they differ from the page's. */
|
|
10
|
+
lang?: string;
|
|
11
|
+
/** The language of the page it opens. */
|
|
12
|
+
hrefLang?: string;
|
|
13
|
+
};
|
|
14
|
+
/** A labelled group of links, drawn as a `nav` named by its label. */
|
|
15
|
+
export type SiteFooterGroup = {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
links: readonly SiteFooterLink[];
|
|
19
|
+
};
|
|
20
|
+
export type SiteFooterProps = {
|
|
21
|
+
/** The site's mark at the top, such as a logo for a dark background. */
|
|
22
|
+
brand: ReactNode;
|
|
23
|
+
/** A line under the brand. */
|
|
24
|
+
tagline?: ReactNode;
|
|
25
|
+
/** One or more groups of links, each a `nav` named by its label. */
|
|
26
|
+
groups: readonly SiteFooterGroup[];
|
|
27
|
+
/** The bottom row's start, such as the copyright. */
|
|
28
|
+
legal?: ReactNode;
|
|
29
|
+
/** The bottom row's end, such as a language switch. */
|
|
30
|
+
end?: ReactNode;
|
|
31
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SiteFooterGroup } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on the footer's groups: none at all, a group with no links,
|
|
4
|
+
* blank or repeated ids (groups, and links within a group), blank labels,
|
|
5
|
+
* and a blank `href`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertSiteFooterGroups(groups: readonly SiteFooterGroup[]): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const blank = (value) => !value.trim();
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on the footer's groups: none at all, a group with no links,
|
|
4
|
+
* blank or repeated ids (groups, and links within a group), blank labels,
|
|
5
|
+
* and a blank `href`.
|
|
6
|
+
*/
|
|
7
|
+
export function assertSiteFooterGroups(groups) {
|
|
8
|
+
if (groups.length === 0)
|
|
9
|
+
throw new RangeError('SiteFooter needs at least one group');
|
|
10
|
+
if (groups.some((group) => blank(group.id) || blank(group.label)))
|
|
11
|
+
throw new RangeError('SiteFooter groups need nonempty ids and labels');
|
|
12
|
+
if (new Set(groups.map((group) => group.id)).size !== groups.length)
|
|
13
|
+
throw new RangeError('SiteFooter groups need unique ids');
|
|
14
|
+
for (const group of groups) {
|
|
15
|
+
if (group.links.length === 0)
|
|
16
|
+
throw new RangeError(`SiteFooter group ${group.id} needs a link`);
|
|
17
|
+
if (group.links.some((link) => blank(link.id) || blank(link.label) || blank(link.href)))
|
|
18
|
+
throw new RangeError(`SiteFooter group ${group.id} links need nonempty ids, labels and hrefs`);
|
|
19
|
+
if (new Set(group.links.map((link) => link.id)).size !== group.links.length)
|
|
20
|
+
throw new RangeError(`SiteFooter group ${group.id} links need unique ids`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SiteHeaderItem } from './types.js';
|
|
2
|
+
/** The destinations as a labelled `nav` of links, in the bar or the menu. */
|
|
3
|
+
export declare function SiteHeaderLinks({ className, items, navLabel, }: {
|
|
4
|
+
className: string;
|
|
5
|
+
items: readonly SiteHeaderItem[];
|
|
6
|
+
navLabel: string;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { tabStop } from '../../tab-stop.js';
|
|
3
|
+
import { navItemAriaCurrent } from '../nav-items.js';
|
|
4
|
+
/** The destinations as a labelled `nav` of links, in the bar or the menu. */
|
|
5
|
+
export function SiteHeaderLinks({ className, items, navLabel, }) {
|
|
6
|
+
return (_jsx("nav", { "aria-label": navLabel, className: className, children: _jsx("ul", { className: "sw-site-header-list", children: items.map((item) => (_jsx("li", { children: _jsx("a", { "aria-current": navItemAriaCurrent(item.current), className: "sw-site-header-link", href: item.href, tabIndex: tabStop, children: item.label }) }, item.id))) }) }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type NavItemCurrent } from '../nav-items.js';
|
|
3
|
+
/** One destination in the site's main navigation. */
|
|
4
|
+
export type SiteHeaderItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
href: string;
|
|
8
|
+
/** `page` (or `true`) for the page shown, `location` for a page inside the item's section. */
|
|
9
|
+
current?: NavItemCurrent;
|
|
10
|
+
};
|
|
11
|
+
/** The menu button's names, in the consumer's words. */
|
|
12
|
+
export type SiteHeaderLabels = {
|
|
13
|
+
/** The closed menu button's name, such as "Open the menu". */
|
|
14
|
+
openMenu: string;
|
|
15
|
+
/** The open menu button's name, such as "Close the menu". */
|
|
16
|
+
closeMenu: string;
|
|
17
|
+
};
|
|
18
|
+
export type SiteHeaderProps = {
|
|
19
|
+
/** The site's mark at the start, such as a logo linked to the home page. */
|
|
20
|
+
brand: ReactNode;
|
|
21
|
+
/** The main navigation's name, such as "Main". */
|
|
22
|
+
navLabel: string;
|
|
23
|
+
/** The main destinations: a row of links from `lg`, the menu's list below it. */
|
|
24
|
+
items: readonly SiteHeaderItem[];
|
|
25
|
+
/** The bar's end from `lg`, such as a language switch, Sign in and a ButtonLink. */
|
|
26
|
+
actions?: ReactNode;
|
|
27
|
+
/** Beside the menu button below `lg`, such as the language switch. */
|
|
28
|
+
compactActions?: ReactNode;
|
|
29
|
+
/** In the open menu under its links, such as full-width ButtonLinks. */
|
|
30
|
+
menuActions?: ReactNode;
|
|
31
|
+
labels: SiteHeaderLabels;
|
|
32
|
+
/** The closed menu button's glyph (☰), decorative. */
|
|
33
|
+
menuIcon: ReactNode;
|
|
34
|
+
/** The open menu button's glyph (✕), decorative. */
|
|
35
|
+
closeIcon: ReactNode;
|
|
36
|
+
/** Whether the bar sticks to the window's top as the page scrolls (default `true`). */
|
|
37
|
+
sticky?: boolean;
|
|
38
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* What script adds to the menu, a native `details` that opens and closes
|
|
4
|
+
* without it: Escape closes it and puts focus back on its button; choosing
|
|
5
|
+
* a link in it, focus leaving it, a press outside it, or the window
|
|
6
|
+
* widening to the row of destinations closes it. Nothing traps focus.
|
|
7
|
+
*/
|
|
8
|
+
export declare function useMenuDismiss(menuRef: RefObject<HTMLDetailsElement | null>): void;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { breakpointQuery } from '../../css/breakpoints.js';
|
|
3
|
+
/** From here the bar shows its row of destinations and hides the menu. */
|
|
4
|
+
const wideQuery = breakpointQuery('from', 'lg');
|
|
5
|
+
/**
|
|
6
|
+
* What script adds to the menu, a native `details` that opens and closes
|
|
7
|
+
* without it: Escape closes it and puts focus back on its button; choosing
|
|
8
|
+
* a link in it, focus leaving it, a press outside it, or the window
|
|
9
|
+
* widening to the row of destinations closes it. Nothing traps focus.
|
|
10
|
+
*/
|
|
11
|
+
export function useMenuDismiss(menuRef) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const menu = menuRef.current;
|
|
14
|
+
if (!menu)
|
|
15
|
+
return;
|
|
16
|
+
const close = () => {
|
|
17
|
+
menu.open = false;
|
|
18
|
+
};
|
|
19
|
+
const onKeyDown = (event) => {
|
|
20
|
+
if (event.key !== 'Escape' || !menu.open)
|
|
21
|
+
return;
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
close();
|
|
24
|
+
menu.querySelector('summary')?.focus();
|
|
25
|
+
};
|
|
26
|
+
const onClick = (event) => {
|
|
27
|
+
if (event.target instanceof Element && event.target.closest('a'))
|
|
28
|
+
close();
|
|
29
|
+
};
|
|
30
|
+
const onFocusOut = (event) => {
|
|
31
|
+
const next = event.relatedTarget;
|
|
32
|
+
if (next instanceof Node && menu.contains(next))
|
|
33
|
+
return;
|
|
34
|
+
if (next !== null)
|
|
35
|
+
close();
|
|
36
|
+
};
|
|
37
|
+
const onPointerDown = (event) => {
|
|
38
|
+
if (event.target instanceof Node && menu.contains(event.target))
|
|
39
|
+
return;
|
|
40
|
+
close();
|
|
41
|
+
};
|
|
42
|
+
const wide = typeof window.matchMedia === 'function'
|
|
43
|
+
? window.matchMedia(wideQuery)
|
|
44
|
+
: undefined;
|
|
45
|
+
const onWiden = () => {
|
|
46
|
+
if (wide?.matches)
|
|
47
|
+
close();
|
|
48
|
+
};
|
|
49
|
+
menu.addEventListener('keydown', onKeyDown);
|
|
50
|
+
menu.addEventListener('click', onClick);
|
|
51
|
+
menu.addEventListener('focusout', onFocusOut);
|
|
52
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
53
|
+
wide?.addEventListener('change', onWiden);
|
|
54
|
+
return () => {
|
|
55
|
+
menu.removeEventListener('keydown', onKeyDown);
|
|
56
|
+
menu.removeEventListener('click', onClick);
|
|
57
|
+
menu.removeEventListener('focusout', onFocusOut);
|
|
58
|
+
document.removeEventListener('pointerdown', onPointerDown);
|
|
59
|
+
wide?.removeEventListener('change', onWiden);
|
|
60
|
+
};
|
|
61
|
+
}, [menuRef]);
|
|
62
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SiteHeaderProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on the header's words and destinations: no items, blank or
|
|
4
|
+
* repeated ids, blank labels or hrefs, and a blank navigation name or menu
|
|
5
|
+
* button name.
|
|
6
|
+
*/
|
|
7
|
+
export declare function assertSiteHeaderProps({ items, labels, navLabel, }: Pick<SiteHeaderProps, 'items' | 'labels' | 'navLabel'>): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { assertNavItems } from '../nav-items.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on the header's words and destinations: no items, blank or
|
|
4
|
+
* repeated ids, blank labels or hrefs, and a blank navigation name or menu
|
|
5
|
+
* button name.
|
|
6
|
+
*/
|
|
7
|
+
export function assertSiteHeaderProps({ items, labels, navLabel, }) {
|
|
8
|
+
if (items.length === 0)
|
|
9
|
+
throw new RangeError('SiteHeader needs at least one item');
|
|
10
|
+
assertNavItems('SiteHeader', items);
|
|
11
|
+
if (items.some((item) => !item.href.trim()))
|
|
12
|
+
throw new RangeError('SiteHeader items need nonempty hrefs');
|
|
13
|
+
if (!navLabel.trim())
|
|
14
|
+
throw new RangeError('SiteHeader needs a nonempty navLabel');
|
|
15
|
+
if (!labels.openMenu.trim() || !labels.closeMenu.trim())
|
|
16
|
+
throw new RangeError('SiteHeader labels need nonempty openMenu and closeMenu');
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Follows the height of an element's border box, in CSS pixels: on
|
|
4
|
+
* mount, before the first paint, and whenever its box changes size. It is
|
|
5
|
+
* `null` before that, on the server, while `enabled` is false and while the
|
|
6
|
+
* element draws no box (`display: none`), so a caller can fall back to a
|
|
7
|
+
* value of its own. A browser with no ResizeObserver measures on window
|
|
8
|
+
* resizes instead.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useElementHeight(ref: RefObject<HTMLElement | null>, enabled?: boolean): number | null;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useLayoutEffect, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* Follows the height of an element's border box, in CSS pixels: on
|
|
5
|
+
* mount, before the first paint, and whenever its box changes size. It is
|
|
6
|
+
* `null` before that, on the server, while `enabled` is false and while the
|
|
7
|
+
* element draws no box (`display: none`), so a caller can fall back to a
|
|
8
|
+
* value of its own. A browser with no ResizeObserver measures on window
|
|
9
|
+
* resizes instead.
|
|
10
|
+
*/
|
|
11
|
+
export function useElementHeight(ref, enabled = true) {
|
|
12
|
+
const [height, setHeight] = useState(null);
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
const box = ref.current;
|
|
15
|
+
if (!box || !enabled) {
|
|
16
|
+
setHeight(null);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
function measure() {
|
|
20
|
+
if (!box)
|
|
21
|
+
return;
|
|
22
|
+
const next = box.getBoundingClientRect().height;
|
|
23
|
+
setHeight(next > 0 ? next : null);
|
|
24
|
+
}
|
|
25
|
+
measure();
|
|
26
|
+
if (typeof ResizeObserver === 'undefined') {
|
|
27
|
+
window.addEventListener('resize', measure);
|
|
28
|
+
return () => window.removeEventListener('resize', measure);
|
|
29
|
+
}
|
|
30
|
+
const observer = new ResizeObserver(measure);
|
|
31
|
+
observer.observe(box);
|
|
32
|
+
return () => observer.disconnect();
|
|
33
|
+
}, [ref, enabled]);
|
|
34
|
+
return height;
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RenderResult } from '@testing-library/react';
|
|
2
|
+
import { type Mock } from 'vitest';
|
|
3
|
+
import { type ContextSwitcherGroup, type ContextSwitcherProps } from './components/ContextSwitcher.js';
|
|
4
|
+
/** Field stations: a bare group, then a labelled one with badges and counts. */
|
|
5
|
+
export declare const stations: ContextSwitcherGroup[];
|
|
6
|
+
/** The switcher with its required props, and any prop overridden. */
|
|
7
|
+
export declare function renderSwitcher(props?: Partial<ContextSwitcherProps>): RenderResult & {
|
|
8
|
+
onSelect: Mock;
|
|
9
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from '@testing-library/react';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import { ContextSwitcher, } from './components/ContextSwitcher.js';
|
|
5
|
+
/** Field stations: a bare group, then a labelled one with badges and counts. */
|
|
6
|
+
export const stations = [
|
|
7
|
+
{
|
|
8
|
+
id: 'home',
|
|
9
|
+
options: [
|
|
10
|
+
{
|
|
11
|
+
id: 'all',
|
|
12
|
+
label: 'All stations',
|
|
13
|
+
icon: _jsx("svg", { "data-glyph": "all" }),
|
|
14
|
+
count: 7,
|
|
15
|
+
countLabel: '7 need you',
|
|
16
|
+
countTone: 'warning',
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'mine',
|
|
22
|
+
label: 'Stations you run',
|
|
23
|
+
options: [
|
|
24
|
+
{
|
|
25
|
+
id: 'heron',
|
|
26
|
+
label: 'Heron Marsh',
|
|
27
|
+
detail: 'Warden',
|
|
28
|
+
badge: { label: 'Live', tone: 'success' },
|
|
29
|
+
},
|
|
30
|
+
{ id: 'pine', label: 'Pine Ridge', detail: 'Ranger' },
|
|
31
|
+
{ id: 'bittern', label: 'Bittern Fen', count: 2 },
|
|
32
|
+
{ id: 'bay', label: 'Bayside Dunes', detail: 'Árbol team' },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
/** The switcher with its required props, and any prop overridden. */
|
|
37
|
+
export function renderSwitcher(props = {}) {
|
|
38
|
+
const onSelect = vi.fn();
|
|
39
|
+
const view = render(_jsx(ContextSwitcher, { currentId: "heron", groups: stations, label: "Station", onSelect: onSelect, trigger: "Heron Marsh", ...props }));
|
|
40
|
+
return { ...view, onSelect };
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
2
|
+
import { zIndex } from './stacking.js';
|
|
3
|
+
import { coarsePointerQuery, touchTarget } from './touch-target.js';
|
|
4
|
+
/*
|
|
5
|
+
* The compact bar's measured height, set on `.sw-app-shell` by the
|
|
6
|
+
* component while it draws a page bar, so the page bar sticks right under
|
|
7
|
+
* the bar whatever its end controls make it.
|
|
8
|
+
*/
|
|
9
|
+
export const appShellBarHeightVariable = '--sw-app-shell-bar-height';
|
|
10
|
+
/*
|
|
11
|
+
* The bar's usual height before it is measured (and without script): the
|
|
12
|
+
* sm menu button, or the touch target on a coarse pointer, inside the
|
|
13
|
+
* bar's space-2 padding, and its hairline.
|
|
14
|
+
*/
|
|
15
|
+
function barHeightFallback(control) {
|
|
16
|
+
return `calc(${control} + 2 * var(--sw-space-2) + 1px)`;
|
|
17
|
+
}
|
|
18
|
+
const fromLg = breakpointQuery('from', 'lg');
|
|
19
|
+
/*
|
|
20
|
+
* `pageBar`: a band at the top of `main` on every layout, the solid
|
|
21
|
+
* surface with a hairline under it and the page's inline padding (space-4,
|
|
22
|
+
* space-6 from `lg`), its children a wrapping row with space between them.
|
|
23
|
+
* Sticky on the top chrome's layer: under the compact bar below `lg`, at
|
|
24
|
+
* the window's top from `lg`, where the bar is gone. Reduce Transparency
|
|
25
|
+
* needs nothing (it is already solid); forced colours keep the hairline.
|
|
26
|
+
*/
|
|
27
|
+
function pageBarRules() {
|
|
28
|
+
return `.sw-app-shell-page-bar {
|
|
29
|
+
align-items: center;
|
|
30
|
+
background: var(--sw-color-surface);
|
|
31
|
+
border-bottom: 1px solid var(--sw-color-border);
|
|
32
|
+
box-sizing: border-box;
|
|
33
|
+
display: flex;
|
|
34
|
+
flex-wrap: wrap;
|
|
35
|
+
gap: var(--sw-space-2) var(--sw-space-3);
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
padding: var(--sw-space-2) var(--sw-space-4);
|
|
38
|
+
position: sticky;
|
|
39
|
+
top: var(${appShellBarHeightVariable}, ${barHeightFallback('var(--sw-control-sm-min-height)')});
|
|
40
|
+
${zIndex('topChrome')}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@media ${coarsePointerQuery} {
|
|
44
|
+
.sw-app-shell-page-bar {
|
|
45
|
+
top: var(${appShellBarHeightVariable}, ${barHeightFallback(touchTarget)});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media ${fromLg} {
|
|
50
|
+
.sw-app-shell-page-bar {
|
|
51
|
+
padding-inline: var(--sw-space-6);
|
|
52
|
+
top: 0;
|
|
53
|
+
}
|
|
54
|
+
}`;
|
|
55
|
+
}
|
|
56
|
+
/*
|
|
57
|
+
* `menuCount`: the shared count pill (`sw-app-shell-count`, its tones
|
|
58
|
+
* included) on the menu button's top end corner, over the glyph's edge as
|
|
59
|
+
* on a rail glyph, mirrored in right-to-left.
|
|
60
|
+
*/
|
|
61
|
+
function menuCountRules() {
|
|
62
|
+
return `.sw-app-shell-menu {
|
|
63
|
+
position: relative;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.sw-app-shell-menu .sw-app-shell-count {
|
|
67
|
+
inset-block-start: calc(-1 * var(--sw-space-1));
|
|
68
|
+
inset-inline-end: calc(-1 * var(--sw-space-1));
|
|
69
|
+
pointer-events: none;
|
|
70
|
+
position: absolute;
|
|
71
|
+
}`;
|
|
72
|
+
}
|
|
73
|
+
/* AppShell's page bar and the menu button's count (see components/AppShell.tsx). */
|
|
74
|
+
export function cssAppShellPageBarClasses() {
|
|
75
|
+
return `${pageBarRules()}
|
|
76
|
+
|
|
77
|
+
${menuCountRules()}`;
|
|
78
|
+
}
|
|
79
|
+
export function appShellPageBarClassCatalog() {
|
|
80
|
+
return ['sw-app-shell-page-bar'];
|
|
81
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { appShellPageBarClassCatalog, cssAppShellPageBarClasses, } from './css-app-shell-page-bar.js';
|
|
4
5
|
import { iconButtonRules } from './css-icon-button.js';
|
|
5
6
|
/*
|
|
6
7
|
* The width from which AppShell's sidebar is full rather than a rail: an
|
|
@@ -110,7 +111,8 @@ ${cssAppShellRailNavRules(' ')}
|
|
|
110
111
|
/*
|
|
111
112
|
* AppShell: a workspace frame with a pinned side navigation (see
|
|
112
113
|
* components/AppShell.tsx). The links, groups and the rail's nav are
|
|
113
|
-
* `css-app-shell-nav.ts`; the menu
|
|
114
|
+
* `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`.
|
|
114
116
|
*/
|
|
115
117
|
export function cssAppShellClasses() {
|
|
116
118
|
return `${barRules()}
|
|
@@ -127,6 +129,8 @@ export function cssAppShellClasses() {
|
|
|
127
129
|
|
|
128
130
|
${cssAppShellNavClasses()}
|
|
129
131
|
|
|
132
|
+
${cssAppShellPageBarClasses()}
|
|
133
|
+
|
|
130
134
|
${sidebarRules()}
|
|
131
135
|
|
|
132
136
|
${cssAppShellDrawerClasses()}`;
|
|
@@ -145,6 +149,7 @@ export function appShellClassCatalog() {
|
|
|
145
149
|
'sw-app-shell-header',
|
|
146
150
|
'sw-app-shell-footer',
|
|
147
151
|
...appShellNavClassCatalog(),
|
|
152
|
+
...appShellPageBarClassCatalog(),
|
|
148
153
|
...appShellDrawerClassCatalog(),
|
|
149
154
|
];
|
|
150
155
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A band's fill: the page's surface, the quiet subtle fill, or the dark inverse band. */
|
|
2
|
+
export declare const bandTones: readonly ["surface", "subtle", "inverse"];
|
|
3
|
+
export type BandTone = (typeof bandTones)[number];
|
|
4
|
+
/** How wide a band's content column runs: the page container or the reading width. */
|
|
5
|
+
export declare const bandWidths: readonly ["container", "reading"];
|
|
6
|
+
export type BandWidth = (typeof bandWidths)[number];
|
|
7
|
+
export declare function bandToneClass(tone: BandTone): string;
|
|
8
|
+
export declare function bandWidthClass(width: BandWidth): string;
|
|
9
|
+
export declare function cssBandClasses(): string;
|
|
10
|
+
export declare function bandClassCatalog(): string[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
2
|
+
/** A band's fill: the page's surface, the quiet subtle fill, or the dark inverse band. */
|
|
3
|
+
export const bandTones = ['surface', 'subtle', 'inverse'];
|
|
4
|
+
/** How wide a band's content column runs: the page container or the reading width. */
|
|
5
|
+
export const bandWidths = ['container', 'reading'];
|
|
6
|
+
export function bandToneClass(tone) {
|
|
7
|
+
return `sw-band-${tone}`;
|
|
8
|
+
}
|
|
9
|
+
export function bandWidthClass(width) {
|
|
10
|
+
return `sw-band-inner-${width}`;
|
|
11
|
+
}
|
|
12
|
+
/*
|
|
13
|
+
* The inverse band re-scopes the theme's colours for everything inside it:
|
|
14
|
+
* words, muted words, hairlines, the quiet subtle fill, links and the
|
|
15
|
+
* accent (a primary button, the focus ring) read on the dark fill, so
|
|
16
|
+
* Text, links and Buttons need no band-aware variant. Every plain link,
|
|
17
|
+
* a quiet one included, takes the band's words with a half-strength
|
|
18
|
+
* underline, since its colour is the words' own (WCAG 1.4.1). The muted
|
|
19
|
+
* words, hairline and subtle fill are mixes of the two band colours, so a
|
|
20
|
+
* brand overlay of `inverse` moves them too; the muted mix keeps 7:1 on
|
|
21
|
+
* the default bands and on a navy one.
|
|
22
|
+
*/
|
|
23
|
+
function inverseScope() {
|
|
24
|
+
return `.sw-band-inverse {
|
|
25
|
+
--sw-color-background: var(--sw-color-inverse);
|
|
26
|
+
--sw-color-surface: var(--sw-color-inverse);
|
|
27
|
+
--sw-color-text: var(--sw-color-onInverse);
|
|
28
|
+
--sw-color-muted: color-mix(in srgb, var(--sw-color-onInverse) 78%, var(--sw-color-inverse));
|
|
29
|
+
--sw-color-border: color-mix(in srgb, var(--sw-color-onInverse) 18%, var(--sw-color-inverse));
|
|
30
|
+
--sw-color-subtle: color-mix(in srgb, var(--sw-color-onInverse) 10%, var(--sw-color-inverse));
|
|
31
|
+
--sw-color-accent: var(--sw-color-onInverse);
|
|
32
|
+
--sw-color-onAccent: var(--sw-color-inverse);
|
|
33
|
+
background: var(--sw-color-inverse);
|
|
34
|
+
color: var(--sw-color-onInverse);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
[data-theme] .sw-band-inverse a:not(.sw-button) {
|
|
38
|
+
color: var(--sw-color-onInverse);
|
|
39
|
+
text-decoration: underline;
|
|
40
|
+
text-decoration-color: color-mix(in srgb, currentColor 50%, transparent);
|
|
41
|
+
text-underline-offset: 0.2em;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
[data-theme] .sw-band-inverse a:not(.sw-button):hover {
|
|
45
|
+
text-decoration-color: currentColor;
|
|
46
|
+
}`;
|
|
47
|
+
}
|
|
48
|
+
/*
|
|
49
|
+
* The surface band is the cards' fill (white in light, a step above the
|
|
50
|
+
* black page in dark), so it stands apart from a grouped or dark page; the
|
|
51
|
+
* subtle band is the quiet grey.
|
|
52
|
+
*
|
|
53
|
+
* A band is a full-width stretch of the page in one fill with its content
|
|
54
|
+
* in a centred column, the page gutter at its sides (the 16 px phone
|
|
55
|
+
* gutter, 24 px from md).
|
|
56
|
+
*/
|
|
57
|
+
export function cssBandClasses() {
|
|
58
|
+
return `.sw-band {
|
|
59
|
+
box-sizing: border-box;
|
|
60
|
+
width: 100%;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.sw-band-surface {
|
|
64
|
+
background: var(--sw-color-surface);
|
|
65
|
+
color: var(--sw-color-text);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.sw-band-subtle {
|
|
69
|
+
background: var(--sw-color-subtle);
|
|
70
|
+
color: var(--sw-color-text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
${inverseScope()}
|
|
74
|
+
|
|
75
|
+
.sw-band-inner {
|
|
76
|
+
box-sizing: border-box;
|
|
77
|
+
margin-inline: auto;
|
|
78
|
+
padding-inline: var(--sw-space-4);
|
|
79
|
+
width: 100%;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.${bandWidthClass('container')} { max-width: calc(var(--sw-container-max) + 2 * var(--sw-space-5)); }
|
|
83
|
+
|
|
84
|
+
.${bandWidthClass('reading')} { max-width: calc(var(--sw-reading-max) + 2 * var(--sw-space-5)); }
|
|
85
|
+
|
|
86
|
+
@media ${breakpointQuery('from', 'md')} {
|
|
87
|
+
.sw-band-inner { padding-inline: var(--sw-space-5); }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@media (forced-colors: active) {
|
|
91
|
+
.sw-band-inverse { border-block: 1px solid CanvasText; }
|
|
92
|
+
}`;
|
|
93
|
+
}
|
|
94
|
+
export function bandClassCatalog() {
|
|
95
|
+
return [
|
|
96
|
+
'sw-band',
|
|
97
|
+
'sw-band-inner',
|
|
98
|
+
...bandTones.map(bandToneClass),
|
|
99
|
+
...bandWidths.map(bandWidthClass),
|
|
100
|
+
];
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { pressedRingGap, pressedRingWidth, webButtonVariants, } from './css-button.js';
|
|
2
|
+
/*
|
|
3
|
+
* A link drawn as a Button (`ButtonLink`) wears the Button's own classes.
|
|
4
|
+
* The canvas paints every link in the accent, with no underline, and in the
|
|
5
|
+
* text colour on hover, from `[data-theme] a`; these rules restore each
|
|
6
|
+
* variant's words over that, hover included, at one class more. A link
|
|
7
|
+
* that is the current page (`aria-current="page"`, such as a site's
|
|
8
|
+
* Contact us on Contact) takes the pressed button's accent ring past a gap
|
|
9
|
+
* in the page background, so the mark keeps the accent's contrast whatever
|
|
10
|
+
* the fill; forced colours drop box shadows, so there the same ring is a
|
|
11
|
+
* Highlight border on an out-of-flow ::after, as a pressed Button's is.
|
|
12
|
+
*/
|
|
13
|
+
const variantWords = {
|
|
14
|
+
primary: 'var(--sw-color-onAccent)',
|
|
15
|
+
secondary: 'var(--sw-color-onSecondary)',
|
|
16
|
+
tertiary: 'var(--sw-color-onTertiary)',
|
|
17
|
+
ghost: 'var(--sw-color-accent)',
|
|
18
|
+
danger: 'var(--sw-color-onDanger)',
|
|
19
|
+
neutral: 'var(--sw-color-text)',
|
|
20
|
+
};
|
|
21
|
+
export const buttonLinkClass = 'sw-button-link';
|
|
22
|
+
export function cssButtonLinkClasses() {
|
|
23
|
+
const words = webButtonVariants
|
|
24
|
+
.map((variant) => `[data-theme] a.${buttonLinkClass}.sw-button-${variant},\n[data-theme] a.${buttonLinkClass}.sw-button-${variant}:hover { color: ${variantWords[variant]}; }`)
|
|
25
|
+
.join('\n');
|
|
26
|
+
return `[data-theme] a.${buttonLinkClass} {
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
${words}
|
|
31
|
+
|
|
32
|
+
[data-theme] a.${buttonLinkClass}[aria-current='page'] {
|
|
33
|
+
box-shadow: 0 0 0 ${pressedRingGap}px var(--sw-color-background), 0 0 0 ${pressedRingWidth}px var(--sw-color-accent);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[data-theme] a.${buttonLinkClass}[aria-current='page']:focus-visible {
|
|
37
|
+
outline-offset: calc(var(--sw-focus-ring-offset) + ${pressedRingWidth}px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@media (forced-colors: active) {
|
|
41
|
+
[data-theme] a.${buttonLinkClass} { color: LinkText; }
|
|
42
|
+
[data-theme] a.${buttonLinkClass}[aria-current='page'] { border-color: Highlight; position: relative; }
|
|
43
|
+
[data-theme] a.${buttonLinkClass}[aria-current='page']::after { border: ${pressedRingWidth - pressedRingGap}px solid Highlight; border-radius: inherit; content: ''; inset: calc(-1px - ${pressedRingWidth}px); pointer-events: none; position: absolute; }
|
|
44
|
+
}`;
|
|
45
|
+
}
|
|
46
|
+
export function buttonLinkClassCatalog() {
|
|
47
|
+
return [buttonLinkClass];
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cssCommandPaletteKeyClasses(): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
const caption = typographyVariants.caption;
|
|
3
|
+
/*
|
|
4
|
+
* A key cap: a key's name or glyph ("⌘K", "↵") in a small rounded hairline
|
|
5
|
+
* box in the muted colour, caption size at weight 500 with tabular figures.
|
|
6
|
+
* CommandPaletteTrigger's shortcut hint and the palette's Enter hint share
|
|
7
|
+
* it. Forced colours keep its edge.
|
|
8
|
+
*/
|
|
9
|
+
export function cssCommandPaletteKeyClasses() {
|
|
10
|
+
return `.sw-command-palette-key {
|
|
11
|
+
align-items: center;
|
|
12
|
+
border: 1px solid var(--sw-color-border);
|
|
13
|
+
border-radius: var(--sw-radius-sm);
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
color: var(--sw-color-muted);
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
flex: none;
|
|
18
|
+
font-family: var(--sw-font-sans);
|
|
19
|
+
font-size: ${caption.fontSize}px;
|
|
20
|
+
font-variant-numeric: tabular-nums;
|
|
21
|
+
font-weight: 500;
|
|
22
|
+
justify-content: center;
|
|
23
|
+
letter-spacing: ${caption.letterSpacing}px;
|
|
24
|
+
line-height: ${caption.lineHeight}px;
|
|
25
|
+
min-width: var(--sw-space-5);
|
|
26
|
+
padding-inline: var(--sw-space-1);
|
|
27
|
+
white-space: nowrap;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (forced-colors: active) {
|
|
31
|
+
.sw-command-palette-key { border-color: CanvasText; color: CanvasText; }
|
|
32
|
+
}`;
|
|
33
|
+
}
|