@leavepulse/ui 0.20.9 → 0.22.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 (53) hide show
  1. package/dist/assets/{LpCommandPalette-C3LdDNHk.css → LpCommandPalette-CEY-pajO.css} +4 -4
  2. package/dist/assets/LpShortcutsDialog-B7H-RZLQ.css +55 -0
  3. package/dist/chunks/{LpAddressInput-B411MOJa.js → LpAddressInput-Dah7asXy.js} +1 -1
  4. package/dist/chunks/{LpAppShell-CfM7fdr3.js → LpAppShell-DGZv907S.js} +2 -2
  5. package/dist/chunks/{LpConfirmDialog-D8pBGPOI.js → LpConfirmDialog-CkMzeKdp.js} +1 -1
  6. package/dist/chunks/{LpDrawer-B4cNj39a.js → LpDrawer-DAJN5rGK.js} +1 -1
  7. package/dist/chunks/{LpFileTree-d9nXtSVo.js → LpFileTree-DWx77B1F.js} +1 -1
  8. package/dist/chunks/{LpLogViewer-BXPBUV21.js → LpLogViewer-B1aLUVdz.js} +1 -1
  9. package/dist/chunks/{LpModal-DKFdzKwV.js → LpModal-CNAFRgCU.js} +36 -3
  10. package/dist/chunks/{LpNotificationBell-Nges4kJk.js → LpNotificationBell-HSGo1pcf.js} +1 -1
  11. package/dist/chunks/{LpScrollArea-Du8tW8c5.js → LpScrollArea-D8xyKXcF.js} +10 -2
  12. package/dist/chunks/{LpSidebar-Bd0148dd.js → LpSidebar-JvMaaYsy.js} +2 -2
  13. package/dist/chunks/{LpSidebarNav-BqGpNr4S.js → LpSidebarNav-BIYzk7e_.js} +1 -1
  14. package/dist/chunks/{LpTable-BBKlNR9j.js → LpTable-DTj52qlg.js} +45 -9
  15. package/dist/chunks/{LpTooltip-CIwoRCag.js → LpTooltip-D1hbjQWG.js} +23 -9
  16. package/dist/chunks/_plugin-vue_export-helper-1tPrXgE0.js +10 -0
  17. package/dist/component-names.d.ts +1 -1
  18. package/dist/components/LpAddressInput.vue.js +1 -1
  19. package/dist/components/LpAppShell.vue.js +1 -1
  20. package/dist/components/LpCommandPalette.vue.js +17 -21
  21. package/dist/components/LpConfirmDialog.vue.js +1 -1
  22. package/dist/components/LpDrawer.vue.js +1 -1
  23. package/dist/components/LpFileTree.vue.js +1 -1
  24. package/dist/components/LpLogViewer.vue.js +1 -1
  25. package/dist/components/LpModal.vue.d.ts +16 -5
  26. package/dist/components/LpModal.vue.js +1 -1
  27. package/dist/components/LpNotificationBell.vue.js +1 -1
  28. package/dist/components/LpScrollArea.vue.js +1 -1
  29. package/dist/components/LpShortcutsDialog.vue.d.ts +25 -0
  30. package/dist/components/LpShortcutsDialog.vue.js +63 -0
  31. package/dist/components/LpSidebar.vue.js +1 -1
  32. package/dist/components/LpSidebarNav.vue.js +1 -1
  33. package/dist/components/LpTable.vue.js +1 -1
  34. package/dist/components/LpTooltip.vue.d.ts +19 -1
  35. package/dist/components/LpTooltip.vue.js +1 -1
  36. package/dist/composables/useHotkeys.d.ts +45 -0
  37. package/dist/composables/useHotkeys.js +53 -0
  38. package/dist/composables/useRovingFocus.d.ts +49 -0
  39. package/dist/composables/useRovingFocus.js +160 -0
  40. package/dist/index.d.ts +6 -0
  41. package/dist/index.js +20 -12
  42. package/package.json +1 -1
  43. package/src/component-names.ts +1 -0
  44. package/src/components/LpCommandPalette.vue +18 -11
  45. package/src/components/LpModal.vue +67 -1
  46. package/src/components/LpScrollArea.vue +26 -6
  47. package/src/components/LpShortcutsDialog.vue +127 -0
  48. package/src/components/LpTable.vue +57 -2
  49. package/src/components/LpTooltip.vue +57 -5
  50. package/src/composables/useHotkeys.ts +131 -0
  51. package/src/composables/useRovingFocus.ts +275 -0
  52. package/src/index.ts +6 -0
  53. package/src/tokens/utilities.css +15 -0
