@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,70 @@
|
|
|
1
|
+
import { isOneOf } from '../../is-one-of.js';
|
|
2
|
+
import { navCountTones } from '../NavCount.js';
|
|
3
|
+
import { assertNavItems } from '../nav-items.js';
|
|
4
|
+
/**
|
|
5
|
+
* An icon that is no glyph: nothing, a boolean or an empty string (which
|
|
6
|
+
* React draws as nothing), or 0 (which it draws as the text "0").
|
|
7
|
+
*/
|
|
8
|
+
function drawsNothing(node) {
|
|
9
|
+
return (node === undefined ||
|
|
10
|
+
node === null ||
|
|
11
|
+
typeof node === 'boolean' ||
|
|
12
|
+
node === '' ||
|
|
13
|
+
node === 0);
|
|
14
|
+
}
|
|
15
|
+
function isBlank(words) {
|
|
16
|
+
return typeof words !== 'string' || !words.trim();
|
|
17
|
+
}
|
|
18
|
+
function assertGroups(groups) {
|
|
19
|
+
if (groups.length === 0)
|
|
20
|
+
throw new RangeError('AppShell needs at least one group');
|
|
21
|
+
if (groups.some((group) => isBlank(group.id)))
|
|
22
|
+
throw new RangeError('AppShell groups need nonempty ids');
|
|
23
|
+
if (new Set(groups.map((group) => group.id)).size !== groups.length)
|
|
24
|
+
throw new RangeError('AppShell groups need unique ids');
|
|
25
|
+
if (groups.some((group) => group.label !== undefined && isBlank(group.label)))
|
|
26
|
+
throw new RangeError('AppShell group labels are nonempty when given');
|
|
27
|
+
if (groups.some((group) => group.items.length === 0))
|
|
28
|
+
throw new RangeError('AppShell groups need at least one item');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Fails closed on the menu button's count: a whole number from 0, a
|
|
32
|
+
* nonempty label and a known tone, each of the last two with a count.
|
|
33
|
+
*/
|
|
34
|
+
function assertMenuCount({ menuCount, menuCountLabel, menuCountTone, }) {
|
|
35
|
+
if (menuCount !== undefined &&
|
|
36
|
+
(!Number.isInteger(menuCount) || menuCount < 0))
|
|
37
|
+
throw new RangeError(`AppShell menuCount must be a whole number from 0: ${String(menuCount)}`);
|
|
38
|
+
if (menuCountLabel !== undefined && isBlank(menuCountLabel))
|
|
39
|
+
throw new RangeError('AppShell menuCountLabel must be nonempty');
|
|
40
|
+
if (menuCountTone !== undefined && !isOneOf(navCountTones, menuCountTone))
|
|
41
|
+
throw new RangeError(`AppShell menuCountTone must be one of ${navCountTones.join(', ')}`);
|
|
42
|
+
if (menuCount === undefined &&
|
|
43
|
+
(menuCountLabel !== undefined || menuCountTone !== undefined))
|
|
44
|
+
throw new RangeError('AppShell menuCountLabel and menuCountTone come with a menuCount');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fails closed on the shell's words, the menu's count, its groups and
|
|
48
|
+
* their items (ids unique across every group, each with an icon, which is
|
|
49
|
+
* all the rail shows).
|
|
50
|
+
*/
|
|
51
|
+
export function assertAppShellProps({ groups, labels, menuCount, menuCountLabel, menuCountTone, navLabel, }) {
|
|
52
|
+
if (isBlank(navLabel))
|
|
53
|
+
throw new RangeError('AppShell navLabel must be nonempty');
|
|
54
|
+
if (isBlank(labels?.openMenu) || isBlank(labels?.closeMenu))
|
|
55
|
+
throw new RangeError('AppShell labels openMenu and closeMenu are nonempty');
|
|
56
|
+
assertMenuCount({ menuCount, menuCountLabel, menuCountTone });
|
|
57
|
+
assertGroups(groups);
|
|
58
|
+
const items = groups.flatMap((group) => group.items);
|
|
59
|
+
assertNavItems('AppShell', items);
|
|
60
|
+
if (items.some((item) => drawsNothing(item.icon)))
|
|
61
|
+
throw new RangeError('AppShell items need an icon, all the rail shows');
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
if (item.countTone === undefined)
|
|
64
|
+
continue;
|
|
65
|
+
if (!isOneOf(navCountTones, item.countTone))
|
|
66
|
+
throw new RangeError(`AppShell item countTone must be one of ${navCountTones.join(', ')}`);
|
|
67
|
+
if (item.count === undefined)
|
|
68
|
+
throw new RangeError('AppShell item countTone comes with a count');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type PointerEvent } from 'react';
|
|
2
|
+
import { type CommandPaletteGroup, type CommandPaletteItem } from './types.js';
|
|
3
|
+
type PaletteListProps = {
|
|
4
|
+
listId: string;
|
|
5
|
+
label: string;
|
|
6
|
+
groups: readonly CommandPaletteGroup[];
|
|
7
|
+
activeId: string | undefined;
|
|
8
|
+
/** The row's DOM id from the item's id. */
|
|
9
|
+
rowId: (item: CommandPaletteItem) => string;
|
|
10
|
+
onActivate: (item: CommandPaletteItem, event: PointerEvent) => void;
|
|
11
|
+
onChoose: (item: CommandPaletteItem) => void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The always-open results: a listbox named by the palette's label, each
|
|
15
|
+
* group a `group` under its caption, each item its mark (hidden), label and
|
|
16
|
+
* detail, and the active row's Enter key hint (hidden: Enter is how a
|
|
17
|
+
* listbox row is chosen).
|
|
18
|
+
*/
|
|
19
|
+
export declare function PaletteList({ activeId, groups, label, listId, onActivate, onChoose, rowId, }: PaletteListProps): import("react").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The always-open results: a listbox named by the palette's label, each
|
|
4
|
+
* group a `group` under its caption, each item its mark (hidden), label and
|
|
5
|
+
* detail, and the active row's Enter key hint (hidden: Enter is how a
|
|
6
|
+
* listbox row is chosen).
|
|
7
|
+
*/
|
|
8
|
+
export function PaletteList({ activeId, groups, label, listId, onActivate, onChoose, rowId, }) {
|
|
9
|
+
return (_jsx("div", { "aria-label": label, className: "sw-command-palette-list", hidden: !groups.some((group) => group.items.length > 0), id: listId, role: "listbox", children: groups.map((group, index) => {
|
|
10
|
+
if (group.items.length === 0)
|
|
11
|
+
return null;
|
|
12
|
+
const headingId = `${listId}-group-${index}`;
|
|
13
|
+
return (_jsxs("div", { "aria-labelledby": headingId, role: "group", children: [_jsx("div", { className: "sw-command-palette-heading", id: headingId, role: "presentation", children: group.label }), group.items.map((item) => {
|
|
14
|
+
const active = item.id === activeId;
|
|
15
|
+
return (_jsxs("div", { "aria-selected": active, className: "sw-command-palette-item", id: rowId(item), onClick: () => onChoose(item), onPointerMove: (event) => {
|
|
16
|
+
if (!active)
|
|
17
|
+
onActivate(item, event);
|
|
18
|
+
}, role: "option", children: [item.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-command-palette-icon", children: item.icon })) : null, _jsxs("span", { className: "sw-command-palette-words", children: [_jsx("span", { className: "sw-command-palette-label", children: item.label }), item.detail ? (_jsxs(_Fragment, { children: [' ', _jsx("span", { className: "sw-command-palette-detail", children: item.detail })] })) : null] }), active ? (_jsx("span", { "aria-hidden": "true", className: "sw-command-palette-key", children: "\u21B5" })) : null] }, item.id));
|
|
19
|
+
})] }, group.id));
|
|
20
|
+
}) }));
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type CommandPaletteShortcut } from './types.js';
|
|
2
|
+
/** Whether the system's command key is ⌘ (Apple) rather than Ctrl. */
|
|
3
|
+
export declare function usesCommandKey(platform: string): boolean;
|
|
4
|
+
/** An element a person types into: a text field, a select or editable text. */
|
|
5
|
+
export declare function isEditable(target: EventTarget | null): boolean;
|
|
6
|
+
type ShortcutKey = Pick<KeyboardEvent, 'key' | 'code' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey' | 'repeat' | 'isComposing'>;
|
|
7
|
+
/**
|
|
8
|
+
* ⌘K on an Apple system or Ctrl+K elsewhere, with no other modifier: the K
|
|
9
|
+
* the layout types, or the key in K's place (`KeyK`) on a layout that types
|
|
10
|
+
* another letter there, such as Cyrillic or Greek.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isModK(event: ShortcutKey, commandKey: boolean): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Listens on the document for the palette's shortcut while `shortcut` is
|
|
15
|
+
* set: it calls `onToggle` (and stops the browser's own use of the keys)
|
|
16
|
+
* unless focus is in another text field than `ownField`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function usePaletteShortcut(shortcut: CommandPaletteShortcut | null | undefined, ownField: () => HTMLElement | null, onToggle: () => void): void;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
/** Whether the system's command key is ⌘ (Apple) rather than Ctrl. */
|
|
4
|
+
export function usesCommandKey(platform) {
|
|
5
|
+
return /mac|iphone|ipad|ipod/i.test(platform);
|
|
6
|
+
}
|
|
7
|
+
function systemPlatform() {
|
|
8
|
+
const nav = navigator;
|
|
9
|
+
return nav.userAgentData?.platform || nav.platform || nav.userAgent || '';
|
|
10
|
+
}
|
|
11
|
+
/** An element a person types into: a text field, a select or editable text. */
|
|
12
|
+
export function isEditable(target) {
|
|
13
|
+
if (!(target instanceof HTMLElement))
|
|
14
|
+
return false;
|
|
15
|
+
if (target.isContentEditable)
|
|
16
|
+
return true;
|
|
17
|
+
if (target instanceof HTMLTextAreaElement ||
|
|
18
|
+
target instanceof HTMLSelectElement)
|
|
19
|
+
return true;
|
|
20
|
+
if (!(target instanceof HTMLInputElement))
|
|
21
|
+
return false;
|
|
22
|
+
return ![
|
|
23
|
+
'button',
|
|
24
|
+
'checkbox',
|
|
25
|
+
'radio',
|
|
26
|
+
'range',
|
|
27
|
+
'color',
|
|
28
|
+
'file',
|
|
29
|
+
'image',
|
|
30
|
+
'reset',
|
|
31
|
+
'submit',
|
|
32
|
+
].includes(target.type);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* ⌘K on an Apple system or Ctrl+K elsewhere, with no other modifier: the K
|
|
36
|
+
* the layout types, or the key in K's place (`KeyK`) on a layout that types
|
|
37
|
+
* another letter there, such as Cyrillic or Greek.
|
|
38
|
+
*/
|
|
39
|
+
export function isModK(event, commandKey) {
|
|
40
|
+
if (event.repeat || event.isComposing)
|
|
41
|
+
return false;
|
|
42
|
+
const isK = event.key.toLowerCase() === 'k' || event.code === 'KeyK';
|
|
43
|
+
if (!isK || event.altKey || event.shiftKey)
|
|
44
|
+
return false;
|
|
45
|
+
return commandKey
|
|
46
|
+
? event.metaKey && !event.ctrlKey
|
|
47
|
+
: event.ctrlKey && !event.metaKey;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Listens on the document for the palette's shortcut while `shortcut` is
|
|
51
|
+
* set: it calls `onToggle` (and stops the browser's own use of the keys)
|
|
52
|
+
* unless focus is in another text field than `ownField`.
|
|
53
|
+
*/
|
|
54
|
+
export function usePaletteShortcut(shortcut, ownField, onToggle) {
|
|
55
|
+
const latest = useRef({ ownField, onToggle });
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
latest.current = { ownField, onToggle };
|
|
58
|
+
});
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (shortcut !== 'mod+k')
|
|
61
|
+
return;
|
|
62
|
+
const commandKey = usesCommandKey(systemPlatform());
|
|
63
|
+
function onKeyDown(event) {
|
|
64
|
+
if (event.defaultPrevented || !isModK(event, commandKey))
|
|
65
|
+
return;
|
|
66
|
+
const target = event.target;
|
|
67
|
+
if (isEditable(target) && target !== latest.current.ownField())
|
|
68
|
+
return;
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
latest.current.onToggle();
|
|
71
|
+
}
|
|
72
|
+
document.addEventListener('keydown', onKeyDown);
|
|
73
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
74
|
+
}, [shortcut]);
|
|
75
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
/** One result or command in the palette. */
|
|
3
|
+
export type CommandPaletteItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
/** A muted line under the label, such as where a result lives. */
|
|
7
|
+
detail?: string;
|
|
8
|
+
/** A decorative mark before the label (a consumer glyph or crest). */
|
|
9
|
+
icon?: ReactNode;
|
|
10
|
+
/**
|
|
11
|
+
* Runs once the palette has closed after it was chosen (the consumer set
|
|
12
|
+
* `open` to false from `onOpenChange(false)`), so it can move focus to
|
|
13
|
+
* the page; without that, focus returns to where it was.
|
|
14
|
+
*/
|
|
15
|
+
onSelect: () => void;
|
|
16
|
+
};
|
|
17
|
+
/** A run of items under a caption, such as "Teams" or "Actions". */
|
|
18
|
+
export type CommandPaletteGroup = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
items: readonly CommandPaletteItem[];
|
|
22
|
+
};
|
|
23
|
+
/** The global shortcut that opens the palette: ⌘K on Apple systems, Ctrl+K elsewhere. */
|
|
24
|
+
export declare const commandPaletteShortcuts: readonly ["mod+k"];
|
|
25
|
+
export type CommandPaletteShortcut = (typeof commandPaletteShortcuts)[number];
|
|
26
|
+
export type CommandPaletteProps = {
|
|
27
|
+
/** Whether the palette is shown. */
|
|
28
|
+
open: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Asked to open (the shortcut) or close (Escape, a press outside, the
|
|
31
|
+
* shortcut again, or a chosen item).
|
|
32
|
+
*/
|
|
33
|
+
onOpenChange: (open: boolean) => void;
|
|
34
|
+
/** The palette's name and its field's, such as "Search". */
|
|
35
|
+
label: string;
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** The field's words; the consumer filters `groups` from them. */
|
|
38
|
+
query: string;
|
|
39
|
+
onQueryChange: (query: string) => void;
|
|
40
|
+
/** The results in groups; a group with no items is not drawn. */
|
|
41
|
+
groups: readonly CommandPaletteGroup[];
|
|
42
|
+
/** Shown, and announced, when no group has an item (and not loading). */
|
|
43
|
+
emptyText: string;
|
|
44
|
+
/** The words announced for the number of items, such as `(n) => \`${n} results\``. */
|
|
45
|
+
countText?: (count: number) => string;
|
|
46
|
+
/** A muted line in the footer, such as how to use the keys. */
|
|
47
|
+
hint?: string;
|
|
48
|
+
/** While true, `loadingText` is shown and announced in place of the empty words. */
|
|
49
|
+
loading?: boolean;
|
|
50
|
+
loadingText?: string;
|
|
51
|
+
/**
|
|
52
|
+
* `'mod+k'` listens on the whole document for ⌘K (Apple) or Ctrl+K
|
|
53
|
+
* (elsewhere) to open the palette, or close it while open; it is left
|
|
54
|
+
* alone while focus is in another text field. `null` or nothing: no
|
|
55
|
+
* shortcut. Give it to one palette per page: two palettes listening for
|
|
56
|
+
* it would both open.
|
|
57
|
+
*/
|
|
58
|
+
shortcut?: CommandPaletteShortcut | null;
|
|
59
|
+
};
|
|
60
|
+
export type CommandPaletteTriggerProps = {
|
|
61
|
+
/** The button's name, such as "Search". Start `placeholder` with its words. */
|
|
62
|
+
label: string;
|
|
63
|
+
/** The words drawn in the search-looking button, such as "Search teams and games". */
|
|
64
|
+
placeholder: string;
|
|
65
|
+
/** The key hint drawn at the end, such as "⌘K" or "Ctrl K"; hidden from assistive technology. */
|
|
66
|
+
shortcutLabel?: string;
|
|
67
|
+
/** The consumer's search glyph (Scalewing has no icon catalog). */
|
|
68
|
+
icon: ReactNode;
|
|
69
|
+
/** An icon-only button, the glyph alone, for a narrow bar. */
|
|
70
|
+
compact?: boolean;
|
|
71
|
+
onPress: () => void;
|
|
72
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type CommandPaletteProps, type CommandPaletteTriggerProps } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Fails closed on a palette it cannot name or draw: blank words, a group
|
|
4
|
+
* without a caption, empty or duplicate group or item ids (items across
|
|
5
|
+
* groups), a blank item label or detail, loading without its words, or an
|
|
6
|
+
* unknown shortcut.
|
|
7
|
+
*/
|
|
8
|
+
export declare function assertCommandPalette({ emptyText, groups, hint, label, loading, loadingText, placeholder, shortcut, }: CommandPaletteProps): void;
|
|
9
|
+
/** Fails closed on a trigger with blank words or a glyph that draws nothing. */
|
|
10
|
+
export declare function assertCommandPaletteTrigger({ icon, label, placeholder, shortcutLabel, }: CommandPaletteTriggerProps): void;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isOneOf } from '../../is-one-of.js';
|
|
2
|
+
import { hasSlot } from '../slot.js';
|
|
3
|
+
import { commandPaletteShortcuts, } from './types.js';
|
|
4
|
+
function isBlank(words) {
|
|
5
|
+
return typeof words !== 'string' || !words.trim();
|
|
6
|
+
}
|
|
7
|
+
function blankWhenGiven(words) {
|
|
8
|
+
return words !== undefined && isBlank(words);
|
|
9
|
+
}
|
|
10
|
+
function hasDuplicates(ids) {
|
|
11
|
+
return new Set(ids).size !== ids.length;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fails closed on a palette it cannot name or draw: blank words, a group
|
|
15
|
+
* without a caption, empty or duplicate group or item ids (items across
|
|
16
|
+
* groups), a blank item label or detail, loading without its words, or an
|
|
17
|
+
* unknown shortcut.
|
|
18
|
+
*/
|
|
19
|
+
export function assertCommandPalette({ emptyText, groups, hint, label, loading, loadingText, placeholder, shortcut, }) {
|
|
20
|
+
const name = 'CommandPalette';
|
|
21
|
+
if (isBlank(label) || isBlank(emptyText))
|
|
22
|
+
throw new RangeError(`${name} label and emptyText must be nonempty`);
|
|
23
|
+
if (blankWhenGiven(placeholder) || blankWhenGiven(hint))
|
|
24
|
+
throw new RangeError(`${name} placeholder and hint are nonempty when given`);
|
|
25
|
+
if (blankWhenGiven(loadingText) || (loading && isBlank(loadingText)))
|
|
26
|
+
throw new RangeError(`${name} loading needs a nonempty loadingText`);
|
|
27
|
+
if (shortcut != null && !isOneOf(commandPaletteShortcuts, shortcut))
|
|
28
|
+
throw new RangeError(`${name} shortcut must be one of ${commandPaletteShortcuts.join(', ')} or null`);
|
|
29
|
+
if (groups.some((group) => isBlank(group.id) || isBlank(group.label)))
|
|
30
|
+
throw new RangeError(`${name} groups need nonempty ids and labels`);
|
|
31
|
+
if (hasDuplicates(groups.map((group) => group.id)))
|
|
32
|
+
throw new RangeError(`${name} groups need unique ids`);
|
|
33
|
+
const items = groups.flatMap((group) => group.items);
|
|
34
|
+
if (items.some((item) => isBlank(item.id) || isBlank(item.label)))
|
|
35
|
+
throw new RangeError(`${name} items need nonempty ids and labels`);
|
|
36
|
+
if (items.some((item) => blankWhenGiven(item.detail)))
|
|
37
|
+
throw new RangeError(`${name} item details are nonempty when given`);
|
|
38
|
+
if (hasDuplicates(items.map((item) => item.id)))
|
|
39
|
+
throw new RangeError(`${name} items need unique ids`);
|
|
40
|
+
}
|
|
41
|
+
/** Fails closed on a trigger with blank words or a glyph that draws nothing. */
|
|
42
|
+
export function assertCommandPaletteTrigger({ icon, label, placeholder, shortcutLabel, }) {
|
|
43
|
+
const name = 'CommandPaletteTrigger';
|
|
44
|
+
if (isBlank(label) || isBlank(placeholder))
|
|
45
|
+
throw new RangeError(`${name} label and placeholder must be nonempty`);
|
|
46
|
+
if (blankWhenGiven(shortcutLabel))
|
|
47
|
+
throw new RangeError(`${name} shortcutLabel is nonempty when given`);
|
|
48
|
+
if (!hasSlot(icon) || (typeof icon === 'string' && !icon.trim()))
|
|
49
|
+
throw new RangeError(`${name} needs its search glyph`);
|
|
50
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type PointerEvent } from 'react';
|
|
2
|
+
import { type SwitcherEntry } from './options.js';
|
|
3
|
+
type SwitcherOptionProps = {
|
|
4
|
+
entry: SwitcherEntry;
|
|
5
|
+
active: boolean;
|
|
6
|
+
current: boolean;
|
|
7
|
+
/** A pointer moved over the row; the popup decides whether it really moved. */
|
|
8
|
+
onActivate: (event: PointerEvent) => void;
|
|
9
|
+
onChoose: () => void;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* One option: its mark (hidden), its label over its detail, its badge and
|
|
13
|
+
* count (both read in its name, a `countLabel` in place of the number) and
|
|
14
|
+
* the current one's check (hidden; `aria-selected` says it).
|
|
15
|
+
*/
|
|
16
|
+
export declare function SwitcherOption({ active, current, entry, onActivate, onChoose, }: SwitcherOptionProps): import("react").JSX.Element;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { cx } from '../../class-names.js';
|
|
3
|
+
import { countToneClass } from '../../css/css-count-pill.js';
|
|
4
|
+
import { Badge } from '../Badge.js';
|
|
5
|
+
/**
|
|
6
|
+
* One option: its mark (hidden), its label over its detail, its badge and
|
|
7
|
+
* count (both read in its name, a `countLabel` in place of the number) and
|
|
8
|
+
* the current one's check (hidden; `aria-selected` says it).
|
|
9
|
+
*/
|
|
10
|
+
export function SwitcherOption({ active, current, entry, onActivate, onChoose, }) {
|
|
11
|
+
const { option } = entry;
|
|
12
|
+
const tone = option.countTone ?? 'danger';
|
|
13
|
+
return (_jsxs("div", { "aria-selected": current, className: "sw-context-switcher-option", "data-active": active ? 'true' : undefined, id: entry.domId, onClick: onChoose,
|
|
14
|
+
// The pointer moves the active row, as the arrow keys do.
|
|
15
|
+
onPointerMove: (event) => {
|
|
16
|
+
if (!active)
|
|
17
|
+
onActivate(event);
|
|
18
|
+
}, role: "option", children: [option.icon ? (_jsx("span", { "aria-hidden": "true", className: "sw-context-switcher-icon", children: option.icon })) : null, _jsxs("span", { className: "sw-context-switcher-words", children: [_jsx("span", { className: "sw-context-switcher-label", children: option.label }), option.detail ? (_jsxs("span", { className: "sw-context-switcher-detail", children: [" ", option.detail] })) : null] }), option.badge ? (_jsx(Badge, { size: "sm", tone: option.badge.tone, children: option.badge.label })) : null, option.count ? (_jsxs(_Fragment, { children: [option.countLabel ? (_jsx("span", { className: "sw-sr-only", children: ` ${option.countLabel}` })) : null, _jsx("span", { "aria-hidden": option.countLabel ? true : undefined, className: cx('sw-context-switcher-count', tone !== 'danger' &&
|
|
19
|
+
countToneClass('sw-context-switcher-count', tone)), children: option.count })] })) : null, current ? (_jsx("span", { "aria-hidden": "true", className: "sw-context-switcher-check" })) : null] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
import { type InlineAlign } from '../../anchored-position.js';
|
|
3
|
+
import { type SwitcherEntry } from './options.js';
|
|
4
|
+
import { type ContextSwitcherAction, type ContextSwitcherFilter, type ContextSwitcherGroup } from './types.js';
|
|
5
|
+
type SwitcherPopupProps = {
|
|
6
|
+
label: string;
|
|
7
|
+
groups: readonly ContextSwitcherGroup[];
|
|
8
|
+
currentId: string | undefined;
|
|
9
|
+
/** The filter, when it is shown (enough options). */
|
|
10
|
+
filter: ContextSwitcherFilter | undefined;
|
|
11
|
+
actions: readonly ContextSwitcherAction[];
|
|
12
|
+
align: InlineAlign;
|
|
13
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
14
|
+
/** Whether a pointer is pressing the trigger (its click toggles the popup). */
|
|
15
|
+
triggerPressed: () => boolean;
|
|
16
|
+
/** Closes the popup; `refocus` hands focus back to the trigger. */
|
|
17
|
+
onClose: (refocus: boolean) => void;
|
|
18
|
+
onChoose: (entry: SwitcherEntry) => void;
|
|
19
|
+
onAction: (action: ContextSwitcherAction) => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The open popup: a non-modal `dialog` named by the switcher's label on the
|
|
23
|
+
* top layer beside the trigger. Focus moves into it, to the filter field
|
|
24
|
+
* (an ARIA combobox over the listbox) or else to the listbox itself; both
|
|
25
|
+
* point at the active row with `aria-activedescendant`. Escape closes it
|
|
26
|
+
* and returns focus to the trigger; a press outside or focus leaving it
|
|
27
|
+
* (Tab past its end, Shift+Tab back to the trigger) closes it where focus
|
|
28
|
+
* went.
|
|
29
|
+
*/
|
|
30
|
+
export declare function SwitcherPopup({ actions, align, currentId, filter, groups, label, onAction, onChoose, onClose, triggerPressed, triggerRef, }: SwitcherPopupProps): import("react").JSX.Element;
|
|
31
|
+
export {};
|
|
@@ -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
|
+
}
|