@leavepulse/ui 0.21.0 → 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 (42) hide show
  1. package/dist/chunks/{LpAddressInput-B411MOJa.js → LpAddressInput-Dah7asXy.js} +1 -1
  2. package/dist/chunks/{LpAppShell-CfM7fdr3.js → LpAppShell-DGZv907S.js} +2 -2
  3. package/dist/chunks/{LpConfirmDialog-D8pBGPOI.js → LpConfirmDialog-CkMzeKdp.js} +1 -1
  4. package/dist/chunks/{LpDrawer-B4cNj39a.js → LpDrawer-DAJN5rGK.js} +1 -1
  5. package/dist/chunks/{LpFileTree-d9nXtSVo.js → LpFileTree-DWx77B1F.js} +1 -1
  6. package/dist/chunks/{LpLogViewer-BXPBUV21.js → LpLogViewer-B1aLUVdz.js} +1 -1
  7. package/dist/chunks/{LpModal-DKFdzKwV.js → LpModal-CNAFRgCU.js} +36 -3
  8. package/dist/chunks/{LpNotificationBell-Nges4kJk.js → LpNotificationBell-HSGo1pcf.js} +1 -1
  9. package/dist/chunks/{LpScrollArea-Du8tW8c5.js → LpScrollArea-D8xyKXcF.js} +10 -2
  10. package/dist/chunks/{LpSidebar-Bd0148dd.js → LpSidebar-JvMaaYsy.js} +2 -2
  11. package/dist/chunks/{LpSidebarNav-BqGpNr4S.js → LpSidebarNav-BIYzk7e_.js} +1 -1
  12. package/dist/chunks/{LpTable-BBKlNR9j.js → LpTable-DTj52qlg.js} +45 -9
  13. package/dist/chunks/{LpTooltip-CIwoRCag.js → LpTooltip-D1hbjQWG.js} +23 -9
  14. package/dist/components/LpAddressInput.vue.js +1 -1
  15. package/dist/components/LpAppShell.vue.js +1 -1
  16. package/dist/components/LpCommandPalette.vue.js +1 -1
  17. package/dist/components/LpConfirmDialog.vue.js +1 -1
  18. package/dist/components/LpDrawer.vue.js +1 -1
  19. package/dist/components/LpFileTree.vue.js +1 -1
  20. package/dist/components/LpLogViewer.vue.js +1 -1
  21. package/dist/components/LpModal.vue.d.ts +16 -5
  22. package/dist/components/LpModal.vue.js +1 -1
  23. package/dist/components/LpNotificationBell.vue.js +1 -1
  24. package/dist/components/LpScrollArea.vue.js +1 -1
  25. package/dist/components/LpShortcutsDialog.vue.js +1 -1
  26. package/dist/components/LpSidebar.vue.js +1 -1
  27. package/dist/components/LpSidebarNav.vue.js +1 -1
  28. package/dist/components/LpTable.vue.js +1 -1
  29. package/dist/components/LpTooltip.vue.d.ts +19 -1
  30. package/dist/components/LpTooltip.vue.js +1 -1
  31. package/dist/composables/useRovingFocus.d.ts +49 -0
  32. package/dist/composables/useRovingFocus.js +160 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +14 -12
  35. package/package.json +1 -1
  36. package/src/components/LpModal.vue +67 -1
  37. package/src/components/LpScrollArea.vue +26 -6
  38. package/src/components/LpTable.vue +57 -2
  39. package/src/components/LpTooltip.vue +57 -5
  40. package/src/composables/useRovingFocus.ts +275 -0
  41. package/src/index.ts +2 -0
  42. 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-B411MOJa.js";
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-CfM7fdr3.js";
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-D8pBGPOI.js";
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-B4cNj39a.js";
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-d9nXtSVo.js";
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-BXPBUV21.js";
35
- import { _ as _31 } from "./chunks/LpModal-DKFdzKwV.js";
36
- import { _ as _32 } from "./chunks/LpNotificationBell-Nges4kJk.js";
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-Du8tW8c5.js";
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-Bd0148dd.js";
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-BBKlNR9j.js";
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-CIwoRCag.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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") {
@@ -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>