@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.
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/command-bar.md +3 -0
- package/dist/generated-docs/llms-full.txt +8 -0
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/manifest.json +8 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/os/launcher.svelte +20 -13
- package/dist/os/launcher.svelte.d.ts +4 -2
- package/dist/os/wm.svelte.d.ts +3 -1
- package/dist/os/wm.svelte.js +10 -2
- package/dist/styles/themes.css +9 -9
- package/dist/ui/button/index.d.ts +1 -1
- package/dist/ui/command-bar/command-bar.svelte +254 -0
- package/dist/ui/command-bar/command-bar.svelte.d.ts +18 -0
- package/dist/ui/command-bar/index.d.ts +2 -0
- package/dist/ui/command-bar/index.js +1 -0
- package/dist/ui/command-bar/types.d.ts +20 -0
- package/dist/ui/command-bar/types.js +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/utils/hotkeys.d.ts +48 -0
- package/dist/utils/hotkeys.js +142 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/package.json +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
type $$ComponentProps = {
|
|
2
2
|
open?: boolean;
|
|
3
|
-
/**
|
|
4
|
-
|
|
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>;
|
package/dist/os/wm.svelte.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/dist/os/wm.svelte.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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)
|
package/dist/styles/themes.css
CHANGED
|
@@ -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 @@
|
|
|
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 {};
|
package/dist/ui/index.d.ts
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';
|
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
|
+
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/utils/index.js
CHANGED
|
@@ -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';
|