@scalewing/react 1.47.0 → 1.49.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 +82 -0
- package/README.md +215 -6
- 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/EmptyState.d.ts +23 -0
- package/dist/components/EmptyState.js +40 -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/Grid.d.ts +11 -3
- package/dist/components/Grid.js +13 -4
- package/dist/components/Hero.d.ts +26 -0
- package/dist/components/Hero.js +23 -0
- 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/Stepper.d.ts +11 -1
- package/dist/components/Stepper.js +10 -8
- package/dist/components/Text.js +3 -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/stepper/StepperCountFrame.d.ts +15 -0
- package/dist/components/stepper/StepperCountFrame.js +25 -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 +30 -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-empty-state.d.ts +2 -0
- package/dist/css/css-empty-state.js +77 -0
- 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-grid-span.js +5 -2
- package/dist/css/css-grid.d.ts +13 -0
- package/dist/css/css-grid.js +17 -1
- package/dist/css/css-hero.d.ts +2 -0
- package/dist/css/css-hero.js +62 -0
- 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/css-stepper-unit.d.ts +9 -0
- package/dist/css/css-stepper-unit.js +102 -0
- package/dist/css/css-stepper.js +5 -1
- package/dist/css/stacking.js +7 -4
- package/dist/css/stylesheet.js +20 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.js +13 -1
- package/dist/stepper-count.d.ts +8 -0
- package/dist/stepper-count.js +17 -0
- package/dist/styles.css +2202 -702
- package/package.json +2 -2
|
@@ -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,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,15 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
export type StepperCountFrameProps = {
|
|
3
|
+
/** The count's input. */
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** A short word after the count; no frame is drawn without one. */
|
|
6
|
+
unit?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The count with its unit inside one control frame ("90 min"), as Field's
|
|
10
|
+
* suffix: the frame draws the control surface and focus ring, and the input
|
|
11
|
+
* inside it drops its own. The unit is hidden from assistive technology,
|
|
12
|
+
* which hears it in the count's `aria-valuetext` instead. Without a unit the
|
|
13
|
+
* input is the frame, unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export declare function StepperCountFrame({ children, unit }: StepperCountFrameProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/*
|
|
3
|
+
* A press on the frame's own chrome (the unit or the padding) focuses the
|
|
4
|
+
* count, as the text cursor over it promises. A press on the input itself
|
|
5
|
+
* keeps the browser's own caret placement and selection.
|
|
6
|
+
*/
|
|
7
|
+
function focusCount(event) {
|
|
8
|
+
const input = event.currentTarget.querySelector('input');
|
|
9
|
+
if (!input || input.disabled || event.target === input)
|
|
10
|
+
return;
|
|
11
|
+
event.preventDefault();
|
|
12
|
+
input.focus();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The count with its unit inside one control frame ("90 min"), as Field's
|
|
16
|
+
* suffix: the frame draws the control surface and focus ring, and the input
|
|
17
|
+
* inside it drops its own. The unit is hidden from assistive technology,
|
|
18
|
+
* which hears it in the count's `aria-valuetext` instead. Without a unit the
|
|
19
|
+
* input is the frame, unchanged.
|
|
20
|
+
*/
|
|
21
|
+
export function StepperCountFrame({ children, unit }) {
|
|
22
|
+
if (unit === undefined)
|
|
23
|
+
return _jsx(_Fragment, { children: children });
|
|
24
|
+
return (_jsxs("span", { className: "sw-stepper-count", onMouseDown: focusCount, children: [children, _jsx("span", { "aria-hidden": "true", className: "sw-stepper-unit", children: unit.trim() })] }));
|
|
25
|
+
}
|
|
@@ -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
|
+
};
|