@leavepulse/ui 0.40.1 → 0.42.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.
Files changed (40) hide show
  1. package/dist/assets/{LpCommandPalette-DDGFwOom.css → LpCommandPalette-Esv0a-gS.css} +21 -21
  2. package/dist/assets/{LpShortcutsDialog-Cap_ALQM.css → LpShortcutsDialog-C6YoQ8_M.css} +7 -7
  3. package/dist/chunks/{LpAddressInput-H5NdhK6c.js → LpAddressInput-CMjaPQVw.js} +1 -1
  4. package/dist/chunks/{LpAppShell-BfqSA1ba.js → LpAppShell-Bcv6EhqU.js} +1 -1
  5. package/dist/chunks/{LpConfirmDialog-BBRnMkV4.js → LpConfirmDialog-BNAvOR24.js} +2 -2
  6. package/dist/chunks/{LpDrawer-B8vBnCWF.js → LpDrawer-DgaS2s2i.js} +6 -4
  7. package/dist/chunks/{LpModal-EyEdRuqF.js → LpModal-8bja5bWS.js} +5 -3
  8. package/dist/chunks/{LpModalNav-BrDtP7b_.js → LpModalNav-w9kfwHqO.js} +3 -3
  9. package/dist/chunks/{LpSidebar-DZ20iBxY.js → LpSidebar-DlTn5nXc.js} +1 -1
  10. package/dist/components/LpAddressInput.vue.js +1 -1
  11. package/dist/components/LpAppShell.vue.js +1 -1
  12. package/dist/components/LpCommandPalette.vue.d.ts +2 -1
  13. package/dist/components/LpCommandPalette.vue.js +140 -76
  14. package/dist/components/LpConfirmDialog.vue.d.ts +2 -1
  15. package/dist/components/LpConfirmDialog.vue.js +1 -1
  16. package/dist/components/LpDrawer.vue.d.ts +4 -2
  17. package/dist/components/LpDrawer.vue.js +1 -1
  18. package/dist/components/LpModal.vue.d.ts +2 -1
  19. package/dist/components/LpModal.vue.js +1 -1
  20. package/dist/components/LpModalNav.vue.d.ts +2 -1
  21. package/dist/components/LpModalNav.vue.js +1 -1
  22. package/dist/components/LpShortcutsDialog.vue.d.ts +8 -11
  23. package/dist/components/LpShortcutsDialog.vue.js +14 -9
  24. package/dist/components/LpSidebar.vue.js +1 -1
  25. package/dist/composables/useGlass.d.ts +9 -0
  26. package/dist/composables/useGlass.js +13 -0
  27. package/dist/composables/useHotkeys.d.ts +43 -2
  28. package/dist/composables/useHotkeys.js +89 -16
  29. package/dist/index.d.ts +3 -3
  30. package/dist/index.js +13 -8
  31. package/package.json +1 -1
  32. package/src/components/LpCommandPalette.vue +114 -36
  33. package/src/components/LpConfirmDialog.vue +2 -2
  34. package/src/components/LpDrawer.vue +9 -5
  35. package/src/components/LpModal.vue +8 -4
  36. package/src/components/LpModalNav.vue +2 -1
  37. package/src/components/LpShortcutsDialog.vue +32 -23
  38. package/src/composables/useGlass.ts +25 -0
  39. package/src/composables/useHotkeys.ts +146 -24
  40. package/src/index.ts +9 -3
@@ -1,4 +1,4 @@
1
- import { onBeforeUnmount, onMounted, type MaybeRefOrGetter, toValue } from "vue"
1
+ import { computed, onBeforeUnmount, onMounted, shallowReactive, type MaybeRefOrGetter, toValue } from "vue"
2
2
 
3
3
  /*
4
4
  * One keydown listener over a registry of bindings, so a shortcut is declared
@@ -39,13 +39,43 @@ export interface HotkeyBinding {
39
39
  * something there — Escape, and the palette the user reaches from anywhere.
40
40
  */
41
41
  allowInInput?: boolean
42
+ /**
43
+ * How the key reference lists this binding while it is mounted. Bindings
44
+ * without it still work, they just stay out of `activeShortcuts`.
45
+ */
46
+ help?: HotkeyHelp
42
47
  /**
43
48
  * Returning `false` declines the key: the event keeps its default and the
44
49
  * search moves on. This is what lets a binding defer to a layer that owns the
45
50
  * key better than it does — Escape belongs to whichever overlay is on top,
46
- * and swallowing it here would close all of them.
51
+ * and swallowing it here would close all of them. The event is passed for
52
+ * the checks only it can answer: where the focus is, whether an inner
53
+ * control already took the key.
54
+ */
55
+ handler: (event: KeyboardEvent) => void | boolean
56
+ }
57
+
58
+ export interface HotkeyHelp {
59
+ /** Section of the reference the entry goes under. */
60
+ group: string
61
+ label: string
62
+ /**
63
+ * Written the way it reads, "+"-separated. Defaults to one built from the
64
+ * binding; set it where the binding is not the whole story, such as the
65
+ * second key of a chord ("G+D") or a key that needs Shift to type ("?").
47
66
  */
48
- handler: () => void | boolean
67
+ combo?: string
68
+ }
69
+
70
+ export interface ShortcutEntry {
71
+ /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
72
+ combo: string
73
+ label: string
74
+ }
75
+
76
+ export interface ShortcutGroup {
77
+ title: string
78
+ entries: ShortcutEntry[]
49
79
  }
