@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
|
@@ -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
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
|
135
|
+
if (!el) return
|
|
119
136
|
|
|
120
|
-
const remeasure = () =>
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
16
|
-
<TooltipRoot>
|
|
17
|
-
|
|
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="[
|
|
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
|
+
}
|