@leavepulse/ui 0.21.0 → 0.22.1
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/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/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +1 -1
- 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.js +1 -1
- 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/useRovingFocus.d.ts +49 -0
- package/dist/composables/useRovingFocus.js +160 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +14 -12
- package/package.json +1 -1
- package/src/components/LpModal.vue +67 -1
- package/src/components/LpScrollArea.vue +26 -6
- package/src/components/LpTable.vue +57 -2
- package/src/components/LpTooltip.vue +61 -5
- package/src/composables/useRovingFocus.ts +275 -0
- package/src/index.ts +2 -0
- package/src/tokens/utilities.css +15 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ref, computed, toValue, watch } from "vue";
|
|
2
|
+
const TYPEAHEAD_RESET_MS = 700;
|
|
3
|
+
function useRovingFocus(items, options = {}) {
|
|
4
|
+
const {
|
|
5
|
+
orientation = "vertical",
|
|
6
|
+
loop = true,
|
|
7
|
+
typeahead = true,
|
|
8
|
+
onActivate,
|
|
9
|
+
onEscape
|
|
10
|
+
} = options;
|
|
11
|
+
const activeId = ref();
|
|
12
|
+
const containerEl = ref(null);
|
|
13
|
+
const enabled = computed(() => toValue(items).filter((item) => !item.disabled));
|
|
14
|
+
watch(
|
|
15
|
+
enabled,
|
|
16
|
+
(list) => {
|
|
17
|
+
if (list.length === 0) {
|
|
18
|
+
activeId.value = void 0;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (!activeId.value || !list.some((item) => item.id === activeId.value)) {
|
|
22
|
+
activeId.value = list[0].id;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{ immediate: true }
|
|
26
|
+
);
|
|
27
|
+
function indexOfActive() {
|
|
28
|
+
return enabled.value.findIndex((item) => item.id === activeId.value);
|
|
29
|
+
}
|
|
30
|
+
function focus(id) {
|
|
31
|
+
if (id) activeId.value = id;
|
|
32
|
+
}
|
|
33
|
+
function move(delta) {
|
|
34
|
+
const list = enabled.value;
|
|
35
|
+
if (list.length === 0) return;
|
|
36
|
+
const from = indexOfActive();
|
|
37
|
+
if (from === -1) {
|
|
38
|
+
focus(list[0].id);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
let next = from + delta;
|
|
42
|
+
if (next < 0) next = loop ? list.length - 1 : 0;
|
|
43
|
+
if (next >= list.length) next = loop ? 0 : list.length - 1;
|
|
44
|
+
focus(list[next].id);
|
|
45
|
+
}
|
|
46
|
+
function first() {
|
|
47
|
+
focus(enabled.value[0]?.id);
|
|
48
|
+
}
|
|
49
|
+
function last() {
|
|
50
|
+
focus(enabled.value.at(-1)?.id);
|
|
51
|
+
}
|
|
52
|
+
let typed = "";
|
|
53
|
+
let typedAt = 0;
|
|
54
|
+
function typeaheadTo(character) {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
typed = now - typedAt > TYPEAHEAD_RESET_MS ? character : typed + character;
|
|
57
|
+
typedAt = now;
|
|
58
|
+
const list = enabled.value;
|
|
59
|
+
const needle = typed.toLowerCase();
|
|
60
|
+
const repeated = typed.length > 1 && [...typed].every((c) => c === typed[0]);
|
|
61
|
+
const query = repeated ? typed[0].toLowerCase() : needle;
|
|
62
|
+
const from = indexOfActive();
|
|
63
|
+
const offset = repeated || typed.length === 1 ? 1 : 0;
|
|
64
|
+
for (let step = 0; step < list.length; step += 1) {
|
|
65
|
+
const item = list[(from + offset + step) % list.length];
|
|
66
|
+
if (item.label?.toLowerCase().startsWith(query)) {
|
|
67
|
+
focus(item.id);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function onKeydown(event) {
|
|
73
|
+
const vertical = orientation === "vertical" || orientation === "both";
|
|
74
|
+
const horizontal = orientation === "horizontal" || orientation === "both";
|
|
75
|
+
switch (event.key) {
|
|
76
|
+
case "ArrowDown":
|
|
77
|
+
if (!vertical) return;
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
move(1);
|
|
80
|
+
return;
|
|
81
|
+
case "ArrowUp":
|
|
82
|
+
if (!vertical) return;
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
move(-1);
|
|
85
|
+
return;
|
|
86
|
+
case "ArrowRight":
|
|
87
|
+
if (!horizontal) return;
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
move(1);
|
|
90
|
+
return;
|
|
91
|
+
case "ArrowLeft":
|
|
92
|
+
if (!horizontal) return;
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
move(-1);
|
|
95
|
+
return;
|
|
96
|
+
case "Home":
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
first();
|
|
99
|
+
return;
|
|
100
|
+
case "End":
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
last();
|
|
103
|
+
return;
|
|
104
|
+
case "Enter":
|
|
105
|
+
case " ":
|
|
106
|
+
if (!activeId.value) return;
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
onActivate?.(activeId.value);
|
|
109
|
+
return;
|
|
110
|
+
case "Escape":
|
|
111
|
+
if (!onEscape) return;
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
onEscape();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (typeahead && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey && event.key !== " ") {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
typeaheadTo(event.key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
watch(activeId, (id) => {
|
|
122
|
+
if (!id || typeof document === "undefined") return;
|
|
123
|
+
const container = containerEl.value;
|
|
124
|
+
if (!container || !container.contains(document.activeElement)) return;
|
|
125
|
+
const target = container.querySelector(
|
|
126
|
+
`[data-roving-id="${CSS.escape(id)}"]`
|
|
127
|
+
);
|
|
128
|
+
target?.focus();
|
|
129
|
+
});
|
|
130
|
+
function setContainer(el) {
|
|
131
|
+
containerEl.value = el;
|
|
132
|
+
}
|
|
133
|
+
const containerProps = computed(() => ({
|
|
134
|
+
ref: (el) => setContainer(el instanceof HTMLElement ? el : null),
|
|
135
|
+
onKeydown
|
|
136
|
+
}));
|
|
137
|
+
function itemProps(id) {
|
|
138
|
+
const active = activeId.value === id;
|
|
139
|
+
return {
|
|
140
|
+
tabindex: active ? 0 : -1,
|
|
141
|
+
"data-active": active ? "" : void 0,
|
|
142
|
+
"data-roving-id": id,
|
|
143
|
+
onFocus: () => focus(id)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
activeId,
|
|
148
|
+
focus,
|
|
149
|
+
move,
|
|
150
|
+
first,
|
|
151
|
+
last,
|
|
152
|
+
onKeydown,
|
|
153
|
+
setContainer,
|
|
154
|
+
containerProps,
|
|
155
|
+
itemProps
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
useRovingFocus
|
|
160
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -113,6 +113,8 @@ export { useHotkeys, isAppleKeyboard, appleModifierLabel } from './composables/u
|
|
|
113
113
|
export type { HotkeyBinding } from './composables/useHotkeys';
|
|
114
114
|
export { useInputFilter } from './composables/useInputFilter';
|
|
115
115
|
export type { UseInputFilterOptions } from './composables/useInputFilter';
|
|
116
|
+
export { useRovingFocus } from './composables/useRovingFocus';
|
|
117
|
+
export type { RovingItem, UseRovingFocusOptions } from './composables/useRovingFocus';
|
|
116
118
|
export { useSettled } from './composables/useSettled';
|
|
117
119
|
export type { UseSettledOptions } from './composables/useSettled';
|
|
118
120
|
export { useTilt } from './composables/useTilt';
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
|
|
2
|
-
import { _ as _2 } from "./chunks/LpAddressInput-
|
|
2
|
+
import { _ as _2 } from "./chunks/LpAddressInput-Dah7asXy.js";
|
|
3
3
|
import { _ as _3 } from "./chunks/LpMapPicker-DFKrd1Ne.js";
|
|
4
4
|
import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
|
|
5
5
|
import { _ as _4 } from "./chunks/LpAlert-BAYHDSS1.js";
|
|
6
|
-
import { _ as _5 } from "./chunks/LpAppShell-
|
|
6
|
+
import { _ as _5 } from "./chunks/LpAppShell-DGZv907S.js";
|
|
7
7
|
import { _ as _6 } from "./chunks/LpAutocomplete-DIgXmtCA.js";
|
|
8
8
|
import { _ as _7 } from "./chunks/LpAvatar-B1Ez0ZAx.js";
|
|
9
9
|
import { _ as _8 } from "./chunks/LpBadge-CXzBPnwO.js";
|
|
@@ -14,15 +14,15 @@ import { _ as _12 } from "./chunks/LpCard-BmrTvWOe.js";
|
|
|
14
14
|
import { _ as _13 } from "./chunks/LpCheckbox-BO0zuuPh.js";
|
|
15
15
|
import { _ as _14 } from "./chunks/LpCodeBlock-CSiOSPs8.js";
|
|
16
16
|
import { default as default2 } from "./components/LpCommandPalette.vue.js";
|
|
17
|
-
import { _ as _15 } from "./chunks/LpConfirmDialog-
|
|
17
|
+
import { _ as _15 } from "./chunks/LpConfirmDialog-CkMzeKdp.js";
|
|
18
18
|
import { _ as _16 } from "./chunks/LpContextMenu-D0mzXqA7.js";
|
|
19
19
|
import { _ as _17 } from "./chunks/LpDatePicker-dY2q4dr4.js";
|
|
20
20
|
import { _ as _18 } from "./chunks/LpDisclosure-DP6yfgfM.js";
|
|
21
21
|
import { _ as _19 } from "./chunks/LpDivider-CUX46KR0.js";
|
|
22
|
-
import { _ as _20 } from "./chunks/LpDrawer-
|
|
22
|
+
import { _ as _20 } from "./chunks/LpDrawer-DAJN5rGK.js";
|
|
23
23
|
import { _ as _21 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
|
|
24
24
|
import { _ as _22 } from "./chunks/LpEmptyState-CrfyRJUG.js";
|
|
25
|
-
import { _ as _23 } from "./chunks/LpFileTree-
|
|
25
|
+
import { _ as _23 } from "./chunks/LpFileTree-DWx77B1F.js";
|
|
26
26
|
import { ancestorIds, checkStateOf, checkedCount, computeStats, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
|
|
27
27
|
import { _ as _24 } from "./chunks/LpFormField-Bn-ZwC6z.js";
|
|
28
28
|
import { _ as _25 } from "./chunks/LpIcon-CCnX5_2j.js";
|
|
@@ -31,9 +31,9 @@ import { _ as _27 } from "./chunks/LpInput-BauZRyzm.js";
|
|
|
31
31
|
import { _ as _28 } from "./chunks/LpLightbox-Ci35Y_xP.js";
|
|
32
32
|
import { fileNameOf } from "./components/lightbox.js";
|
|
33
33
|
import { _ as _29 } from "./chunks/LpLink-DHwOEp4e.js";
|
|
34
|
-
import { _ as _30 } from "./chunks/LpLogViewer-
|
|
35
|
-
import { _ as _31 } from "./chunks/LpModal-
|
|
36
|
-
import { _ as _32 } from "./chunks/LpNotificationBell-
|
|
34
|
+
import { _ as _30 } from "./chunks/LpLogViewer-B1aLUVdz.js";
|
|
35
|
+
import { _ as _31 } from "./chunks/LpModal-CNAFRgCU.js";
|
|
36
|
+
import { _ as _32 } from "./chunks/LpNotificationBell-HSGo1pcf.js";
|
|
37
37
|
import { _ as _33 } from "./chunks/LpNumberField-C5ar-OwE.js";
|
|
38
38
|
import { _ as _34 } from "./chunks/LpOtpInput-DqaT0Z75.js";
|
|
39
39
|
import { _ as _35 } from "./chunks/LpPagination-CeGuNe_q.js";
|
|
@@ -44,18 +44,18 @@ import { _ as _38 } from "./chunks/LpPopover-8268UmlC.js";
|
|
|
44
44
|
import { _ as _39 } from "./chunks/LpProgress-BhI0IbjC.js";
|
|
45
45
|
import { _ as _40 } from "./chunks/LpRadio-CL-pI0_O.js";
|
|
46
46
|
import { _ as _41 } from "./chunks/LpRadioGroup-B4yDTmi6.js";
|
|
47
|
-
import { _ as _42 } from "./chunks/LpScrollArea-
|
|
47
|
+
import { _ as _42 } from "./chunks/LpScrollArea-D8xyKXcF.js";
|
|
48
48
|
import { _ as _43 } from "./chunks/LpSegmented-BmpanyAo.js";
|
|
49
49
|
import { _ as _44 } from "./chunks/LpSelect-D5mU4cZO.js";
|
|
50
50
|
import { _ as _45 } from "./chunks/LpServiceNode-Dc-Ut5RS.js";
|
|
51
51
|
import { default as default3 } from "./components/LpShortcutsDialog.vue.js";
|
|
52
|
-
import { _ as _46 } from "./chunks/LpSidebar-
|
|
52
|
+
import { _ as _46 } from "./chunks/LpSidebar-JvMaaYsy.js";
|
|
53
53
|
import { _ as _47 } from "./chunks/LpSkeleton-eSFIo4kD.js";
|
|
54
54
|
import { _ as _48 } from "./chunks/LpSlider-B1SF3pRV.js";
|
|
55
55
|
import { _ as _49 } from "./chunks/LpStat-CdbEFOvn.js";
|
|
56
56
|
import { _ as _50 } from "./chunks/LpStepper-5dFTrW21.js";
|
|
57
57
|
import { _ as _51 } from "./chunks/LpSwitch-v6fnccSM.js";
|
|
58
|
-
import { _ as _52 } from "./chunks/LpTable-
|
|
58
|
+
import { _ as _52 } from "./chunks/LpTable-DTj52qlg.js";
|
|
59
59
|
import { _ as _53 } from "./chunks/LpTableOfContents-CjvxPMpU.js";
|
|
60
60
|
import { _ as _54 } from "./chunks/LpTabs-Cefe_lys.js";
|
|
61
61
|
import { _ as _55 } from "./chunks/LpTextarea-CRAkhemZ.js";
|
|
@@ -63,7 +63,7 @@ import { p as presets, a as parseTheme, s as serializeTheme } from "./chunks/LpT
|
|
|
63
63
|
import { D, _ as _56, N, T, b, c, d, e, f, g, i, l, h, j, n, r, t, k, m, u, v } from "./chunks/LpThemeSwitcher-B_kj0T2b.js";
|
|
64
64
|
import { _ as _57 } from "./chunks/LpTilt-DuyKbpkA.js";
|
|
65
65
|
import { _ as _58 } from "./chunks/LpToaster-gDB1EaU3.js";
|
|
66
|
-
import { _ as _59 } from "./chunks/LpTooltip-
|
|
66
|
+
import { _ as _59 } from "./chunks/LpTooltip-D1hbjQWG.js";
|
|
67
67
|
import { _ as _60 } from "./chunks/LpTopologyCanvas-DVwyQfKa.js";
|
|
68
68
|
import { _ as _61 } from "./chunks/LpUptimeBar-CB366k0O.js";
|
|
69
69
|
import { deserializeLayout, serializeLayout } from "./layout/tree.js";
|
|
@@ -72,6 +72,7 @@ import { blockTitle, defineBlocks } from "./layout/registry.js";
|
|
|
72
72
|
import { useLayout } from "./layout/useLayout.js";
|
|
73
73
|
import { appleModifierLabel, isAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
|
|
74
74
|
import { useInputFilter } from "./composables/useInputFilter.js";
|
|
75
|
+
import { useRovingFocus } from "./composables/useRovingFocus.js";
|
|
75
76
|
import { useSettled } from "./composables/useSettled.js";
|
|
76
77
|
import { useTilt } from "./composables/useTilt.js";
|
|
77
78
|
import { useZoomPan } from "./composables/useZoomPan.js";
|
|
@@ -236,6 +237,7 @@ export {
|
|
|
236
237
|
useHotkeys,
|
|
237
238
|
useInputFilter,
|
|
238
239
|
useLayout,
|
|
240
|
+
useRovingFocus,
|
|
239
241
|
useSettled,
|
|
240
242
|
u as useTheme,
|
|
241
243
|
useTilt,
|
package/package.json
CHANGED
|
@@ -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") {
|
|
@@ -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,32 @@
|
|
|
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
|
+
*
|
|
26
|
+
* Do NOT wrap the `#trigger` of LpDropdownMenu, LpPopover or LpContextMenu:
|
|
27
|
+
* those bind their handlers to the slot's direct child, so a wrapper swallows
|
|
28
|
+
* them and the control stops opening. Put `aria-label` on the trigger instead.
|
|
29
|
+
*/
|
|
2
30
|
import {
|
|
3
31
|
TooltipContent,
|
|
4
32
|
TooltipPortal,
|
|
@@ -8,19 +36,47 @@ import {
|
|
|
8
36
|
} from "reka-ui"
|
|
9
37
|
import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
|
|
10
38
|
|
|
11
|
-
|
|
39
|
+
withDefaults(
|
|
40
|
+
defineProps<{
|
|
41
|
+
content?: string
|
|
42
|
+
sideOffset?: number
|
|
43
|
+
/** Which edge to prefer; flips automatically when there is no room. */
|
|
44
|
+
side?: "top" | "right" | "bottom" | "left"
|
|
45
|
+
/**
|
|
46
|
+
* The tip names the control rather than describing it — for a trigger with
|
|
47
|
+
* no visible text of its own. Sets the accessible name instead of only
|
|
48
|
+
* adding a description.
|
|
49
|
+
*/
|
|
50
|
+
label?: boolean
|
|
51
|
+
/** Milliseconds before the tip appears on hover. */
|
|
52
|
+
delay?: number
|
|
53
|
+
/**
|
|
54
|
+
* Show on any focus, not just keyboard focus. Off by default: a tip that
|
|
55
|
+
* pops up after every mouse click on the trigger is noise.
|
|
56
|
+
*/
|
|
57
|
+
showOnPointerFocus?: boolean
|
|
58
|
+
}>(),
|
|
59
|
+
{ delay: 200, side: "top" },
|
|
60
|
+
)
|
|
12
61
|
</script>
|
|
13
62
|
|
|
14
63
|
<template>
|
|
15
|
-
<TooltipProvider :delay-duration="
|
|
16
|
-
<TooltipRoot>
|
|
17
|
-
|
|
64
|
+
<TooltipProvider :delay-duration="delay">
|
|
65
|
+
<TooltipRoot :ignore-non-keyboard-focus="!showOnPointerFocus">
|
|
66
|
+
<!-- `as-child` merges these onto the caller's own element, so the label
|
|
67
|
+
lands on the real button rather than on a wrapper. -->
|
|
68
|
+
<TooltipTrigger as-child :aria-label="label ? content : undefined">
|
|
18
69
|
<slot />
|
|
19
70
|
</TooltipTrigger>
|
|
20
71
|
<TooltipPortal>
|
|
21
72
|
<TooltipContent
|
|
73
|
+
:side="side"
|
|
22
74
|
:side-offset="sideOffset ?? 6"
|
|
23
|
-
:class="[
|
|
75
|
+
:class="[
|
|
76
|
+
PANEL_SURFACE,
|
|
77
|
+
TOOLTIP_ANIM,
|
|
78
|
+
'z-(--z-tooltip) max-w-[min(20rem,90vw)] px-2.5 py-1.5 text-xs text-ink',
|
|
79
|
+
]"
|
|
24
80
|
>
|
|
25
81
|
<slot name="content">{{ content }}</slot>
|
|
26
82
|
</TooltipContent>
|