50
80
 
51
81
  /**
@@ -112,6 +142,8 @@ function matches(event: KeyboardEvent, binding: HotkeyBinding): boolean {
112
142
  * formality.
113
143
  */
114
144
  export interface UseHotkeysOptions {
145
+ enabled?: MaybeRefOrGetter<boolean>
146
+ priority?: MaybeRefOrGetter<number>
115
147
  /**
116
148
  * Only act while the focus is inside this element.
117
149
  *
@@ -127,34 +159,124 @@ export interface UseHotkeysOptions {
127
159
  scope?: MaybeRefOrGetter<HTMLElement | null | undefined>
128
160
  }
129
161
 
162
+ interface MountedHotkeys {
163
+ read: () => HotkeyBinding[]
164
+ scope: MaybeRefOrGetter<HTMLElement | null | undefined> | undefined
165
+ enabled: MaybeRefOrGetter<boolean> | undefined
166
+ priority: MaybeRefOrGetter<number> | undefined
167
+ order: number
168
+ }
169
+
170
+ const mounted = shallowReactive(new Set<MountedHotkeys>())
171
+ let mountOrder = 0
172
+ let listening = false
173
+
174
+ function inScope(target: EventTarget | null, scope: MountedHotkeys["scope"]): boolean {
175
+ const el = toValue(scope)
176
+ if (!el) return true
177
+ const node = target instanceof Node ? target : null
178
+ return !!node && (el === node || el.contains(node))
179
+ }
180
+
181
+ function onKeydown(event: KeyboardEvent) {
182
+ if (event.isComposing) return
183
+ const inTextEntry = isTextEntry(event.target)
184
+ const entries = [...mounted].sort(
185
+ (a, b) => Number(toValue(b.priority) || 0) - Number(toValue(a.priority) || 0) || b.order - a.order,
186
+ )
187
+ for (const entry of entries) {
188
+ if (toValue(entry.enabled) === false || !inScope(event.target, entry.scope)) continue
189
+ for (const binding of entry.read()) {
190
+ if (inTextEntry && !binding.allowInInput) continue
191
+ if (!matches(event, binding)) continue
192
+ if (binding.handler(event) === false) continue
193
+ event.preventDefault()
194
+ return
195
+ }
196
+ }
197
+ }
198
+
130
199
  export function useHotkeys(
131
200
  bindings: MaybeRefOrGetter<HotkeyBinding[]>,
132
201
  options: UseHotkeysOptions = {},
133
202
  ) {
134
- function inScope(target: EventTarget | null): boolean {
135
- const el = toValue(options.scope)
136
- if (!el) return true
137
- const node = target instanceof Node ? target : null
138
- // The element itself counts: a focusable canvas holds the focus without
139
- // containing anything that could.
140
- return !!node && (el === node || el.contains(node))
203
+ const entry: MountedHotkeys = {
204
+ read: () => toValue(bindings),
205
+ scope: options.scope,
206
+ enabled: options.enabled,
207
+ priority: options.priority,
208
+ order: ++mountOrder,
141
209
  }
210
+ onMounted(() => {
211
+ mounted.add(entry)
212
+ if (!listening) {
213
+ window.addEventListener("keydown", onKeydown)
214
+ listening = true
215
+ }
216
+ })
217
+ onBeforeUnmount(() => {
218
+ mounted.delete(entry)
219
+ if (!mounted.size && listening) {
220
+ window.removeEventListener("keydown", onKeydown)
221
+ listening = false
222
+ }
223
+ })
224
+ }
142
225
 
143
- function onKeydown(event: KeyboardEvent) {
144
- if (event.isComposing) return
145
- if (!inScope(event.target)) return
146
- const inTextEntry = isTextEntry(event.target)
147
- for (const binding of toValue(bindings)) {
148
- if (inTextEntry && !binding.allowInInput) continue
149
- if (!matches(event, binding)) continue
150
- // Claimed only once the handler has run and not declined, so a binding
151
- // that stands down leaves the key untouched for whoever handles it next.
152
- if (binding.handler() === false) continue
153
- event.preventDefault()
154
- return
226
+ const KEY_NAMES: Record<string, string> = {
227
+ escape: "Esc",
228
+ enter: "Enter",
229
+ backspace: "Backspace",
230
+ delete: "Delete",
231
+ tab: "Tab",
232
+ " ": "Space",
233
+ arrowup: "↑",
234
+ arrowdown: "↓",
235
+ arrowleft: "←",
236
+ arrowright: "→",
237
+ }
238
+
239
+ function comboOf(binding: HotkeyBinding): string {
240
+ const key = binding.key.toLowerCase()
241
+ const name = KEY_NAMES[key] ?? (key.length === 1 || /^f\d+$/.test(key) ? key.toUpperCase() : binding.key)
242
+ return [binding.ctrl && "Ctrl", binding.alt && "Alt", binding.shift && "Shift", name].filter(Boolean).join("+")
243
+ }
244
+
245
+ /**
246
+ * The shortcuts that work right now, grouped for `LpShortcutsDialog`: every
247
+ * binding with `help` from every mounted `useHotkeys`. A screen that binds its
248
+ * own keys adds its section while it is open, so the reference never lists a
249
+ * key that does nothing here or misses one that does.
250
+ */
251
+ export const activeShortcuts = computed<ShortcutGroup[]>(() => {
252
+ const groups = new Map<string, ShortcutEntry[]>()
253
+ for (const entry of mounted) {
254
+ if (toValue(entry.enabled) === false) continue
255
+ for (const binding of entry.read()) {
256
+ if (!binding.help) continue
257
+ const combo = binding.help.combo ?? comboOf(binding)
258
+ const entries = groups.get(binding.help.group) ?? []
259
+ if (!entries.some((e) => e.combo === combo)) entries.push({ combo, label: binding.help.label })
260
+ groups.set(binding.help.group, entries)
155
261
  }
156
262
  }
263
+ return [...groups].map(([title, entries]) => ({ title, entries }))
264
+ })
157
265
 
158
- onMounted(() => window.addEventListener("keydown", onKeydown))
159
- onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
266
+ /**
267
+ * Groups merged by title, entries kept once per combo: what a reference shows
268
+ * when it lists both its own entries and the live ones.
269
+ */
270
+ export function mergeShortcutGroups(...lists: ShortcutGroup[][]): ShortcutGroup[] {
271
+ const groups = new Map<string, ShortcutEntry[]>()
272
+ for (const list of lists) {
273
+ for (const group of list) {
274
+ const entries = groups.get(group.title) ?? []
275
+ for (const entry of group.entries) {
276
+ if (!entries.some((e) => e.combo === entry.combo)) entries.push(entry)
277
+ }
278
+ groups.set(group.title, entries)
279
+ }
280
+ }
281
+ return [...groups].map(([title, entries]) => ({ title, entries }))
160
282
  }
package/src/index.ts CHANGED
@@ -141,7 +141,6 @@ export { default as LpServiceNode } from "./components/LpServiceNode.vue"
141
141
  export type { ServiceNodeData } from "./components/LpServiceNode.vue"
142
142
  export { default as LpShift } from "./components/LpShift.vue"
143
143
  export { default as LpShortcutsDialog } from "./components/LpShortcutsDialog.vue"
144
- export type { ShortcutEntry, ShortcutGroup } from "./components/LpShortcutsDialog.vue"
145
144
  export { default as LpSidebar } from "./components/LpSidebar.vue"
146
145
  export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
147
146
  export { default as LpSkeleton } from "./components/LpSkeleton.vue"
@@ -228,8 +227,15 @@ export type { UiConfig } from "./theme/config"
228
227
 
229
228
  export { useClipboard } from "./composables/useClipboard"
230
229
  export type { CopyOptions, UseClipboard } from "./composables/useClipboard"
231
- export { useHotkeys, isAppleKeyboard, appleModifierLabel } from "./composables/useHotkeys"
232
- export type { HotkeyBinding } from "./composables/useHotkeys"
230
+ export {
231
+ useHotkeys,
232
+ isAppleKeyboard,
233
+ appleModifierLabel,
234
+ activeShortcuts,
235
+ mergeShortcutGroups,
236
+ } from "./composables/useHotkeys"
237
+ export type { HotkeyBinding, HotkeyHelp, ShortcutEntry, ShortcutGroup } from "./composables/useHotkeys"
238
+ export { provideGlass, useGlass } from "./composables/useGlass"
233
239
  export { useMergedClass, useMergedAttrs, mergeClass } from "./composables/useMergedClass"
234
240
  export { useInputFilter } from "./composables/useInputFilter"
235
241
  export type { UseInputFilterOptions } from "./composables/useInputFilter"