@@ -0,0 +1,275 @@
1
+ import { computed, ref, watch, type MaybeRefOrGetter, toValue } from "vue"
2
+
3
+ /*
4
+ * Arrow-key navigation over a list, with a roving tabindex.
5
+ *
6
+ * The problem it solves: Tab is navigation BETWEEN regions, not within one. A
7
+ * list of two hundred mods is two hundred Tab presses, which is the same as
8
+ * having no keyboard support at all. The standard answer — what every native
9
+ * list does — is that the whole list is ONE Tab stop, and once you are in it
10
+ * the arrows move a cursor.
11
+ *
12
+ * Tab / Shift+Tab in and out of the list
13
+ * ↑ ↓ (or ← →) move within it
14
+ * Home / End first / last
15
+ * typing letters jump to the item that starts with them
16
+ *
17
+ * Only the focused item carries `tabindex="0"`; everything else is `-1`. That
18
+ * is what keeps the list a single stop and what makes Tab return to wherever
19
+ * the user left off rather than to the top.
20
+ *
21
+ * The caller owns rendering. This owns "which item is current" and translates
22
+ * keys into moves; bind `itemProps(id)` on each row and `containerProps` on the
23
+ * element that wraps them.
24
+ */
25
+
26
+ export interface RovingItem {
27
+ /** Stable identity of the row. */
28
+ id: string
29
+ /**
30
+ * What typeahead matches against. Omit to opt this row out of typeahead —
31
+ * a separator, say.
32
+ */
33
+ label?: string
34
+ disabled?: boolean
35
+ }
36
+
37
+ export interface UseRovingFocusOptions {
38
+ /**
39
+ * Which axis the arrows follow. `vertical` (default) uses ↑↓, `horizontal`
40
+ * uses ←→, `both` accepts either — for a grid whose wrapping the caller
41
+ * cannot describe here.
42
+ */
43
+ orientation?: "vertical" | "horizontal" | "both"
44
+ /** Wrap around at the ends instead of stopping. Default true. */
45
+ loop?: boolean
46
+ /** Jump to an item by typing its first letters. Default true. */
47
+ typeahead?: boolean
48
+ /** Called when the user activates the current item (Enter or Space). */
49
+ onActivate?: (id: string) => void
50
+ /**
51
+ * Called when focus should leave the list without activating anything —
52
+ * Escape. Lets a dialog close, or a filter field take focus back.
53
+ */
54
+ onEscape?: () => void
55
+ }
56
+
57
+ /** How long typed letters keep accumulating into one search string. */
58
+ const TYPEAHEAD_RESET_MS = 700
59
+
60
+ export function useRovingFocus(
61
+ items: MaybeRefOrGetter<RovingItem[]>,
62
+ options: UseRovingFocusOptions = {},
63
+ ) {
64
+ const {
65
+ orientation = "vertical",
66
+ loop = true,
67
+ typeahead = true,
68
+ onActivate,
69
+ onEscape,
70
+ } = options
71
+
72
+ const activeId = ref<string | undefined>()
73
+ const containerEl = ref<HTMLElement | null>(null)
74
+
75
+ const enabled = computed(() => toValue(items).filter((item) => !item.disabled))
76
+
77
+ // The cursor has to survive the list changing under it — a filter narrowing,
78
+ // a row finishing loading. Falling back to the first row keeps the list
79
+ // reachable by Tab before anything has been picked.
80
+ watch(
81
+ enabled,
82
+ (list) => {
83
+ if (list.length === 0) {
84
+ activeId.value = undefined
85
+ return
86
+ }
87
+ if (!activeId.value || !list.some((item) => item.id === activeId.value)) {
88
+ activeId.value = list[0]!.id
89
+ }
90
+ },
91
+ { immediate: true },
92
+ )
93
+
94
+ function indexOfActive(): number {
95
+ return enabled.value.findIndex((item) => item.id === activeId.value)
96
+ }
97
+
98
+ function focus(id: string | undefined) {
99
+ if (id) activeId.value = id
100
+ }
101
+
102
+ function move(delta: number) {
103
+ const list = enabled.value
104
+ if (list.length === 0) return
105
+ const from = indexOfActive()
106
+ if (from === -1) {
107
+ focus(list[0]!.id)
108
+ return
109
+ }
110
+ let next = from + delta
111
+ if (next < 0) next = loop ? list.length - 1 : 0
112
+ if (next >= list.length) next = loop ? 0 : list.length - 1
113
+ focus(list[next]!.id)
114
+ }
115
+
116
+ function first() {
117
+ focus(enabled.value[0]?.id)
118
+ }
119
+
120
+ function last() {
121
+ focus(enabled.value.at(-1)?.id)
122
+ }
123
+
124
+ let typed = ""
125
+ let typedAt = 0
126
+
127
+ /**
128
+ * Jump to the next item starting with what has been typed. Searching from
129
+ * AFTER the current one is what makes repeated presses of the same letter
130
+ * cycle through the items sharing it, the way a file manager does.
131
+ */
132
+ function typeaheadTo(character: string) {
133
+ const now = Date.now()
134
+ typed = now - typedAt > TYPEAHEAD_RESET_MS ? character : typed + character
135
+ typedAt = now
136
+
137
+ const list = enabled.value
138
+ const needle = typed.toLowerCase()
139
+ // A single repeated letter means "next one starting with it", not "the one
140
+ // whose name is that letter twice over".
141
+ const repeated = typed.length > 1 && [...typed].every((c) => c === typed[0])
142
+ const query = repeated ? typed[0]!.toLowerCase() : needle
143
+
144
+ const from = indexOfActive()
145
+ const offset = repeated || typed.length === 1 ? 1 : 0
146
+ for (let step = 0; step < list.length; step += 1) {
147
+ const item = list[(from + offset + step) % list.length]!
148
+ if (item.label?.toLowerCase().startsWith(query)) {
149
+ focus(item.id)
150
+ return
151
+ }
152
+ }
153
+ }
154
+
155
+ function onKeydown(event: KeyboardEvent) {
156
+ const vertical = orientation === "vertical" || orientation === "both"
157
+ const horizontal = orientation === "horizontal" || orientation === "both"
158
+
159
+ switch (event.key) {
160
+ case "ArrowDown":
161
+ if (!vertical) return
162
+ event.preventDefault()
163
+ move(1)
164
+ return
165
+ case "ArrowUp":
166
+ if (!vertical) return
167
+ event.preventDefault()
168
+ move(-1)
169
+ return
170
+ case "ArrowRight":
171
+ if (!horizontal) return
172
+ event.preventDefault()
173
+ move(1)
174
+ return
175
+ case "ArrowLeft":
176
+ if (!horizontal) return
177
+ event.preventDefault()
178
+ move(-1)
179
+ return
180
+ case "Home":
181
+ event.preventDefault()
182
+ first()
183
+ return
184
+ case "End":
185
+ event.preventDefault()
186
+ last()
187
+ return
188
+ case "Enter":
189
+ case " ":
190
+ if (!activeId.value) return
191
+ event.preventDefault()
192
+ onActivate?.(activeId.value)
193
+ return
194
+ case "Escape":
195
+ if (!onEscape) return
196
+ event.preventDefault()
197
+ onEscape()
198
+ return
199
+ }
200
+
201
+ // Anything else that produced a single printable character is typeahead.
202
+ // Modifier combos are left alone — they belong to the app's own shortcuts.
203
+ if (
204
+ typeahead
205
+ && event.key.length === 1
206
+ && !event.ctrlKey
207
+ && !event.metaKey
208
+ && !event.altKey
209
+ && event.key !== " "
210
+ ) {
211
+ event.preventDefault()
212
+ typeaheadTo(event.key)
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Moving the cursor has to move real DOM focus, not just the styling — a
218
+ * roving tabindex whose focus stays put means the next Tab leaves from the
219
+ * wrong place and a screen reader never hears about the move. The element is
220
+ * found by the id the caller stamped on it via `itemProps`.
221
+ *
222
+ * Only when the list already holds focus: stealing it because a filter
223
+ * narrowed the rows would yank the user out of the field they are typing in.
224
+ */
225
+ watch(activeId, (id) => {
226
+ if (!id || typeof document === "undefined") return
227
+ const container = containerEl.value
228
+ if (!container || !container.contains(document.activeElement)) return
229
+ const target = container.querySelector<HTMLElement>(
230
+ `[data-roving-id="${CSS.escape(id)}"]`,
231
+ )
232
+ target?.focus()
233
+ })
234
+
235
+ /**
236
+ * Point the composable at the element wrapping the rows, so it can find them
237
+ * to focus. Either bind `containerProps`, or call this from a `ref` when the
238
+ * caller wants the keydown somewhere else — on the rows themselves, say.
239
+ */
240
+ function setContainer(el: HTMLElement | null) {
241
+ containerEl.value = el
242
+ }
243
+
244
+ /** Bind on the element wrapping the rows. */
245
+ const containerProps = computed(() => ({
246
+ ref: (el: unknown) => setContainer(el instanceof HTMLElement ? el : null),
247
+ onKeydown,
248
+ }))
249
+
250
+ /**
251
+ * Bind on each row. Only the active row is tabbable, which is what makes the
252
+ * whole list a single Tab stop.
253
+ */
254
+ function itemProps(id: string) {
255
+ const active = activeId.value === id
256
+ return {
257
+ tabindex: active ? 0 : -1,
258
+ "data-active": active ? "" : undefined,
259
+ "data-roving-id": id,
260
+ onFocus: () => focus(id),
261
+ }
262
+ }
263
+
264
+ return {
265
+ activeId,
266
+ focus,
267
+ move,
268
+ first,
269
+ last,
270
+ onKeydown,
271
+ setContainer,
272
+ containerProps,
273
+ itemProps,
274
+ }
275
+ }
package/src/index.ts CHANGED
@@ -99,6 +99,8 @@ export { default as LpSelect } from "./components/LpSelect.vue"
99
99
  export type { SelectOption, SelectValue } from "./components/LpSelect.vue"
100
100
  export { default as LpServiceNode } from "./components/LpServiceNode.vue"
101
101
  export type { ServiceNodeData } from "./components/LpServiceNode.vue"
102
+ export { default as LpShortcutsDialog } from "./components/LpShortcutsDialog.vue"
103
+ export type { ShortcutEntry, ShortcutGroup } from "./components/LpShortcutsDialog.vue"
102
104
  export { default as LpSidebar } from "./components/LpSidebar.vue"
103
105
  export type { SidebarItem, SidebarSection } from "./components/LpSidebar.vue"
104
106
  export { default as LpSkeleton } from "./components/LpSkeleton.vue"
@@ -182,8 +184,12 @@ export type { PresetName } from "./theme/presets"
182
184
  export { parseConfig, serializeConfig, UI_CONFIG_VERSION } from "./theme/config"
183
185
  export type { UiConfig } from "./theme/config"
184
186
 
187
+ export { useHotkeys, isAppleKeyboard, appleModifierLabel } from "./composables/useHotkeys"
188
+ export type { HotkeyBinding } from "./composables/useHotkeys"
185
189
  export { useInputFilter } from "./composables/useInputFilter"
186
190
  export type { UseInputFilterOptions } from "./composables/useInputFilter"
191
+ export { useRovingFocus } from "./composables/useRovingFocus"
192
+ export type { RovingItem, UseRovingFocusOptions } from "./composables/useRovingFocus"
187
193
  export { useSettled } from "./composables/useSettled"
188
194
  export type { UseSettledOptions } from "./composables/useSettled"
189
195
  export { useTilt } from "./composables/useTilt"
@@ -21,3 +21,18 @@
21
21
  width: 0;
22
22
  height: 0;
23
23
  }
24
+
25
+ /*
26
+ * The focus ring for focus we MOVED rather than the user walking to it.
27
+ *
28
+ * `:focus-visible` is the browser's own heuristic and deliberately excludes
29
+ * programmatic focus: a dialog opened by mouse focuses its first field, the
30
+ * pseudo-class does not match, and no ring is drawn — so the keyboard looks
31
+ * dead while working perfectly. Components that place focus themselves set this
32
+ * attribute and drop it on blur, leaving focus-visible in charge everywhere
33
+ * else. Mirrors `focus-visible:ring-2 ring-ring`.
34
+ */
35
+ [data-lp-focus-ring] {
36
+ outline: none;
37
+ box-shadow: 0 0 0 2px var(--color-ring);
38
+ }