@leavepulse/ui 0.51.1 → 0.53.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/{LpTable-D8jvowDb.css → LpTable-BNzavdlv.css} +19 -19
- package/dist/chunks/{LpBreadcrumbs-CQJd73Gm.js → LpBreadcrumbs-D4TsoFju.js} +15 -12
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpTable.vue.d.ts +1 -0
- package/dist/components/LpTable.vue.js +12 -3
- package/dist/composables/spatialNav.d.ts +22 -0
- package/dist/composables/spatialNav.js +72 -0
- package/dist/composables/useGamepadNav.d.ts +14 -0
- package/dist/composables/useGamepadNav.js +137 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -1
- package/package.json +1 -1
- package/src/components/LpBreadcrumbs.vue +12 -8
- package/src/components/LpTable.vue +18 -1
- package/src/composables/spatialNav.ts +119 -0
- package/src/composables/useGamepadNav.ts +189 -0
- package/src/index.ts +4 -0
- package/src/tokens/utilities.css +11 -0
|
@@ -11,17 +11,17 @@
|
|
|
11
11
|
* fade before closing the gap and the whole list lurches at the end instead of
|
|
12
12
|
* gliding.
|
|
13
13
|
*/
|
|
14
|
-
.lp-row-enter-active[data-v-
|
|
15
|
-
.lp-row-leave-active[data-v-
|
|
14
|
+
.lp-row-enter-active[data-v-a6bdc221],
|
|
15
|
+
.lp-row-leave-active[data-v-a6bdc221] {
|
|
16
16
|
transition:
|
|
17
17
|
opacity var(--duration-fast) var(--ease-emphasized),
|
|
18
18
|
transform var(--duration-fast) var(--ease-emphasized);
|
|
19
19
|
}
|
|
20
|
-
.lp-row-move[data-v-
|
|
20
|
+
.lp-row-move[data-v-a6bdc221] {
|
|
21
21
|
transition: transform var(--duration-medium) var(--ease-emphasized);
|
|
22
22
|
}
|
|
23
|
-
.lp-row-enter-from[data-v-
|
|
24
|
-
.lp-row-leave-to[data-v-
|
|
23
|
+
.lp-row-enter-from[data-v-a6bdc221],
|
|
24
|
+
.lp-row-leave-to[data-v-a6bdc221] {
|
|
25
25
|
opacity: 0;
|
|
26
26
|
transform: translateX(-0.5rem);
|
|
27
27
|
}
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
* drops out of the table layout entirely. Nothing sits below this row to be
|
|
36
36
|
* dragged around, so simply not animating its exit costs nothing.
|
|
37
37
|
*/
|
|
38
|
-
.lp-row-empty.lp-row-leave-active[data-v-
|
|
38
|
+
.lp-row-empty.lp-row-leave-active[data-v-a6bdc221] {
|
|
39
39
|
display: none;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
* once anything is selected. An earlier version collapsed the column to nothing
|
|
55
55
|
* and floated the hover box over the first cell, where it sat on the text.
|
|
56
56
|
*/
|
|
57
|
-
.lp-tick[data-v-
|
|
57
|
+
.lp-tick[data-v-a6bdc221] {
|
|
58
58
|
display: flex;
|
|
59
59
|
align-items: center;
|
|
60
60
|
max-width: 0;
|
|
@@ -66,17 +66,17 @@
|
|
|
66
66
|
padding var(--duration-medium) var(--ease-emphasized),
|
|
67
67
|
opacity var(--duration-fast) ease;
|
|
68
68
|
}
|
|
69
|
-
.lp-tick-open[data-v-
|
|
69
|
+
.lp-tick-open[data-v-a6bdc221] {
|
|
70
70
|
max-width: 3.25rem;
|
|
71
71
|
padding-inline: 1rem;
|
|
72
72
|
opacity: 1;
|
|
73
73
|
}
|
|
74
|
-
.lp-tick-hidden[data-v-
|
|
75
|
-
.lp-tick-cue[data-v-
|
|
74
|
+
.lp-tick-hidden[data-v-a6bdc221],
|
|
75
|
+
.lp-tick-cue[data-v-a6bdc221] {
|
|
76
76
|
opacity: 0;
|
|
77
77
|
}
|
|
78
|
-
tr:hover .lp-tick-cue[data-v-
|
|
79
|
-
.lp-tick-cue[data-v-
|
|
78
|
+
tr:hover .lp-tick-cue[data-v-a6bdc221],
|
|
79
|
+
.lp-tick-cue[data-v-a6bdc221]:focus-within {
|
|
80
80
|
opacity: 1;
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -84,24 +84,24 @@ tr:hover .lp-tick-cue[data-v-602c1271],
|
|
|
84
84
|
turned down the rows change without animating rather than not changing. */
|
|
85
85
|
/* Only where there is a hover to reveal it; a finger long-presses instead. */
|
|
86
86
|
@media (hover: none) {
|
|
87
|
-
tr:hover .lp-tick-cue[data-v-
|
|
87
|
+
tr:hover .lp-tick-cue[data-v-a6bdc221] {
|
|
88
88
|
opacity: 0;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/* Nor is there anything for the column's width to hold until then: on a
|
|
92
92
|
touch screen it stays closed until a long press starts selecting, and
|
|
93
93
|
eases open like the rest of the column. */
|
|
94
|
-
.lp-tick-hidden[data-v-
|
|
95
|
-
.lp-tick-cue[data-v-
|
|
94
|
+
.lp-tick-hidden[data-v-a6bdc221],
|
|
95
|
+
.lp-tick-cue[data-v-a6bdc221] {
|
|
96
96
|
max-width: 0;
|
|
97
97
|
padding-inline: 0;
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
@media (prefers-reduced-motion: reduce) {
|
|
101
|
-
.lp-row-enter-active[data-v-
|
|
102
|
-
.lp-row-leave-active[data-v-
|
|
103
|
-
.lp-row-move[data-v-
|
|
104
|
-
.lp-tick[data-v-
|
|
101
|
+
.lp-row-enter-active[data-v-a6bdc221],
|
|
102
|
+
.lp-row-leave-active[data-v-a6bdc221],
|
|
103
|
+
.lp-row-move[data-v-a6bdc221],
|
|
104
|
+
.lp-tick[data-v-a6bdc221] {
|
|
105
105
|
transition: none;
|
|
106
106
|
}
|
|
107
107
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { defineComponent, computed, openBlock, createElementBlock, mergeProps, unref, createElementVNode, normalizeClass, Fragment, renderList, createBlock, createCommentVNode, resolveDynamicComponent, withCtx,
|
|
1
|
+
import { defineComponent, computed, openBlock, createElementBlock, mergeProps, unref, createElementVNode, normalizeClass, Fragment, renderList, createBlock, createCommentVNode, resolveDynamicComponent, withCtx, toDisplayString } from "vue";
|
|
2
2
|
import { _ as _sfc_main$1 } from "./LpIcon-DYtaid0K.js";
|
|
3
3
|
import { useLabels, useLinkComponent } from "../composables/useLpConfig.js";
|
|
4
4
|
import { useMergedAttrs } from "../composables/useMergedClass.js";
|
|
5
5
|
import { resolveLink } from "../components/link.js";
|
|
6
6
|
const _hoisted_1 = ["aria-label"];
|
|
7
|
-
const _hoisted_2 =
|
|
7
|
+
const _hoisted_2 = { class: "truncate" };
|
|
8
|
+
const _hoisted_3 = ["aria-current"];
|
|
9
|
+
const _hoisted_4 = { class: "truncate" };
|
|
8
10
|
const __default__ = { inheritAttrs: false };
|
|
9
11
|
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
10
12
|
...__default__,
|
|
@@ -34,11 +36,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
34
36
|
}))
|
|
35
37
|
);
|
|
36
38
|
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
37
|
-
"flex items-center gap-1.5 text-sm"
|
|
39
|
+
"flex min-w-0 items-center gap-1.5 text-sm"
|
|
38
40
|
);
|
|
39
41
|
return (_ctx, _cache) => {
|
|
40
42
|
return openBlock(), createElementBlock("nav", mergeProps({
|
|
41
|
-
"aria-label": unref(text).nav
|
|
43
|
+
"aria-label": unref(text).nav,
|
|
44
|
+
class: "min-w-0"
|
|
42
45
|
}, unref(rest)), [
|
|
43
46
|
createElementVNode("ol", {
|
|
44
47
|
class: normalizeClass(unref(rootClass))
|
|
@@ -46,20 +49,20 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
46
49
|
(openBlock(true), createElementBlock(Fragment, null, renderList(crumbs.value, ({ item, last, link }, i) => {
|
|
47
50
|
return openBlock(), createElementBlock("li", {
|
|
48
51
|
key: i,
|
|
49
|
-
class: "inline-flex items-center gap-1.5"
|
|
52
|
+
class: normalizeClass(["inline-flex min-w-0 items-center gap-1.5", last ? "shrink" : "shrink-0"])
|
|
50
53
|
}, [
|
|
51
54
|
i > 0 ? (openBlock(), createBlock(_sfc_main$1, {
|
|
52
55
|
key: 0,
|
|
53
56
|
name: "lucide:chevron-right",
|
|
54
57
|
size: 14,
|
|
55
|
-
class: "text-muted",
|
|
58
|
+
class: "shrink-0 text-muted",
|
|
56
59
|
"aria-hidden": "true"
|
|
57
60
|
})) : createCommentVNode("", true),
|
|
58
61
|
link ? (openBlock(), createBlock(resolveDynamicComponent(link.is), mergeProps({
|
|
59
62
|
key: 1,
|
|
60
63
|
ref_for: true
|
|
61
64
|
}, link.props, {
|
|
62
|
-
class: "inline-flex items-center gap-1.5 rounded-xs text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring",
|
|
65
|
+
class: "inline-flex min-w-0 items-center gap-1.5 rounded-xs text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring",
|
|
63
66
|
onClick: ($event) => _ctx.$emit("navigate", item, i)
|
|
64
67
|
}), {
|
|
65
68
|
default: withCtx(() => [
|
|
@@ -68,12 +71,12 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
68
71
|
name: item.icon,
|
|
69
72
|
size: 14
|
|
70
73
|
}, null, 8, ["name"])) : createCommentVNode("", true),
|
|
71
|
-
|
|
74
|
+
createElementVNode("span", _hoisted_2, toDisplayString(item.label), 1)
|
|
72
75
|
]),
|
|
73
76
|
_: 2
|
|
74
77
|
}, 1040, ["onClick"])) : (openBlock(), createElementBlock("span", {
|
|
75
78
|
key: 2,
|
|
76
|
-
class: normalizeClass(["inline-flex items-center gap-1.5", last ? "text-ink" : "text-muted"]),
|
|
79
|
+
class: normalizeClass(["inline-flex min-w-0 items-center gap-1.5", last ? "text-ink" : "text-muted"]),
|
|
77
80
|
"aria-current": last ? "page" : void 0
|
|
78
81
|
}, [
|
|
79
82
|
item.icon ? (openBlock(), createBlock(_sfc_main$1, {
|
|
@@ -81,9 +84,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
81
84
|
name: item.icon,
|
|
82
85
|
size: 14
|
|
83
86
|
}, null, 8, ["name"])) : createCommentVNode("", true),
|
|
84
|
-
|
|
85
|
-
], 10,
|
|
86
|
-
]);
|
|
87
|
+
createElementVNode("span", _hoisted_4, toDisplayString(item.label), 1)
|
|
88
|
+
], 10, _hoisted_3))
|
|
89
|
+
], 2);
|
|
87
90
|
}), 128))
|
|
88
91
|
], 2)
|
|
89
92
|
], 16, _hoisted_1);
|
|
@@ -18,7 +18,7 @@ import { _ as _sfc_main$1 } from "../chunks/LpButton-CKSwC6Mu.js";
|
|
|
18
18
|
import { toCsv, downloadFile, fileStem } from "./exportData.js";
|
|
19
19
|
import { summarizeShown, parseTableState, serializeTableState, countValues } from "./tableFilter.js";
|
|
20
20
|
import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
|
|
21
|
-
import '../assets/LpTable-
|
|
21
|
+
import '../assets/LpTable-BNzavdlv.css';const _hoisted_1 = {
|
|
22
22
|
key: 0,
|
|
23
23
|
class: "flex justify-end"
|
|
24
24
|
};
|
|
@@ -164,6 +164,14 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
164
164
|
function onRowContextMenu(row) {
|
|
165
165
|
menuRow.value = row;
|
|
166
166
|
}
|
|
167
|
+
function openRowMenu(event) {
|
|
168
|
+
const button = event.currentTarget;
|
|
169
|
+
if (!(button instanceof HTMLElement)) return;
|
|
170
|
+
const rect = button.getBoundingClientRect();
|
|
171
|
+
button.dispatchEvent(
|
|
172
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: rect.left, clientY: rect.bottom })
|
|
173
|
+
);
|
|
174
|
+
}
|
|
167
175
|
const contextMenu = computed(() => {
|
|
168
176
|
if (paint.painting.value) return [];
|
|
169
177
|
const table = tableMenu();
|
|
@@ -917,7 +925,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
917
925
|
}, [
|
|
918
926
|
renderSlot(_ctx.$slots, `cell-${col.key}`, {
|
|
919
927
|
row,
|
|
920
|
-
value: row[col.key]
|
|
928
|
+
value: row[col.key],
|
|
929
|
+
openMenu: openRowMenu
|
|
921
930
|
}, () => [
|
|
922
931
|
createTextVNode(toDisplayString(row[col.key]), 1)
|
|
923
932
|
], true)
|
|
@@ -947,7 +956,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
|
947
956
|
};
|
|
948
957
|
}
|
|
949
958
|
});
|
|
950
|
-
const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-
|
|
959
|
+
const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-a6bdc221"]]);
|
|
951
960
|
export {
|
|
952
961
|
LpTable as default
|
|
953
962
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Direction = "up" | "down" | "left" | "right";
|
|
2
|
+
export interface Box {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The index of the box to move to from `from` in `direction`, or -1 when
|
|
10
|
+
* nothing lies that way. Only boxes whose near edge is past `from`'s far edge
|
|
11
|
+
* in that direction count (so a move never goes backwards or sideways only);
|
|
12
|
+
* among those the nearest wins, measured between the facing edges along the
|
|
13
|
+
* direction plus the weighted offset across it, which picks the neighbour in
|
|
14
|
+
* the same row or column over one diagonally closer.
|
|
15
|
+
*/
|
|
16
|
+
export declare function nextInDirection(from: Box, candidates: Box[], direction: Direction): number;
|
|
17
|
+
/**
|
|
18
|
+
* The elements under `root` a directional move can land on: focusable, not
|
|
19
|
+
* disabled, not taken out of the tab order on purpose, not inert, and
|
|
20
|
+
* actually on screen.
|
|
21
|
+
*/
|
|
22
|
+
export declare function focusableIn(root: ParentNode): HTMLElement[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const SIDEWAYS_WEIGHT = 2;
|
|
2
|
+
function nextInDirection(from, candidates, direction) {
|
|
3
|
+
let best = -1;
|
|
4
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
5
|
+
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 };
|
|
6
|
+
candidates.forEach((box, index) => {
|
|
7
|
+
const ahead = distanceAhead(from, box, direction);
|
|
8
|
+
if (ahead < 0) return;
|
|
9
|
+
const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
10
|
+
const across = direction === "left" || direction === "right" ? overlapGap(from.y, from.height, box.y, box.height, fromCenter.y, center.y) : overlapGap(from.x, from.width, box.x, box.width, fromCenter.x, center.x);
|
|
11
|
+
const score = ahead + across * SIDEWAYS_WEIGHT;
|
|
12
|
+
if (score < bestScore) {
|
|
13
|
+
bestScore = score;
|
|
14
|
+
best = index;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
return best;
|
|
18
|
+
}
|
|
19
|
+
function distanceAhead(from, box, direction) {
|
|
20
|
+
const slack = 1;
|
|
21
|
+
switch (direction) {
|
|
22
|
+
case "right":
|
|
23
|
+
return box.x + slack >= from.x + from.width ? box.x - (from.x + from.width) : -1;
|
|
24
|
+
case "left":
|
|
25
|
+
return box.x + box.width - slack <= from.x ? from.x - (box.x + box.width) : -1;
|
|
26
|
+
case "down":
|
|
27
|
+
return box.y + slack >= from.y + from.height ? box.y - (from.y + from.height) : -1;
|
|
28
|
+
case "up":
|
|
29
|
+
return box.y + box.height - slack <= from.y ? from.y - (box.y + box.height) : -1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function overlapGap(start, size, otherStart, otherSize, center, otherCenter) {
|
|
33
|
+
const overlaps = otherStart < start + size && start < otherStart + otherSize;
|
|
34
|
+
return overlaps ? 0 : Math.abs(otherCenter - center);
|
|
35
|
+
}
|
|
36
|
+
const FOCUSABLE = [
|
|
37
|
+
"a[href]",
|
|
38
|
+
"button",
|
|
39
|
+
"input",
|
|
40
|
+
"select",
|
|
41
|
+
"textarea",
|
|
42
|
+
"summary",
|
|
43
|
+
"[tabindex]",
|
|
44
|
+
"[contenteditable='true']"
|
|
45
|
+
].join(",");
|
|
46
|
+
const ROVING_ITEMS = [
|
|
47
|
+
"button",
|
|
48
|
+
"a[href]",
|
|
49
|
+
"[role=option]",
|
|
50
|
+
"[role=menuitem]",
|
|
51
|
+
"[role=menuitemcheckbox]",
|
|
52
|
+
"[role=menuitemradio]",
|
|
53
|
+
"[role=tab]",
|
|
54
|
+
"[role=radio]",
|
|
55
|
+
"[role=row]",
|
|
56
|
+
"[role=gridcell]",
|
|
57
|
+
"[role=treeitem]"
|
|
58
|
+
].join(",");
|
|
59
|
+
function focusableIn(root) {
|
|
60
|
+
return [...root.querySelectorAll(FOCUSABLE)].filter((element) => {
|
|
61
|
+
if (element.matches(":disabled") || element.closest("[inert]")) return false;
|
|
62
|
+
if (element.getAttribute("tabindex") === "-1" && !element.matches(ROVING_ITEMS)) return false;
|
|
63
|
+
const rect = element.getBoundingClientRect();
|
|
64
|
+
if (rect.width === 0 || rect.height === 0) return false;
|
|
65
|
+
const style = getComputedStyle(element);
|
|
66
|
+
return style.visibility !== "hidden" && style.display !== "none";
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export {
|
|
70
|
+
focusableIn,
|
|
71
|
+
nextInDirection
|
|
72
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter } from "vue";
|
|
2
|
+
export interface UseGamepadNavOptions {
|
|
3
|
+
enabled?: MaybeRefOrGetter<boolean>;
|
|
4
|
+
/** B with no dialog open: go back, as a browser's back would. */
|
|
5
|
+
onBack?: () => void;
|
|
6
|
+
/** Left and right bumpers: the previous and next section, tab or page. */
|
|
7
|
+
onPrevious?: () => void;
|
|
8
|
+
onNext?: () => void;
|
|
9
|
+
/** Start (the menu button): a menu or search, say. */
|
|
10
|
+
onMenu?: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare function useGamepadNav(options?: UseGamepadNavOptions): {
|
|
13
|
+
connected: import("vue").Ref<boolean, boolean>;
|
|
14
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { ref, onMounted, onBeforeUnmount, toValue } from "vue";
|
|
2
|
+
import { focusableIn, nextInDirection } from "./spatialNav.js";
|
|
3
|
+
const BUTTON = { a: 0, b: 1, lb: 4, rb: 5, start: 9, up: 12, down: 13, left: 14, right: 15 };
|
|
4
|
+
const STICK_THRESHOLD = 0.55;
|
|
5
|
+
const REPEAT_DELAY_MS = 380;
|
|
6
|
+
const REPEAT_MS = 110;
|
|
7
|
+
const SCROLL_SPEED = 18;
|
|
8
|
+
const OPEN_DIALOG = "[role=dialog][data-state=open], [role=alertdialog][data-state=open], [role=menu][data-state=open], [role=listbox][data-state=open]";
|
|
9
|
+
function useGamepadNav(options = {}) {
|
|
10
|
+
const connected = ref(false);
|
|
11
|
+
let frame = 0;
|
|
12
|
+
const held = /* @__PURE__ */ new Map();
|
|
13
|
+
function enabled() {
|
|
14
|
+
return options.enabled === void 0 ? true : toValue(options.enabled);
|
|
15
|
+
}
|
|
16
|
+
function scope() {
|
|
17
|
+
const layers = document.querySelectorAll(OPEN_DIALOG);
|
|
18
|
+
return layers[layers.length - 1] ?? document.body;
|
|
19
|
+
}
|
|
20
|
+
function markGamepad() {
|
|
21
|
+
document.documentElement.dataset.input = "gamepad";
|
|
22
|
+
}
|
|
23
|
+
function focus(element) {
|
|
24
|
+
element.setAttribute("data-lp-focus-ring", "");
|
|
25
|
+
element.addEventListener("blur", () => element.removeAttribute("data-lp-focus-ring"), { once: true });
|
|
26
|
+
element.focus({ preventScroll: true });
|
|
27
|
+
element.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
28
|
+
}
|
|
29
|
+
function move(direction) {
|
|
30
|
+
const candidates = focusableIn(scope());
|
|
31
|
+
if (!candidates.length) return;
|
|
32
|
+
const current = document.activeElement;
|
|
33
|
+
if (!current || !candidates.includes(current)) {
|
|
34
|
+
focus(candidates[0]);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const others = candidates.filter((element) => element !== current);
|
|
38
|
+
const next = nextInDirection(
|
|
39
|
+
current.getBoundingClientRect(),
|
|
40
|
+
others.map((element) => element.getBoundingClientRect()),
|
|
41
|
+
direction
|
|
42
|
+
);
|
|
43
|
+
if (next >= 0) focus(others[next]);
|
|
44
|
+
}
|
|
45
|
+
function press() {
|
|
46
|
+
const current = document.activeElement;
|
|
47
|
+
if (!current || current === document.body) return;
|
|
48
|
+
if (current.matches("input:not([type=checkbox]):not([type=radio]), textarea, [contenteditable=true]")) {
|
|
49
|
+
current.focus();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
current.click();
|
|
53
|
+
}
|
|
54
|
+
function back() {
|
|
55
|
+
if (document.querySelector(OPEN_DIALOG)) {
|
|
56
|
+
const target = document.activeElement ?? document.body;
|
|
57
|
+
target.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true }));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
options.onBack?.();
|
|
61
|
+
}
|
|
62
|
+
function edge(key, down, now, act, repeats) {
|
|
63
|
+
const since = held.get(key);
|
|
64
|
+
if (!down) {
|
|
65
|
+
held.delete(key);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (since === void 0) {
|
|
69
|
+
held.set(key, now);
|
|
70
|
+
act();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (repeats && now - since >= REPEAT_DELAY_MS) {
|
|
74
|
+
held.set(key, now - REPEAT_DELAY_MS + REPEAT_MS);
|
|
75
|
+
act();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function scrollBy(dx, dy) {
|
|
79
|
+
let element = document.activeElement;
|
|
80
|
+
while (element && element !== document.body) {
|
|
81
|
+
const style = getComputedStyle(element);
|
|
82
|
+
if (/(auto|scroll)/.test(style.overflowY + style.overflowX) && element.scrollHeight > element.clientHeight) break;
|
|
83
|
+
element = element.parentElement;
|
|
84
|
+
}
|
|
85
|
+
(element && element !== document.body ? element : document.scrollingElement)?.scrollBy(dx, dy);
|
|
86
|
+
}
|
|
87
|
+
function poll(now) {
|
|
88
|
+
frame = requestAnimationFrame(poll);
|
|
89
|
+
if (!enabled() || document.hidden) return;
|
|
90
|
+
const pad = [...navigator.getGamepads()].find((entry) => entry?.connected && entry.mapping === "standard");
|
|
91
|
+
if (!pad) return;
|
|
92
|
+
const pressed = (index) => pad.buttons[index]?.pressed ?? false;
|
|
93
|
+
const [lx = 0, ly = 0, rx = 0, ry = 0] = pad.axes;
|
|
94
|
+
const before = held.size;
|
|
95
|
+
edge("up", pressed(BUTTON.up) || ly < -STICK_THRESHOLD, now, () => move("up"), true);
|
|
96
|
+
edge("down", pressed(BUTTON.down) || ly > STICK_THRESHOLD, now, () => move("down"), true);
|
|
97
|
+
edge("left", pressed(BUTTON.left) || lx < -STICK_THRESHOLD, now, () => move("left"), true);
|
|
98
|
+
edge("right", pressed(BUTTON.right) || lx > STICK_THRESHOLD, now, () => move("right"), true);
|
|
99
|
+
edge("a", pressed(BUTTON.a), now, press, false);
|
|
100
|
+
edge("b", pressed(BUTTON.b), now, back, false);
|
|
101
|
+
edge("lb", pressed(BUTTON.lb), now, () => options.onPrevious?.(), false);
|
|
102
|
+
edge("rb", pressed(BUTTON.rb), now, () => options.onNext?.(), false);
|
|
103
|
+
edge("start", pressed(BUTTON.start), now, () => options.onMenu?.(), false);
|
|
104
|
+
const scrollX = Math.abs(rx) > 0.2 ? rx * SCROLL_SPEED : 0;
|
|
105
|
+
const scrollY = Math.abs(ry) > 0.2 ? ry * SCROLL_SPEED : 0;
|
|
106
|
+
if (scrollX || scrollY) scrollBy(scrollX, scrollY);
|
|
107
|
+
if (held.size > before || scrollX || scrollY) markGamepad();
|
|
108
|
+
}
|
|
109
|
+
function onConnect() {
|
|
110
|
+
connected.value = [...navigator.getGamepads()].some((entry) => entry?.connected);
|
|
111
|
+
cancelAnimationFrame(frame);
|
|
112
|
+
if (connected.value) frame = requestAnimationFrame(poll);
|
|
113
|
+
}
|
|
114
|
+
function onOtherInput(event) {
|
|
115
|
+
if (!event.isTrusted) return;
|
|
116
|
+
if (document.documentElement.dataset.input === "gamepad") delete document.documentElement.dataset.input;
|
|
117
|
+
}
|
|
118
|
+
onMounted(() => {
|
|
119
|
+
if (typeof navigator === "undefined" || !("getGamepads" in navigator)) return;
|
|
120
|
+
window.addEventListener("gamepadconnected", onConnect);
|
|
121
|
+
window.addEventListener("gamepaddisconnected", onConnect);
|
|
122
|
+
window.addEventListener("pointerdown", onOtherInput, { capture: true, passive: true });
|
|
123
|
+
window.addEventListener("keydown", onOtherInput, { capture: true, passive: true });
|
|
124
|
+
onConnect();
|
|
125
|
+
});
|
|
126
|
+
onBeforeUnmount(() => {
|
|
127
|
+
cancelAnimationFrame(frame);
|
|
128
|
+
window.removeEventListener("gamepadconnected", onConnect);
|
|
129
|
+
window.removeEventListener("gamepaddisconnected", onConnect);
|
|
130
|
+
window.removeEventListener("pointerdown", onOtherInput, { capture: true });
|
|
131
|
+
window.removeEventListener("keydown", onOtherInput, { capture: true });
|
|
132
|
+
});
|
|
133
|
+
return { connected };
|
|
134
|
+
}
|
|
135
|
+
export {
|
|
136
|
+
useGamepadNav
|
|
137
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -187,6 +187,10 @@ export { findHotkeyConflicts, hotkeyConflicts, hotkeySteps, matchesHotkey, pendi
|
|
|
187
187
|
export type { HotkeyConflict, HotkeyStep } from "./composables/useHotkeys";
|
|
188
188
|
export { anyModalOpen, topModalElement, useModalLayer } from "./components/modalDepth";
|
|
189
189
|
export type { ModalLayer, ModalLayerOptions } from "./components/modalDepth";
|
|
190
|
+
export { useGamepadNav } from "./composables/useGamepadNav";
|
|
191
|
+
export type { UseGamepadNavOptions } from "./composables/useGamepadNav";
|
|
192
|
+
export { focusableIn, nextInDirection } from "./composables/spatialNav";
|
|
193
|
+
export type { Box, Direction } from "./composables/spatialNav";
|
|
190
194
|
export { provideGlass, useGlass } from "./composables/useGlass";
|
|
191
195
|
export { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig";
|
|
192
196
|
export type { LabelScope, LpConfig } from "./composables/useLpConfig";
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { _ as _6 } from "./chunks/LpAppShell-BmMYcEVx.js";
|
|
|
8
8
|
import { _ as _7 } from "./chunks/LpAutocomplete-C8IgSMsi.js";
|
|
9
9
|
import { _ as _8 } from "./chunks/LpAvatar-Cb1jtOVv.js";
|
|
10
10
|
import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
|
|
11
|
-
import { _ as _10 } from "./chunks/LpBreadcrumbs-
|
|
11
|
+
import { _ as _10 } from "./chunks/LpBreadcrumbs-D4TsoFju.js";
|
|
12
12
|
import { _ as _11 } from "./chunks/LpButton-CKSwC6Mu.js";
|
|
13
13
|
import { _ as _12 } from "./chunks/LpCalendar-DrA2McTU.js";
|
|
14
14
|
import { _ as _13 } from "./chunks/LpCard-BdxkMXjo.js";
|
|
@@ -116,6 +116,8 @@ import { D, G, N, T, b as b2, c as c2, d, e, f, g, h, i as i2, l, j, n, k, m, r,
|
|
|
116
116
|
import { useClipboard } from "./composables/useClipboard.js";
|
|
117
117
|
import { activeShortcuts, appleModifierLabel, findHotkeyConflicts, hotkeyConflicts, hotkeySteps, isAppleKeyboard, matchesHotkey, mergeShortcutGroups, pendingHotkey, sequenceProgress, useAppleKeyboard, useHotkeys } from "./composables/useHotkeys.js";
|
|
118
118
|
import { anyModalOpen, topModalElement, useModalLayer } from "./components/modalDepth.js";
|
|
119
|
+
import { useGamepadNav } from "./composables/useGamepadNav.js";
|
|
120
|
+
import { focusableIn, nextInDirection } from "./composables/spatialNav.js";
|
|
119
121
|
import { provideGlass, useGlass } from "./composables/useGlass.js";
|
|
120
122
|
import { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig.js";
|
|
121
123
|
import { mergeClass, useMergedAttrs, useMergedClass } from "./composables/useMergedClass.js";
|
|
@@ -325,6 +327,7 @@ export {
|
|
|
325
327
|
filterCountries,
|
|
326
328
|
findHotkeyConflicts,
|
|
327
329
|
flagEmoji,
|
|
330
|
+
focusableIn,
|
|
328
331
|
formatAddress,
|
|
329
332
|
formatClipTime,
|
|
330
333
|
formatColor,
|
|
@@ -360,6 +363,7 @@ export {
|
|
|
360
363
|
mergeConfig,
|
|
361
364
|
mergeShortcutGroups,
|
|
362
365
|
moveLeaf,
|
|
366
|
+
nextInDirection,
|
|
363
367
|
niceScale,
|
|
364
368
|
n as nord,
|
|
365
369
|
normalizeHue,
|
|
@@ -434,6 +438,7 @@ export {
|
|
|
434
438
|
useFieldNotice,
|
|
435
439
|
useForm,
|
|
436
440
|
useFormDraft,
|
|
441
|
+
useGamepadNav,
|
|
437
442
|
useGlass,
|
|
438
443
|
useHotkeys,
|
|
439
444
|
useInputFilter,
|
package/package.json
CHANGED
|
@@ -53,43 +53,47 @@ const crumbs = computed(() =>
|
|
|
53
53
|
|
|
54
54
|
// The trail sets its own gap, which a dense header bar needs to reduce.
|
|
55
55
|
const { class: rootClass, attrs: rest } = useMergedAttrs(
|
|
56
|
-
"flex items-center gap-1.5 text-sm",
|
|
56
|
+
"flex min-w-0 items-center gap-1.5 text-sm",
|
|
57
57
|
)
|
|
58
58
|
</script>
|
|
59
59
|
|
|
60
60
|
<template>
|
|
61
|
-
|
|
61
|
+
<!-- One line however long the labels: the earlier crumbs keep their size
|
|
62
|
+
and the current one gives way with an ellipsis. Wrapping, a long name
|
|
63
|
+
pushed the trail onto three lines over whatever bar held it. -->
|
|
64
|
+
<nav :aria-label="text.nav" class="min-w-0" v-bind="rest">
|
|
62
65
|
<ol :class="rootClass">
|
|
63
66
|
<li
|
|
64
67
|
v-for="({ item, last, link }, i) in crumbs"
|
|
65
68
|
:key="i"
|
|
66
|
-
class="inline-flex items-center gap-1.5"
|
|
69
|
+
class="inline-flex min-w-0 items-center gap-1.5"
|
|
70
|
+
:class="last ? 'shrink' : 'shrink-0'"
|
|
67
71
|
>
|
|
68
72
|
<LpIcon
|
|
69
73
|
v-if="i > 0"
|
|
70
74
|
name="lucide:chevron-right"
|
|
71
75
|
:size="14"
|
|
72
|
-
class="text-muted"
|
|
76
|
+
class="shrink-0 text-muted"
|
|
73
77
|
aria-hidden="true"
|
|
74
78
|
/>
|
|
75
79
|
<component
|
|
76
80
|
:is="link.is"
|
|
77
81
|
v-if="link"
|
|
78
82
|
v-bind="link.props"
|
|
79
|
-
class="inline-flex items-center gap-1.5 rounded-xs text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
83
|
+
class="inline-flex min-w-0 items-center gap-1.5 rounded-xs text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink focus-visible:ring-2 focus-visible:ring-ring"
|
|
80
84
|
@click="$emit('navigate', item, i)"
|
|
81
85
|
>
|
|
82
86
|
<LpIcon v-if="item.icon" :name="item.icon" :size="14" />
|
|
83
|
-
{{ item.label }}
|
|
87
|
+
<span class="truncate">{{ item.label }}</span>
|
|
84
88
|
</component>
|
|
85
89
|
<span
|
|
86
90
|
v-else
|
|
87
|
-
class="inline-flex items-center gap-1.5"
|
|
91
|
+
class="inline-flex min-w-0 items-center gap-1.5"
|
|
88
92
|
:class="last ? 'text-ink' : 'text-muted'"
|
|
89
93
|
:aria-current="last ? 'page' : undefined"
|
|
90
94
|
>
|
|
91
95
|
<LpIcon v-if="item.icon" :name="item.icon" :size="14" />
|
|
92
|
-
{{ item.label }}
|
|
96
|
+
<span class="truncate">{{ item.label }}</span>
|
|
93
97
|
</span>
|
|
94
98
|
</li>
|
|
95
99
|
</ol>
|
|
@@ -355,6 +355,23 @@ function onRowContextMenu(row: T) {
|
|
|
355
355
|
menuRow.value = row
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
+
/*
|
|
359
|
+
* The same menu on a button of the row's own, a "more" button in an actions
|
|
360
|
+
* cell: cell slots get `openMenu` for its click. A dropdown per row stood up
|
|
361
|
+
* the full reka menu stack in every row, closed — some twenty components a
|
|
362
|
+
* row, which on a phone made a list of a few hundred rows take most of a
|
|
363
|
+
* second to appear. Here the click becomes a right-click at the button's
|
|
364
|
+
* corner, which the row remembers on its way up and the shared menu opens at.
|
|
365
|
+
*/
|
|
366
|
+
function openRowMenu(event: Event) {
|
|
367
|
+
const button = event.currentTarget
|
|
368
|
+
if (!(button instanceof HTMLElement)) return
|
|
369
|
+
const rect = button.getBoundingClientRect()
|
|
370
|
+
button.dispatchEvent(
|
|
371
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: rect.left, clientY: rect.bottom }),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
358
375
|
// Row items first — they are what the pointer is actually on — then the
|
|
359
376
|
// table-wide ones under a rule, when the row has any of its own.
|
|
360
377
|
const contextMenu = computed<ContextMenuItemDef[]>(() => {
|
|
@@ -1300,7 +1317,7 @@ const barInsetTop = computed(() =>
|
|
|
1300
1317
|
class="px-4 text-ink"
|
|
1301
1318
|
:class="[alignClass(col.align), cellY, col.nowrap ? 'whitespace-nowrap' : '']"
|
|
1302
1319
|
>
|
|
1303
|
-
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
|
|
1320
|
+
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :open-menu="openRowMenu">
|
|
1304
1321
|
{{ row[col.key] }}
|
|
1305
1322
|
</slot>
|
|
1306
1323
|
</td>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Moving focus by direction rather than by document order: what a D-pad, a
|
|
3
|
+
* stick or a TV remote needs, where "next" means "the thing to the right", not
|
|
4
|
+
* "the thing after it in the markup".
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type Direction = "up" | "down" | "left" | "right"
|
|
8
|
+
|
|
9
|
+
export interface Box {
|
|
10
|
+
x: number
|
|
11
|
+
y: number
|
|
12
|
+
width: number
|
|
13
|
+
height: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
* How far off the line of travel a candidate may sit, relative to how far
|
|
18
|
+
* along it is. A move right should prefer the button beside, not the one
|
|
19
|
+
* further right and a row below; the weight makes a sideways offset cost more
|
|
20
|
+
* than the same distance straight ahead.
|
|
21
|
+
*/
|
|
22
|
+
const SIDEWAYS_WEIGHT = 2
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The index of the box to move to from `from` in `direction`, or -1 when
|
|
26
|
+
* nothing lies that way. Only boxes whose near edge is past `from`'s far edge
|
|
27
|
+
* in that direction count (so a move never goes backwards or sideways only);
|
|
28
|
+
* among those the nearest wins, measured between the facing edges along the
|
|
29
|
+
* direction plus the weighted offset across it, which picks the neighbour in
|
|
30
|
+
* the same row or column over one diagonally closer.
|
|
31
|
+
*/
|
|
32
|
+
export function nextInDirection(from: Box, candidates: Box[], direction: Direction): number {
|
|
33
|
+
let best = -1
|
|
34
|
+
let bestScore = Number.POSITIVE_INFINITY
|
|
35
|
+
const fromCenter = { x: from.x + from.width / 2, y: from.y + from.height / 2 }
|
|
36
|
+
candidates.forEach((box, index) => {
|
|
37
|
+
const ahead = distanceAhead(from, box, direction)
|
|
38
|
+
if (ahead < 0) return
|
|
39
|
+
const center = { x: box.x + box.width / 2, y: box.y + box.height / 2 }
|
|
40
|
+
const across = direction === "left" || direction === "right"
|
|
41
|
+
? overlapGap(from.y, from.height, box.y, box.height, fromCenter.y, center.y)
|
|
42
|
+
: overlapGap(from.x, from.width, box.x, box.width, fromCenter.x, center.x)
|
|
43
|
+
const score = ahead + across * SIDEWAYS_WEIGHT
|
|
44
|
+
if (score < bestScore) {
|
|
45
|
+
bestScore = score
|
|
46
|
+
best = index
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
return best
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Gap between the facing edges along `direction`; negative when `box` is not ahead. */
|
|
53
|
+
function distanceAhead(from: Box, box: Box, direction: Direction): number {
|
|
54
|
+
// A small tolerance, so boxes that touch or overlap by a pixel still count.
|
|
55
|
+
const slack = 1
|
|
56
|
+
switch (direction) {
|
|
57
|
+
case "right":
|
|
58
|
+
return box.x + slack >= from.x + from.width ? box.x - (from.x + from.width) : -1
|
|
59
|
+
case "left":
|
|
60
|
+
return box.x + box.width - slack <= from.x ? from.x - (box.x + box.width) : -1
|
|
61
|
+
case "down":
|
|
62
|
+
return box.y + slack >= from.y + from.height ? box.y - (from.y + from.height) : -1
|
|
63
|
+
case "up":
|
|
64
|
+
return box.y + box.height - slack <= from.y ? from.y - (box.y + box.height) : -1
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How far apart two spans are across the line of travel: zero when they
|
|
70
|
+
* overlap (the same row or column), otherwise the distance between their
|
|
71
|
+
* centres, so a box straight ahead always beats one beside the line.
|
|
72
|
+
*/
|
|
73
|
+
function overlapGap(start: number, size: number, otherStart: number, otherSize: number, center: number, otherCenter: number) {
|
|
74
|
+
const overlaps = otherStart < start + size && start < otherStart + otherSize
|
|
75
|
+
return overlaps ? 0 : Math.abs(otherCenter - center)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const FOCUSABLE = [
|
|
79
|
+
"a[href]",
|
|
80
|
+
"button",
|
|
81
|
+
"input",
|
|
82
|
+
"select",
|
|
83
|
+
"textarea",
|
|
84
|
+
"summary",
|
|
85
|
+
"[tabindex]",
|
|
86
|
+
"[contenteditable='true']",
|
|
87
|
+
].join(",")
|
|
88
|
+
|
|
89
|
+
const ROVING_ITEMS = [
|
|
90
|
+
"button",
|
|
91
|
+
"a[href]",
|
|
92
|
+
"[role=option]",
|
|
93
|
+
"[role=menuitem]",
|
|
94
|
+
"[role=menuitemcheckbox]",
|
|
95
|
+
"[role=menuitemradio]",
|
|
96
|
+
"[role=tab]",
|
|
97
|
+
"[role=radio]",
|
|
98
|
+
"[role=row]",
|
|
99
|
+
"[role=gridcell]",
|
|
100
|
+
"[role=treeitem]",
|
|
101
|
+
].join(",")
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The elements under `root` a directional move can land on: focusable, not
|
|
105
|
+
* disabled, not taken out of the tab order on purpose, not inert, and
|
|
106
|
+
* actually on screen.
|
|
107
|
+
*/
|
|
108
|
+
export function focusableIn(root: ParentNode): HTMLElement[] {
|
|
109
|
+
return [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter((element) => {
|
|
110
|
+
if (element.matches(":disabled") || element.closest("[inert]")) return false
|
|
111
|
+
// Out of the tab order on purpose, unless it is an item of a widget that
|
|
112
|
+
// moves its own focus (a roving list, tabs, a menu): those are reachable.
|
|
113
|
+
if (element.getAttribute("tabindex") === "-1" && !element.matches(ROVING_ITEMS)) return false
|
|
114
|
+
const rect = element.getBoundingClientRect()
|
|
115
|
+
if (rect.width === 0 || rect.height === 0) return false
|
|
116
|
+
const style = getComputedStyle(element)
|
|
117
|
+
return style.visibility !== "hidden" && style.display !== "none"
|
|
118
|
+
})
|
|
119
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { onBeforeUnmount, onMounted, ref, type MaybeRefOrGetter, toValue } from "vue"
|
|
2
|
+
import { focusableIn, nextInDirection, type Direction } from "./spatialNav"
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* A game controller driving the interface: the D-pad and the left stick move
|
|
6
|
+
* focus to the nearest control in that direction, A presses it, B backs out
|
|
7
|
+
* (closing the open dialog first), the bumpers and Start are the app's to
|
|
8
|
+
* use, and the right stick scrolls whatever the focus sits in. Uses the
|
|
9
|
+
* Gamepad API's standard mapping, which Chromium (desktop and Android web
|
|
10
|
+
* views) and Firefox give the common pads.
|
|
11
|
+
*
|
|
12
|
+
* While a controller is in use the root carries `data-input="gamepad"`, so
|
|
13
|
+
* focus can be drawn more boldly than for a mouse; touching the mouse, the
|
|
14
|
+
* screen or the keyboard takes it off again.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface UseGamepadNavOptions {
|
|
18
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
19
|
+
/** B with no dialog open: go back, as a browser's back would. */
|
|
20
|
+
onBack?: () => void
|
|
21
|
+
/** Left and right bumpers: the previous and next section, tab or page. */
|
|
22
|
+
onPrevious?: () => void
|
|
23
|
+
onNext?: () => void
|
|
24
|
+
/** Start (the menu button): a menu or search, say. */
|
|
25
|
+
onMenu?: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Standard-mapping button indices (W3C Gamepad, "standard" layout). */
|
|
29
|
+
const BUTTON = { a: 0, b: 1, lb: 4, rb: 5, start: 9, up: 12, down: 13, left: 14, right: 15 } as const
|
|
30
|
+
|
|
31
|
+
/** How far a stick goes before it counts as a direction. */
|
|
32
|
+
const STICK_THRESHOLD = 0.55
|
|
33
|
+
/** Held, a direction repeats after this long, then every REPEAT_MS. */
|
|
34
|
+
const REPEAT_DELAY_MS = 380
|
|
35
|
+
const REPEAT_MS = 110
|
|
36
|
+
/** Pixels the right stick scrolls per frame at full tilt. */
|
|
37
|
+
const SCROLL_SPEED = 18
|
|
38
|
+
|
|
39
|
+
const OPEN_DIALOG = "[role=dialog][data-state=open], [role=alertdialog][data-state=open], [role=menu][data-state=open], [role=listbox][data-state=open]"
|
|
40
|
+
|
|
41
|
+
export function useGamepadNav(options: UseGamepadNavOptions = {}) {
|
|
42
|
+
/** A controller is connected. */
|
|
43
|
+
const connected = ref(false)
|
|
44
|
+
let frame = 0
|
|
45
|
+
const held = new Map<string, number>()
|
|
46
|
+
|
|
47
|
+
function enabled() {
|
|
48
|
+
return options.enabled === undefined ? true : toValue(options.enabled)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where focus moves: the open dialog or menu on top, else the page. */
|
|
52
|
+
function scope(): ParentNode {
|
|
53
|
+
const layers = document.querySelectorAll<HTMLElement>(OPEN_DIALOG)
|
|
54
|
+
return layers[layers.length - 1] ?? document.body
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function markGamepad() {
|
|
58
|
+
document.documentElement.dataset.input = "gamepad"
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function focus(element: HTMLElement) {
|
|
62
|
+
// Focus placed by code gets no :focus-visible; the kit's own marker for
|
|
63
|
+
// moved focus draws the ring, and goes with the focus.
|
|
64
|
+
element.setAttribute("data-lp-focus-ring", "")
|
|
65
|
+
element.addEventListener("blur", () => element.removeAttribute("data-lp-focus-ring"), { once: true })
|
|
66
|
+
element.focus({ preventScroll: true })
|
|
67
|
+
element.scrollIntoView({ block: "nearest", inline: "nearest" })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function move(direction: Direction) {
|
|
71
|
+
const candidates = focusableIn(scope())
|
|
72
|
+
if (!candidates.length) return
|
|
73
|
+
const current = document.activeElement as HTMLElement | null
|
|
74
|
+
if (!current || !candidates.includes(current)) {
|
|
75
|
+
focus(candidates[0]!)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const others = candidates.filter((element) => element !== current)
|
|
79
|
+
const next = nextInDirection(
|
|
80
|
+
current.getBoundingClientRect(),
|
|
81
|
+
others.map((element) => element.getBoundingClientRect()),
|
|
82
|
+
direction,
|
|
83
|
+
)
|
|
84
|
+
if (next >= 0) focus(others[next]!)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function press() {
|
|
88
|
+
const current = document.activeElement as HTMLElement | null
|
|
89
|
+
if (!current || current === document.body) return
|
|
90
|
+
if (current.matches("input:not([type=checkbox]):not([type=radio]), textarea, [contenteditable=true]")) {
|
|
91
|
+
current.focus()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
current.click()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function back() {
|
|
98
|
+
if (document.querySelector(OPEN_DIALOG)) {
|
|
99
|
+
// The dialog libraries close on Escape; a synthetic one reaches them
|
|
100
|
+
// the way the key would.
|
|
101
|
+
const target = (document.activeElement as HTMLElement | null) ?? document.body
|
|
102
|
+
target.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true }))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
options.onBack?.()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Fires `act` on a press, and again on a repeat schedule while held. */
|
|
109
|
+
function edge(key: string, down: boolean, now: number, act: () => void, repeats: boolean) {
|
|
110
|
+
const since = held.get(key)
|
|
111
|
+
if (!down) {
|
|
112
|
+
held.delete(key)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (since === undefined) {
|
|
116
|
+
held.set(key, now)
|
|
117
|
+
act()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (repeats && now - since >= REPEAT_DELAY_MS) {
|
|
121
|
+
held.set(key, now - REPEAT_DELAY_MS + REPEAT_MS)
|
|
122
|
+
act()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scrollBy(dx: number, dy: number) {
|
|
127
|
+
let element = document.activeElement as HTMLElement | null
|
|
128
|
+
while (element && element !== document.body) {
|
|
129
|
+
const style = getComputedStyle(element)
|
|
130
|
+
if (/(auto|scroll)/.test(style.overflowY + style.overflowX) && element.scrollHeight > element.clientHeight) break
|
|
131
|
+
element = element.parentElement
|
|
132
|
+
}
|
|
133
|
+
;(element && element !== document.body ? element : document.scrollingElement)?.scrollBy(dx, dy)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function poll(now: number) {
|
|
137
|
+
frame = requestAnimationFrame(poll)
|
|
138
|
+
if (!enabled() || document.hidden) return
|
|
139
|
+
const pad = [...navigator.getGamepads()].find((entry) => entry?.connected && entry.mapping === "standard")
|
|
140
|
+
if (!pad) return
|
|
141
|
+
const pressed = (index: number) => pad.buttons[index]?.pressed ?? false
|
|
142
|
+
const [lx = 0, ly = 0, rx = 0, ry = 0] = pad.axes
|
|
143
|
+
const before = held.size
|
|
144
|
+
edge("up", pressed(BUTTON.up) || ly < -STICK_THRESHOLD, now, () => move("up"), true)
|
|
145
|
+
edge("down", pressed(BUTTON.down) || ly > STICK_THRESHOLD, now, () => move("down"), true)
|
|
146
|
+
edge("left", pressed(BUTTON.left) || lx < -STICK_THRESHOLD, now, () => move("left"), true)
|
|
147
|
+
edge("right", pressed(BUTTON.right) || lx > STICK_THRESHOLD, now, () => move("right"), true)
|
|
148
|
+
edge("a", pressed(BUTTON.a), now, press, false)
|
|
149
|
+
edge("b", pressed(BUTTON.b), now, back, false)
|
|
150
|
+
edge("lb", pressed(BUTTON.lb), now, () => options.onPrevious?.(), false)
|
|
151
|
+
edge("rb", pressed(BUTTON.rb), now, () => options.onNext?.(), false)
|
|
152
|
+
edge("start", pressed(BUTTON.start), now, () => options.onMenu?.(), false)
|
|
153
|
+
const scrollX = Math.abs(rx) > 0.2 ? rx * SCROLL_SPEED : 0
|
|
154
|
+
const scrollY = Math.abs(ry) > 0.2 ? ry * SCROLL_SPEED : 0
|
|
155
|
+
if (scrollX || scrollY) scrollBy(scrollX, scrollY)
|
|
156
|
+
if (held.size > before || scrollX || scrollY) markGamepad()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function onConnect() {
|
|
160
|
+
connected.value = [...navigator.getGamepads()].some((entry) => entry?.connected)
|
|
161
|
+
cancelAnimationFrame(frame)
|
|
162
|
+
if (connected.value) frame = requestAnimationFrame(poll)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onOtherInput(event: Event) {
|
|
166
|
+
// Our own Escape for B is synthetic; only a real key or touch hands over.
|
|
167
|
+
if (!event.isTrusted) return
|
|
168
|
+
if (document.documentElement.dataset.input === "gamepad") delete document.documentElement.dataset.input
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
onMounted(() => {
|
|
172
|
+
if (typeof navigator === "undefined" || !("getGamepads" in navigator)) return
|
|
173
|
+
window.addEventListener("gamepadconnected", onConnect)
|
|
174
|
+
window.addEventListener("gamepaddisconnected", onConnect)
|
|
175
|
+
window.addEventListener("pointerdown", onOtherInput, { capture: true, passive: true })
|
|
176
|
+
window.addEventListener("keydown", onOtherInput, { capture: true, passive: true })
|
|
177
|
+
onConnect()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
onBeforeUnmount(() => {
|
|
181
|
+
cancelAnimationFrame(frame)
|
|
182
|
+
window.removeEventListener("gamepadconnected", onConnect)
|
|
183
|
+
window.removeEventListener("gamepaddisconnected", onConnect)
|
|
184
|
+
window.removeEventListener("pointerdown", onOtherInput, { capture: true })
|
|
185
|
+
window.removeEventListener("keydown", onOtherInput, { capture: true })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
return { connected }
|
|
189
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -294,6 +294,10 @@ export {
|
|
|
294
294
|
export type { HotkeyConflict, HotkeyStep } from "./composables/useHotkeys"
|
|
295
295
|
export { anyModalOpen, topModalElement, useModalLayer } from "./components/modalDepth"
|
|
296
296
|
export type { ModalLayer, ModalLayerOptions } from "./components/modalDepth"
|
|
297
|
+
export { useGamepadNav } from "./composables/useGamepadNav"
|
|
298
|
+
export type { UseGamepadNavOptions } from "./composables/useGamepadNav"
|
|
299
|
+
export { focusableIn, nextInDirection } from "./composables/spatialNav"
|
|
300
|
+
export type { Box, Direction } from "./composables/spatialNav"
|
|
297
301
|
export { provideGlass, useGlass } from "./composables/useGlass"
|
|
298
302
|
export { LP_CONFIG, lpConfig, mergeConfig, provideLpConfig, useLabels, useLinkComponent, useLpConfig } from "./composables/useLpConfig"
|
|
299
303
|
export type { LabelScope, LpConfig } from "./composables/useLpConfig"
|
package/src/tokens/utilities.css
CHANGED
|
@@ -37,6 +37,17 @@
|
|
|
37
37
|
box-shadow: 0 0 0 2px var(--color-ring);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/*
|
|
41
|
+
* Driven by a controller (useGamepadNav), focus is the only cursor there is,
|
|
42
|
+
* often seen from a sofa: the ring is thicker and stands off the control.
|
|
43
|
+
*/
|
|
44
|
+
:root[data-input="gamepad"] [data-lp-focus-ring],
|
|
45
|
+
:root[data-input="gamepad"] :focus-visible {
|
|
46
|
+
outline: 3px solid var(--color-ring);
|
|
47
|
+
outline-offset: 2px;
|
|
48
|
+
box-shadow: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
/*
|
|
41
52
|
* Autofilled fields, in the kit's own palette.
|
|
42
53
|
*
|