@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +215 -6
  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/EmptyState.d.ts +23 -0
  19. package/dist/components/EmptyState.js +40 -0
  20. package/dist/components/FixtureGroup.d.ts +9 -0
  21. package/dist/components/FixtureGroup.js +16 -0
  22. package/dist/components/FixtureRow.d.ts +10 -0
  23. package/dist/components/FixtureRow.js +46 -0
  24. package/dist/components/GlyphTile.d.ts +9 -3
  25. package/dist/components/GlyphTile.js +15 -4
  26. package/dist/components/Grid.d.ts +11 -3
  27. package/dist/components/Grid.js +13 -4
  28. package/dist/components/Hero.d.ts +26 -0
  29. package/dist/components/Hero.js +23 -0
  30. package/dist/components/SiteFooter.d.ts +12 -0
  31. package/dist/components/SiteFooter.js +23 -0
  32. package/dist/components/SiteHeader.d.ts +14 -0
  33. package/dist/components/SiteHeader.js +24 -0
  34. package/dist/components/Skeleton.d.ts +22 -0
  35. package/dist/components/Skeleton.js +30 -0
  36. package/dist/components/Stepper.d.ts +11 -1
  37. package/dist/components/Stepper.js +10 -8
  38. package/dist/components/Text.js +3 -0
  39. package/dist/components/app-shell/AppShellBar.d.ts +23 -0
  40. package/dist/components/app-shell/AppShellBar.js +25 -0
  41. package/dist/components/app-shell/types.d.ts +36 -1
  42. package/dist/components/app-shell/validate.d.ts +4 -3
  43. package/dist/components/app-shell/validate.js +21 -3
  44. package/dist/components/command-palette/PaletteList.d.ts +20 -0
  45. package/dist/components/command-palette/PaletteList.js +21 -0
  46. package/dist/components/command-palette/shortcut.d.ts +19 -0
  47. package/dist/components/command-palette/shortcut.js +75 -0
  48. package/dist/components/command-palette/types.d.ts +72 -0
  49. package/dist/components/command-palette/types.js +2 -0
  50. package/dist/components/command-palette/validate.d.ts +10 -0
  51. package/dist/components/command-palette/validate.js +50 -0
  52. package/dist/components/context-switcher/SwitcherOption.d.ts +17 -0
  53. package/dist/components/context-switcher/SwitcherOption.js +20 -0
  54. package/dist/components/context-switcher/SwitcherPopup.d.ts +31 -0
  55. package/dist/components/context-switcher/SwitcherPopup.js +111 -0
  56. package/dist/components/context-switcher/options.d.ts +33 -0
  57. package/dist/components/context-switcher/options.js +60 -0
  58. package/dist/components/context-switcher/types.d.ts +96 -0
  59. package/dist/components/context-switcher/types.js +1 -0
  60. package/dist/components/context-switcher/use-switcher-list.d.ts +29 -0
  61. package/dist/components/context-switcher/use-switcher-list.js +93 -0
  62. package/dist/components/context-switcher/validate.d.ts +8 -0
  63. package/dist/components/context-switcher/validate.js +94 -0
  64. package/dist/components/fixture/fixture-list-context.d.ts +4 -0
  65. package/dist/components/fixture/fixture-list-context.js +9 -0
  66. package/dist/components/fixture/types.d.ts +50 -0
  67. package/dist/components/fixture/types.js +1 -0
  68. package/dist/components/fixture/validate.d.ts +8 -0
  69. package/dist/components/fixture/validate.js +36 -0
  70. package/dist/components/site-footer/types.d.ts +31 -0
  71. package/dist/components/site-footer/types.js +1 -0
  72. package/dist/components/site-footer/validate.d.ts +7 -0
  73. package/dist/components/site-footer/validate.js +22 -0
  74. package/dist/components/site-header/SiteHeaderLinks.d.ts +7 -0
  75. package/dist/components/site-header/SiteHeaderLinks.js +7 -0
  76. package/dist/components/site-header/types.d.ts +38 -0
  77. package/dist/components/site-header/types.js +1 -0
  78. package/dist/components/site-header/use-menu-dismiss.d.ts +8 -0
  79. package/dist/components/site-header/use-menu-dismiss.js +62 -0
  80. package/dist/components/site-header/validate.d.ts +7 -0
  81. package/dist/components/site-header/validate.js +17 -0
  82. package/dist/components/stepper/StepperCountFrame.d.ts +15 -0
  83. package/dist/components/stepper/StepperCountFrame.js +25 -0
  84. package/dist/components/use-element-height.d.ts +10 -0
  85. package/dist/components/use-element-height.js +35 -0
  86. package/dist/context-switcher.test-support.d.ts +9 -0
  87. package/dist/context-switcher.test-support.js +41 -0
  88. package/dist/css/css-app-shell-page-bar.d.ts +3 -0
  89. package/dist/css/css-app-shell-page-bar.js +81 -0
  90. package/dist/css/css-app-shell.js +6 -1
  91. package/dist/css/css-band.d.ts +10 -0
  92. package/dist/css/css-band.js +101 -0
  93. package/dist/css/css-button-link.d.ts +3 -0
  94. package/dist/css/css-button-link.js +48 -0
  95. package/dist/css/css-command-palette-key.d.ts +1 -0
  96. package/dist/css/css-command-palette-key.js +33 -0
  97. package/dist/css/css-command-palette-trigger.d.ts +2 -0
  98. package/dist/css/css-command-palette-trigger.js +88 -0
  99. package/dist/css/css-command-palette.d.ts +2 -0
  100. package/dist/css/css-command-palette.js +209 -0
  101. package/dist/css/css-components.js +30 -0
  102. package/dist/css/css-context-switcher-trigger.d.ts +15 -0
  103. package/dist/css/css-context-switcher-trigger.js +175 -0
  104. package/dist/css/css-context-switcher.d.ts +4 -0
  105. package/dist/css/css-context-switcher.js +219 -0
  106. package/dist/css/css-count-pill.d.ts +2 -2
  107. package/dist/css/css-count-pill.js +11 -3
  108. package/dist/css/css-empty-state.d.ts +2 -0
  109. package/dist/css/css-empty-state.js +77 -0
  110. package/dist/css/css-fixture.d.ts +5 -0
  111. package/dist/css/css-fixture.js +260 -0
  112. package/dist/css/css-glyph-tile.d.ts +2 -0
  113. package/dist/css/css-glyph-tile.js +29 -1
  114. package/dist/css/css-grid-span.js +5 -2
  115. package/dist/css/css-grid.d.ts +13 -0
  116. package/dist/css/css-grid.js +17 -1
  117. package/dist/css/css-hero.d.ts +2 -0
  118. package/dist/css/css-hero.js +62 -0
  119. package/dist/css/css-site-footer.d.ts +2 -0
  120. package/dist/css/css-site-footer.js +152 -0
  121. package/dist/css/css-site-header.d.ts +2 -0
  122. package/dist/css/css-site-header.js +262 -0
  123. package/dist/css/css-skeleton.d.ts +10 -0
  124. package/dist/css/css-skeleton.js +110 -0
  125. package/dist/css/css-stepper-unit.d.ts +9 -0
  126. package/dist/css/css-stepper-unit.js +102 -0
  127. package/dist/css/css-stepper.js +5 -1
  128. package/dist/css/stacking.js +7 -4
  129. package/dist/css/stylesheet.js +20 -0
  130. package/dist/index.d.ts +18 -2
  131. package/dist/index.js +13 -1
  132. package/dist/stepper-count.d.ts +8 -0
  133. package/dist/stepper-count.js +17 -0
  134. package/dist/styles.css +2202 -702
  135. package/package.json +2 -2
@@ -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,2 @@
1
+ /** The global shortcut that opens the palette: ⌘K on Apple systems, Ctrl+K elsewhere. */
2
+ export const commandPaletteShortcuts = ['mod+k'];
@@ -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
+ }
@@ -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 {};