@signal9/era-ui 1.24.0 → 1.26.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.
@@ -0,0 +1,256 @@
1
+ <script lang="ts">
2
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
3
+ import { cn } from '../../utils/index.js';
4
+ import { keys, 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
+ * `['/', '$mod+k']`). Ignored while another field has focus. */
21
+ hotkey?: string | string[];
22
+ class?: string;
23
+ } = $props();
24
+
25
+ let input = $state<HTMLInputElement | null>(null);
26
+ let value = $state('');
27
+ let focused = $state(false);
28
+ let selectedIndex = $state(0);
29
+ // Drill-in stack: each entry is a completed segment (its label renders as a
30
+ // locked breadcrumb before the input; Backspace on an empty input pops it).
31
+ let stack = $state<CommandBarItem[]>([]);
32
+
33
+ const view = $derived(stack.at(-1));
34
+ const viewItems = $derived(view?.items ?? items);
35
+ const viewPlaceholder = $derived(view?.placeholder ?? placeholder);
36
+
37
+ // Substring filter with exact-match priority (2 exact, 1 substring), the
38
+ // same scoring the terminal uses — exact hits sort first so completion
39
+ // always grabs the literal match.
40
+ const filtered = $derived.by(() => {
41
+ const q = value.trim().toLowerCase();
42
+ if (!q) return viewItems;
43
+ return viewItems
44
+ .map((item) => {
45
+ const label = item.label.toLowerCase();
46
+ const kw = item.keywords?.map((k) => k.toLowerCase()) ?? [];
47
+ let score = 0;
48
+ if (label === q || kw.includes(q)) score = 2;
49
+ else if (
50
+ label.includes(q) ||
51
+ item.value.toLowerCase().includes(q) ||
52
+ kw.some((k) => k.includes(q))
53
+ )
54
+ score = 1;
55
+ return { item, score };
56
+ })
57
+ .filter((s) => s.score > 0)
58
+ .sort((a, b) => b.score - a.score)
59
+ .map((s) => s.item);
60
+ });
61
+ const selected = $derived(filtered[selectedIndex]);
62
+ const open = $derived(focused || value !== '');
63
+
64
+ // zsh-style ghost completion: the selected item's remaining characters laid
65
+ // under the caret (or the view placeholder while empty).
66
+ const ghost = $derived.by(() => {
67
+ if (!value) return viewPlaceholder;
68
+ if (!selected) return '';
69
+ const label = selected.label;
70
+ return label.toLowerCase().startsWith(value.toLowerCase()) ? label.slice(value.length) : '';
71
+ });
72
+
73
+ // New keystrokes / view changes re-aim the selection at the top match.
74
+ $effect(() => {
75
+ void value;
76
+ void stack.length;
77
+ selectedIndex = 0;
78
+ });
79
+
80
+ function reset() {
81
+ value = '';
82
+ stack = [];
83
+ selectedIndex = 0;
84
+ }
85
+
86
+ function drill(item: CommandBarItem) {
87
+ stack.push(item);
88
+ value = '';
89
+ }
90
+
91
+ /** Enter / click: run the item, or descend if it's a view. */
92
+ function choose(item: CommandBarItem) {
93
+ if (item.onSelect) {
94
+ item.onSelect();
95
+ reset();
96
+ input?.blur();
97
+ } else if (item.items) {
98
+ drill(item);
99
+ }
100
+ }
101
+
102
+ /** Tab / Space: completion — descend into a view, else run. */
103
+ function complete(item: CommandBarItem) {
104
+ if (item.items) drill(item);
105
+ else choose(item);
106
+ }
107
+
108
+ function onKeydown(e: KeyboardEvent) {
109
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
110
+ e.preventDefault();
111
+ const delta = e.key === 'ArrowDown' ? 1 : -1;
112
+ selectedIndex = Math.min(Math.max(selectedIndex + delta, 0), filtered.length - 1);
113
+ } else if (e.key === 'Enter') {
114
+ if (selected) choose(selected);
115
+ } else if (e.key === 'Tab' || e.key === ' ') {
116
+ if (selected) {
117
+ e.preventDefault();
118
+ complete(selected);
119
+ }
120
+ } else if (e.key === 'Escape') {
121
+ e.stopPropagation();
122
+ reset();
123
+ input?.blur();
124
+ } else if (e.key === 'Backspace' && value === '' && stack.length) {
125
+ e.preventDefault();
126
+ stack.pop();
127
+ } else if (e.key.toLowerCase() === 'u' && (e.ctrlKey || e.metaKey)) {
128
+ e.preventDefault();
129
+ reset();
130
+ }
131
+ }
132
+
133
+ // Focus hotkeys: a central map on window (tinykeys grammar). Guarded the
134
+ // way the terminal guards them — ignored while any other field is focused,
135
+ // so typing '/' in a text input never steals the caret.
136
+ function fieldFocused() {
137
+ const el = document.activeElement;
138
+ return (
139
+ el instanceof HTMLElement &&
140
+ (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
141
+ );
142
+ }
143
+ const bindings = $derived.by((): KeyBindingMap => {
144
+ if (!hotkey) return {};
145
+ const list = Array.isArray(hotkey) ? hotkey : [hotkey];
146
+ return Object.fromEntries(
147
+ list.map((b) => [
148
+ b,
149
+ (e: KeyboardEvent) => {
150
+ if (fieldFocused()) return;
151
+ e.preventDefault();
152
+ input?.focus();
153
+ }
154
+ ])
155
+ );
156
+ });
157
+
158
+ export function focusInput() {
159
+ input?.focus();
160
+ }
161
+ </script>
162
+
163
+ <svelte:window use:keys={bindings} />
164
+
165
+ <!-- The terminal command bar: a bare prompt line, not a floating palette. The
166
+ dropdown is a flat sheet anchored under it — no border, shadow or radius,
167
+ just the elevated surface, exactly like the shell it mirrors. -->
168
+ <div class={cn('relative flex h-(--era-h-md) w-full min-w-0 items-center font-mono', className)}>
169
+ <ChevronRight class="mx-(--era-gap) size-(--era-h-xs) shrink-0 text-muted" />
170
+
171
+ <!-- Completed segments: the locked prefix. Backspace (empty input) pops. -->
172
+ {#each stack as seg (seg.value)}
173
+ <span class="shrink-0 text-body text-muted">{seg.label}</span>
174
+ <ChevronRight class="mx-(--era-gap) size-(--era-h-xxs) shrink-0 text-muted opacity-60" />
175
+ {/each}
176
+
177
+ <div class="relative h-full min-w-0 flex-1">
178
+ <input
179
+ bind:this={input}
180
+ bind:value
181
+ type="text"
182
+ role="combobox"
183
+ aria-expanded={open}
184
+ aria-controls="era-command-bar-list"
185
+ aria-activedescendant={selected ? `era-cmdbar-${selected.value}` : undefined}
186
+ aria-label={viewPlaceholder}
187
+ autocapitalize="off"
188
+ autocomplete="off"
189
+ autocorrect="off"
190
+ spellcheck="false"
191
+ class="h-full w-full bg-transparent text-body text-bright outline-none"
192
+ onkeydown={onKeydown}
193
+ onfocus={() => (focused = true)}
194
+ onblur={() => (focused = false)}
195
+ />
196
+ <!-- Ghost text rides at the end of the typed value (ch units — the bar
197
+ is mono by contract, so 1ch == one typed character). -->
198
+ <span
199
+ aria-hidden="true"
200
+ class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-body whitespace-pre text-fg opacity-40"
201
+ style:left="{value.length}ch">{ghost}</span
202
+ >
203
+ </div>
204
+
205
+ {#if open}
206
+ <div
207
+ id="era-command-bar-list"
208
+ role="listbox"
209
+ tabindex="-1"
210
+ aria-label="Commands"
211
+ class="absolute top-full left-0 z-50 max-h-64 w-full overflow-x-hidden overflow-y-auto bg-2"
212
+ onmousedown={(e) => e.preventDefault()}
213
+ >
214
+ {#if !filtered.length}
215
+ <div class="flex h-(--era-h-md) items-center px-(--era-inset-md) text-body text-muted">
216
+ {empty}
217
+ </div>
218
+ {:else}
219
+ {#each filtered as item, i (item.value)}
220
+ <!-- Selection is keyboard-driven (selectedIndex), not :hover —
221
+ mousedown is cancelled so a click never blurs the input. The
222
+ keyboard path is the combobox input above (WAI-ARIA combobox:
223
+ options are not tab stops). -->
224
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
225
+ <div
226
+ id="era-cmdbar-{item.value}"
227
+ role="option"
228
+ tabindex={-1}
229
+ aria-selected={i === selectedIndex}
230
+ class={cn(
231
+ 'flex h-(--era-h-md) cursor-default items-center gap-(--era-gap) px-(--era-inset-md) text-body whitespace-nowrap',
232
+ i === selectedIndex ? 'bg-3 text-bright' : 'text-fg'
233
+ )}
234
+ onclick={() => choose(item)}
235
+ >
236
+ {#if typeof item.icon === 'string'}
237
+ <span class="shrink-0">{item.icon}</span>
238
+ {:else if item.icon}
239
+ {@const Icon = item.icon}
240
+ <Icon class="size-(--era-h-xs) shrink-0 text-muted" />
241
+ {/if}
242
+ <span class="shrink-0">{item.label}</span>
243
+ {#if item.items}
244
+ <ChevronRight class="size-(--era-h-xxs) shrink-0 text-muted" />
245
+ {/if}
246
+ {#if item.sublabel}
247
+ <span class="ml-auto truncate pl-(--era-inset-md) text-body text-muted">
248
+ {item.sublabel}
249
+ </span>
250
+ {/if}
251
+ </div>
252
+ {/each}
253
+ {/if}
254
+ </div>
255
+ {/if}
256
+ </div>
@@ -0,0 +1,17 @@
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
+ * `['/', '$mod+k']`). Ignored while another field has focus. */
10
+ hotkey?: string | string[];
11
+ class?: string;
12
+ };
13
+ declare const CommandBar: import("svelte").Component<$$ComponentProps, {
14
+ focusInput: () => void;
15
+ }, "">;
16
+ type CommandBar = ReturnType<typeof CommandBar>;
17
+ 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,43 @@
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. */
35
+ export declare function hotkeys(target: Window | HTMLElement, bindings: KeyBindingMap, options?: KeyBindingOptions): () => void;
36
+ /** Svelte action form — `<svelte:window use:keys={{ '$mod+k': open }} />`. */
37
+ export declare function keys(node: Window | HTMLElement, bindings: KeyBindingMap): {
38
+ update(next: KeyBindingMap): void;
39
+ destroy(): void;
40
+ };
41
+ /** Format one binding for display: `'$mod+k'` → `⌘K` on Apple, `Ctrl+K`
42
+ * elsewhere. Sequences join with spaces (`'g d'` → `G D`). */
43
+ export declare function formatKeybinding(binding: string): string;
@@ -0,0 +1,129 @@
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. */
91
+ export function hotkeys(target, bindings, options = {}) {
92
+ const handler = createKeybindingsHandler(bindings, options);
93
+ const event = options.event ?? 'keydown';
94
+ target.addEventListener(event, handler, { capture: options.capture });
95
+ return () => target.removeEventListener(event, handler, { capture: options.capture });
96
+ }
97
+ /** Svelte action form — `<svelte:window use:keys={{ '$mod+k': open }} />`. */
98
+ export function keys(node, bindings) {
99
+ let off = hotkeys(node, bindings);
100
+ return {
101
+ update(next) {
102
+ off();
103
+ off = hotkeys(node, next);
104
+ },
105
+ destroy() {
106
+ off();
107
+ }
108
+ };
109
+ }
110
+ const APPLE_GLYPHS = {
111
+ Meta: '⌘',
112
+ Alt: '⌥',
113
+ Shift: '⇧',
114
+ Control: '⌃'
115
+ };
116
+ /** Format one binding for display: `'$mod+k'` → `⌘K` on Apple, `Ctrl+K`
117
+ * elsewhere. Sequences join with spaces (`'g d'` → `G D`). */
118
+ export function formatKeybinding(binding) {
119
+ return parseKeybinding(binding)
120
+ .map(([mods, key]) => {
121
+ const keyLabel = key instanceof RegExp ? String(key.source) : key.toUpperCase();
122
+ if (isApplePlatform()) {
123
+ return [...mods.map((m) => APPLE_GLYPHS[m] ?? m), keyLabel].join('');
124
+ }
125
+ const names = mods.map((m) => (m === 'Control' ? 'Ctrl' : m));
126
+ return [...names, keyLabel].join('+');
127
+ })
128
+ .join(' ');
129
+ }
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",