@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.
- package/dist/assets/{LpCommandPalette-C3LdDNHk.css → LpCommandPalette-CEY-pajO.css} +4 -4
- package/dist/assets/LpShortcutsDialog-B7H-RZLQ.css +55 -0
- package/dist/chunks/{LpAddressInput-B411MOJa.js → LpAddressInput-Dah7asXy.js} +1 -1
- package/dist/chunks/{LpAppShell-CfM7fdr3.js → LpAppShell-DGZv907S.js} +2 -2
- package/dist/chunks/{LpConfirmDialog-D8pBGPOI.js → LpConfirmDialog-CkMzeKdp.js} +1 -1
- package/dist/chunks/{LpDrawer-B4cNj39a.js → LpDrawer-DAJN5rGK.js} +1 -1
- package/dist/chunks/{LpFileTree-d9nXtSVo.js → LpFileTree-DWx77B1F.js} +1 -1
- package/dist/chunks/{LpLogViewer-BXPBUV21.js → LpLogViewer-B1aLUVdz.js} +1 -1
- package/dist/chunks/{LpModal-DKFdzKwV.js → LpModal-CNAFRgCU.js} +36 -3
- package/dist/chunks/{LpNotificationBell-Nges4kJk.js → LpNotificationBell-HSGo1pcf.js} +1 -1
- package/dist/chunks/{LpScrollArea-Du8tW8c5.js → LpScrollArea-D8xyKXcF.js} +10 -2
- package/dist/chunks/{LpSidebar-Bd0148dd.js → LpSidebar-JvMaaYsy.js} +2 -2
- package/dist/chunks/{LpSidebarNav-BqGpNr4S.js → LpSidebarNav-BIYzk7e_.js} +1 -1
- package/dist/chunks/{LpTable-BBKlNR9j.js → LpTable-DTj52qlg.js} +45 -9
- package/dist/chunks/{LpTooltip-CIwoRCag.js → LpTooltip-D1hbjQWG.js} +23 -9
- package/dist/chunks/_plugin-vue_export-helper-1tPrXgE0.js +10 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +17 -21
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpModal.vue.d.ts +16 -5
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpScrollArea.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.d.ts +25 -0
- package/dist/components/LpShortcutsDialog.vue.js +63 -0
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +1 -1
- package/dist/components/LpTooltip.vue.d.ts +19 -1
- package/dist/components/LpTooltip.vue.js +1 -1
- package/dist/composables/useHotkeys.d.ts +45 -0
- package/dist/composables/useHotkeys.js +53 -0
- package/dist/composables/useRovingFocus.d.ts +49 -0
- package/dist/composables/useRovingFocus.js +160 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +20 -12
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpCommandPalette.vue +18 -11
- package/src/components/LpModal.vue +67 -1
- package/src/components/LpScrollArea.vue +26 -6
- package/src/components/LpShortcutsDialog.vue +127 -0
- package/src/components/LpTable.vue +57 -2
- package/src/components/LpTooltip.vue +57 -5
- package/src/composables/useHotkeys.ts +131 -0
- package/src/composables/useRovingFocus.ts +275 -0
- package/src/index.ts +6 -0
- 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"
|
package/src/tokens/utilities.css
CHANGED
|
@@ -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
|
+
}
|