@c9up/nebula 0.1.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/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/adapters/css.d.ts +33 -0
- package/dist/adapters/css.js +67 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/tailwind.d.ts +25 -0
- package/dist/adapters/tailwind.js +122 -0
- package/dist/adapters/types.d.ts +49 -0
- package/dist/adapters/types.js +21 -0
- package/dist/adapters/unocss.d.ts +17 -0
- package/dist/adapters/unocss.js +107 -0
- package/dist/atoms/AspectRatio.d.ts +17 -0
- package/dist/atoms/AspectRatio.js +19 -0
- package/dist/atoms/Avatar.d.ts +23 -0
- package/dist/atoms/Avatar.js +46 -0
- package/dist/atoms/Badge.d.ts +25 -0
- package/dist/atoms/Badge.js +28 -0
- package/dist/atoms/Button.d.ts +58 -0
- package/dist/atoms/Button.js +67 -0
- package/dist/atoms/Checkbox.d.ts +41 -0
- package/dist/atoms/Checkbox.js +47 -0
- package/dist/atoms/Input.d.ts +36 -0
- package/dist/atoms/Input.js +50 -0
- package/dist/atoms/Kbd.d.ts +14 -0
- package/dist/atoms/Kbd.js +17 -0
- package/dist/atoms/Label.d.ts +24 -0
- package/dist/atoms/Label.js +26 -0
- package/dist/atoms/Marker.d.ts +42 -0
- package/dist/atoms/Marker.js +55 -0
- package/dist/atoms/NativeSelect.d.ts +43 -0
- package/dist/atoms/NativeSelect.js +81 -0
- package/dist/atoms/Progress.d.ts +21 -0
- package/dist/atoms/Progress.js +48 -0
- package/dist/atoms/ScrollArea.d.ts +28 -0
- package/dist/atoms/ScrollArea.js +37 -0
- package/dist/atoms/Separator.d.ts +16 -0
- package/dist/atoms/Separator.js +21 -0
- package/dist/atoms/Skeleton.d.ts +13 -0
- package/dist/atoms/Skeleton.js +18 -0
- package/dist/atoms/Slider.d.ts +30 -0
- package/dist/atoms/Slider.js +40 -0
- package/dist/atoms/Spinner.d.ts +17 -0
- package/dist/atoms/Spinner.js +20 -0
- package/dist/atoms/Switch.d.ts +24 -0
- package/dist/atoms/Switch.js +41 -0
- package/dist/atoms/Textarea.d.ts +28 -0
- package/dist/atoms/Textarea.js +36 -0
- package/dist/atoms/Toggle.d.ts +38 -0
- package/dist/atoms/Toggle.js +45 -0
- package/dist/atoms/index.d.ts +32 -0
- package/dist/atoms/index.js +32 -0
- package/dist/cli/add.d.ts +44 -0
- package/dist/cli/add.js +99 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.js +83 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.js +43 -0
- package/dist/configure.d.ts +34 -0
- package/dist/configure.js +85 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/lib/children.d.ts +28 -0
- package/dist/lib/children.js +27 -0
- package/dist/lib/cn.d.ts +13 -0
- package/dist/lib/cn.js +13 -0
- package/dist/lib/cva.d.ts +83 -0
- package/dist/lib/cva.js +178 -0
- package/dist/lib/icons.d.ts +44 -0
- package/dist/lib/icons.js +79 -0
- package/dist/lib/id.d.ts +31 -0
- package/dist/lib/id.js +41 -0
- package/dist/lib/index.d.ts +12 -0
- package/dist/lib/index.js +12 -0
- package/dist/lib/motion.d.ts +31 -0
- package/dist/lib/motion.js +39 -0
- package/dist/lib/props.d.ts +48 -0
- package/dist/lib/props.js +59 -0
- package/dist/lib/styled.d.ts +31 -0
- package/dist/lib/styled.js +39 -0
- package/dist/molecules/Accordion.d.ts +35 -0
- package/dist/molecules/Accordion.js +117 -0
- package/dist/molecules/Alert.d.ts +30 -0
- package/dist/molecules/Alert.js +35 -0
- package/dist/molecules/Attachment.d.ts +39 -0
- package/dist/molecules/Attachment.js +103 -0
- package/dist/molecules/Breadcrumb.d.ts +29 -0
- package/dist/molecules/Breadcrumb.js +73 -0
- package/dist/molecules/Bubble.d.ts +56 -0
- package/dist/molecules/Bubble.js +92 -0
- package/dist/molecules/ButtonGroup.d.ts +23 -0
- package/dist/molecules/ButtonGroup.js +30 -0
- package/dist/molecules/Card.d.ts +17 -0
- package/dist/molecules/Card.js +17 -0
- package/dist/molecules/Collapsible.d.ts +40 -0
- package/dist/molecules/Collapsible.js +71 -0
- package/dist/molecules/Empty.d.ts +16 -0
- package/dist/molecules/Empty.js +16 -0
- package/dist/molecules/Field.d.ts +54 -0
- package/dist/molecules/Field.js +82 -0
- package/dist/molecules/InputGroup.d.ts +33 -0
- package/dist/molecules/InputGroup.js +35 -0
- package/dist/molecules/InputOTP.d.ts +33 -0
- package/dist/molecules/InputOTP.js +140 -0
- package/dist/molecules/Item.d.ts +41 -0
- package/dist/molecules/Item.js +48 -0
- package/dist/molecules/Message.d.ts +43 -0
- package/dist/molecules/Message.js +82 -0
- package/dist/molecules/Pagination.d.ts +41 -0
- package/dist/molecules/Pagination.js +144 -0
- package/dist/molecules/RadioGroup.d.ts +32 -0
- package/dist/molecules/RadioGroup.js +65 -0
- package/dist/molecules/Resizable.d.ts +37 -0
- package/dist/molecules/Resizable.js +136 -0
- package/dist/molecules/Table.d.ts +37 -0
- package/dist/molecules/Table.js +78 -0
- package/dist/molecules/Tabs.d.ts +37 -0
- package/dist/molecules/Tabs.js +109 -0
- package/dist/molecules/ToggleGroup.d.ts +37 -0
- package/dist/molecules/ToggleGroup.js +68 -0
- package/dist/molecules/Typography.d.ts +26 -0
- package/dist/molecules/Typography.js +67 -0
- package/dist/molecules/index.d.ts +37 -0
- package/dist/molecules/index.js +37 -0
- package/dist/organisms/AlertDialog.d.ts +40 -0
- package/dist/organisms/AlertDialog.js +104 -0
- package/dist/organisms/Calendar.d.ts +96 -0
- package/dist/organisms/Calendar.js +353 -0
- package/dist/organisms/Carousel.d.ts +29 -0
- package/dist/organisms/Carousel.js +128 -0
- package/dist/organisms/Chart.d.ts +55 -0
- package/dist/organisms/Chart.js +189 -0
- package/dist/organisms/Combobox.d.ts +41 -0
- package/dist/organisms/Combobox.js +102 -0
- package/dist/organisms/Command.d.ts +46 -0
- package/dist/organisms/Command.js +171 -0
- package/dist/organisms/CommandDialog.d.ts +42 -0
- package/dist/organisms/CommandDialog.js +116 -0
- package/dist/organisms/ContextMenu.d.ts +29 -0
- package/dist/organisms/ContextMenu.js +102 -0
- package/dist/organisms/DataTable.d.ts +50 -0
- package/dist/organisms/DataTable.js +257 -0
- package/dist/organisms/DatePicker.d.ts +34 -0
- package/dist/organisms/DatePicker.js +115 -0
- package/dist/organisms/DateRangePicker.d.ts +34 -0
- package/dist/organisms/DateRangePicker.js +122 -0
- package/dist/organisms/Dialog.d.ts +50 -0
- package/dist/organisms/Dialog.js +116 -0
- package/dist/organisms/Drawer.d.ts +40 -0
- package/dist/organisms/Drawer.js +131 -0
- package/dist/organisms/DropdownMenu.d.ts +30 -0
- package/dist/organisms/DropdownMenu.js +91 -0
- package/dist/organisms/Form.d.ts +114 -0
- package/dist/organisms/Form.js +151 -0
- package/dist/organisms/HoverCard.d.ts +30 -0
- package/dist/organisms/HoverCard.js +68 -0
- package/dist/organisms/Menubar.d.ts +28 -0
- package/dist/organisms/Menubar.js +147 -0
- package/dist/organisms/MessageScroller.d.ts +36 -0
- package/dist/organisms/MessageScroller.js +124 -0
- package/dist/organisms/NavigationMenu.d.ts +41 -0
- package/dist/organisms/NavigationMenu.js +139 -0
- package/dist/organisms/Popover.d.ts +33 -0
- package/dist/organisms/Popover.js +58 -0
- package/dist/organisms/Questionnaire.d.ts +74 -0
- package/dist/organisms/Questionnaire.js +235 -0
- package/dist/organisms/Select.d.ts +47 -0
- package/dist/organisms/Select.js +250 -0
- package/dist/organisms/Sheet.d.ts +34 -0
- package/dist/organisms/Sheet.js +107 -0
- package/dist/organisms/Sidebar.d.ts +100 -0
- package/dist/organisms/Sidebar.js +228 -0
- package/dist/organisms/Toaster.d.ts +67 -0
- package/dist/organisms/Toaster.js +189 -0
- package/dist/organisms/Tooltip.d.ts +39 -0
- package/dist/organisms/Tooltip.js +94 -0
- package/dist/organisms/index.d.ts +40 -0
- package/dist/organisms/index.js +40 -0
- package/dist/primitives/controllable.d.ts +38 -0
- package/dist/primitives/controllable.js +43 -0
- package/dist/primitives/dismissable.d.ts +53 -0
- package/dist/primitives/dismissable.js +158 -0
- package/dist/primitives/floating.d.ts +116 -0
- package/dist/primitives/floating.js +289 -0
- package/dist/primitives/floatingSurface.d.ts +56 -0
- package/dist/primitives/floatingSurface.js +142 -0
- package/dist/primitives/focusTrap.d.ts +47 -0
- package/dist/primitives/focusTrap.js +101 -0
- package/dist/primitives/focusable.d.ts +53 -0
- package/dist/primitives/focusable.js +109 -0
- package/dist/primitives/index.d.ts +27 -0
- package/dist/primitives/index.js +27 -0
- package/dist/primitives/menu.d.ts +102 -0
- package/dist/primitives/menu.js +356 -0
- package/dist/primitives/modalSurface.d.ts +54 -0
- package/dist/primitives/modalSurface.js +146 -0
- package/dist/primitives/portal.d.ts +34 -0
- package/dist/primitives/portal.js +42 -0
- package/dist/primitives/presence.d.ts +45 -0
- package/dist/primitives/presence.js +152 -0
- package/dist/primitives/rovingFocus.d.ts +47 -0
- package/dist/primitives/rovingFocus.js +176 -0
- package/dist/primitives/scrollLock.d.ts +21 -0
- package/dist/primitives/scrollLock.js +75 -0
- package/dist/primitives/typeahead.d.ts +39 -0
- package/dist/primitives/typeahead.js +123 -0
- package/dist/templates/AppShell.d.ts +30 -0
- package/dist/templates/AppShell.js +42 -0
- package/dist/templates/AuthLayout.d.ts +23 -0
- package/dist/templates/AuthLayout.js +42 -0
- package/dist/templates/SettingsLayout.d.ts +27 -0
- package/dist/templates/SettingsLayout.js +42 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.js +16 -0
- package/nebula.css +2 -0
- package/package.json +92 -0
- package/registry.json +1107 -0
- package/src/adapters/css.ts +75 -0
- package/src/adapters/index.ts +40 -0
- package/src/adapters/tailwind.ts +134 -0
- package/src/adapters/types.ts +53 -0
- package/src/adapters/unocss.ts +116 -0
- package/src/atoms/AspectRatio.ts +28 -0
- package/src/atoms/Avatar.ts +67 -0
- package/src/atoms/Badge.ts +46 -0
- package/src/atoms/Button.ts +96 -0
- package/src/atoms/Checkbox.ts +76 -0
- package/src/atoms/Input.ts +76 -0
- package/src/atoms/Kbd.ts +28 -0
- package/src/atoms/Label.ts +38 -0
- package/src/atoms/Marker.ts +79 -0
- package/src/atoms/NativeSelect.ts +115 -0
- package/src/atoms/Progress.ts +64 -0
- package/src/atoms/ScrollArea.ts +53 -0
- package/src/atoms/Separator.ts +36 -0
- package/src/atoms/Skeleton.ts +24 -0
- package/src/atoms/Slider.ts +65 -0
- package/src/atoms/Spinner.ts +28 -0
- package/src/atoms/Switch.ts +56 -0
- package/src/atoms/Textarea.ts +58 -0
- package/src/atoms/Toggle.ts +67 -0
- package/src/atoms/index.ts +62 -0
- package/src/cli/add.ts +126 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/registry.ts +116 -0
- package/src/config.ts +102 -0
- package/src/configure.ts +127 -0
- package/src/index.ts +53 -0
- package/src/lib/children.ts +43 -0
- package/src/lib/cn.ts +14 -0
- package/src/lib/cva.ts +249 -0
- package/src/lib/icons.ts +138 -0
- package/src/lib/id.ts +44 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/motion.ts +48 -0
- package/src/lib/props.ts +83 -0
- package/src/lib/styled.ts +60 -0
- package/src/molecules/Accordion.ts +161 -0
- package/src/molecules/Alert.ts +58 -0
- package/src/molecules/Attachment.ts +132 -0
- package/src/molecules/Breadcrumb.ts +107 -0
- package/src/molecules/Bubble.ts +134 -0
- package/src/molecules/ButtonGroup.ts +55 -0
- package/src/molecules/Card.ts +42 -0
- package/src/molecules/Collapsible.ts +89 -0
- package/src/molecules/Empty.ts +43 -0
- package/src/molecules/Field.ts +131 -0
- package/src/molecules/InputGroup.ts +62 -0
- package/src/molecules/InputOTP.ts +166 -0
- package/src/molecules/Item.ts +89 -0
- package/src/molecules/Message.ts +127 -0
- package/src/molecules/Pagination.ts +197 -0
- package/src/molecules/RadioGroup.ts +104 -0
- package/src/molecules/Resizable.ts +166 -0
- package/src/molecules/Table.ts +118 -0
- package/src/molecules/Tabs.ts +148 -0
- package/src/molecules/ToggleGroup.ts +109 -0
- package/src/molecules/Typography.ts +94 -0
- package/src/molecules/index.ts +144 -0
- package/src/organisms/AlertDialog.ts +133 -0
- package/src/organisms/Calendar.ts +470 -0
- package/src/organisms/Carousel.ts +161 -0
- package/src/organisms/Chart.ts +257 -0
- package/src/organisms/Combobox.ts +140 -0
- package/src/organisms/Command.ts +211 -0
- package/src/organisms/CommandDialog.ts +149 -0
- package/src/organisms/ContextMenu.ts +125 -0
- package/src/organisms/DataTable.ts +328 -0
- package/src/organisms/DatePicker.ts +141 -0
- package/src/organisms/DateRangePicker.ts +149 -0
- package/src/organisms/Dialog.ts +162 -0
- package/src/organisms/Drawer.ts +167 -0
- package/src/organisms/DropdownMenu.ts +112 -0
- package/src/organisms/Form.ts +217 -0
- package/src/organisms/HoverCard.ts +92 -0
- package/src/organisms/Menubar.ts +167 -0
- package/src/organisms/MessageScroller.ts +145 -0
- package/src/organisms/NavigationMenu.ts +184 -0
- package/src/organisms/Popover.ts +84 -0
- package/src/organisms/Questionnaire.ts +322 -0
- package/src/organisms/Select.ts +286 -0
- package/src/organisms/Sheet.ts +144 -0
- package/src/organisms/Sidebar.ts +336 -0
- package/src/organisms/Toaster.ts +246 -0
- package/src/organisms/Tooltip.ts +124 -0
- package/src/organisms/index.ts +134 -0
- package/src/primitives/controllable.ts +64 -0
- package/src/primitives/dismissable.ts +195 -0
- package/src/primitives/floating.ts +453 -0
- package/src/primitives/floatingSurface.ts +209 -0
- package/src/primitives/focusTrap.ts +132 -0
- package/src/primitives/focusable.ts +108 -0
- package/src/primitives/index.ts +88 -0
- package/src/primitives/menu.ts +491 -0
- package/src/primitives/modalSurface.ts +199 -0
- package/src/primitives/portal.ts +61 -0
- package/src/primitives/presence.ts +185 -0
- package/src/primitives/rovingFocus.ts +212 -0
- package/src/primitives/scrollLock.ts +79 -0
- package/src/primitives/typeahead.ts +156 -0
- package/src/templates/AppShell.ts +56 -0
- package/src/templates/AuthLayout.ts +66 -0
- package/src/templates/SettingsLayout.ts +63 -0
- package/src/templates/index.ts +21 -0
- package/theme.css +151 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Portal — mount a template outside the component's own DOM position.
|
|
4
|
+
*
|
|
5
|
+
* Overlays have to escape their parent. A Popover rendered where it is
|
|
6
|
+
* declared inherits every `overflow: hidden`, `transform` and `z-index` stack
|
|
7
|
+
* between it and the root, and any one of those will clip it or bury it. The
|
|
8
|
+
* fix is universal: render into `<body>` and position with fixed coordinates.
|
|
9
|
+
*
|
|
10
|
+
* Aurora's `render()` already mounts a `TemplateResult` anywhere and hands
|
|
11
|
+
* back a disposer, so a portal is that call plus a host element to hold the
|
|
12
|
+
* content and the discipline to tear both down together.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { render, type TemplateResult } from "@c9up/aurora";
|
|
16
|
+
|
|
17
|
+
export interface PortalOptions {
|
|
18
|
+
/** Where to mount. Defaults to `document.body`. */
|
|
19
|
+
container?: () => Element | null;
|
|
20
|
+
/** Extra classes for the host element. */
|
|
21
|
+
class?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Portal {
|
|
25
|
+
/** The element holding the portalled content. */
|
|
26
|
+
readonly host: HTMLElement;
|
|
27
|
+
/** Unmount the content and remove the host. Safe to call twice. */
|
|
28
|
+
close(): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mount `content` into a fresh host element appended to the container.
|
|
33
|
+
*
|
|
34
|
+
* A host element rather than mounting straight into `<body>`: it gives the
|
|
35
|
+
* content one node to be removed as a unit, keeps sibling portals from
|
|
36
|
+
* interleaving, and gives the layer a place to hang `data-nebula-portal` so a
|
|
37
|
+
* test — or a developer in the inspector — can see what put it there.
|
|
38
|
+
*/
|
|
39
|
+
export function portal(
|
|
40
|
+
content: TemplateResult,
|
|
41
|
+
options: PortalOptions = {},
|
|
42
|
+
): Portal {
|
|
43
|
+
const container = options.container?.() ?? document.body;
|
|
44
|
+
const host = document.createElement("div");
|
|
45
|
+
host.setAttribute("data-nebula-portal", "");
|
|
46
|
+
if (options.class !== undefined) host.className = options.class;
|
|
47
|
+
container.appendChild(host);
|
|
48
|
+
|
|
49
|
+
const dispose = render(content, host);
|
|
50
|
+
|
|
51
|
+
let closed = false;
|
|
52
|
+
return {
|
|
53
|
+
host,
|
|
54
|
+
close(): void {
|
|
55
|
+
if (closed) return;
|
|
56
|
+
closed = true;
|
|
57
|
+
dispose();
|
|
58
|
+
host.remove();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Presence — keep a node mounted until its exit animation has finished.
|
|
4
|
+
*
|
|
5
|
+
* Closing an overlay by removing it from the DOM cancels any exit animation:
|
|
6
|
+
* the node is gone before the first frame plays. Radix solves this with a
|
|
7
|
+
* `Presence` wrapper, and shadcn's markup depends on it — the `data-[state=
|
|
8
|
+
* closed]:animate-out` utilities on every overlay assume something is holding
|
|
9
|
+
* the node on screen while that animation runs.
|
|
10
|
+
*
|
|
11
|
+
* `presence()` owns a `state` signal that leads the DOM: it flips to `closed`
|
|
12
|
+
* immediately so the exit animation can start, and only reports `mounted`
|
|
13
|
+
* false once the animation ends. A surface with no animation unmounts on the
|
|
14
|
+
* next tick, so the abstraction costs nothing when unused.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { type ReadSignal, signal } from "@c9up/aurora";
|
|
18
|
+
|
|
19
|
+
export type PresenceState = "open" | "closed";
|
|
20
|
+
|
|
21
|
+
export interface Presence {
|
|
22
|
+
/** Should the node be in the DOM right now? */
|
|
23
|
+
readonly mounted: ReadSignal<boolean>;
|
|
24
|
+
/** The value for `data-state` — drives the enter/exit utility classes. */
|
|
25
|
+
readonly state: ReadSignal<PresenceState>;
|
|
26
|
+
/** Show the node. Cancels a pending unmount. */
|
|
27
|
+
open(): void;
|
|
28
|
+
/** Start the exit animation. Unmount follows when it finishes. */
|
|
29
|
+
close(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Hand over the element once it is live. Presence watches it for the end of
|
|
32
|
+
* the exit animation; until it is given one, close() unmounts immediately.
|
|
33
|
+
*/
|
|
34
|
+
attach(element: HTMLElement | null): void;
|
|
35
|
+
/** Drop listeners and timers. */
|
|
36
|
+
dispose(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function presence(initiallyOpen = false): Presence {
|
|
40
|
+
const mounted = signal(initiallyOpen);
|
|
41
|
+
const state = signal<PresenceState>(initiallyOpen ? "open" : "closed");
|
|
42
|
+
|
|
43
|
+
let element: HTMLElement | null = null;
|
|
44
|
+
let pendingUnmount = false;
|
|
45
|
+
|
|
46
|
+
function finishClose(): void {
|
|
47
|
+
if (!pendingUnmount) return;
|
|
48
|
+
pendingUnmount = false;
|
|
49
|
+
mounted(false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The animation is only ours if it played on the surface itself.
|
|
54
|
+
*
|
|
55
|
+
* Events bubble, so a child's animation would otherwise unmount the parent
|
|
56
|
+
* mid-flight — a spinner inside a closing dialog is enough to trigger it.
|
|
57
|
+
*/
|
|
58
|
+
function onAnimationEnd(event: AnimationEvent | TransitionEvent): void {
|
|
59
|
+
if (event.target !== element) return;
|
|
60
|
+
finishClose();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function detach(): void {
|
|
64
|
+
if (element === null) return;
|
|
65
|
+
element.removeEventListener("animationend", onAnimationEnd);
|
|
66
|
+
element.removeEventListener("animationcancel", onAnimationEnd);
|
|
67
|
+
element.removeEventListener("transitionend", onAnimationEnd);
|
|
68
|
+
element.removeEventListener("transitioncancel", onAnimationEnd);
|
|
69
|
+
element = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
mounted,
|
|
74
|
+
state,
|
|
75
|
+
|
|
76
|
+
open(): void {
|
|
77
|
+
pendingUnmount = false;
|
|
78
|
+
mounted(true);
|
|
79
|
+
state("open");
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
close(): void {
|
|
83
|
+
if (!mounted()) return;
|
|
84
|
+
state("closed");
|
|
85
|
+
|
|
86
|
+
// No element yet means nothing can be animating — unmount now rather
|
|
87
|
+
// than waiting for an event that will never arrive.
|
|
88
|
+
if (element === null) {
|
|
89
|
+
mounted(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pendingUnmount = true;
|
|
94
|
+
if (!isAnimating(element)) finishClose();
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
attach(next: HTMLElement | null): void {
|
|
98
|
+
detach();
|
|
99
|
+
element = next;
|
|
100
|
+
if (element === null) return;
|
|
101
|
+
element.addEventListener("animationend", onAnimationEnd);
|
|
102
|
+
element.addEventListener("animationcancel", onAnimationEnd);
|
|
103
|
+
element.addEventListener("transitionend", onAnimationEnd);
|
|
104
|
+
element.addEventListener("transitioncancel", onAnimationEnd);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
dispose(): void {
|
|
108
|
+
pendingUnmount = false;
|
|
109
|
+
detach();
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run `done` once the element's exit animation finishes, or immediately when
|
|
116
|
+
* there is none. Returns a cancel function.
|
|
117
|
+
*
|
|
118
|
+
* The portalled surfaces do not use `presence()` — they own their own mounting
|
|
119
|
+
* through `portal()`, so a second `mounted` signal would just be a shadow of
|
|
120
|
+
* the portal's own lifetime. What they do need is this: the answer to "may I
|
|
121
|
+
* remove the node yet". Call it *after* flipping `data-state` to `closed`, so
|
|
122
|
+
* the computed style already reflects the closing rules.
|
|
123
|
+
*/
|
|
124
|
+
export function onExitFinished(
|
|
125
|
+
element: HTMLElement,
|
|
126
|
+
done: () => void,
|
|
127
|
+
): () => void {
|
|
128
|
+
if (!isAnimating(element)) {
|
|
129
|
+
done();
|
|
130
|
+
return () => {};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function finish(event: AnimationEvent | TransitionEvent): void {
|
|
134
|
+
// Bubbled events from children would cut the parent's exit short.
|
|
135
|
+
if (event.target !== element) return;
|
|
136
|
+
cancel();
|
|
137
|
+
done();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cancel(): void {
|
|
141
|
+
element.removeEventListener("animationend", finish);
|
|
142
|
+
element.removeEventListener("animationcancel", finish);
|
|
143
|
+
element.removeEventListener("transitionend", finish);
|
|
144
|
+
element.removeEventListener("transitioncancel", finish);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
element.addEventListener("animationend", finish);
|
|
148
|
+
element.addEventListener("animationcancel", finish);
|
|
149
|
+
element.addEventListener("transitionend", finish);
|
|
150
|
+
element.addEventListener("transitioncancel", finish);
|
|
151
|
+
return cancel;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Is an exit animation actually running?
|
|
156
|
+
*
|
|
157
|
+
* Read after `data-state` has flipped, so the computed style already reflects
|
|
158
|
+
* the closing rules. `animationName: none` and a zero transition duration both
|
|
159
|
+
* mean there is nothing to wait for, and waiting anyway would strand the node
|
|
160
|
+
* in the DOM forever — the failure mode this check exists to prevent.
|
|
161
|
+
*/
|
|
162
|
+
function isAnimating(element: HTMLElement): boolean {
|
|
163
|
+
if (typeof getComputedStyle !== "function") return false;
|
|
164
|
+
|
|
165
|
+
const style = getComputedStyle(element);
|
|
166
|
+
const hasAnimation =
|
|
167
|
+
style.animationName !== "" && style.animationName !== "none";
|
|
168
|
+
if (hasAnimation) return true;
|
|
169
|
+
|
|
170
|
+
return parseDuration(style.transitionDuration) > 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Longest duration in a comma-separated CSS time list, in milliseconds. */
|
|
174
|
+
function parseDuration(value: string): number {
|
|
175
|
+
let longest = 0;
|
|
176
|
+
for (const part of value.split(",")) {
|
|
177
|
+
const trimmed = part.trim();
|
|
178
|
+
if (trimmed === "") continue;
|
|
179
|
+
const numeric = Number.parseFloat(trimmed);
|
|
180
|
+
if (Number.isNaN(numeric)) continue;
|
|
181
|
+
const ms = trimmed.endsWith("ms") ? numeric : numeric * 1000;
|
|
182
|
+
if (ms > longest) longest = ms;
|
|
183
|
+
}
|
|
184
|
+
return longest;
|
|
185
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Roving focus — arrow-key navigation over a group of items.
|
|
4
|
+
*
|
|
5
|
+
* The WAI-ARIA composite-widget pattern: a menu, listbox, tab list or toolbar
|
|
6
|
+
* is *one* tab stop, and arrows move within it. Exactly one item carries
|
|
7
|
+
* `tabindex="0"` at a time; the rest are `-1`. That is what stops a 40-item
|
|
8
|
+
* menu from swallowing 40 presses of Tab.
|
|
9
|
+
*
|
|
10
|
+
* Used by DropdownMenu, ContextMenu, Menubar, Select, Combobox, Command, Tabs
|
|
11
|
+
* and ToggleGroup. The differences between them are configuration, not
|
|
12
|
+
* behaviour: orientation, whether the ends wrap, and whether moving focus also
|
|
13
|
+
* selects.
|
|
14
|
+
*
|
|
15
|
+
* Items are found by querying the container on each keypress rather than being
|
|
16
|
+
* registered up front. Menu contents are reactive — filtered by a Command
|
|
17
|
+
* input, grown by a loaded page — and a registry captured at mount goes stale
|
|
18
|
+
* the moment they change. The query costs a few microseconds per keypress,
|
|
19
|
+
* which is not a budget worth optimising against correctness.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { focusSilently, isVisible } from "./focusable.js";
|
|
23
|
+
|
|
24
|
+
export type Orientation = "vertical" | "horizontal" | "both";
|
|
25
|
+
|
|
26
|
+
export interface RovingFocusOptions {
|
|
27
|
+
/** The group element. Read lazily — it mounts after the group registers. */
|
|
28
|
+
container: () => HTMLElement | null;
|
|
29
|
+
/** Which descendants are navigable. Default: nebula's own item marker. */
|
|
30
|
+
itemSelector?: string;
|
|
31
|
+
/** Which arrows move focus. Default `"vertical"`. */
|
|
32
|
+
orientation?: Orientation;
|
|
33
|
+
/** Wrap from last to first and back. Default `true`. */
|
|
34
|
+
loop?: boolean;
|
|
35
|
+
/** Enter / Space on the focused item. */
|
|
36
|
+
onSelect?: (item: HTMLElement, event: KeyboardEvent) => void;
|
|
37
|
+
/** Focus moved to a new item — used by menus that highlight on navigate. */
|
|
38
|
+
onFocusChange?: (item: HTMLElement) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RovingFocus {
|
|
42
|
+
/** Focus the first enabled item. */
|
|
43
|
+
focusFirst(): void;
|
|
44
|
+
/** Focus the last enabled item. */
|
|
45
|
+
focusLast(): void;
|
|
46
|
+
/** The navigable items, in document order. */
|
|
47
|
+
items(): HTMLElement[];
|
|
48
|
+
/** Re-apply `tabindex` after the item list changes. */
|
|
49
|
+
sync(): void;
|
|
50
|
+
/** Detach the key handler. Safe to call twice. */
|
|
51
|
+
destroy(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_ITEM_SELECTOR = "[data-nebula-item]";
|
|
55
|
+
|
|
56
|
+
export function rovingFocus(options: RovingFocusOptions): RovingFocus {
|
|
57
|
+
const selector = options.itemSelector ?? DEFAULT_ITEM_SELECTOR;
|
|
58
|
+
const orientation = options.orientation ?? "vertical";
|
|
59
|
+
const loop = options.loop !== false;
|
|
60
|
+
|
|
61
|
+
function items(): HTMLElement[] {
|
|
62
|
+
const container = options.container();
|
|
63
|
+
if (container === null) return [];
|
|
64
|
+
|
|
65
|
+
const found: HTMLElement[] = [];
|
|
66
|
+
for (const node of container.querySelectorAll(selector)) {
|
|
67
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
68
|
+
// Disabled items stay in the DOM for layout and screen-reader context
|
|
69
|
+
// but must not be landed on — matching how a disabled menu item behaves
|
|
70
|
+
// everywhere else.
|
|
71
|
+
if (node.hasAttribute("data-disabled")) continue;
|
|
72
|
+
if (node.getAttribute("aria-disabled") === "true") continue;
|
|
73
|
+
if (!isVisible(node)) continue;
|
|
74
|
+
found.push(node);
|
|
75
|
+
}
|
|
76
|
+
return found;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One item at `tabindex="0"`, everything else at `-1`.
|
|
81
|
+
*
|
|
82
|
+
* The active one is whichever currently holds focus, falling back to the
|
|
83
|
+
* first. The fallback is what makes the group re-enterable by Tab after
|
|
84
|
+
* focus has left it.
|
|
85
|
+
*/
|
|
86
|
+
function sync(): void {
|
|
87
|
+
const list = items();
|
|
88
|
+
const focused = document.activeElement;
|
|
89
|
+
const activeIndex =
|
|
90
|
+
focused instanceof HTMLElement ? list.indexOf(focused) : -1;
|
|
91
|
+
const active = activeIndex === -1 ? 0 : activeIndex;
|
|
92
|
+
|
|
93
|
+
list.forEach((item, index) => {
|
|
94
|
+
item.tabIndex = index === active ? 0 : -1;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function move(delta: number): void {
|
|
99
|
+
const list = items();
|
|
100
|
+
if (list.length === 0) return;
|
|
101
|
+
|
|
102
|
+
const focused = document.activeElement;
|
|
103
|
+
const current = focused instanceof HTMLElement ? list.indexOf(focused) : -1;
|
|
104
|
+
const next = step(current, delta, list.length, loop);
|
|
105
|
+
if (next === null) return;
|
|
106
|
+
|
|
107
|
+
const target = list[next];
|
|
108
|
+
if (target === undefined) return;
|
|
109
|
+
|
|
110
|
+
for (const item of list) item.tabIndex = -1;
|
|
111
|
+
target.tabIndex = 0;
|
|
112
|
+
focusSilently(target);
|
|
113
|
+
options.onFocusChange?.(target);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function focusEdge(edge: "first" | "last"): void {
|
|
117
|
+
const list = items();
|
|
118
|
+
const target = edge === "first" ? list[0] : list[list.length - 1];
|
|
119
|
+
if (target === undefined) return;
|
|
120
|
+
|
|
121
|
+
for (const item of list) item.tabIndex = -1;
|
|
122
|
+
target.tabIndex = 0;
|
|
123
|
+
focusSilently(target);
|
|
124
|
+
options.onFocusChange?.(target);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
128
|
+
const container = options.container();
|
|
129
|
+
if (container === null) return;
|
|
130
|
+
if (event.target instanceof Node && !container.contains(event.target))
|
|
131
|
+
return;
|
|
132
|
+
// A modifier means the user is talking to the browser, not the widget.
|
|
133
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
134
|
+
|
|
135
|
+
const direction = directionFor(event.key, orientation);
|
|
136
|
+
if (direction !== 0) {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
move(direction);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (event.key === "Home") {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
focusEdge("first");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (event.key === "End") {
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
focusEdge("last");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
154
|
+
const active = document.activeElement;
|
|
155
|
+
if (!(active instanceof HTMLElement)) return;
|
|
156
|
+
if (!items().includes(active)) return;
|
|
157
|
+
// Space would scroll the page and Enter would submit a surrounding form;
|
|
158
|
+
// inside a composite widget both mean "activate this item".
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
options.onSelect?.(active, event);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
165
|
+
|
|
166
|
+
let destroyed = false;
|
|
167
|
+
return {
|
|
168
|
+
focusFirst: () => focusEdge("first"),
|
|
169
|
+
focusLast: () => focusEdge("last"),
|
|
170
|
+
items,
|
|
171
|
+
sync,
|
|
172
|
+
destroy(): void {
|
|
173
|
+
if (destroyed) return;
|
|
174
|
+
destroyed = true;
|
|
175
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `-1` for previous, `+1` for next, `0` when the key is not ours. */
|
|
181
|
+
function directionFor(key: string, orientation: Orientation): number {
|
|
182
|
+
const vertical = orientation === "vertical" || orientation === "both";
|
|
183
|
+
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
184
|
+
|
|
185
|
+
if (vertical && key === "ArrowDown") return 1;
|
|
186
|
+
if (vertical && key === "ArrowUp") return -1;
|
|
187
|
+
if (horizontal && key === "ArrowRight") return 1;
|
|
188
|
+
if (horizontal && key === "ArrowLeft") return -1;
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Next index, or `null` when the move is refused at an unlooped end.
|
|
194
|
+
*
|
|
195
|
+
* `current === -1` means focus is not on an item yet — the first arrow press
|
|
196
|
+
* after opening a menu. It enters at the first item going down and the last
|
|
197
|
+
* going up, which is what makes ArrowUp on a fresh menu land on its bottom
|
|
198
|
+
* entry the way every native menu does.
|
|
199
|
+
*/
|
|
200
|
+
function step(
|
|
201
|
+
current: number,
|
|
202
|
+
delta: number,
|
|
203
|
+
length: number,
|
|
204
|
+
loop: boolean,
|
|
205
|
+
): number | null {
|
|
206
|
+
if (current === -1) return delta > 0 ? 0 : length - 1;
|
|
207
|
+
|
|
208
|
+
const next = current + delta;
|
|
209
|
+
if (next >= 0 && next < length) return next;
|
|
210
|
+
if (!loop) return null;
|
|
211
|
+
return next < 0 ? length - 1 : 0;
|
|
212
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Body scroll lock — stop the page scrolling behind a modal surface.
|
|
4
|
+
*
|
|
5
|
+
* Two details separate a working lock from the naive `overflow: hidden`:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Reference counting.** Two modals can be open at once (a confirm dialog
|
|
8
|
+
* over a sheet). The second lock must not re-read the "original" overflow,
|
|
9
|
+
* which is by then already `hidden`, and the first release must not unlock
|
|
10
|
+
* while the second is still up.
|
|
11
|
+
* 2. **Scrollbar compensation.** Hiding overflow removes the scrollbar, the
|
|
12
|
+
* viewport widens by its width, and the whole page jumps sideways as the
|
|
13
|
+
* modal opens. Padding the body by exactly that width holds it still.
|
|
14
|
+
*
|
|
15
|
+
* The saved styles are the *inline* ones, not the computed ones. Restoring a
|
|
16
|
+
* computed value would write a hardcoded padding onto a body that had none,
|
|
17
|
+
* and the layout would stay shifted after the last modal closed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let depth = 0;
|
|
21
|
+
let savedOverflow = "";
|
|
22
|
+
let savedPaddingRight = "";
|
|
23
|
+
|
|
24
|
+
/** Lock page scrolling. Returns the matching unlock; safe to call twice. */
|
|
25
|
+
export function lockScroll(): () => void {
|
|
26
|
+
if (typeof document === "undefined") return () => {};
|
|
27
|
+
|
|
28
|
+
depth += 1;
|
|
29
|
+
if (depth === 1) applyLock();
|
|
30
|
+
|
|
31
|
+
let released = false;
|
|
32
|
+
return (): void => {
|
|
33
|
+
if (released) return;
|
|
34
|
+
released = true;
|
|
35
|
+
depth -= 1;
|
|
36
|
+
if (depth === 0) removeLock();
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Is the page currently locked? Exposed for tests. */
|
|
41
|
+
export function isScrollLocked(): boolean {
|
|
42
|
+
return depth > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function applyLock(): void {
|
|
46
|
+
const body = document.body;
|
|
47
|
+
savedOverflow = body.style.overflow;
|
|
48
|
+
savedPaddingRight = body.style.paddingRight;
|
|
49
|
+
|
|
50
|
+
const gap = scrollbarWidth();
|
|
51
|
+
if (gap > 0) {
|
|
52
|
+
// Add to whatever padding the body already has rather than replacing it.
|
|
53
|
+
const existing = Number.parseFloat(getComputedStyle(body).paddingRight);
|
|
54
|
+
const base = Number.isNaN(existing) ? 0 : existing;
|
|
55
|
+
body.style.paddingRight = `${base + gap}px`;
|
|
56
|
+
}
|
|
57
|
+
body.style.overflow = "hidden";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function removeLock(): void {
|
|
61
|
+
const body = document.body;
|
|
62
|
+
body.style.overflow = savedOverflow;
|
|
63
|
+
body.style.paddingRight = savedPaddingRight;
|
|
64
|
+
savedOverflow = "";
|
|
65
|
+
savedPaddingRight = "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Width of the vertical scrollbar, or 0 when it overlays the content.
|
|
70
|
+
*
|
|
71
|
+
* `innerWidth - documentElement.clientWidth` measures the real gutter, which
|
|
72
|
+
* is 0 on macOS overlay scrollbars and on touch devices. Measuring it beats
|
|
73
|
+
* assuming a value, because assuming produces a *reverse* jump on exactly the
|
|
74
|
+
* platforms where there is no scrollbar to compensate for.
|
|
75
|
+
*/
|
|
76
|
+
function scrollbarWidth(): number {
|
|
77
|
+
if (typeof window === "undefined") return 0;
|
|
78
|
+
return Math.max(0, window.innerWidth - document.documentElement.clientWidth);
|
|
79
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Type-ahead — jump to an item by typing its first letters.
|
|
4
|
+
*
|
|
5
|
+
* Native `<select>` does it, so a listbox or menu that does not feels broken.
|
|
6
|
+
* The WAI-ARIA practices call for it on listbox, menu, tree and combobox.
|
|
7
|
+
*
|
|
8
|
+
* Two rules carry the whole behaviour, and both come from the native control:
|
|
9
|
+
*
|
|
10
|
+
* - Keys typed within the timeout build one search string, so "st" reaches
|
|
11
|
+
* "Stockholm" rather than stopping at anything beginning with "t".
|
|
12
|
+
* - Repeating a single letter cycles through the items starting with it, which
|
|
13
|
+
* is how you reach the third "S" entry without knowing the rest of its name.
|
|
14
|
+
*
|
|
15
|
+
* Matching starts *after* the currently focused item and wraps, so a match
|
|
16
|
+
* always moves forward — otherwise every search would snap back to the first
|
|
17
|
+
* hit in the list and the cycling rule could never take effect.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const RESET_AFTER_MS = 1000;
|
|
21
|
+
|
|
22
|
+
export interface TypeaheadOptions {
|
|
23
|
+
/** Current candidates, in display order. Re-read on every keypress. */
|
|
24
|
+
items: () => readonly HTMLElement[];
|
|
25
|
+
/** The item to search from. Defaults to whatever has focus. */
|
|
26
|
+
current?: () => HTMLElement | null;
|
|
27
|
+
/** A match was found. */
|
|
28
|
+
onMatch: (item: HTMLElement) => void;
|
|
29
|
+
/** Text to match against. Defaults to the item's trimmed text content. */
|
|
30
|
+
textOf?: (item: HTMLElement) => string;
|
|
31
|
+
/** How long keys keep accumulating. Default `1000`ms. */
|
|
32
|
+
resetAfter?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Typeahead {
|
|
36
|
+
/**
|
|
37
|
+
* Feed a keydown in. Returns `true` when the key was consumed as search
|
|
38
|
+
* input, so the caller can skip its own handling for that press.
|
|
39
|
+
*/
|
|
40
|
+
handleKey(event: KeyboardEvent): boolean;
|
|
41
|
+
/** Drop the buffer — call when the surface closes. */
|
|
42
|
+
reset(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function typeahead(options: TypeaheadOptions): Typeahead {
|
|
46
|
+
const resetAfter = options.resetAfter ?? RESET_AFTER_MS;
|
|
47
|
+
let buffer = "";
|
|
48
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
49
|
+
|
|
50
|
+
function reset(): void {
|
|
51
|
+
buffer = "";
|
|
52
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
53
|
+
timer = undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function restartTimer(): void {
|
|
57
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
58
|
+
timer = setTimeout(reset, resetAfter);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
reset,
|
|
63
|
+
|
|
64
|
+
handleKey(event: KeyboardEvent): boolean {
|
|
65
|
+
if (!isSearchKey(event, buffer.length > 0)) return false;
|
|
66
|
+
|
|
67
|
+
buffer += event.key.toLowerCase();
|
|
68
|
+
restartTimer();
|
|
69
|
+
|
|
70
|
+
const items = options.items();
|
|
71
|
+
if (items.length === 0) return true;
|
|
72
|
+
|
|
73
|
+
// A buffer of one repeated character means "next item starting with it",
|
|
74
|
+
// not "item starting with that string repeated".
|
|
75
|
+
const query = allSameCharacter(buffer) ? (buffer[0] ?? "") : buffer;
|
|
76
|
+
const skipCurrent = allSameCharacter(buffer) && buffer.length > 1;
|
|
77
|
+
|
|
78
|
+
const from = startIndex(items, options.current?.() ?? focusedElement());
|
|
79
|
+
const match = findFrom(items, query, from, skipCurrent, options.textOf);
|
|
80
|
+
if (match !== null) options.onMatch(match);
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this keypress search input?
|
|
88
|
+
*
|
|
89
|
+
* Single printable characters only. `event.key` is `"a"` for a letter but
|
|
90
|
+
* `"ArrowDown"` or `"Enter"` for a control key, so a length check separates
|
|
91
|
+
* them without a keycode table. Modifiers are excluded because Ctrl+F belongs
|
|
92
|
+
* to the browser — but Shift is not, since it is how you type a capital.
|
|
93
|
+
*
|
|
94
|
+
* Space is the one character that depends on context. On an empty buffer it
|
|
95
|
+
* means "activate the focused item" and must reach the caller untouched; once
|
|
96
|
+
* a search is under way it is a word separator, and swallowing it is what lets
|
|
97
|
+
* "new y" reach "New York".
|
|
98
|
+
*/
|
|
99
|
+
function isSearchKey(event: KeyboardEvent, hasBuffer: boolean): boolean {
|
|
100
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
|
101
|
+
if (event.key.length !== 1) return false;
|
|
102
|
+
if (event.key === " ") return hasBuffer;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function allSameCharacter(value: string): boolean {
|
|
107
|
+
if (value.length < 2) return true;
|
|
108
|
+
const first = value[0];
|
|
109
|
+
for (const character of value) {
|
|
110
|
+
if (character !== first) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function focusedElement(): HTMLElement | null {
|
|
116
|
+
if (typeof document === "undefined") return null;
|
|
117
|
+
const active = document.activeElement;
|
|
118
|
+
return active instanceof HTMLElement ? active : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function startIndex(
|
|
122
|
+
items: readonly HTMLElement[],
|
|
123
|
+
current: HTMLElement | null,
|
|
124
|
+
): number {
|
|
125
|
+
if (current === null) return 0;
|
|
126
|
+
const index = items.indexOf(current);
|
|
127
|
+
return index === -1 ? 0 : index;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* First item matching `query`, searching forward from `from` and wrapping.
|
|
132
|
+
*
|
|
133
|
+
* `skipCurrent` is the cycling rule: repeating a letter must advance past the
|
|
134
|
+
* item already selected. A fresh search keeps the current item eligible, so
|
|
135
|
+
* typing the first letters of what is already highlighted does not jump away.
|
|
136
|
+
*/
|
|
137
|
+
function findFrom(
|
|
138
|
+
items: readonly HTMLElement[],
|
|
139
|
+
query: string,
|
|
140
|
+
from: number,
|
|
141
|
+
skipCurrent: boolean,
|
|
142
|
+
textOf?: (item: HTMLElement) => string,
|
|
143
|
+
): HTMLElement | null {
|
|
144
|
+
if (query === "") return null;
|
|
145
|
+
|
|
146
|
+
const offset = skipCurrent ? 1 : 0;
|
|
147
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
148
|
+
const item = items[(from + i + offset) % items.length];
|
|
149
|
+
if (item === undefined) continue;
|
|
150
|
+
const text = (textOf?.(item) ?? item.textContent ?? "")
|
|
151
|
+
.trim()
|
|
152
|
+
.toLowerCase();
|
|
153
|
+
if (text.startsWith(query)) return item;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|