@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +171 -4
  3. package/dist/command-palette.test-support.d.ts +21 -0
  4. package/dist/command-palette.test-support.js +55 -0
  5. package/dist/components/AppHeader.d.ts +3 -0
  6. package/dist/components/AppShell.d.ts +4 -3
  7. package/dist/components/AppShell.js +24 -7
  8. package/dist/components/Band.d.ts +30 -0
  9. package/dist/components/Band.js +23 -0
  10. package/dist/components/ButtonLink.d.ts +33 -0
  11. package/dist/components/ButtonLink.js +27 -0
  12. package/dist/components/CommandPalette.d.ts +12 -0
  13. package/dist/components/CommandPalette.js +143 -0
  14. package/dist/components/CommandPaletteTrigger.d.ts +9 -0
  15. package/dist/components/CommandPaletteTrigger.js +16 -0
  16. package/dist/components/ContextSwitcher.d.ts +12 -0
  17. package/dist/components/ContextSwitcher.js +62 -0
  18. package/dist/components/FixtureGroup.d.ts +9 -0
  19. package/dist/components/FixtureGroup.js +16 -0
  20. package/dist/components/FixtureRow.d.ts +10 -0
  21. package/dist/components/FixtureRow.js +46 -0
  22. package/dist/components/GlyphTile.d.ts +9 -3
  23. package/dist/components/GlyphTile.js +15 -4
  24. package/dist/components/SiteFooter.d.ts +12 -0
  25. package/dist/components/SiteFooter.js +23 -0
  26. package/dist/components/SiteHeader.d.ts +14 -0
  27. package/dist/components/SiteHeader.js +24 -0
  28. package/dist/components/Skeleton.d.ts +22 -0
  29. package/dist/components/Skeleton.js +30 -0
  30. package/dist/components/app-shell/AppShellBar.d.ts +23 -0
  31. package/dist/components/app-shell/AppShellBar.js +25 -0
  32. package/dist/components/app-shell/types.d.ts +36 -1
  33. package/dist/components/app-shell/validate.d.ts +4 -3
  34. package/dist/components/app-shell/validate.js +21 -3
  35. package/dist/components/command-palette/PaletteList.d.ts +20 -0
  36. package/dist/components/command-palette/PaletteList.js +21 -0
  37. package/dist/components/command-palette/shortcut.d.ts +19 -0
  38. package/dist/components/command-palette/shortcut.js +75 -0
  39. package/dist/components/command-palette/types.d.ts +72 -0
  40. package/dist/components/command-palette/types.js +2 -0
  41. package/dist/components/command-palette/validate.d.ts +10 -0
  42. package/dist/components/command-palette/validate.js +50 -0
  43. package/dist/components/context-switcher/SwitcherOption.d.ts +17 -0
  44. package/dist/components/context-switcher/SwitcherOption.js +20 -0
  45. package/dist/components/context-switcher/SwitcherPopup.d.ts +31 -0
  46. package/dist/components/context-switcher/SwitcherPopup.js +111 -0
  47. package/dist/components/context-switcher/options.d.ts +33 -0
  48. package/dist/components/context-switcher/options.js +60 -0
  49. package/dist/components/context-switcher/types.d.ts +96 -0
  50. package/dist/components/context-switcher/types.js +1 -0
  51. package/dist/components/context-switcher/use-switcher-list.d.ts +29 -0
  52. package/dist/components/context-switcher/use-switcher-list.js +93 -0
  53. package/dist/components/context-switcher/validate.d.ts +8 -0
  54. package/dist/components/context-switcher/validate.js +94 -0
  55. package/dist/components/fixture/fixture-list-context.d.ts +4 -0
  56. package/dist/components/fixture/fixture-list-context.js +9 -0
  57. package/dist/components/fixture/types.d.ts +50 -0
  58. package/dist/components/fixture/types.js +1 -0
  59. package/dist/components/fixture/validate.d.ts +8 -0
  60. package/dist/components/fixture/validate.js +36 -0
  61. package/dist/components/site-footer/types.d.ts +31 -0
  62. package/dist/components/site-footer/types.js +1 -0
  63. package/dist/components/site-footer/validate.d.ts +7 -0
  64. package/dist/components/site-footer/validate.js +22 -0
  65. package/dist/components/site-header/SiteHeaderLinks.d.ts +7 -0
  66. package/dist/components/site-header/SiteHeaderLinks.js +7 -0
  67. package/dist/components/site-header/types.d.ts +38 -0
  68. package/dist/components/site-header/types.js +1 -0
  69. package/dist/components/site-header/use-menu-dismiss.d.ts +8 -0
  70. package/dist/components/site-header/use-menu-dismiss.js +62 -0
  71. package/dist/components/site-header/validate.d.ts +7 -0
  72. package/dist/components/site-header/validate.js +17 -0
  73. package/dist/components/use-element-height.d.ts +10 -0
  74. package/dist/components/use-element-height.js +35 -0
  75. package/dist/context-switcher.test-support.d.ts +9 -0
  76. package/dist/context-switcher.test-support.js +41 -0
  77. package/dist/css/css-app-shell-page-bar.d.ts +3 -0
  78. package/dist/css/css-app-shell-page-bar.js +81 -0
  79. package/dist/css/css-app-shell.js +6 -1
  80. package/dist/css/css-band.d.ts +10 -0
  81. package/dist/css/css-band.js +101 -0
  82. package/dist/css/css-button-link.d.ts +3 -0
  83. package/dist/css/css-button-link.js +48 -0
  84. package/dist/css/css-command-palette-key.d.ts +1 -0
  85. package/dist/css/css-command-palette-key.js +33 -0
  86. package/dist/css/css-command-palette-trigger.d.ts +2 -0
  87. package/dist/css/css-command-palette-trigger.js +88 -0
  88. package/dist/css/css-command-palette.d.ts +2 -0
  89. package/dist/css/css-command-palette.js +209 -0
  90. package/dist/css/css-components.js +24 -0
  91. package/dist/css/css-context-switcher-trigger.d.ts +15 -0
  92. package/dist/css/css-context-switcher-trigger.js +175 -0
  93. package/dist/css/css-context-switcher.d.ts +4 -0
  94. package/dist/css/css-context-switcher.js +219 -0
  95. package/dist/css/css-count-pill.d.ts +2 -2
  96. package/dist/css/css-count-pill.js +11 -3
  97. package/dist/css/css-fixture.d.ts +5 -0
  98. package/dist/css/css-fixture.js +260 -0
  99. package/dist/css/css-glyph-tile.d.ts +2 -0
  100. package/dist/css/css-glyph-tile.js +29 -1
  101. package/dist/css/css-site-footer.d.ts +2 -0
  102. package/dist/css/css-site-footer.js +152 -0
  103. package/dist/css/css-site-header.d.ts +2 -0
  104. package/dist/css/css-site-header.js +262 -0
  105. package/dist/css/css-skeleton.d.ts +10 -0
  106. package/dist/css/css-skeleton.js +110 -0
  107. package/dist/css/stacking.js +7 -4
  108. package/dist/css/stylesheet.js +16 -0
  109. package/dist/index.d.ts +13 -0
  110. package/dist/index.js +10 -0
  111. package/dist/styles.css +2008 -719
  112. package/package.json +2 -2
