@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
@@ -32,8 +32,18 @@ const props = withDefaults(
32
32
  * regions (e.g. multi-pane catalogues) — the panes scroll, not the modal.
33
33
  */
34
34
  fillBody?: boolean
35
+ /**
36
+ * Where the keyboard lands when the dialog opens. Default `"auto"` picks
37
+ * the first meaningful control in the body — a field to type in, or failing
38
+ * that the primary action — instead of the close button, which is what
39
+ * happens if the DOM order is left to decide.
40
+ *
41
+ * `"none"` leaves reka's own behaviour alone; pass a CSS selector to name
42
+ * the element yourself.
43
+ */
44
+ autoFocus?: "auto" | "none" | string
35
45
  }>(),
36
- { size: "md" },
46
+ { size: "md", autoFocus: "auto" },
37
47
  )
38
48
 
39
49
  defineEmits<{ (e: "update:open", value: boolean): void }>()
@@ -89,6 +99,58 @@ const bodyPad = computed(() =>
89
99
  // reka renders a real element but exposes it as a component instance, so the
90
100
  // panel is reached through $el.
91
101
  const panelRef = ref<ComponentPublicInstance | null>(null)
102
+
103
+ /**
104
+ * Controls that are worth landing on when the dialog opens, in order of
105
+ * preference. A field the user is going to type in wins over a button, since
106
+ * typing is the reason most dialogs exist.
107
+ */
108
+ const AUTO_FOCUS_ORDER = [
109
+ "input:not([type=hidden]):not([disabled]):not([readonly])",
110
+ "textarea:not([disabled]):not([readonly])",
111
+ "select:not([disabled])",
112
+ "[data-lp-autofocus]",
113
+ "button:not([disabled]):not([data-lp-dialog-close])",
114
+ "[href]",
115
+ "[tabindex]:not([tabindex='-1'])",
116
+ ]
117
+
118
+ /**
119
+ * reka focuses the first tabbable node, which in this layout is the close
120
+ * button in the header — the one control nobody opens a dialog to press. Worse,
121
+ * a dialog opened by mouse gets a programmatic focus, and `:focus-visible` does
122
+ * not match programmatic focus, so the ring never drew and the keyboard looked
123
+ * dead even though it worked.
124
+ *
125
+ * Both are fixed here: pick a meaningful target, and mark it so the ring shows.
126
+ */
127
+ function onOpenAutoFocus(event: Event) {
128
+ if (props.autoFocus === "none") return
129
+ const panel = panelRef.value?.$el
130
+ if (!(panel instanceof HTMLElement)) return
131
+
132
+ const selectors =
133
+ props.autoFocus === "auto" ? AUTO_FOCUS_ORDER : [props.autoFocus]
134
+ for (const selector of selectors) {
135
+ const target = panel.querySelector<HTMLElement>(selector)
136
+ if (!target || target.hasAttribute("disabled")) continue
137
+ event.preventDefault()
138
+ // `focus-visible` is the browser's own heuristic and cannot be forced, so
139
+ // the ring is driven off an attribute the kit owns. Removed as soon as the
140
+ // element loses focus, leaving normal focus-visible behaviour in charge.
141
+ target.setAttribute("data-lp-focus-ring", "")
142
+ target.addEventListener(
143
+ "blur",
144
+ () => target.removeAttribute("data-lp-focus-ring"),
145
+ { once: true },
146
+ )
147
+ target.focus()
148
+ if (target instanceof HTMLInputElement && target.type === "text") {
149
+ target.select()
150
+ }
151
+ return
152
+ }
153
+ }
92
154
  const tweening = ref(false)
93
155
  const resizing = ref(false)
94
156
 
@@ -248,6 +310,7 @@ onBeforeUnmount(() => {
248
310
  :class="[widthClass, tweening ? 'transition-[height] duration-fast ease-[var(--ease-emphasized)] motion-reduce:transition-none' : '']"
249
311
  :style="width ? { width } : undefined"
250
312
  v-bind="describedByAttrs"
313
+ @open-auto-focus="onOpenAutoFocus"
251
314
  >
252
315
  <header v-if="title || $slots.title" class="flex shrink-0 items-start justify-between gap-4 p-5 pb-3">
253
316
  <div class="flex flex-col gap-1">
@@ -258,7 +321,10 @@ onBeforeUnmount(() => {
258
321
  {{ description }}
259
322
  </DialogDescription>
260
323
  </div>
324
+ <!-- Marked so the auto-focus pass skips it: it is first in the DOM
325
+ and last in usefulness. Still reachable by Tab and Escape. -->
261
326
  <DialogClose
327
+ data-lp-dialog-close
262
328
  class="group flex shrink-0 items-center rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
263
329
  aria-label="Close"
264
330
  >
@@ -92,15 +92,32 @@ function measureEdges(el: HTMLElement) {
92
92
  atBottom.value = el.scrollTop + el.clientHeight >= el.scrollHeight - 1
93
93
  }
94
94
 
95
+ /**
96
+ * reka hard-codes `tabindex="0"` on the viewport so a scrollable region can be
97
+ * reached and scrolled by keyboard. That is right when there IS something to
98
+ * scroll and wrong when there isn't: in a dialog whose body fits, the first Tab
99
+ * landed on the box itself, nothing moved on screen, and the shortcut read as
100
+ * broken. The attribute is set after `$attrs`, so a prop cannot override it —
101
+ * it has to come off the element.
102
+ *
103
+ * Kept in sync with content size rather than set once: a list that fills in
104
+ * later becomes scrollable without anything else changing.
105
+ */
106
+ function syncViewportFocusable(el: HTMLElement) {
107
+ const scrollable = el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth
108
+ if (scrollable) el.setAttribute("tabindex", "0")
109
+ else el.removeAttribute("tabindex")
110
+ }
111
+
95
112
  function onViewportScroll(event: Event) {
96
113
  if (props.fade) measureEdges(event.target as HTMLElement)
97
114
  emit("scroll", event)
98
115
  }
99
116
 
100
- // Scrolling is not the only thing that changes which edges hide content: a list
101
- // that fills in, a panel that resizes, or content short enough to need no fade
102
- // at all never fire a scroll event. Watched only when `fade` is on, since
103
- // nothing else here depends on the measurements.
117
+ // Scrolling is not the only thing that changes what we measure: a list that
118
+ // fills in, a panel that resizes, or content short enough to need no fade at
119
+ // all never fire a scroll event. Runs regardless of `fade` — the focusable
120
+ // state has to track content size even where the fade is off.
104
121
  let edgeResize: ResizeObserver | undefined
105
122
  let edgeMutation: MutationObserver | undefined
106
123
 
@@ -115,9 +132,12 @@ watch(
115
132
  [viewportEl, () => props.fade],
116
133
  ([el, enabled]) => {
117
134
  stopEdgeWatch()
118
- if (!el || !enabled) return
135
+ if (!el) return
119
136
 
120
- const remeasure = () => measureEdges(el)
137
+ const remeasure = () => {
138
+ if (enabled) measureEdges(el)
139
+ syncViewportFocusable(el)
140
+ }
121
141
  remeasure()
122
142
 
123
143
  if (typeof ResizeObserver !== "undefined") {
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ /*
3
+ * A keyboard reference, conventionally opened with F1.
4
+ *
5
+ * Shortcuts are passed in as data rather than read off a live binding list: a
6
+ * binding knows its key but not what to call it, and in most apps half of them
7
+ * are only mounted in the section they belong to — a reference that lists what
8
+ * happens to be active is not a reference.
9
+ *
10
+ * Combos are written the way they read ("Ctrl+Shift+M", "←→↑↓") and split on
11
+ * "+" into one <kbd> each.
12
+ */
13
+ import LpModal from "./LpModal.vue"
14
+ import { appleModifierLabel } from "../composables/useHotkeys"
15
+
16
+ export interface ShortcutEntry {
17
+ /** Rendered one <kbd> per "+"-separated key, so write it the way it reads. */
18
+ combo: string
19
+ label: string
20
+ }
21
+
22
+ export interface ShortcutGroup {
23
+ title: string
24
+ entries: ShortcutEntry[]
25
+ }
26
+
27
+ const props = withDefaults(
28
+ defineProps<{
29
+ open?: boolean
30
+ groups: ShortcutGroup[]
31
+ title?: string
32
+ description?: string
33
+ }>(),
34
+ { open: false, title: "Keyboard shortcuts" },
35
+ )
36
+
37
+ const emit = defineEmits<{ (e: "update:open", value: boolean): void }>()
38
+ </script>
39
+
40
+ <template>
41
+ <LpModal
42
+ :open="props.open"
43
+ size="lg"
44
+ :title="props.title"
45
+ :description="props.description"
46
+ @update:open="(value: boolean) => emit('update:open', value)"
47
+ >
48
+ <div class="shortcut-groups">
49
+ <section v-for="group in props.groups" :key="group.title" class="shortcut-group">
50
+ <h3>{{ group.title }}</h3>
51
+ <dl>
52
+ <div v-for="entry in group.entries" :key="entry.combo" class="shortcut-row">
53
+ <dt>{{ entry.label }}</dt>
54
+ <dd>
55
+ <kbd v-for="(key, index) in entry.combo.split('+')" :key="`${key}-${index}`">
56
+ {{ appleModifierLabel(key.trim()) }}
57
+ </kbd>
58
+ </dd>
59
+ </div>
60
+ </dl>
61
+ </section>
62
+ </div>
63
+ </LpModal>
64
+ </template>
65
+
66
+ <style scoped>
67
+ /* Two columns where there is room: the list is long enough that one column
68
+ pushes the last group off a laptop screen. */
69
+ .shortcut-groups {
70
+ display: grid;
71
+ gap: 18px;
72
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
73
+ }
74
+
75
+ .shortcut-group h3 {
76
+ margin: 0 0 6px;
77
+ color: var(--color-muted);
78
+ font-size: 0.7rem;
79
+ font-weight: 600;
80
+ letter-spacing: 0.06em;
81
+ text-transform: uppercase;
82
+ }
83
+
84
+ .shortcut-group dl {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 1px;
88
+ margin: 0;
89
+ }
90
+
91
+ .shortcut-row {
92
+ display: flex;
93
+ align-items: center;
94
+ justify-content: space-between;
95
+ gap: 12px;
96
+ padding: 5px 0;
97
+ }
98
+
99
+ .shortcut-row dt {
100
+ color: var(--color-ink);
101
+ font-size: 0.82rem;
102
+ min-width: 0;
103
+ }
104
+
105
+ .shortcut-row dd {
106
+ display: flex;
107
+ flex-shrink: 0;
108
+ gap: 3px;
109
+ margin: 0;
110
+ }
111
+
112
+ kbd {
113
+ display: inline-flex;
114
+ min-width: 20px;
115
+ align-items: center;
116
+ justify-content: center;
117
+ padding: 2px 6px;
118
+ border: 1px solid var(--color-line-strong);
119
+ border-bottom-width: 2px;
120
+ border-radius: 5px;
121
+ background: color-mix(in srgb, var(--color-ink) 6%, transparent);
122
+ color: var(--color-muted-strong);
123
+ font-family: inherit;
124
+ font-size: 0.7rem;
125
+ line-height: 1.5;
126
+ }
127
+ </style>
@@ -4,6 +4,7 @@ import LpCheckbox from "./LpCheckbox.vue"
4
4
  import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
5
5
  import LpIcon from "./LpIcon.vue"
6
6
  import LpScrollArea from "./LpScrollArea.vue"
7
+ import { useRovingFocus } from "../composables/useRovingFocus"
7
8
 
8
9
  export interface TableColumn<Row> {
9
10
  key: string
@@ -134,6 +135,54 @@ function toggleRow(key: RowKey, checked: boolean) {
134
135
 
135
136
  const colSpan = computed(() => props.columns.length + (props.selectable ? 1 : 0))
136
137
 
138
+ /*
139
+ * Keyboard navigation over the rows. A table is one Tab stop, not one per row:
140
+ * a few hundred rows would otherwise be a few hundred Tab presses, which is the
141
+ * same as having no keyboard support at all.
142
+ *
143
+ * Typeahead matches the FIRST column, which in practice is the name — the thing
144
+ * a person looking for a row would type. Rows are addressed by their key so the
145
+ * cursor survives a re-sort.
146
+ */
147
+ const rovingItems = computed(() =>
148
+ displayRows.value.map((row, index) => ({
149
+ id: String(keyFor(row, index)),
150
+ label: String(row[props.columns[0]?.key ?? ""] ?? ""),
151
+ })),
152
+ )
153
+
154
+ const rowByKey = computed(() => {
155
+ const map = new Map<string, T>()
156
+ displayRows.value.forEach((row, index) => map.set(String(keyFor(row, index)), row))
157
+ return map
158
+ })
159
+
160
+ const roving = useRovingFocus(rovingItems, {
161
+ onActivate: (id) => {
162
+ const row = rowByKey.value.get(id)
163
+ if (!row) return
164
+ // Space ticks the box where the table has one, since that is what a
165
+ // selectable list is for; Enter always means "open this row".
166
+ emit("rowClick", row)
167
+ },
168
+ })
169
+
170
+ function onRowKeydown(event: KeyboardEvent) {
171
+ // Space ticks rather than activates when the table is selectable — the
172
+ // standard split for a multi-select list.
173
+ if (event.key === " " && props.selectable && roving.activeId.value) {
174
+ event.preventDefault()
175
+ const key = roving.activeId.value
176
+ const row = rowByKey.value.get(key)
177
+ if (row) {
178
+ const rowKey = keyFor(row, displayRows.value.indexOf(row))
179
+ toggleRow(rowKey, !selectedSet.value.has(rowKey))
180
+ }
181
+ return
182
+ }
183
+ roving.onKeydown(event)
184
+ }
185
+
137
186
  // With stickyHeader, the overlay scrollbar would otherwise run up under the
138
187
  // pinned header. Measure the header height and inset the bar by it so the bar
139
188
  // starts below the header. Tracked live (density/content can change it).
@@ -213,7 +262,11 @@ const barInsetTop = computed(() =>
213
262
  </th>
214
263
  </tr>
215
264
  </thead>
216
- <tbody>
265
+ <!-- The keydown lives on the rows, not here: only a focused row should
266
+ steer, and a stray key from a control inside a cell must not move the
267
+ cursor. The container is bound only so the composable can find rows
268
+ to focus. -->
269
+ <tbody :ref="(el) => roving.setContainer(el as HTMLElement | null)">
217
270
  <tr v-if="displayRows.length === 0">
218
271
  <td :colspan="colSpan" class="px-4 py-10 text-center text-muted">
219
272
  <LpIcon :name="emptyIcon" :size="22" class="mx-auto mb-2 opacity-60" />
@@ -230,9 +283,11 @@ const barInsetTop = computed(() =>
230
283
  :items="menuFor(row)"
231
284
  >
232
285
  <tr
233
- class="border-b border-line/60 transition-colors last:border-0 hover:bg-surface-soft/60"
286
+ class="border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
234
287
  :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
288
+ v-bind="roving.itemProps(String(keyFor(row, index)))"
235
289
  @click="emit('rowClick', row)"
290
+ @keydown="onRowKeydown"
236
291
  >
237
292
  <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
238
293
  <LpCheckbox
@@ -1,4 +1,28 @@
1
1
  <script setup lang="ts">
2
+ /*
3
+ * Tooltip for controls whose meaning is not written on them — an icon button,
4
+ * a truncated value, a disabled action that owes an explanation.
5
+ *
6
+ * Why not the native `title` attribute: it is drawn by the operating system, so
7
+ * it looks different on every platform and cannot be themed; it waits about a
8
+ * second before appearing; it cannot wrap or hold markup; and screen-reader
9
+ * support for it is inconsistent — it is often ignored outright.
10
+ *
11
+ * Accessibility contract, which is the part that is easy to get wrong:
12
+ *
13
+ * `label` — the tip IS the control's name (an icon button). The text is put
14
+ * on the TRIGGER as aria-label, so a screen reader announces "Delete,
15
+ * button" rather than "button". reka only ever wires aria-describedby,
16
+ * which describes a control that already has a name — it cannot name a
17
+ * nameless one, so naming has to happen here.
18
+ *
19
+ * default — the tip merely ADDS to a control that already has a name. reka's
20
+ * own aria-describedby carries it, and the name is announced first.
21
+ *
22
+ * Either way the trigger keeps a real accessible name, which is what `title`
23
+ * alone could not guarantee. reka opens the tip on keyboard focus as well as
24
+ * hover and closes it on Escape, satisfying WCAG 1.4.13.
25
+ */
2
26
  import {
3
27
  TooltipContent,
4
28
  TooltipPortal,
@@ -8,19 +32,47 @@ import {
8
32
  } from "reka-ui"
9
33
  import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
10
34
 
11
- defineProps<{ content?: string; sideOffset?: number }>()
35
+ withDefaults(
36
+ defineProps<{
37
+ content?: string
38
+ sideOffset?: number
39
+ /** Which edge to prefer; flips automatically when there is no room. */
40
+ side?: "top" | "right" | "bottom" | "left"
41
+ /**
42
+ * The tip names the control rather than describing it — for a trigger with
43
+ * no visible text of its own. Sets the accessible name instead of only
44
+ * adding a description.
45
+ */
46
+ label?: boolean
47
+ /** Milliseconds before the tip appears on hover. */
48
+ delay?: number
49
+ /**
50
+ * Show on any focus, not just keyboard focus. Off by default: a tip that
51
+ * pops up after every mouse click on the trigger is noise.
52
+ */
53
+ showOnPointerFocus?: boolean
54
+ }>(),
55
+ { delay: 200, side: "top" },
56
+ )
12
57
  </script>
13
58
 
14
59
  <template>
15
- <TooltipProvider :delay-duration="200">
16
- <TooltipRoot>
17
- <TooltipTrigger as-child>
60
+ <TooltipProvider :delay-duration="delay">
61
+ <TooltipRoot :ignore-non-keyboard-focus="!showOnPointerFocus">
62
+ <!-- `as-child` merges these onto the caller's own element, so the label
63
+ lands on the real button rather than on a wrapper. -->
64
+ <TooltipTrigger as-child :aria-label="label ? content : undefined">
18
65
  <slot />
19
66
  </TooltipTrigger>
20
67
  <TooltipPortal>
21
68
  <TooltipContent
69
+ :side="side"
22
70
  :side-offset="sideOffset ?? 6"
23
- :class="[PANEL_SURFACE, TOOLTIP_ANIM, 'z-(--z-tooltip) px-2.5 py-1.5 text-xs text-ink']"
71
+ :class="[
72
+ PANEL_SURFACE,
73
+ TOOLTIP_ANIM,
74
+ 'z-(--z-tooltip) max-w-[min(20rem,90vw)] px-2.5 py-1.5 text-xs text-ink',
75
+ ]"
24
76
  >
25
77
  <slot name="content">{{ content }}</slot>
26
78
  </TooltipContent>
@@ -0,0 +1,131 @@
1
+ import { onBeforeUnmount, onMounted, type MaybeRefOrGetter, toValue } from "vue"
2
+
3
+ /*
4
+ * One keydown listener over a registry of bindings, so a shortcut is declared
5
+ * next to the action it runs instead of growing another window-level handler.
6
+ *
7
+ * Three things every hand-rolled shortcut handler tends to get wrong, and the
8
+ * reason this is worth sharing rather than rewriting per app:
9
+ *
10
+ * layout — a binding names its key twice over. `key` is what the layout
11
+ * produced, `code` is the physical position, and either matching counts. On
12
+ * a Cyrillic layout Ctrl+K arrives as `key: "л"` with `code: "KeyK"`, and a
13
+ * user pressing the key printed "K" expects it to work either way.
14
+ *
15
+ * IME — a composing input delivers every keystroke as its own event, so
16
+ * acting on them fires shortcuts mid-word.
17
+ *
18
+ * text entry — a shortcut that steals a key from someone typing is a bug,
19
+ * except for the few that mean something in a field (Escape, a palette).
20
+ *
21
+ * Modifier note: Ctrl and Meta are one modifier here, since macOS binds ⌘ where
22
+ * the rest bind Ctrl. Label the difference in the UI, not in the binding.
23
+ */
24
+
25
+ export interface HotkeyBinding {
26
+ /** Layout-produced key, compared case-insensitively ("l", "f2", "escape"). */
27
+ key: string
28
+ /**
29
+ * Physical key position ("KeyL", "F2", "Digit1"). Matches on its own, which
30
+ * is what makes non-Latin layouts work.
31
+ */
32
+ code?: string
33
+ /** Ctrl on Windows/Linux, ⌘ on macOS — the two are treated as one. */
34
+ ctrl?: boolean
35
+ shift?: boolean
36
+ alt?: boolean
37
+ /**
38
+ * Runs even while a text field has focus. Only for shortcuts that mean
39
+ * something there — Escape, and the palette the user reaches from anywhere.
40
+ */
41
+ allowInInput?: boolean
42
+ /**
43
+ * Returning `false` declines the key: the event keeps its default and the
44
+ * search moves on. This is what lets a binding defer to a layer that owns the
45
+ * key better than it does — Escape belongs to whichever overlay is on top,
46
+ * and swallowing it here would close all of them.
47
+ */
48
+ handler: () => void | boolean
49
+ }
50
+
51
+ /**
52
+ * Whether this machine prints ⌘ where the rest print Ctrl. Read once: the
53
+ * keyboard does not change under a running window, and the check is only ever
54
+ * used to pick a label.
55
+ */
56
+ export const isAppleKeyboard =
57
+ typeof navigator !== "undefined"
58
+ && /Mac|iPhone|iPad|iPod/.test(navigator.platform || "")
59
+
60
+ /**
61
+ * The symbol to print for a modifier on this platform. Bindings treat Ctrl and
62
+ * Meta as one modifier, so only the label may differ — never the binding.
63
+ */
64
+ export function appleModifierLabel(key: string): string {
65
+ if (!isAppleKeyboard) return key
66
+ if (key === "Ctrl") return "⌘"
67
+ if (key === "Alt") return "⌥"
68
+ return key
69
+ }
70
+
71
+ const TEXT_INPUT_TYPES = new Set([
72
+ "text",
73
+ "search",
74
+ "email",
75
+ "number",
76
+ "password",
77
+ "tel",
78
+ "url",
79
+ ])
80
+
81
+ /**
82
+ * Whether the event lands in something the user is typing into. Checked off the
83
+ * event target rather than `document.activeElement` so it stays right inside a
84
+ * dialog's focus trap.
85
+ */
86
+ function isTextEntry(target: EventTarget | null): boolean {
87
+ if (!(target instanceof HTMLElement)) return false
88
+ if (target.isContentEditable) return true
89
+ if (target instanceof HTMLTextAreaElement) return true
90
+ if (target instanceof HTMLInputElement) {
91
+ return TEXT_INPUT_TYPES.has(target.type)
92
+ }
93
+ return false
94
+ }
95
+
96
+ function matches(event: KeyboardEvent, binding: HotkeyBinding): boolean {
97
+ const ctrlHeld = event.ctrlKey || event.metaKey
98
+ if (ctrlHeld !== Boolean(binding.ctrl)) return false
99
+ if (event.shiftKey !== Boolean(binding.shift)) return false
100
+ if (event.altKey !== Boolean(binding.alt)) return false
101
+ return (
102
+ event.key.toLowerCase() === binding.key.toLowerCase()
103
+ || (binding.code !== undefined && event.code === binding.code)
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Bind a list of hotkeys for as long as the calling component is mounted.
109
+ *
110
+ * The list is read on every keystroke rather than captured once, so bindings
111
+ * may come and go with the state that owns them — a getter is the point, not a
112
+ * formality.
113
+ */
114
+ export function useHotkeys(bindings: MaybeRefOrGetter<HotkeyBinding[]>) {
115
+ function onKeydown(event: KeyboardEvent) {
116
+ if (event.isComposing) return
117
+ const inTextEntry = isTextEntry(event.target)
118
+ for (const binding of toValue(bindings)) {
119
+ if (inTextEntry && !binding.allowInInput) continue
120
+ if (!matches(event, binding)) continue
121
+ // Claimed only once the handler has run and not declined, so a binding
122
+ // that stands down leaves the key untouched for whoever handles it next.
123
+ if (binding.handler() === false) continue
124
+ event.preventDefault()
125
+ return
126
+ }
127
+ }
128
+
129
+ onMounted(() => window.addEventListener("keydown", onKeydown))
130
+ onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown))
131
+ }