@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.
@@ -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-602c1271],
15
- .lp-row-leave-active[data-v-602c1271] {
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-602c1271] {
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-602c1271],
24
- .lp-row-leave-to[data-v-602c1271] {
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-602c1271] {
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-602c1271] {
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-602c1271] {
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-602c1271],
75
- .lp-tick-cue[data-v-602c1271] {
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-602c1271],
79
- .lp-tick-cue[data-v-602c1271]:focus-within {
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-602c1271] {
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-602c1271],
95
- .lp-tick-cue[data-v-602c1271] {
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-602c1271],
102
- .lp-row-leave-active[data-v-602c1271],
103
- .lp-row-move[data-v-602c1271],
104
- .lp-tick[data-v-602c1271] {
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, createTextVNode, toDisplayString } from "vue";
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 = ["aria-current"];
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
- createTextVNode(" " + toDisplayString(item.label), 1)
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
- createTextVNode(" " + toDisplayString(item.label), 1)
85
- ], 10, _hoisted_2))
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);
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpBreadcrumbs-CQJd73Gm.js";
1
+ import { _ as _sfc_main } from "../chunks/LpBreadcrumbs-D4TsoFju.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -211,6 +211,7 @@ declare const __VLS_export: <T extends Record<string, unknown>>(__VLS_props: Non
211
211
  [x: `cell-${string}`]: ((props: {
212
212
  row: T;
213
213
  value: unknown;
214
+ openMenu: (event: Event) => void;
214
215
  }) => any) | undefined;
215
216
  } & {
216
217
  bulk?: (props: {
@@ -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-D8jvowDb.css';const _hoisted_1 = {
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-602c1271"]]);
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-CQJd73Gm.js";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.51.1",
3
+ "version": "0.53.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -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
- <nav :aria-label="text.nav" v-bind="rest">
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"
@@ -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
  *