@@ -0,0 +1,111 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Fragment, useId, useLayoutEffect, useRef, } from 'react';
4
+ import { Button } from '../Button.js';
5
+ import { contextSwitcherTriggerWidthVariable } from '../../css/css-context-switcher.js';
6
+ import { useAnchoredPopover } from '../use-anchored-popover.js';
7
+ import { SwitcherOption } from './SwitcherOption.js';
8
+ import { useSwitcherList } from './use-switcher-list.js';
9
+ /**
10
+ * The open popup: a non-modal `dialog` named by the switcher's label on the
11
+ * top layer beside the trigger. Focus moves into it, to the filter field
12
+ * (an ARIA combobox over the listbox) or else to the listbox itself; both
13
+ * point at the active row with `aria-activedescendant`. Escape closes it
14
+ * and returns focus to the trigger; a press outside or focus leaving it
15
+ * (Tab past its end, Shift+Tab back to the trigger) closes it where focus
16
+ * went.
17
+ */
18
+ export function SwitcherPopup({ actions, align, currentId, filter, groups, label, onAction, onChoose, onClose, triggerPressed, triggerRef, }) {
19
+ const popupRef = useRef(null);
20
+ const focusRef = useRef(null);
21
+ const listId = useId();
22
+ const list = useSwitcherList({
23
+ currentId,
24
+ filtering: filter !== undefined,
25
+ groups,
26
+ listId,
27
+ onChoose,
28
+ });
29
+ useAnchoredPopover(popupRef, triggerRef, triggerRef, () => onClose(false), {
30
+ align,
31
+ flipInline: true,
32
+ });
33
+ // At least as wide as the trigger (the stylesheet keeps its own least
34
+ // width and the viewport cap), followed while the trigger resizes.
35
+ useLayoutEffect(() => {
36
+ const popup = popupRef.current;
37
+ const trigger = triggerRef.current;
38
+ if (!popup || !trigger)
39
+ return;
40
+ const fit = () => popup.style.setProperty(contextSwitcherTriggerWidthVariable, `${trigger.offsetWidth}px`);
41
+ fit();
42
+ if (typeof ResizeObserver === 'undefined')
43
+ return;
44
+ const observer = new ResizeObserver(fit);
45
+ observer.observe(trigger);
46
+ return () => observer.disconnect();
47
+ }, [triggerRef]);
48
+ // The last place the pointer moved to: a list scrolled under a still
49
+ // pointer can report a move with no movement, which must not take the
50
+ // active row from the keys.
51
+ const pointerAt = useRef(null);
52
+ function pointerMoved(event) {
53
+ const last = pointerAt.current;
54
+ pointerAt.current = { x: event.clientX, y: event.clientY };
55
+ return !last || last.x !== event.clientX || last.y !== event.clientY;
56
+ }
57
+ // A press anywhere but the field or a command keeps focus where it is
58
+ // (the field or the listbox), so the keys keep working after a press on
59
+ // a caption, the padding or a row.
60
+ function keepFocus(event) {
61
+ const target = event.target;
62
+ if (target instanceof Element && target.closest('input, button'))
63
+ return;
64
+ event.preventDefault();
65
+ }
66
+ useLayoutEffect(() => {
67
+ focusRef.current?.focus({ preventScroll: true });
68
+ }, []);
69
+ const activeDomId = list.active?.domId;
70
+ useLayoutEffect(() => {
71
+ if (activeDomId)
72
+ document
73
+ .getElementById(activeDomId)
74
+ ?.scrollIntoView?.({ block: 'nearest' });
75
+ }, [activeDomId]);
76
+ function onKeyDown(event) {
77
+ if (event.key === 'Escape') {
78
+ // Stops a dialog round the trigger (a drawer) from closing too.
79
+ event.preventDefault();
80
+ event.stopPropagation();
81
+ onClose(true);
82
+ return;
83
+ }
84
+ if (event.target !== focusRef.current)
85
+ return;
86
+ if (list.onListKey(event))
87
+ event.preventDefault();
88
+ }
89
+ function onBlur(event) {
90
+ const next = event.relatedTarget;
91
+ if (!(next instanceof Node) || popupRef.current?.contains(next))
92
+ return;
93
+ // Shift+Tab back to the trigger closes it too; a press on the trigger
94
+ // leaves that to the trigger's own toggle.
95
+ if (triggerRef.current?.contains(next) && triggerPressed())
96
+ return;
97
+ onClose(false);
98
+ }
99
+ const empty = list.entries.length === 0;
100
+ const activeDescendant = list.active?.domId;
101
+ return (_jsxs("div", { "aria-label": label, className: "sw-context-switcher-popup", onBlur: onBlur, onKeyDown: onKeyDown, onMouseDown: keepFocus, ref: popupRef, role: "dialog", children: [filter ? (_jsx("input", { "aria-activedescendant": activeDescendant, "aria-autocomplete": "list", "aria-controls": listId, "aria-expanded": "true", "aria-label": filter.label, autoComplete: "off", className: "sw-context-switcher-filter", onChange: (event) => list.changeQuery(event.target.value), placeholder: filter.placeholder, ref: focusRef, role: "combobox", spellCheck: false, type: "text", value: list.query })) : null, _jsx("div", { "aria-activedescendant": filter ? undefined : activeDescendant, "aria-label": label, className: "sw-context-switcher-list", hidden: empty, id: listId, ref: filter ? undefined : focusRef, role: "listbox", tabIndex: filter ? undefined : 0, children: list.sections.map(({ entries, group }, index) => {
102
+ const rows = entries.map((entry) => (_jsx(SwitcherOption, { active: entry === list.active, current: entry.option.id === currentId, entry: entry, onActivate: (event) => {
103
+ if (pointerMoved(event))
104
+ list.setActiveId(entry.option.id);
105
+ }, onChoose: () => onChoose(entry) }, entry.option.id)));
106
+ if (!group.label)
107
+ return _jsx(Fragment, { children: rows }, group.id);
108
+ const headingId = `${listId}-group-${index}`;
109
+ return (_jsxs("div", { "aria-labelledby": headingId, role: "group", children: [_jsx("div", { className: "sw-context-switcher-heading", id: headingId, role: "presentation", children: group.label }), rows] }, group.id));
110
+ }) }), filter ? (_jsxs(_Fragment, { children: [empty ? (_jsx("p", { "aria-hidden": "true", className: "sw-context-switcher-empty", children: filter.emptyText })) : null, _jsx("div", { className: "sw-sr-only", role: "status", children: empty ? filter.emptyText : '' })] })) : null, actions.length > 0 ? (_jsx("div", { className: "sw-context-switcher-footer", children: actions.map((action) => (_jsxs(Button, { onPress: () => onAction(action), size: "sm", variant: "ghost", children: [action.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-context-switcher-action-icon", children: action.icon })) : null, action.label] }, action.id))) })) : null] }));
111
+ }
@@ -0,0 +1,33 @@
1
+ import { type ContextSwitcherGroup, type ContextSwitcherOption } from './types.js';
2
+ /** An option in list order, with the stable DOM id its row keeps while filtered. */
3
+ export type SwitcherEntry = {
4
+ option: ContextSwitcherOption;
5
+ groupId: string;
6
+ /** The row's id under the listbox, from its place in the full list. */
7
+ domId: string;
8
+ };
9
+ /** A group with the entries left after filtering (none are dropped here). */
10
+ export type SwitcherSection = {
11
+ group: ContextSwitcherGroup;
12
+ entries: SwitcherEntry[];
13
+ };
14
+ /** Words folded for matching: no case and no accents ("Árbol" → "arbol"). */
15
+ export declare function foldWords(words: string): string;
16
+ /** Every option's entry, in order, each with a DOM id under `listId`. */
17
+ export declare function switcherSections(listId: string, groups: readonly ContextSwitcherGroup[]): SwitcherSection[];
18
+ /**
19
+ * The sections whose options match `query` in their label or detail
20
+ * (folded, anywhere in the words); a blank query keeps them all, and a
21
+ * section left with no entries is dropped.
22
+ */
23
+ export declare function filterSections(sections: readonly SwitcherSection[], query: string): SwitcherSection[];
24
+ /** The entries of the sections in list order: what the arrow keys move over. */
25
+ export declare function flatEntries(sections: readonly SwitcherSection[]): SwitcherEntry[];
26
+ /** How many options the groups hold together. */
27
+ export declare function optionCount(groups: readonly ContextSwitcherGroup[]): number;
28
+ /**
29
+ * The entry typeahead lands on: the first whose label starts with `typed`
30
+ * (folded), searching after `from` and wrapping. Repeating one letter
31
+ * ("b", "bb") steps through the labels starting with it. -1 when none.
32
+ */
33
+ export declare function typeaheadIndex(entries: readonly SwitcherEntry[], typed: string, from: number): number;
@@ -0,0 +1,60 @@
1
+ /** Words folded for matching: no case and no accents ("Árbol" → "arbol"). */
2
+ export function foldWords(words) {
3
+ return words.normalize('NFD').replace(/\p{M}/gu, '').toLocaleLowerCase();
4
+ }
5
+ /** Every option's entry, in order, each with a DOM id under `listId`. */
6
+ export function switcherSections(listId, groups) {
7
+ let place = 0;
8
+ return groups.map((group) => ({
9
+ group,
10
+ entries: group.options.map((option) => ({
11
+ option,
12
+ groupId: group.id,
13
+ domId: `${listId}-option-${place++}`,
14
+ })),
15
+ }));
16
+ }
17
+ /**
18
+ * The sections whose options match `query` in their label or detail
19
+ * (folded, anywhere in the words); a blank query keeps them all, and a
20
+ * section left with no entries is dropped.
21
+ */
22
+ export function filterSections(sections, query) {
23
+ const needle = foldWords(query.trim());
24
+ if (!needle)
25
+ return [...sections];
26
+ return sections
27
+ .map((section) => ({
28
+ group: section.group,
29
+ entries: section.entries.filter(({ option }) => foldWords(`${option.label} ${option.detail ?? ''}`).includes(needle)),
30
+ }))
31
+ .filter((section) => section.entries.length > 0);
32
+ }
33
+ /** The entries of the sections in list order: what the arrow keys move over. */
34
+ export function flatEntries(sections) {
35
+ return sections.flatMap((section) => section.entries);
36
+ }
37
+ /** How many options the groups hold together. */
38
+ export function optionCount(groups) {
39
+ return groups.reduce((total, group) => total + group.options.length, 0);
40
+ }
41
+ /**
42
+ * The entry typeahead lands on: the first whose label starts with `typed`
43
+ * (folded), searching after `from` and wrapping. Repeating one letter
44
+ * ("b", "bb") steps through the labels starting with it. -1 when none.
45
+ */
46
+ export function typeaheadIndex(entries, typed, from) {
47
+ const folded = foldWords(typed);
48
+ if (!folded || entries.length === 0)
49
+ return -1;
50
+ const repeated = [...folded].every((letter) => letter === folded[0]);
51
+ const prefix = repeated ? folded[0] : folded;
52
+ // A fresh word may stay on the current entry; a repeated letter moves on.
53
+ const start = repeated ? from + 1 : Math.max(from, 0);
54
+ for (let step = 0; step < entries.length; step += 1) {
55
+ const index = (start + step) % entries.length;
56
+ if (foldWords(entries[index].option.label).startsWith(prefix))
57
+ return index;
58
+ }
59
+ return -1;
60
+ }
@@ -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
+ }