@scalewing/react 1.46.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 +74 -0
- package/README.md +228 -4
- package/dist/app-shell.test-support.d.ts +5 -0
- package/dist/app-shell.test-support.js +45 -0
- 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 +13 -0
- package/dist/components/AppShell.js +65 -0
- 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/NavCount.d.ts +12 -5
- package/dist/components/NavCount.js +9 -4
- package/dist/components/SectionNav.d.ts +2 -1
- package/dist/components/SectionNav.js +3 -19
- 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/AppShellDrawer.d.ts +24 -0
- package/dist/components/app-shell/AppShellDrawer.js +17 -0
- package/dist/components/app-shell/AppShellLink.d.ts +14 -0
- package/dist/components/app-shell/AppShellLink.js +27 -0
- package/dist/components/app-shell/AppShellNav.d.ts +15 -0
- package/dist/components/app-shell/AppShellNav.js +29 -0
- package/dist/components/app-shell/AppShellSidebar.d.ts +18 -0
- package/dist/components/app-shell/AppShellSidebar.js +18 -0
- package/dist/components/app-shell/types.d.ts +122 -0
- package/dist/components/app-shell/types.js +1 -0
- package/dist/components/app-shell/validate.d.ts +7 -0
- package/dist/components/app-shell/validate.js +70 -0
- 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/nav-items.d.ts +21 -0
- package/dist/components/nav-items.js +26 -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-drawer.d.ts +2 -0
- package/dist/css/css-app-shell-drawer.js +91 -0
- package/dist/css/css-app-shell-nav.d.ts +3 -0
- package/dist/css/css-app-shell-nav.js +167 -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.d.ts +4 -0
- package/dist/css/css-app-shell.js +155 -0
- 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 +27 -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 +7 -0
- package/dist/css/css-count-pill.js +50 -12
- 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 +18 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/styles.css +2122 -512
- package/package.json +2 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type InlineAlign } from '../../anchored-position.js';
|
|
3
|
+
import { type BadgeTone } from '../../css/css-data.js';
|
|
4
|
+
import { type ContextSwitcherTriggerAppearance, type ContextSwitcherWidth } from '../../css/css-context-switcher-trigger.js';
|
|
5
|
+
import { type NavCountTone } from '../NavCount.js';
|
|
6
|
+
/** A short status after an option's words, such as a season's "Live". */
|
|
7
|
+
export type ContextSwitcherBadge = {
|
|
8
|
+
label: string;
|
|
9
|
+
tone: BadgeTone;
|
|
10
|
+
};
|
|
11
|
+
/** One context the person can switch to, such as a workspace or a season. */
|
|
12
|
+
export type ContextSwitcherOption = {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
/** A muted line under the label, such as the person's role there. */
|
|
16
|
+
detail?: string;
|
|
17
|
+
/** A decorative mark before the label (a consumer glyph or crest). */
|
|
18
|
+
icon?: ReactNode;
|
|
19
|
+
/** A status Badge after the words, read in the option's name. */
|
|
20
|
+
badge?: ContextSwitcherBadge;
|
|
21
|
+
/**
|
|
22
|
+
* How many things wait there: the shared count pill at the row's end,
|
|
23
|
+
* read in the option's name. A whole number; 0 shows no pill.
|
|
24
|
+
*/
|
|
25
|
+
count?: number;
|
|
26
|
+
/** What the count means ("7 need you"), read in place of the number. Needs `count`. */
|
|
27
|
+
countLabel?: string;
|
|
28
|
+
/** The pill's colour, NavCount's tones (`danger` by default). Needs `count`. */
|
|
29
|
+
countTone?: NavCountTone;
|
|
30
|
+
};
|
|
31
|
+
/** A run of options, under a caption when it has a `label`. */
|
|
32
|
+
export type ContextSwitcherGroup = {
|
|
33
|
+
id: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
options: readonly ContextSwitcherOption[];
|
|
36
|
+
};
|
|
37
|
+
/** A command in the popup's footer, such as "Create a workspace". */
|
|
38
|
+
export type ContextSwitcherAction = {
|
|
39
|
+
id: string;
|
|
40
|
+
label: string;
|
|
41
|
+
/** A decorative glyph before the label. */
|
|
42
|
+
icon?: ReactNode;
|
|
43
|
+
onSelect: () => void;
|
|
44
|
+
};
|
|
45
|
+
/** The filter field over the options, for a long list. */
|
|
46
|
+
export type ContextSwitcherFilter = {
|
|
47
|
+
/** The field's name, such as "Find a workspace". */
|
|
48
|
+
label: string;
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
/** Shown, and announced, when nothing matches the typed words. */
|
|
51
|
+
emptyText: string;
|
|
52
|
+
/**
|
|
53
|
+
* The field shows only from this many options (all groups together);
|
|
54
|
+
* 0, the default, always shows it.
|
|
55
|
+
*/
|
|
56
|
+
minOptions?: number;
|
|
57
|
+
};
|
|
58
|
+
export type ContextSwitcherProps = {
|
|
59
|
+
/**
|
|
60
|
+
* What is being switched, such as "Workspace": the start of the
|
|
61
|
+
* trigger's name (then what the trigger shows) and the popup's name.
|
|
62
|
+
*/
|
|
63
|
+
label: string;
|
|
64
|
+
/**
|
|
65
|
+
* What the trigger shows, usually the current context (its words, or a
|
|
66
|
+
* mark and words). With `triggerAppearance="icon"` it is a glyph alone,
|
|
67
|
+
* and the name is `label` and the current option's label.
|
|
68
|
+
*/
|
|
69
|
+
trigger: ReactNode;
|
|
70
|
+
/**
|
|
71
|
+
* `framed` (default) is the outlined control with a chevron; `text` is
|
|
72
|
+
* the words and a chevron with no fill until hovered or open; `pill` is
|
|
73
|
+
* a rounded outlined pill at label weight with a chevron; `icon` is the
|
|
74
|
+
* glyph alone in a round quiet button, no chevron (a compact rail).
|
|
75
|
+
*/
|
|
76
|
+
triggerAppearance?: ContextSwitcherTriggerAppearance;
|
|
77
|
+
/**
|
|
78
|
+
* `full` makes the trigger fill its column, such as a sidebar's top,
|
|
79
|
+
* its content at the start and the chevron at the end; `content` (the
|
|
80
|
+
* default) sizes it to what it shows.
|
|
81
|
+
*/
|
|
82
|
+
width?: ContextSwitcherWidth;
|
|
83
|
+
groups: readonly ContextSwitcherGroup[];
|
|
84
|
+
/** The option in use, marked with a check and `aria-selected`. */
|
|
85
|
+
currentId?: string;
|
|
86
|
+
/**
|
|
87
|
+
* Called with the chosen option after the popup closes and focus is back
|
|
88
|
+
* on the trigger. Choosing the current option only closes the popup.
|
|
89
|
+
*/
|
|
90
|
+
onSelect: (option: ContextSwitcherOption) => void;
|
|
91
|
+
filter?: ContextSwitcherFilter;
|
|
92
|
+
/** Commands under the options, after a hairline. */
|
|
93
|
+
actions?: readonly ContextSwitcherAction[];
|
|
94
|
+
/** The trigger edge the popup lines up with (start by default). */
|
|
95
|
+
align?: InlineAlign;
|
|
96
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type KeyboardEvent } from 'react';
|
|
2
|
+
import { type SwitcherEntry } from './options.js';
|
|
3
|
+
import { type ContextSwitcherGroup } from './types.js';
|
|
4
|
+
type SwitcherListOptions = {
|
|
5
|
+
listId: string;
|
|
6
|
+
groups: readonly ContextSwitcherGroup[];
|
|
7
|
+
currentId: string | undefined;
|
|
8
|
+
/** Whether the filter field holds focus (it keeps Home, End and letters). */
|
|
9
|
+
filtering: boolean;
|
|
10
|
+
onChoose: (entry: SwitcherEntry) => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* The open popup's list model: the filter's words, the sections left by
|
|
14
|
+
* them, the active row (by its option id, so it stays put while the list
|
|
15
|
+
* changes), and the keys. Arrows move the active row (stopping at the
|
|
16
|
+
* ends), Enter chooses it; in the listbox Home and End go to the ends,
|
|
17
|
+
* Space chooses and letters jump by typeahead; in the filter field those
|
|
18
|
+
* keys edit the words, which make the first match active.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useSwitcherList({ currentId, filtering, groups, listId, onChoose, }: SwitcherListOptions): {
|
|
21
|
+
active: SwitcherEntry | undefined;
|
|
22
|
+
changeQuery: (next: string) => void;
|
|
23
|
+
entries: SwitcherEntry[];
|
|
24
|
+
onListKey: (event: KeyboardEvent) => boolean;
|
|
25
|
+
query: string;
|
|
26
|
+
sections: import("./options.js").SwitcherSection[];
|
|
27
|
+
setActiveId: import("react").Dispatch<import("react").SetStateAction<string | undefined>>;
|
|
28
|
+
};
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { filterSections, flatEntries, switcherSections, typeaheadIndex, } from './options.js';
|
|
4
|
+
/* How long typed letters build one typeahead word. */
|
|
5
|
+
const typeaheadPause = 500;
|
|
6
|
+
/** A key that types a letter, not a shortcut or a control key. */
|
|
7
|
+
function isPrintable(event) {
|
|
8
|
+
return (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The open popup's list model: the filter's words, the sections left by
|
|
12
|
+
* them, the active row (by its option id, so it stays put while the list
|
|
13
|
+
* changes), and the keys. Arrows move the active row (stopping at the
|
|
14
|
+
* ends), Enter chooses it; in the listbox Home and End go to the ends,
|
|
15
|
+
* Space chooses and letters jump by typeahead; in the filter field those
|
|
16
|
+
* keys edit the words, which make the first match active.
|
|
17
|
+
*/
|
|
18
|
+
export function useSwitcherList({ currentId, filtering, groups, listId, onChoose, }) {
|
|
19
|
+
const [query, setQuery] = useState('');
|
|
20
|
+
const [activeId, setActiveId] = useState(currentId);
|
|
21
|
+
const typed = useRef({ words: '', at: 0 });
|
|
22
|
+
const all = useMemo(() => switcherSections(listId, groups), [listId, groups]);
|
|
23
|
+
const sections = useMemo(() => filterSections(all, query), [all, query]);
|
|
24
|
+
const entries = useMemo(() => flatEntries(sections), [sections]);
|
|
25
|
+
const activeIndex = Math.max(entries.findIndex((entry) => entry.option.id === activeId), entries.length > 0 ? 0 : -1);
|
|
26
|
+
const active = entries[activeIndex];
|
|
27
|
+
function changeQuery(next) {
|
|
28
|
+
setQuery(next);
|
|
29
|
+
setActiveId(flatEntries(filterSections(all, next))[0]?.option.id);
|
|
30
|
+
}
|
|
31
|
+
function moveTo(index) {
|
|
32
|
+
const entry = entries[Math.min(Math.max(index, 0), entries.length - 1)];
|
|
33
|
+
if (entry)
|
|
34
|
+
setActiveId(entry.option.id);
|
|
35
|
+
}
|
|
36
|
+
function typeahead(letter) {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const words = now - typed.current.at > typeaheadPause
|
|
39
|
+
? letter
|
|
40
|
+
: typed.current.words + letter;
|
|
41
|
+
typed.current = { words, at: now };
|
|
42
|
+
const index = typeaheadIndex(entries, words, activeIndex);
|
|
43
|
+
if (index >= 0)
|
|
44
|
+
moveTo(index);
|
|
45
|
+
}
|
|
46
|
+
/** Handles a key in the listbox or the filter field; true when it was used. */
|
|
47
|
+
function onListKey(event) {
|
|
48
|
+
// Keys that build a word in an input method are the method's.
|
|
49
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229)
|
|
50
|
+
return false;
|
|
51
|
+
const typing = Date.now() - typed.current.at <= typeaheadPause;
|
|
52
|
+
switch (event.key) {
|
|
53
|
+
case 'ArrowDown':
|
|
54
|
+
moveTo(activeIndex + 1);
|
|
55
|
+
return true;
|
|
56
|
+
case 'ArrowUp':
|
|
57
|
+
moveTo(activeIndex - 1);
|
|
58
|
+
return true;
|
|
59
|
+
case 'Enter':
|
|
60
|
+
if (active)
|
|
61
|
+
onChoose(active);
|
|
62
|
+
return true;
|
|
63
|
+
case 'Home':
|
|
64
|
+
case 'End':
|
|
65
|
+
if (filtering)
|
|
66
|
+
return false;
|
|
67
|
+
moveTo(event.key === 'Home' ? 0 : entries.length - 1);
|
|
68
|
+
return true;
|
|
69
|
+
case ' ':
|
|
70
|
+
if (filtering)
|
|
71
|
+
return false;
|
|
72
|
+
if (typing)
|
|
73
|
+
typeahead(' ');
|
|
74
|
+
else if (active)
|
|
75
|
+
onChoose(active);
|
|
76
|
+
return true;
|
|
77
|
+
default:
|
|
78
|
+
if (filtering || !isPrintable(event))
|
|
79
|
+
return false;
|
|
80
|
+
typeahead(event.key);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
active,
|
|
86
|
+
changeQuery,
|
|
87
|
+
entries,
|
|
88
|
+
onListKey,
|
|
89
|
+
query,
|
|
90
|
+
sections,
|
|
91
|
+
setActiveId,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ContextSwitcherProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on a switcher it cannot draw or name: blank words, empty or
|
|
4
|
+
* duplicate groups, options (ids unique across groups) or actions, a bad
|
|
5
|
+
* count, badge or tone, a `currentId` that names no option, a trigger that
|
|
6
|
+
* draws nothing, or an unknown appearance, width or alignment.
|
|
7
|
+
*/
|
|
8
|
+
export declare function assertContextSwitcher({ actions, align, currentId, filter, groups, label, trigger, triggerAppearance, width, }: ContextSwitcherProps): void;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { contextSwitcherTriggerAppearances, contextSwitcherWidths, } from '../../css/css-context-switcher-trigger.js';
|
|
2
|
+
import { badgeTones } from '../../css/css-data.js';
|
|
3
|
+
import { isOneOf } from '../../is-one-of.js';
|
|
4
|
+
import { navCountTones } from '../NavCount.js';
|
|
5
|
+
import { hasSlot } from '../slot.js';
|
|
6
|
+
const name = 'ContextSwitcher';
|
|
7
|
+
/** The trigger edges the popup can line up with. */
|
|
8
|
+
const inlineAligns = ['start', 'end'];
|
|
9
|
+
function isBlank(words) {
|
|
10
|
+
return typeof words !== 'string' || !words.trim();
|
|
11
|
+
}
|
|
12
|
+
function isWholeFromZero(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
14
|
+
}
|
|
15
|
+
function hasDuplicates(ids) {
|
|
16
|
+
return new Set(ids).size !== ids.length;
|
|
17
|
+
}
|
|
18
|
+
function assertGroups(groups) {
|
|
19
|
+
if (groups.length === 0)
|
|
20
|
+
throw new RangeError(`${name} needs at least one group`);
|
|
21
|
+
if (groups.some((group) => isBlank(group.id)))
|
|
22
|
+
throw new RangeError(`${name} groups need nonempty ids`);
|
|
23
|
+
if (hasDuplicates(groups.map((group) => group.id)))
|
|
24
|
+
throw new RangeError(`${name} groups need unique ids`);
|
|
25
|
+
if (groups.some((group) => group.label !== undefined && isBlank(group.label)))
|
|
26
|
+
throw new RangeError(`${name} group labels are nonempty when given`);
|
|
27
|
+
if (groups.some((group) => group.options.length === 0))
|
|
28
|
+
throw new RangeError(`${name} groups need at least one option`);
|
|
29
|
+
}
|
|
30
|
+
function assertOption(option) {
|
|
31
|
+
if (isBlank(option.id) || isBlank(option.label))
|
|
32
|
+
throw new RangeError(`${name} options need nonempty ids and labels`);
|
|
33
|
+
if (option.detail !== undefined && isBlank(option.detail))
|
|
34
|
+
throw new RangeError(`${name} option details are nonempty when given`);
|
|
35
|
+
if (option.badge !== undefined &&
|
|
36
|
+
(isBlank(option.badge.label) || !isOneOf(badgeTones, option.badge.tone)))
|
|
37
|
+
throw new RangeError(`${name} option badges need a label and one of ${badgeTones.join(', ')}`);
|
|
38
|
+
if (option.count !== undefined && !isWholeFromZero(option.count))
|
|
39
|
+
throw new RangeError(`${name} option counts are whole numbers from 0`);
|
|
40
|
+
if (option.countLabel !== undefined &&
|
|
41
|
+
(isBlank(option.countLabel) || option.count === undefined))
|
|
42
|
+
throw new RangeError(`${name} option countLabel is nonempty and comes with a count`);
|
|
43
|
+
if (option.countTone !== undefined &&
|
|
44
|
+
(!isOneOf(navCountTones, option.countTone) || option.count === undefined))
|
|
45
|
+
throw new RangeError(`${name} option countTone is one of ${navCountTones.join(', ')} and comes with a count`);
|
|
46
|
+
}
|
|
47
|
+
function assertFilter(filter) {
|
|
48
|
+
if (filter === undefined)
|
|
49
|
+
return;
|
|
50
|
+
if (isBlank(filter.label) || isBlank(filter.emptyText))
|
|
51
|
+
throw new RangeError(`${name} filter needs a label and an emptyText`);
|
|
52
|
+
if (filter.placeholder !== undefined && isBlank(filter.placeholder))
|
|
53
|
+
throw new RangeError(`${name} filter placeholder is nonempty when given`);
|
|
54
|
+
if (filter.minOptions !== undefined && !isWholeFromZero(filter.minOptions))
|
|
55
|
+
throw new RangeError(`${name} filter minOptions is a whole number from 0`);
|
|
56
|
+
}
|
|
57
|
+
function assertActions(actions) {
|
|
58
|
+
if (actions === undefined)
|
|
59
|
+
return;
|
|
60
|
+
if (actions.some((action) => isBlank(action.id) || isBlank(action.label)))
|
|
61
|
+
throw new RangeError(`${name} actions need nonempty ids and labels`);
|
|
62
|
+
if (hasDuplicates(actions.map((action) => action.id)))
|
|
63
|
+
throw new RangeError(`${name} actions need unique ids`);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fails closed on a switcher it cannot draw or name: blank words, empty or
|
|
67
|
+
* duplicate groups, options (ids unique across groups) or actions, a bad
|
|
68
|
+
* count, badge or tone, a `currentId` that names no option, a trigger that
|
|
69
|
+
* draws nothing, or an unknown appearance, width or alignment.
|
|
70
|
+
*/
|
|
71
|
+
export function assertContextSwitcher({ actions, align, currentId, filter, groups, label, trigger, triggerAppearance, width, }) {
|
|
72
|
+
if (isBlank(label))
|
|
73
|
+
throw new RangeError(`${name} label must be nonempty`);
|
|
74
|
+
if (!hasSlot(trigger) ||
|
|
75
|
+
(typeof trigger === 'string' && trigger.trim() === ''))
|
|
76
|
+
throw new RangeError(`${name} needs a trigger to draw`);
|
|
77
|
+
if (triggerAppearance !== undefined &&
|
|
78
|
+
!isOneOf(contextSwitcherTriggerAppearances, triggerAppearance))
|
|
79
|
+
throw new RangeError(`${name} triggerAppearance must be one of ${contextSwitcherTriggerAppearances.join(', ')}`);
|
|
80
|
+
if (width !== undefined && !isOneOf(contextSwitcherWidths, width))
|
|
81
|
+
throw new RangeError(`${name} width must be one of ${contextSwitcherWidths.join(', ')}`);
|
|
82
|
+
if (align !== undefined && !isOneOf(inlineAligns, align))
|
|
83
|
+
throw new RangeError(`${name} align must be one of ${inlineAligns.join(', ')}`);
|
|
84
|
+
assertGroups(groups);
|
|
85
|
+
const options = groups.flatMap((group) => group.options);
|
|
86
|
+
options.forEach(assertOption);
|
|
87
|
+
if (hasDuplicates(options.map((option) => option.id)))
|
|
88
|
+
throw new RangeError(`${name} options need unique ids`);
|
|
89
|
+
if (currentId !== undefined &&
|
|
90
|
+
!options.some((option) => option.id === currentId))
|
|
91
|
+
throw new RangeError(`${name} currentId must name one of its options`);
|
|
92
|
+
assertFilter(filter);
|
|
93
|
+
assertActions(actions);
|
|
94
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Set by FixtureGroup around its list, so a FixtureRow knows it is in one. */
|
|
2
|
+
export declare const FixtureListContext: import("react").Context<boolean>;
|
|
3
|
+
/** Fails closed on a FixtureRow outside a FixtureGroup (its `li` needs the list). */
|
|
4
|
+
export declare function useFixtureList(): void;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
/** Set by FixtureGroup around its list, so a FixtureRow knows it is in one. */
|
|
4
|
+
export const FixtureListContext = createContext(false);
|
|
5
|
+
/** Fails closed on a FixtureRow outside a FixtureGroup (its `li` needs the list). */
|
|
6
|
+
export function useFixtureList() {
|
|
7
|
+
if (!useContext(FixtureListContext))
|
|
8
|
+
throw new RangeError('A FixtureRow lives in a FixtureGroup');
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type MouseEvent, type ReactNode } from 'react';
|
|
2
|
+
import { type VisibilityBreakpoint } from '../../css/breakpoints.js';
|
|
3
|
+
import { type BadgeTone } from '../../css/css-data.js';
|
|
4
|
+
import { type FixtureCenterKind } from '../../css/css-fixture.js';
|
|
5
|
+
/** One side of a fixture: its name and an optional decorative mark (a crest). */
|
|
6
|
+
export type FixtureSide = {
|
|
7
|
+
name: string;
|
|
8
|
+
/** A mark beside the name, on the centre's side; hidden from assistive technology. */
|
|
9
|
+
mark?: ReactNode;
|
|
10
|
+
};
|
|
11
|
+
/** A status after the row's details, such as "Final" or "To confirm". */
|
|
12
|
+
export type FixtureStatus = {
|
|
13
|
+
label: string;
|
|
14
|
+
tone: BadgeTone;
|
|
15
|
+
};
|
|
16
|
+
export type FixtureRowProps = {
|
|
17
|
+
home: FixtureSide;
|
|
18
|
+
away: FixtureSide;
|
|
19
|
+
/** What sits between the sides: a kickoff time ("8:00") or a score ("2–1"). */
|
|
20
|
+
center: string;
|
|
21
|
+
/**
|
|
22
|
+
* `time` (default) draws the centre as a small grey chip; `score` as
|
|
23
|
+
* bold figures. Both are tabular, so the rows' centres line up.
|
|
24
|
+
*/
|
|
25
|
+
centerKind?: FixtureCenterKind;
|
|
26
|
+
/** Details at the row's end, such as the field; under the sides below `md`. */
|
|
27
|
+
meta?: ReactNode;
|
|
28
|
+
/** Hides `meta` below this breakpoint (`md` or `lg`), such as a field a phone leaves out. */
|
|
29
|
+
metaHideBelow?: VisibilityBreakpoint;
|
|
30
|
+
status?: FixtureStatus;
|
|
31
|
+
/** Makes the whole row a link to this address. */
|
|
32
|
+
href?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Makes the whole row a button; with `href`, a plain press on the link
|
|
35
|
+
* (main button, no modifier) is handed here for a client router.
|
|
36
|
+
*/
|
|
37
|
+
onPress?: (event: MouseEvent<HTMLElement>) => void;
|
|
38
|
+
/**
|
|
39
|
+
* The row's name when it is a link or a button, in the consumer's words
|
|
40
|
+
* ("Heron Marsh with Otter Pool, 8:00"); without it the
|
|
41
|
+
* name is what the row shows, in order.
|
|
42
|
+
*/
|
|
43
|
+
'aria-label'?: string;
|
|
44
|
+
};
|
|
45
|
+
export type FixtureGroupProps = {
|
|
46
|
+
/** The run's caption, such as a date and a venue; it names the list. */
|
|
47
|
+
title: string;
|
|
48
|
+
/** The run's FixtureRows. */
|
|
49
|
+
children: ReactNode;
|
|
50
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FixtureRowProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on a row it cannot name or draw: a side without a name, a
|
|
4
|
+
* blank centre, an unknown centre kind or breakpoint, a status with blank
|
|
5
|
+
* words or an unknown tone, a blank address or `aria-label`, or an
|
|
6
|
+
* `aria-label` on a row that is neither a link nor a button.
|
|
7
|
+
*/
|
|
8
|
+
export declare function assertFixtureRow({ away, center, centerKind, home, href, metaHideBelow, onPress, status, 'aria-label': ariaLabel, }: FixtureRowProps): void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { visibilityBreakpoints } from '../../css/breakpoints.js';
|
|
2
|
+
import { badgeTones } from '../../css/css-data.js';
|
|
3
|
+
import { fixtureCenterKinds } from '../../css/css-fixture.js';
|
|
4
|
+
import { isOneOf } from '../../is-one-of.js';
|
|
5
|
+
function isBlank(words) {
|
|
6
|
+
return typeof words !== 'string' || !words.trim();
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Fails closed on a row it cannot name or draw: a side without a name, a
|
|
10
|
+
* blank centre, an unknown centre kind or breakpoint, a status with blank
|
|
11
|
+
* words or an unknown tone, a blank address or `aria-label`, or an
|
|
12
|
+
* `aria-label` on a row that is neither a link nor a button.
|
|
13
|
+
*/
|
|
14
|
+
export function assertFixtureRow({ away, center, centerKind, home, href, metaHideBelow, onPress, status, 'aria-label': ariaLabel, }) {
|
|
15
|
+
const name = 'FixtureRow';
|
|
16
|
+
if (isBlank(home?.name) || isBlank(away?.name))
|
|
17
|
+
throw new RangeError(`${name} home and away need nonempty names`);
|
|
18
|
+
if (isBlank(center))
|
|
19
|
+
throw new RangeError(`${name} center must be nonempty`);
|
|
20
|
+
if (centerKind !== undefined && !isOneOf(fixtureCenterKinds, centerKind))
|
|
21
|
+
throw new RangeError(`${name} centerKind must be one of ${fixtureCenterKinds.join(', ')}`);
|
|
22
|
+
if (metaHideBelow !== undefined &&
|
|
23
|
+
!isOneOf(visibilityBreakpoints, metaHideBelow))
|
|
24
|
+
throw new RangeError(`${name} metaHideBelow must be one of ${visibilityBreakpoints.join(', ')}`);
|
|
25
|
+
if (status !== undefined &&
|
|
26
|
+
(isBlank(status.label) || !isOneOf(badgeTones, status.tone)))
|
|
27
|
+
throw new RangeError(`${name} status needs a label and one of ${badgeTones.join(', ')}`);
|
|
28
|
+
if (href !== undefined && isBlank(href))
|
|
29
|
+
throw new RangeError(`${name} href must be nonempty when given`);
|
|
30
|
+
if (ariaLabel !== undefined) {
|
|
31
|
+
if (isBlank(ariaLabel))
|
|
32
|
+
throw new RangeError(`${name} aria-label must be nonempty when given`);
|
|
33
|
+
if (href === undefined && onPress === undefined)
|
|
34
|
+
throw new RangeError(`${name} aria-label names a link or a button: give it href or onPress`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** What a navigation's item is marked by: `aria-current`, or nothing. */
|
|
2
|
+
export type NavItemCurrent = 'page' | 'location' | boolean;
|
|
3
|
+
/** The parts of a navigation item that SectionNav and AppShell validate alike. */
|
|
4
|
+
type CheckedNavItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
count?: number;
|
|
8
|
+
countLabel?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* `aria-current` for an item: `page`, `location`, `true` for a bare `true`,
|
|
12
|
+
* and none for `false` or nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function navItemAriaCurrent(current: NavItemCurrent | undefined): 'page' | 'location' | 'true' | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Fails closed on a navigation's items, named after the component that
|
|
17
|
+
* draws them: empty or duplicate ids, empty labels, a count that is not a
|
|
18
|
+
* whole number from 0, and a blank `countLabel` or one without a count.
|
|
19
|
+
*/
|
|
20
|
+
export declare function assertNavItems(component: string, items: readonly CheckedNavItem[]): void;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aria-current` for an item: `page`, `location`, `true` for a bare `true`,
|
|
3
|
+
* and none for `false` or nothing.
|
|
4
|
+
*/
|
|
5
|
+
export function navItemAriaCurrent(current) {
|
|
6
|
+
if (current === true)
|
|
7
|
+
return 'true';
|
|
8
|
+
return current || undefined;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Fails closed on a navigation's items, named after the component that
|
|
12
|
+
* draws them: empty or duplicate ids, empty labels, a count that is not a
|
|
13
|
+
* whole number from 0, and a blank `countLabel` or one without a count.
|
|
14
|
+
*/
|
|
15
|
+
export function assertNavItems(component, items) {
|
|
16
|
+
if (items.some((item) => !item.id.trim() || !item.label.trim()))
|
|
17
|
+
throw new RangeError(`${component} items need nonempty ids and labels`);
|
|
18
|
+
if (new Set(items.map((item) => item.id)).size !== items.length)
|
|
19
|
+
throw new RangeError(`${component} items need unique ids`);
|
|
20
|
+
if (items.some((item) => item.count !== undefined &&
|
|
21
|
+
(!Number.isInteger(item.count) || item.count < 0)))
|
|
22
|
+
throw new RangeError(`${component} item counts are whole numbers from 0`);
|
|
23
|
+
if (items.some((item) => item.countLabel !== undefined &&
|
|
24
|
+
(!item.countLabel.trim() || item.count === undefined)))
|
|
25
|
+
throw new RangeError(`${component} item countLabel is nonempty and comes with a count`);
|
|
26
|
+
}
|
|
@@ -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;
|