@signal9/era-ui 1.25.0 → 1.26.1

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.
@@ -1,7 +1,9 @@
1
1
  type $$ComponentProps = {
2
2
  open?: boolean;
3
- /** Register Ctrl/Cmd+K to toggle and Escape to close (default true). */
4
- hotkey?: boolean;
3
+ /** Global binding(s) that toggle the launcher (tinykeys grammar, e.g.
4
+ * `'$mod+k'`). No default — the taskbar button is the base affordance.
5
+ * Escape always closes while open. */
6
+ hotkey?: string | string[];
5
7
  };
6
8
  declare const Launcher: import("svelte").Component<$$ComponentProps, {}, "open">;
7
9
  type Launcher = ReturnType<typeof Launcher>;
@@ -126,7 +126,9 @@ export declare class WindowManager {
126
126
  * singleton — from any workspace; it is brought to the active one). */
127
127
  open(appId: string, overrides?: Partial<Pick<AppWindow, 'title' | 'pos' | 'size' | 'resizable' | 'workspaceId'>>): string | null;
128
128
  close(id: string): void;
129
- /** Bring a window to the front (and un-minimize it if needed). */
129
+ /** Make a window the frontmost VISIBLE one: switch to its workspace,
130
+ * un-minimize it, and bring it to the front. Focus means reveal at every
131
+ * level — never a silent off-screen z bump. */
130
132
  focus(id: string): void;
131
133
  minimize(id: string): void;
132
134
  /** Taskbar behaviour: focus if not focused, else minimize; restore if minimized. */
@@ -77,7 +77,11 @@ export class WindowManager {
77
77
  if (app.singleton) {
78
78
  const existing = this.windows.find((w) => w.appId === appId);
79
79
  if (existing) {
80
- existing.workspaceId = overrides?.workspaceId ?? this.activeWorkspaceId;
80
+ // Reveal, don't relocate: restore() → focus() switches to the
81
+ // workspace the window already lives on, the way every virtual
82
+ // desktop behaves. An explicit override still moves it.
83
+ if (overrides?.workspaceId)
84
+ existing.workspaceId = overrides.workspaceId;
81
85
  this.restore(existing.id);
82
86
  return existing.id;
83
87
  }
@@ -108,11 +112,15 @@ export class WindowManager {
108
112
  close(id) {
109
113
  this.windows = this.windows.filter((w) => w.id !== id);
110
114
  }
111
- /** Bring a window to the front (and un-minimize it if needed). */
115
+ /** Make a window the frontmost VISIBLE one: switch to its workspace,
116
+ * un-minimize it, and bring it to the front. Focus means reveal at every
117
+ * level — never a silent off-screen z bump. */
112
118
  focus(id) {
113
119
  const w = this.#find(id);
114
120
  if (!w)
115
121
  return;
122
+ if (w.workspaceId !== this.activeWorkspaceId)
123
+ this.switchWorkspace(w.workspaceId);
116
124
  if (w.state === 'minimized')
117
125
  w.state = 'normal';
118
126
  if (w.z <= this.#z)
@@ -56,7 +56,7 @@
56
56
  --color-destructive-fg: oklch(0.96 0 0);
57
57
  --color-success: oklch(0.65 0.17 145);
58
58
  --color-success-fg: oklch(0.96 0 0);
59
- --color-warning: oklch(0.8 0.16 85);
59
+ --color-warning: oklch(0.8 0.16 85);
60
60
  --color-warning-fg: oklch(0.162 0 0);
61
61
  }
62
62
 
@@ -95,7 +95,7 @@
95
95
  --color-destructive-fg: oklch(0.98 0 0);
96
96
  --color-success: oklch(0.5 0.17 145);
97
97
  --color-success-fg: oklch(0.98 0 0);
98
- --color-warning: oklch(0.72 0.15 85);
98
+ --color-warning: oklch(0.72 0.15 85);
99
99
  --color-warning-fg: oklch(0.162 0 0);
100
100
  }
101
101
  }
@@ -131,7 +131,7 @@
131
131
  --color-destructive-fg: oklch(0.98 0 0);
132
132
  --color-success: oklch(0.5 0.17 145);
133
133
  --color-success-fg: oklch(0.98 0 0);
134
- --color-warning: oklch(0.72 0.15 85);
134
+ --color-warning: oklch(0.72 0.15 85);
135
135
  --color-warning-fg: oklch(0.162 0 0);
136
136
  }
137
137
 
@@ -175,7 +175,7 @@
175
175
  --color-destructive-fg: oklch(0.96 0.016 90);
176
176
  --color-success: oklch(0.7 0.125 184);
177
177
  --color-success-fg: oklch(0.19 0.014 50);
178
- --color-warning: oklch(0.78 0.14 82);
178
+ --color-warning: oklch(0.78 0.14 82);
179
179
  --color-warning-fg: oklch(0.19 0.014 50);
180
180
  }
