@signal9/era-ui 1.26.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.
@@ -3,34 +3,39 @@
3
3
  import * as Command from '../ui/command';
4
4
  import Search from '@lucide/svelte/icons/search';
5
5
  import { cn } from '../utils/index.js';
6
+ import { keys, isTextEntryFocused, type KeyBindingMap } from '../utils/hotkeys.js';
6
7
  import { getWindowManager } from './wm.svelte.js';
7
8
  import { LAYER } from './layers.js';
8
9
 
9
10
  let {
10
11
  open = $bindable(false),
11
- hotkey = true
12
+ hotkey
12
13
  }: {
13
14
  open?: boolean;
14
- /** Register Ctrl/Cmd+K to toggle and Escape to close (default true). */
15
- hotkey?: boolean;
15
+ /** Global binding(s) that toggle the launcher (tinykeys grammar, e.g.
16
+ * `'$mod+k'`). No default — the taskbar button is the base affordance.
17
+ * Escape always closes while open. */
18
+ hotkey?: string | string[];
16
19
  } = $props();
17
20
 
18
21
  const wm = getWindowManager();
19
22
  const apps = $derived(Object.values(wm.apps));
20
23
  let query = $state('');
21
24
 
22
- $effect(() => {
23
- if (!hotkey) return;
24
- const onKey = (e: KeyboardEvent) => {
25
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
25
+ // One central binding map through the shared hotkeys engine ($mod
26
+ // normalization, exact-modifier matching, text-entry guard) instead of a
27
+ // hand-rolled listener. An empty map (no hotkey, closed) attaches nothing.
28
+ const bindings = $derived.by((): KeyBindingMap => {
29
+ const map: KeyBindingMap = {};
30
+ for (const b of hotkey ? (Array.isArray(hotkey) ? hotkey : [hotkey]) : []) {
31
+ map[b] = (e) => {
32
+ if (isTextEntryFocused() && !open) return;
26
33
  e.preventDefault();
27
34
  open = !open;
28
- } else if (e.key === 'Escape' && open) {
29
- open = false;
30
- }
31
- };
32
- window.addEventListener('keydown', onKey);
33
- return () => window.removeEventListener('keydown', onKey);
35
+ };
36
+ }
37
+ if (open) map['Escape'] = () => (open = false);
38
+ return map;
34
39
  });
35
40
 
36
41
  // Reset the query each time it opens so it's fresh.
@@ -44,6 +49,8 @@
44
49
  }
45
50
  </script>
46
51
 
52
+ <svelte:window use:keys={bindings} />
53
+
47
54
  {#if open}
48
55
  <div
49
56
  class={cn(
@@ -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;
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
2
  import ChevronRight from '@lucide/svelte/icons/chevron-right';
3
3
  import { cn } from '../../utils/index.js';
4
- import { keys, type KeyBindingMap } from '../../utils/hotkeys.js';
4
+ import { keys, isTextEntryFocused, type KeyBindingMap } from '../../utils/hotkeys.js';
5
5
  import type { CommandBarItem } from './types.js';
6
6
 
7
7
  let {
@@ -17,7 +17,8 @@
17
17
  /** Empty-state message when the filter matches nothing. */
18
18
  empty?: string;
19
19
  /** Global binding(s) that focus the bar (tinykeys grammar, e.g.
20
- * `['/', '$mod+k']`). Ignored while another field has focus. */
20
+ * `['`', 'Escape']`). While another field has focus only Escape
21
+ * acts — it blurs that field instead of stealing the caret. */
21
22
  hotkey?: string | string[];
22
23
  class?: string;
23
24
  } = $props();
@@ -131,15 +132,9 @@
131
132
  }
132
133
 
133
134
  // 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
- }
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.
143
138
  const bindings = $derived.by((): KeyBindingMap => {
144
139
  if (!hotkey) return {};
145
140
  const list = Array.isArray(hotkey) ? hotkey : [hotkey];
@@ -147,7 +142,10 @@
147
142
  list.map((b) => [
148
143
  b,
149
144
  (e: KeyboardEvent) => {
150
- if (fieldFocused()) return;
145
+ if (isTextEntryFocused()) {
146
+ if (b === 'Escape') (document.activeElement as HTMLElement).blur();
147
+ return;
148
+ }
151
149
  e.preventDefault();
152
150
  input?.focus();
153
151
  }
@@ -6,7 +6,8 @@ type $$ComponentProps = {
6
6
  /** Empty-state message when the filter matches nothing. */
7
7
  empty?: string;
8
8
  /** Global binding(s) that focus the bar (tinykeys grammar, e.g.
9
- * `['/', '$mod+k']`). Ignored while another field has focus. */
9
+ * `['`', 'Escape']`). While another field has focus only Escape
10
+ * acts — it blurs that field instead of stealing the caret. */
10
11
  hotkey?: string | string[];
11
12
  class?: string;
12
13
  };
@@ -31,13 +31,18 @@ export declare function parseKeybinding(binding: string): KeyBindingPress[];
31
31
  export declare function matchKeyBindingPress(event: KeyboardEvent, [mods, key]: KeyBindingPress): boolean;
32
32
  /** One listener that walks the whole map, tracking partial sequence matches. */
33
33
  export declare function createKeybindingsHandler(bindings: KeyBindingMap, options?: KeyBindingOptions): (event: Event) => void;
34
- /** Attach a binding map to a target; returns the unsubscriber. */
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. */
35
36
  export declare function hotkeys(target: Window | HTMLElement, bindings: KeyBindingMap, options?: KeyBindingOptions): () => void;
36
37
  /** Svelte action form — `<svelte:window use:keys={{ '$mod+k': open }} />`. */
37
38
  export declare function keys(node: Window | HTMLElement, bindings: KeyBindingMap): {
38
39
  update(next: KeyBindingMap): void;
39
40
  destroy(): void;
40
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;
41
46
  /** Format one binding for display: `'$mod+k'` → `⌘K` on Apple, `Ctrl+K`
42
47
  * elsewhere. Sequences join with spaces (`'g d'` → `G D`). */
43
48
  export declare function formatKeybinding(binding: string): string;
@@ -87,8 +87,11 @@ export function createKeybindingsHandler(bindings, options = {}) {
87
87
  timer = setTimeout(() => possible.clear(), timeout);
88
88
  };
89
89
  }
90
- /** Attach a binding map to a target; returns the unsubscriber. */
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. */
91
92
  export function hotkeys(target, bindings, options = {}) {
93
+ if (Object.keys(bindings).length === 0)
94
+ return () => { };
92
95
  const handler = createKeybindingsHandler(bindings, options);
93
96
  const event = options.event ?? 'keydown';
94
97
  target.addEventListener(event, handler, { capture: options.capture });
@@ -107,6 +110,16 @@ export function keys(node, bindings) {
107
110
  }
108
111
  };
109
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
+ }
110
123
  const APPLE_GLYPHS = {
111
124
  Meta: '⌘',
112
125
  Alt: '⌥',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signal9/era-ui",
3
- "version": "1.26.0",
3
+ "version": "1.26.1",
4
4
  "scripts": {
5
5
  "dev": "vite dev --host",
6
6
  "build": "vite build && npm run prepack",