181
181
 
@@ -218,7 +218,7 @@
218
218
  --color-destructive-fg: oklch(0.98 0.012 78);
219
219
  --color-success: oklch(0.5 0.13 158);
220
220
  --color-success-fg: oklch(0.98 0.012 78);
221
- --color-warning: oklch(0.7 0.13 80);
221
+ --color-warning: oklch(0.7 0.13 80);
222
222
  --color-warning-fg: oklch(0.19 0.014 50);
223
223
  }
224
224
  }
@@ -253,7 +253,7 @@
253
253
  --color-destructive-fg: oklch(0.98 0.012 78);
254
254
  --color-success: oklch(0.5 0.13 158);
255
255
  --color-success-fg: oklch(0.98 0.012 78);
256
- --color-warning: oklch(0.7 0.13 80);
256
+ --color-warning: oklch(0.7 0.13 80);
257
257
  --color-warning-fg: oklch(0.19 0.014 50);
258
258
  }
259
259
 
@@ -291,7 +291,7 @@
291
291
  --color-destructive-fg: var(--color-12);
292
292
  --color-success: oklch(0.71 0.09 164);
293
293
  --color-success-fg: var(--color-1);
294
- --color-warning: oklch(0.78 0.1 85);
294
+ --color-warning: oklch(0.78 0.1 85);
295
295
  --color-warning-fg: var(--color-1);
296
296
  }
297
297
 
@@ -329,7 +329,7 @@
329
329
  --color-destructive-fg: oklch(0.98 0.008 80);
330
330
  --color-success: oklch(0.54 0.11 164);
331
331
  --color-success-fg: oklch(0.98 0.008 80);
332
- --color-warning: oklch(0.68 0.1 82);
332
+ --color-warning: oklch(0.68 0.1 82);
333
333
  --color-warning-fg: oklch(0.2 0.012 60);
334
334
  }
335
335
  }
@@ -364,6 +364,6 @@
364
364
  --color-destructive-fg: oklch(0.98 0.008 80);
365
365
  --color-success: oklch(0.54 0.11 164);
366
366
  --color-success-fg: oklch(0.98 0.008 80);
367
- --color-warning: oklch(0.68 0.1 82);
367
+ --color-warning: oklch(0.68 0.1 82);
368
368
  --color-warning-fg: oklch(0.2 0.012 60);
369
369
  }
@@ -2,7 +2,7 @@ import Button from './button.svelte';
2
2
  export { Button };
3
3
  export { buttonVariants } from './variants.js';
4
4
  export interface ButtonVariants {
5
- tone?: 'default' | 'accent' | 'destructive' | 'success';
5
+ tone?: 'default' | 'accent' | 'destructive' | 'success' | 'warning';
6
6
  size?: 'default' | 'sm' | 'xs' | 'icon';
7
7
  variant?: 'filled' | 'link';
8
8
  icon?: boolean;
@@ -0,0 +1,254 @@
1
+ <script lang="ts">
2
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
3
+ import { cn } from '../../utils/index.js';
4
+ import { keys, isTextEntryFocused, type KeyBindingMap } from '../../utils/hotkeys.js';
5
+ import type { CommandBarItem } from './types.js';
6
+
7
+ let {
8
+ items,
9
+ placeholder = 'Type a command…',
10
+ empty = 'No results found.',
11
+ hotkey,
12
+ class: className
13
+ }: {
14
+ /** The root view. */
15
+ items: CommandBarItem[];
16
+ placeholder?: string;
17
+ /** Empty-state message when the filter matches nothing. */
18
+ empty?: string;
19
+ /** Global binding(s) that focus the bar (tinykeys grammar, e.g.
20
+ * `['`', 'Escape']`). While another field has focus only Escape
21
+ * acts — it blurs that field instead of stealing the caret. */
22
+ hotkey?: string | string[];
23
+ class?: string;
24
+ } = $props();
25
+
26
+ let input = $state<HTMLInputElement | null>(null);
27
+ let value = $state('');
28
+ let focused = $state(false);
29
+ let selectedIndex = $state(0);
30
+ // Drill-in stack: each entry is a completed segment (its label renders as a
31
+ // locked breadcrumb before the input; Backspace on an empty input pops it).
32
+ let stack = $state<CommandBarItem[]>([]);
33
+
34
+ const view = $derived(stack.at(-1));
35
+ const viewItems = $derived(view?.items ?? items);
36
+ const viewPlaceholder = $derived(view?.placeholder ?? placeholder);
37
+
38
+ // Substring filter with exact-match priority (2 exact, 1 substring), the
39
+ // same scoring the terminal uses — exact hits sort first so completion
40
+ // always grabs the literal match.
41
+ const filtered = $derived.by(() => {
42
+ const q = value.trim().toLowerCase();
43
+ if (!q) return viewItems;
44
+ return viewItems
45
+ .map((item) => {
46
+ const label = item.label.toLowerCase();
47
+ const kw = item.keywords?.map((k) => k.toLowerCase()) ?? [];
48
+ let score = 0;
49
+ if (label === q || kw.includes(q)) score = 2;
50
+ else if (
51
+ label.includes(q) ||
52
+ item.value.toLowerCase().includes(q) ||
53
+ kw.some((k) => k.includes(q))
54
+ )
55
+ score = 1;
56
+ return { item, score };
57
+ })
58
+ .filter((s) => s.score > 0)
59
+ .sort((a, b) => b.score - a.score)
60
+ .map((s) => s.item);
61
+ });
62
+ const selected = $derived(filtered[selectedIndex]);
63
+ const open = $derived(focused || value !== '');
64
+
65
+ // zsh-style ghost completion: the selected item's remaining characters laid
66
+ // under the caret (or the view placeholder while empty).
67
+ const ghost = $derived.by(() => {
68
+ if (!value) return viewPlaceholder;
69
+ if (!selected) return '';
70
+ const label = selected.label;
71
+ return label.toLowerCase().startsWith(value.toLowerCase()) ? label.slice(value.length) : '';
72
+ });
73
+
74
+ // New keystrokes / view changes re-aim the selection at the top match.
75
+ $effect(() => {
76
+ void value;
77
+ void stack.length;
78
+ selectedIndex = 0;
79
+ });
80
+
81
+ function reset() {
82
+ value = '';
83
+ stack = [];
84
+ selectedIndex = 0;
85
+ }
86
+
87
+ function drill(item: CommandBarItem) {
88
+ stack.push(item);
89
+ value = '';
90
+ }
91
+
92
+ /** Enter / click: run the item, or descend if it's a view. */
93
+ function choose(item: CommandBarItem) {
94
+ if (item.onSelect) {
95
+ item.onSelect();
96
+ reset();
97
+ input?.blur();
98
+ } else if (item.items) {
99
+ drill(item);
100
+ }
101
+ }
102
+
103
+ /** Tab / Space: completion — descend into a view, else run. */
104
+ function complete(item: CommandBarItem) {
105
+ if (item.items) drill(item);
106
+ else choose(item);
107
+ }
108
+
109
+ function onKeydown(e: KeyboardEvent) {
110
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
111
+ e.preventDefault();
112
+ const delta = e.key === 'ArrowDown' ? 1 : -1;
113
+ selectedIndex = Math.min(Math.max(selectedIndex + delta, 0), filtered.length - 1);
114
+ } else if (e.key === 'Enter') {
115
+ if (selected) choose(selected);
116
+ } else if (e.key === 'Tab' || e.key === ' ') {
117
+ if (selected) {
118
+ e.preventDefault();
119
+ complete(selected);
120
+ }
121
+ } else if (e.key === 'Escape') {
122
+ e.stopPropagation();
123
+ reset();
124
+ input?.blur();
125
+ } else if (e.key === 'Backspace' && value === '' && stack.length) {
126
+ e.preventDefault();
127
+ stack.pop();
128
+ } else if (e.key.toLowerCase() === 'u' && (e.ctrlKey || e.metaKey)) {
129
+ e.preventDefault();
130
+ reset();
131
+ }
132
+ }
133
+
134
+ // Focus hotkeys: a central map on window (tinykeys grammar). Guarded the
135
+ // way the terminal guards them — while any field is focused, only Escape
136
+ // acts (it blurs the field); every other binding is ignored so typing a
137
+ // backtick in a text input never steals the caret.
138
+ const bindings = $derived.by((): KeyBindingMap => {
139
+ if (!hotkey) return {};
140
+ const list = Array.isArray(hotkey) ? hotkey : [hotkey];
141
+ return Object.fromEntries(
142
+ list.map((b) => [
143
+ b,
144
+ (e: KeyboardEvent) => {
145
+ if (isTextEntryFocused()) {
146
+ if (b === 'Escape') (document.activeElement as HTMLElement).blur();
147
+ return;
148
+ }
149
+ e.preventDefault();
150
+ input?.focus();
151
+ }
152
+ ])
153
+ );
154
+ });
155
+
156
+ export function focusInput() {
157
+ input?.focus();
158
+ }
159
+ </script>
160
+
161
+ <svelte:window use:keys={bindings} />
162
+
163
+ <!-- The terminal command bar: a bare prompt line, not a floating palette. The
164
+ dropdown is a flat sheet anchored under it — no border, shadow or radius,
165
+ just the elevated surface, exactly like the shell it mirrors. -->
166
+ <div class={cn('relative flex h-(--era-h-md) w-full min-w-0 items-center font-mono', className)}>
167
+ <ChevronRight class="mx-(--era-gap) size-(--era-h-xs) shrink-0 text-muted" />
168
+
169
+ <!-- Completed segments: the locked prefix. Backspace (empty input) pops. -->
170
+ {#each stack as seg (seg.value)}
171
+ <span class="shrink-0 text-body text-muted">{seg.label}</span>
172
+ <ChevronRight class="mx-(--era-gap) size-(--era-h-xxs) shrink-0 text-muted opacity-60" />
173
+ {/each}
174
+
175
+ <div class="relative h-full min-w-0 flex-1">
176
+ <input
177
+ bind:this={input}
178
+ bind:value
179
+ type="text"
180
+ role="combobox"
181
+ aria-expanded={open}
182
+ aria-controls="era-command-bar-list"
183
+ aria-activedescendant={selected ? `era-cmdbar-${selected.value}` : undefined}
184
+ aria-label={viewPlaceholder}
185
+ autocapitalize="off"
186
+ autocomplete="off"
187
+ autocorrect="off"
188
+ spellcheck="false"
189
+ class="h-full w-full bg-transparent text-body text-bright outline-none"
190
+ onkeydown={onKeydown}
191
+ onfocus={() => (focused = true)}
192
+ onblur={() => (focused = false)}
193
+ />
194
+ <!-- Ghost text rides at the end of the typed value (ch units — the bar
195
+ is mono by contract, so 1ch == one typed character). -->
196
+ <span
197
+ aria-hidden="true"
198
+ class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-body whitespace-pre text-fg opacity-40"
199
+ style:left="{value.length}ch">{ghost}</span
200
+ >
201
+ </div>
202
+
203
+ {#if open}
204
+ <div
205
+ id="era-command-bar-list"
206
+ role="listbox"
207
+ tabindex="-1"
208
+ aria-label="Commands"
209
+ class="absolute top-full left-0 z-50 max-h-64 w-full overflow-x-hidden overflow-y-auto bg-2"
210
+ onmousedown={(e) => e.preventDefault()}
211
+ >
212
+ {#if !filtered.length}
213
+ <div class="flex h-(--era-h-md) items-center px-(--era-inset-md) text-body text-muted">
214
+ {empty}
215
+ </div>
216
+ {:else}
217
+ {#each filtered as item, i (item.value)}
218
+ <!-- Selection is keyboard-driven (selectedIndex), not :hover —
219
+ mousedown is cancelled so a click never blurs the input. The
220
+ keyboard path is the combobox input above (WAI-ARIA combobox:
221
+ options are not tab stops). -->
222
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
223
+ <div
224
+ id="era-cmdbar-{item.value}"
225
+ role="option"
226
+ tabindex={-1}
227
+ aria-selected={i === selectedIndex}
228
+ class={cn(
229
+ 'flex h-(--era-h-md) cursor-default items-center gap-(--era-gap) px-(--era-inset-md) text-body whitespace-nowrap',
230
+ i === selectedIndex ? 'bg-3 text-bright' : 'text-fg'
231
+ )}
232
+ onclick={() => choose(item)}
233
+ >
234
+ {#if typeof item.icon === 'string'}
235
+ <span class="shrink-0">{item.icon}</span>
236
+ {:else if item.icon}
237
+ {@const Icon = item.icon}
238
+ <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
239
+ {/if}
240
+ <span class="shrink-0">{item.label}</span>
241
+ {#if item.items}
242
+ <ChevronRight class="size-(--era-h-xxs) shrink-0 text-muted" />
243
+ {/if}
244
+ {#if item.sublabel}
245
+ <span class="ml-auto truncate pl-(--era-inset-md) text-body text-muted">
246
+ {item.sublabel}
247
+ </span>
248
+ {/if}
249
+ </div>
250
+ {/each}
251
+ {/if}
252
+ </div>
253
+ {/if}
254
+ </div>
@@ -0,0 +1,18 @@
1
+ import type { CommandBarItem } from './types.js';
2
+ type $$ComponentProps = {
3
+ /** The root view. */
4
+ items: CommandBarItem[];
5
+ placeholder?: string;
6
+ /** Empty-state message when the filter matches nothing. */
7
+ empty?: string;
8
+ /** Global binding(s) that focus the bar (tinykeys grammar, e.g.
9
+ * `['`', 'Escape']`). While another field has focus only Escape
10
+ * acts — it blurs that field instead of stealing the caret. */
11
+ hotkey?: string | string[];
12
+ class?: string;
13
+ };
14
+ declare const CommandBar: import("svelte").Component<$$ComponentProps, {
15
+ focusInput: () => void;
16
+ }, "">;
17
+ type CommandBar = ReturnType<typeof CommandBar>;
18
+ export default CommandBar;
@@ -0,0 +1,2 @@
1
+ export { default as CommandBar } from './command-bar.svelte';
2
+ export type { CommandBarItem } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as CommandBar } from './command-bar.svelte';
@@ -0,0 +1,20 @@
1
+ import type { Component } from 'svelte';
2
+ import type { IconProps } from '@lucide/svelte';
3
+ /** One row of a CommandBar view. Selecting it runs `onSelect`; if it has
4
+ * `items` it's a drill-in view instead (Tab/Space/Enter descends into it,
5
+ * Backspace at an empty input pops back out). */
6
+ export interface CommandBarItem {
7
+ /** Stable id, also matched by the filter. */
8
+ value: string;
9
+ label: string;
10
+ keywords?: string[];
11
+ /** Right-aligned muted description. */
12
+ sublabel?: string;
13
+ /** Emoji/text or a Lucide component. */
14
+ icon?: Component<IconProps> | string;
15
+ onSelect?: () => void;
16
+ /** Sub-view items — makes this a drill-in segment. */
17
+ items?: CommandBarItem[];
18
+ /** Placeholder shown while this sub-view is open. */
19
+ placeholder?: string;
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -10,6 +10,7 @@ export * as Checkbox from './checkbox/index.js';
10
10
  export * as Combobox from './combobox/index.js';
11
11
  export * as Collapsible from './collapsible/index.js';
12
12
  export * as Command from './command/index.js';
13
+ export * from './command-bar/index.js';
13
14
  export * as ContextMenu from './context-menu/index.js';
14
15
  export * as DateField from './date-field/index.js';
15
16
  export * as DatePicker from './date-picker/index.js';
package/dist/ui/index.js CHANGED
@@ -10,6 +10,7 @@ export * as Checkbox from './checkbox/index.js';
10
10
  export * as Combobox from './combobox/index.js';
11
11
  export * as Collapsible from './collapsible/index.js';
12
12
  export * as Command from './command/index.js';
13
+ export * from './command-bar/index.js';
13
14
  export * as ContextMenu from './context-menu/index.js';
14
15
  export * as DateField from './date-field/index.js';
15
16
  export * as DatePicker from './date-picker/index.js';
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Global hotkeys — a tinykeys-style engine (after Jamie Kyle's tinykeys, MIT).
3
+ *
4
+ * Bindings are declared as one central map from binding strings to handlers:
5
+ *
6
+ * <svelte:window use:keys={{ '$mod+k': openPalette, '/': focusSearch }} />
7
+ *
8
+ * Grammar: a binding is a space-separated SEQUENCE of presses (chords like
9
+ * `'g d'` work, with a timeout between presses); each press is
10
+ * `<mod>+<mod>+<key>`; a parenthesised key `'([1-9])'` matches as a regex
11
+ * against event.key/code. `$mod` normalises to Meta on Apple platforms and
12
+ * Control everywhere else. Matching is exact on modifiers — extra held
13
+ * modifiers make a press NOT match, so `$mod+k` never also fires on
14
+ * `$mod+shift+k`.
15
+ */
16
+ export type KeyBindingPress = [mods: string[], key: string | RegExp];
17
+ export type KeyBindingMap = Record<string, (event: KeyboardEvent) => void>;
18
+ export interface KeyBindingOptions {
19
+ /** Keyboard event to listen for (default `'keydown'`). */
20
+ event?: 'keydown' | 'keyup';
21
+ capture?: boolean;
22
+ /** ms allowed between presses of a sequence (default 1000). */
23
+ timeout?: number;
24
+ }
25
+ /** SSR-safe platform sniff — resolved lazily so importing this module never
26
+ * touches `navigator` on the server. */
27
+ export declare function isApplePlatform(): boolean;
28
+ /** What `$mod` means here: `'Meta'` (⌘) on Apple platforms, `'Control'` else. */
29
+ export declare function modKey(): 'Meta' | 'Control';
30
+ export declare function parseKeybinding(binding: string): KeyBindingPress[];
31
+ export declare function matchKeyBindingPress(event: KeyboardEvent, [mods, key]: KeyBindingPress): boolean;
32
+ /** One listener that walks the whole map, tracking partial sequence matches. */
33
+ export declare function createKeybindingsHandler(bindings: KeyBindingMap, options?: KeyBindingOptions): (event: Event) => void;
34
+ /** Attach a binding map to a target; returns the unsubscriber. An empty map
35
+ * attaches nothing — a consumer with no bindings costs zero per keystroke. */
36
+ export declare function hotkeys(target: Window | HTMLElement, bindings: KeyBindingMap, options?: KeyBindingOptions): () => void;
37
+ /** Svelte action form — `<svelte:window use:keys={{ '$mod+k': open }} />`. */
38
+ export declare function keys(node: Window | HTMLElement, bindings: KeyBindingMap): {
39
+ update(next: KeyBindingMap): void;
40
+ destroy(): void;
41
+ };
42
+ /** Is the user currently typing somewhere? The standard guard for global
43
+ * focus-stealing hotkeys — shared so every consumer agrees on what counts
44
+ * as a text-entry context. */
45
+ export declare function isTextEntryFocused(): boolean;
46
+ /** Format one binding for display: `'$mod+k'` → `⌘K` on Apple, `Ctrl+K`
47
+ * elsewhere. Sequences join with spaces (`'g d'` → `G D`). */
48
+ export declare function formatKeybinding(binding: string): string;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Global hotkeys — a tinykeys-style engine (after Jamie Kyle's tinykeys, MIT).
3
+ *
4
+ * Bindings are declared as one central map from binding strings to handlers:
5
+ *
6
+ * <svelte:window use:keys={{ '$mod+k': openPalette, '/': focusSearch }} />
7
+ *
8
+ * Grammar: a binding is a space-separated SEQUENCE of presses (chords like
9
+ * `'g d'` work, with a timeout between presses); each press is
10
+ * `<mod>+<mod>+<key>`; a parenthesised key `'([1-9])'` matches as a regex
11
+ * against event.key/code. `$mod` normalises to Meta on Apple platforms and
12
+ * Control everywhere else. Matching is exact on modifiers — extra held
13
+ * modifiers make a press NOT match, so `$mod+k` never also fires on
14
+ * `$mod+shift+k`.
15
+ */
16
+ const MODIFIER_KEYS = ['Shift', 'Meta', 'Alt', 'Control'];
17
+ const DEFAULT_TIMEOUT = 1000;
18
+ /** SSR-safe platform sniff — resolved lazily so importing this module never
19
+ * touches `navigator` on the server. */
20
+ export function isApplePlatform() {
21
+ if (typeof navigator === 'undefined')
22
+ return false;
23
+ const platform = navigator.userAgentData?.platform ??
24
+ navigator.platform;
25
+ return /mac|iphone|ipad|ipod/i.test(platform);
26
+ }
27
+ /** What `$mod` means here: `'Meta'` (⌘) on Apple platforms, `'Control'` else. */
28
+ export function modKey() {
29
+ return isApplePlatform() ? 'Meta' : 'Control';
30
+ }
31
+ export function parseKeybinding(binding) {
32
+ return binding
33
+ .trim()
34
+ .split(' ')
35
+ .map((press) => {
36
+ const parts = press.split(/\b\+/);
37
+ const rawKey = parts.pop();
38
+ const key = /^\(.+\)$/.test(rawKey) ? new RegExp(`^${rawKey.slice(1, -1)}$`) : rawKey;
39
+ const mods = parts.map((mod) => (mod === '$mod' ? modKey() : mod));
40
+ return [mods, key];
41
+ });
42
+ }
43
+ function getModifierState(event, mod) {
44
+ return typeof event.getModifierState === 'function' ? event.getModifierState(mod) : false;
45
+ }
46
+ export function matchKeyBindingPress(event, [mods, key]) {
47
+ const keyMatches = key instanceof RegExp
48
+ ? key.test(event.key) || key.test(event.code)
49
+ : key.toUpperCase() === event.key.toUpperCase() || key === event.code;
50
+ if (!keyMatches)
51
+ return false;
52
+ // Every required modifier must be down…
53
+ if (mods.some((mod) => !getModifierState(event, mod)))
54
+ return false;
55
+ // …and no extra one may be (exact-match kills superset collisions).
56
+ return !MODIFIER_KEYS.some((mod) => !mods.includes(mod) && key !== mod && getModifierState(event, mod));
57
+ }
58
+ /** One listener that walks the whole map, tracking partial sequence matches. */
59
+ export function createKeybindingsHandler(bindings, options = {}) {
60
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
61
+ const parsed = Object.entries(bindings).map(([binding, handler]) => [parseKeybinding(binding), handler]);
62
+ const possible = new Map();
63
+ let timer = null;
64
+ return (event) => {
65
+ if (!(event instanceof KeyboardEvent))
66
+ return;
67
+ for (const [sequence, handler] of parsed) {
68
+ const remaining = possible.get(sequence) ?? sequence;
69
+ if (matchKeyBindingPress(event, remaining[0])) {
70
+ if (remaining.length > 1) {
71
+ possible.set(sequence, remaining.slice(1));
72
+ }
73
+ else {
74
+ possible.delete(sequence);
75
+ handler(event);
76
+ }
77
+ }
78
+ else if (!MODIFIER_KEYS.includes(event.key)) {
79
+ // A wrong non-modifier press breaks the sequence (a lone modifier
80
+ // doesn't — it may be the start of the next press's chord).
81
+ possible.delete(sequence);
82
+ }
83
+ }
84
+ if (timer)
85
+ clearTimeout(timer);
86
+ if (possible.size)
87
+ timer = setTimeout(() => possible.clear(), timeout);
88
+ };
89
+ }
90
+ /** Attach a binding map to a target; returns the unsubscriber. An empty map
91
+ * attaches nothing — a consumer with no bindings costs zero per keystroke. */
92
+ export function hotkeys(target, bindings, options = {}) {
93
+ if (Object.keys(bindings).length === 0)
94
+ return () => { };
95
+ const handler = createKeybindingsHandler(bindings, options);
96
+ const event = options.event ?? 'keydown';
97
+ target.addEventListener(event, handler, { capture: options.capture });
98
+ return () => target.removeEventListener(event, handler, { capture: options.capture });
99
+ }
100
+ /** Svelte action form — `<svelte:window use:keys={{ '$mod+k': open }} />`. */
101
+ export function keys(node, bindings) {
102
+ let off = hotkeys(node, bindings);
103
+ return {
104
+ update(next) {
105
+ off();
106
+ off = hotkeys(node, next);
107
+ },
108
+ destroy() {
109
+ off();
110
+ }
111
+ };
112
+ }
113
+ /** Is the user currently typing somewhere? The standard guard for global
114
+ * focus-stealing hotkeys — shared so every consumer agrees on what counts
115
+ * as a text-entry context. */
116
+ export function isTextEntryFocused() {
117
+ if (typeof document === 'undefined')
118
+ return false;
119
+ const el = document.activeElement;
120
+ return (el instanceof HTMLElement &&
121
+ (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable));
122
+ }
123
+ const APPLE_GLYPHS = {
124
+ Meta: '⌘',
125
+ Alt: '⌥',
126
+ Shift: '⇧',
127
+ Control: '⌃'
128
+ };
129
+ /** Format one binding for display: `'$mod+k'` → `⌘K` on Apple, `Ctrl+K`
130
+ * elsewhere. Sequences join with spaces (`'g d'` → `G D`). */
131
+ export function formatKeybinding(binding) {
132
+ return parseKeybinding(binding)
133
+ .map(([mods, key]) => {
134
+ const keyLabel = key instanceof RegExp ? String(key.source) : key.toUpperCase();
135
+ if (isApplePlatform()) {
136
+ return [...mods.map((m) => APPLE_GLYPHS[m] ?? m), keyLabel].join('');
137
+ }
138
+ const names = mods.map((m) => (m === 'Control' ? 'Ctrl' : m));
139
+ return [...names, keyLabel].join('+');
140
+ })
141
+ .join(' ');
142
+ }
@@ -13,3 +13,4 @@ export type { VariantProps } from 'tailwind-variants';
13
13
  export type PartProps<T> = WithoutChildrenOrChild<T> & {
14
14
  children?: Snippet;
15
15
  };
16
+ export { hotkeys, keys, parseKeybinding, matchKeyBindingPress, createKeybindingsHandler, formatKeybinding, isApplePlatform, modKey, type KeyBindingMap, type KeyBindingPress, type KeyBindingOptions } from './hotkeys.js';
@@ -22,3 +22,4 @@ export function cn(...inputs) {
22
22
  }
23
23
  /** `tv` preconfigured with this library's tailwind-merge settings. */
24
24
  export const tv = createTV({ twMergeConfig });
25
+ export { hotkeys, keys, parseKeybinding, matchKeyBindingPress, createKeybindingsHandler, formatKeybinding, isApplePlatform, modKey } from './hotkeys.js';