@negative-space/roving-focus 1.1.0 → 1.2.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/index.d.mts CHANGED
@@ -1,27 +1,27 @@
1
- import * as react from 'react';
2
-
3
- interface Item {
1
+ interface RovingFocusItem {
4
2
  id: string;
5
3
  ref: React.RefObject<HTMLElement>;
6
4
  disabled?: boolean;
7
5
  }
8
- declare function useRovingFocus(): {
6
+ interface UseRovingFocusOptions {
7
+ wrap?: boolean;
8
+ allowHorizontal?: boolean;
9
+ containerRole?: string;
10
+ }
11
+ declare function useRovingFocus(options?: UseRovingFocusOptions): {
9
12
  activeId: string | null;
10
13
  hasInteracted: boolean;
11
- registerItem: (item: Item) => void;
14
+ registerItem: (item: RovingFocusItem) => void;
12
15
  unregisterItem: (id: string) => void;
13
- move: (dir: 1 | -1) => void;
14
- setActiveId: react.Dispatch<react.SetStateAction<string | null>>;
15
- focusItem: (itemId: string) => void;
16
+ focusItem: (id: string) => void;
16
17
  focusFirst: () => void;
17
- getFirstEnabledId: () => string;
18
+ focusLast: () => void;
19
+ getFirstEnabledId: () => string | null;
18
20
  reset: () => void;
19
21
  handleGroupKeyDown: (e: React.KeyboardEvent) => void;
20
- handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (value: string) => void, options?: {
21
- role?: "radio" | "option";
22
- allowHorizontal?: boolean;
23
- }) => void;
22
+ handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (id: string) => void, containerRef?: React.RefObject<HTMLElement>) => void;
24
23
  handleGroupBlur: (e: React.FocusEvent) => void;
25
24
  };
25
+ type RovingFocusReturn = ReturnType<typeof useRovingFocus>;
26
26
 
27
- export { useRovingFocus };
27
+ export { type RovingFocusItem, type RovingFocusReturn, type UseRovingFocusOptions, useRovingFocus };
package/dist/index.d.ts CHANGED
@@ -1,27 +1,27 @@
1
- import * as react from 'react';
2
-
3
- interface Item {
1
+ interface RovingFocusItem {
4
2
  id: string;
5
3
  ref: React.RefObject<HTMLElement>;
6
4
  disabled?: boolean;
7
5
  }
8
- declare function useRovingFocus(): {
6
+ interface UseRovingFocusOptions {
7
+ wrap?: boolean;
8
+ allowHorizontal?: boolean;
9
+ containerRole?: string;
10
+ }
11
+ declare function useRovingFocus(options?: UseRovingFocusOptions): {
9
12
  activeId: string | null;
10
13
  hasInteracted: boolean;
11
- registerItem: (item: Item) => void;
14
+ registerItem: (item: RovingFocusItem) => void;
12
15
  unregisterItem: (id: string) => void;
13
- move: (dir: 1 | -1) => void;
14
- setActiveId: react.Dispatch<react.SetStateAction<string | null>>;
15
- focusItem: (itemId: string) => void;
16
+ focusItem: (id: string) => void;
16
17
  focusFirst: () => void;
17
- getFirstEnabledId: () => string;
18
+ focusLast: () => void;
19
+ getFirstEnabledId: () => string | null;
18
20
  reset: () => void;
19
21
  handleGroupKeyDown: (e: React.KeyboardEvent) => void;
20
- handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (value: string) => void, options?: {
21
- role?: "radio" | "option";
22
- allowHorizontal?: boolean;
23
- }) => void;
22
+ handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (id: string) => void, containerRef?: React.RefObject<HTMLElement>) => void;
24
23
  handleGroupBlur: (e: React.FocusEvent) => void;
25
24
  };
25
+ type RovingFocusReturn = ReturnType<typeof useRovingFocus>;
26
26
 
27
- export { useRovingFocus };
27
+ export { type RovingFocusItem, type RovingFocusReturn, type UseRovingFocusOptions, useRovingFocus };
package/dist/index.js CHANGED
@@ -3,83 +3,119 @@
3
3
  var react = require('react');
4
4
 
5
5
  // src/index.ts
6
- function useRovingFocus() {
6
+ function useRovingFocus(options = {}) {
7
+ const { wrap = true, allowHorizontal = true, containerRole } = options;
7
8
  const items = react.useRef([]);
8
- const [activeId, setActiveId] = react.useState(null);
9
- const [hasInteracted, setHasInteracted] = react.useState(false);
9
+ const activeIdRef = react.useRef(null);
10
+ const [activeId, _setActiveId] = react.useState(null);
11
+ const hasInteractedRef = react.useRef(false);
12
+ const [hasInteracted, _setHasInteracted] = react.useState(false);
13
+ const setActiveId = react.useCallback((id) => {
14
+ activeIdRef.current = id;
15
+ _setActiveId(id);
16
+ }, []);
17
+ const setHasInteracted = react.useCallback((value) => {
18
+ hasInteractedRef.current = value;
19
+ _setHasInteracted(value);
20
+ }, []);
10
21
  const registerItem = react.useCallback(
11
22
  (item) => {
12
- const existingIndex = items.current.findIndex((i) => i.id === item.id);
13
- if (existingIndex === -1) {
23
+ const idx = items.current.findIndex((i) => i.id === item.id);
24
+ if (idx === -1) {
14
25
  items.current.push(item);
15
26
  } else {
16
- items.current[existingIndex] = item;
27
+ items.current[idx] = item;
17
28
  }
18
- if (!activeId && !item.disabled) {
29
+ if (!activeIdRef.current && !item.disabled) {
19
30
  setActiveId(item.id);
20
31
  }
21
32
  },
22
- [activeId]
33
+ [setActiveId]
34
+ );
35
+ const unregisterItem = react.useCallback(
36
+ (id) => {
37
+ items.current = items.current.filter((i) => i.id !== id);
38
+ if (activeIdRef.current === id) {
39
+ const next = items.current.find((i) => !i.disabled);
40
+ setActiveId(next?.id ?? null);
41
+ }
42
+ },
43
+ [setActiveId]
23
44
  );
24
- const unregisterItem = react.useCallback((id) => {
25
- items.current = items.current.filter((i) => i.id !== id);
26
- }, []);
27
45
  const move = react.useCallback(
28
46
  (dir) => {
29
47
  setHasInteracted(true);
30
48
  const enabled = items.current.filter((i) => !i.disabled);
31
49
  if (!enabled.length) return;
32
- const index = enabled.findIndex((i) => i.id === activeId);
33
- const next = enabled[(index + dir + enabled.length) % enabled.length];
50
+ const idx = enabled.findIndex((i) => i.id === activeIdRef.current);
51
+ const nextIdx = wrap ? (idx + dir + enabled.length) % enabled.length : Math.min(Math.max(idx + dir, 0), enabled.length - 1);
52
+ const next = enabled[nextIdx];
34
53
  setActiveId(next.id);
35
54
  next.ref.current?.focus();
36
55
  },
37
- [activeId]
56
+ [wrap, setActiveId, setHasInteracted]
38
57
  );
39
- const focusItem = react.useCallback((itemId) => {
40
- setHasInteracted(true);
41
- setActiveId(itemId);
42
- }, []);
43
58
  const focusFirst = react.useCallback(() => {
59
+ const first = items.current.find((i) => !i.disabled);
60
+ if (!first) return;
61
+ setHasInteracted(true);
62
+ setActiveId(first.id);
63
+ first.ref.current?.focus();
64
+ }, [setActiveId, setHasInteracted]);
65
+ const focusLast = react.useCallback(() => {
44
66
  const enabled = items.current.filter((i) => !i.disabled);
45
- if (enabled.length > 0) {
46
- const first = enabled[0];
67
+ const last = enabled[enabled.length - 1];
68
+ if (!last) return;
69
+ setHasInteracted(true);
70
+ setActiveId(last.id);
71
+ last.ref.current?.focus();
72
+ }, [setActiveId, setHasInteracted]);
73
+ const focusItem = react.useCallback(
74
+ (id) => {
47
75
  setHasInteracted(true);
48
- setActiveId(first.id);
49
- first.ref.current?.focus();
50
- }
51
- }, []);
52
- const getFirstEnabledId = react.useCallback(() => {
53
- const enabled = items.current.filter((i) => !i.disabled);
54
- return enabled[0]?.id ?? null;
55
- }, []);
76
+ setActiveId(id);
77
+ },
78
+ [setActiveId, setHasInteracted]
79
+ );
80
+ const getFirstEnabledId = react.useCallback(
81
+ () => items.current.find((i) => !i.disabled)?.id ?? null,
82
+ []
83
+ );
56
84
  const reset = react.useCallback(() => {
57
85
  setHasInteracted(false);
58
- }, []);
86
+ }, [setHasInteracted]);
59
87
  const handleGroupKeyDown = react.useCallback(
60
88
  (e) => {
61
- if (!hasInteracted) {
62
- if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowLeft") {
63
- e.preventDefault();
64
- focusFirst();
65
- }
66
- }
89
+ const isArrow = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"].includes(e.key);
90
+ if (!isArrow) return;
91
+ if (!e.currentTarget.contains(document.activeElement)) return;
92
+ if (e.currentTarget !== document.activeElement) return;
93
+ e.preventDefault();
94
+ focusFirst();
95
+ },
96
+ [focusFirst]
97
+ );
98
+ const handleGroupBlur = react.useCallback(
99
+ (e) => {
100
+ const currentTarget = e.currentTarget;
101
+ setTimeout(() => {
102
+ if (!currentTarget.contains(document.activeElement)) reset();
103
+ }, 0);
67
104
  },
68
- [hasInteracted, focusFirst]
105
+ [reset]
69
106
  );
70
107
  const handleItemKeyDown = react.useCallback(
71
- (e, itemId, onSelect, options) => {
72
- const role = options?.role ?? "radio";
73
- const allowHorizontal = options?.allowHorizontal ?? true;
74
- if (e.key === "Tab" && e.shiftKey) {
75
- e.preventDefault();
76
- reset();
77
- if (role === "radio") {
78
- const fieldset = e.target.closest('[role="radiogroup"]');
79
- fieldset?.focus();
80
- } else if (role === "option") {
81
- const listbox = e.target.closest('[role="listbox"]');
82
- listbox?.focus();
108
+ (e, itemId, onSelect, containerRef) => {
109
+ if (e.key === "Tab") {
110
+ if (e.shiftKey) {
111
+ e.preventDefault();
112
+ reset();
113
+ const container = containerRef?.current ?? (containerRole ? e.target.closest(
114
+ `[role="${containerRole}"]`
115
+ ) : null);
116
+ container?.focus();
117
+ } else {
118
+ reset();
83
119
  }
84
120
  return;
85
121
  }
@@ -93,33 +129,31 @@ function useRovingFocus() {
93
129
  move(-1);
94
130
  return;
95
131
  }
96
- if (e.key === " " || e.key === "Enter") {
132
+ if (e.key === "Home") {
133
+ e.preventDefault();
134
+ focusFirst();
135
+ return;
136
+ }
137
+ if (e.key === "End") {
138
+ e.preventDefault();
139
+ focusLast();
140
+ return;
141
+ }
142
+ if (e.key === "Enter" || e.key === " ") {
97
143
  e.preventDefault();
98
144
  onSelect?.(itemId);
99
145
  }
100
146
  },
101
- [move, reset]
102
- );
103
- const handleGroupBlur = react.useCallback(
104
- (e) => {
105
- const currentTarget = e.currentTarget;
106
- setTimeout(() => {
107
- if (!currentTarget.contains(document.activeElement)) {
108
- reset();
109
- }
110
- }, 0);
111
- },
112
- [reset]
147
+ [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]
113
148
  );
114
149
  return {
115
150
  activeId,
116
151
  hasInteracted,
117
152
  registerItem,
118
153
  unregisterItem,
119
- move,
120
- setActiveId,
121
154
  focusItem,
122
155
  focusFirst,
156
+ focusLast,
123
157
  getFirstEnabledId,
124
158
  reset,
125
159
  handleGroupKeyDown,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["useRef","useState","useCallback"],"mappings":";;;;;AAQO,SAAS,cAAA,GAAiB;AAC/B,EAAA,MAAM,KAAA,GAAQA,YAAA,CAAe,EAAE,CAAA;AAC/B,EAAA,MAAM,CAAC,QAAA,EAAU,WAAW,CAAA,GAAIC,eAAwB,IAAI,CAAA;AAC5D,EAAA,MAAM,CAAC,aAAA,EAAe,gBAAgB,CAAA,GAAIA,eAAS,KAAK,CAAA;AAExD,EAAA,MAAM,YAAA,GAAeC,iBAAA;AAAA,IACnB,CAAC,IAAA,KAAe;AACd,MAAA,MAAM,aAAA,GAAgB,MAAM,OAAA,CAAQ,SAAA,CAAU,CAAC,CAAA,KAAM,CAAA,CAAE,EAAA,KAAO,IAAA,CAAK,EAAE,CAAA;AAErE,MAAA,IAAI,kBAAkB,EAAA,EAAI;AACxB,QAAA,KAAA,CAAM,OAAA,CAAQ,KAAK,IAAI,CAAA;AAAA,MACzB,CAAA,MAAO;AACL,QAAA,KAAA,CAAM,OAAA,CAAQ,aAAa,CAAA,GAAI,IAAA;AAAA,MACjC;AAEA,MAAA,IAAI,CAAC,QAAA,IAAY,CAAC,IAAA,CAAK,QAAA,EAAU;AAC/B,QAAA,WAAA,CAAY,KAAK,EAAE,CAAA;AAAA,MACrB;AAAA,IACF,CAAA;AAAA,IACA,CAAC,QAAQ;AAAA,GACX;AAEA,EAAA,MAAM,cAAA,GAAiBA,iBAAA,CAAY,CAAC,EAAA,KAAe;AACjD,IAAA,KAAA,CAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,OAAO,EAAE,CAAA;AAAA,EACzD,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,IAAA,GAAOA,iBAAA;AAAA,IACX,CAAC,GAAA,KAAgB;AACf,MAAA,gBAAA,CAAiB,IAAI,CAAA;AAErB,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACvD,MAAA,IAAI,CAAC,QAAQ,MAAA,EAAQ;AAErB,MAAA,MAAM,QAAQ,OAAA,CAAQ,SAAA,CAAU,CAAC,CAAA,KAAM,CAAA,CAAE,OAAO,QAAQ,CAAA;AACxD,MAAA,MAAM,OAAO,OAAA,CAAA,CAAS,KAAA,GAAQ,MAAM,OAAA,CAAQ,MAAA,IAAU,QAAQ,MAAM,CAAA;AAEpE,MAAA,WAAA,CAAY,KAAK,EAAE,CAAA;AACnB,MAAA,IAAA,CAAK,GAAA,CAAI,SAAS,KAAA,EAAM;AAAA,IAC1B,CAAA;AAAA,IACA,CAAC,QAAQ;AAAA,GACX;AAEA,EAAA,MAAM,SAAA,GAAYA,iBAAA,CAAY,CAAC,MAAA,KAAmB;AAChD,IAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,IAAA,WAAA,CAAY,MAAM,CAAA;AAAA,EACpB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,UAAA,GAAaA,kBAAY,MAAM;AACnC,IAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACvD,IAAA,IAAI,OAAA,CAAQ,SAAS,CAAA,EAAG;AACtB,MAAA,MAAM,KAAA,GAAQ,QAAQ,CAAC,CAAA;AACvB,MAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,MAAA,WAAA,CAAY,MAAM,EAAE,CAAA;AACpB,MAAA,KAAA,CAAM,GAAA,CAAI,SAAS,KAAA,EAAM;AAAA,IAC3B;AAAA,EACF,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,iBAAA,GAAoBA,kBAAY,MAAM;AAC1C,IAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACvD,IAAA,OAAO,OAAA,CAAQ,CAAC,CAAA,EAAG,EAAA,IAAM,IAAA;AAAA,EAC3B,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,KAAA,GAAQA,kBAAY,MAAM;AAC9B,IAAA,gBAAA,CAAiB,KAAK,CAAA;AAAA,EACxB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,kBAAA,GAAqBA,iBAAA;AAAA,IACzB,CAAC,CAAA,KAA2B;AAC1B,MAAA,IAAI,CAAC,aAAA,EAAe;AAClB,QAAA,IACE,CAAA,CAAE,GAAA,KAAQ,WAAA,IACV,CAAA,CAAE,GAAA,KAAQ,YAAA,IACV,CAAA,CAAE,GAAA,KAAQ,SAAA,IACV,CAAA,CAAE,GAAA,KAAQ,WAAA,EACV;AACA,UAAA,CAAA,CAAE,cAAA,EAAe;AACjB,UAAA,UAAA,EAAW;AAAA,QACb;AAAA,MACF;AAAA,IACF,CAAA;AAAA,IACA,CAAC,eAAe,UAAU;AAAA,GAC5B;AAEA,EAAA,MAAM,iBAAA,GAAoBA,iBAAA;AAAA,IACxB,CACE,CAAA,EACA,MAAA,EACA,QAAA,EACA,OAAA,KAIG;AACH,MAAA,MAAM,IAAA,GAAO,SAAS,IAAA,IAAQ,OAAA;AAC9B,MAAA,MAAM,eAAA,GAAkB,SAAS,eAAA,IAAmB,IAAA;AAEpD,MAAA,IAAI,CAAA,CAAE,GAAA,KAAQ,KAAA,IAAS,CAAA,CAAE,QAAA,EAAU;AACjC,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,KAAA,EAAM;AACN,QAAA,IAAI,SAAS,OAAA,EAAS;AACpB,UAAA,MAAM,QAAA,GAAY,CAAA,CAAE,MAAA,CAAuB,OAAA,CAAQ,qBAAqB,CAAA;AACxE,UAAA,QAAA,EAAU,KAAA,EAAM;AAAA,QAClB,CAAA,MAAA,IAAW,SAAS,QAAA,EAAU;AAC5B,UAAA,MAAM,OAAA,GAAW,CAAA,CAAE,MAAA,CAAuB,OAAA,CAAQ,kBAAkB,CAAA;AACpE,UAAA,OAAA,EAAS,KAAA,EAAM;AAAA,QACjB;AACA,QAAA;AAAA,MACF;AAEA,MAAA,IAAI,EAAE,GAAA,KAAQ,WAAA,IAAgB,eAAA,IAAmB,CAAA,CAAE,QAAQ,YAAA,EAAe;AACxE,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,IAAA,CAAK,CAAC,CAAA;AACN,QAAA;AAAA,MACF;AAEA,MAAA,IAAI,EAAE,GAAA,KAAQ,SAAA,IAAc,eAAA,IAAmB,CAAA,CAAE,QAAQ,WAAA,EAAc;AACrE,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,IAAA,CAAK,EAAE,CAAA;AACP,QAAA;AAAA,MACF;AAEA,MAAA,IAAI,CAAA,CAAE,GAAA,KAAQ,GAAA,IAAO,CAAA,CAAE,QAAQ,OAAA,EAAS;AACtC,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,QAAA,GAAW,MAAM,CAAA;AAAA,MACnB;AAAA,IACF,CAAA;AAAA,IACA,CAAC,MAAM,KAAK;AAAA,GACd;AAEA,EAAA,MAAM,eAAA,GAAkBA,iBAAA;AAAA,IACtB,CAAC,CAAA,KAAwB;AACvB,MAAA,MAAM,gBAAgB,CAAA,CAAE,aAAA;AACxB,MAAA,UAAA,CAAW,MAAM;AACf,QAAA,IAAI,CAAC,aAAA,CAAc,QAAA,CAAS,QAAA,CAAS,aAAa,CAAA,EAAG;AACnD,UAAA,KAAA,EAAM;AAAA,QACR;AAAA,MACF,GAAG,CAAC,CAAA;AAAA,IACN,CAAA;AAAA,IACA,CAAC,KAAK;AAAA,GACR;AAEA,EAAA,OAAO;AAAA,IACL,QAAA;AAAA,IACA,aAAA;AAAA,IACA,YAAA;AAAA,IACA,cAAA;AAAA,IACA,IAAA;AAAA,IACA,WAAA;AAAA,IACA,SAAA;AAAA,IACA,UAAA;AAAA,IACA,iBAAA;AAAA,IACA,KAAA;AAAA,IACA,kBAAA;AAAA,IACA,iBAAA;AAAA,IACA;AAAA,GACF;AACF","file":"index.js","sourcesContent":["import { useCallback, useRef, useState } from 'react'\n\ninterface Item {\n id: string\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport function useRovingFocus() {\n const items = useRef<Item[]>([])\n const [activeId, setActiveId] = useState<string | null>(null)\n const [hasInteracted, setHasInteracted] = useState(false)\n\n const registerItem = useCallback(\n (item: Item) => {\n const existingIndex = items.current.findIndex((i) => i.id === item.id)\n\n if (existingIndex === -1) {\n items.current.push(item)\n } else {\n items.current[existingIndex] = item\n }\n\n if (!activeId && !item.disabled) {\n setActiveId(item.id)\n }\n },\n [activeId]\n )\n\n const unregisterItem = useCallback((id: string) => {\n items.current = items.current.filter((i) => i.id !== id)\n }, [])\n\n const move = useCallback(\n (dir: 1 | -1) => {\n setHasInteracted(true)\n\n const enabled = items.current.filter((i) => !i.disabled)\n if (!enabled.length) return\n\n const index = enabled.findIndex((i) => i.id === activeId)\n const next = enabled[(index + dir + enabled.length) % enabled.length]\n\n setActiveId(next.id)\n next.ref.current?.focus()\n },\n [activeId]\n )\n\n const focusItem = useCallback((itemId: string) => {\n setHasInteracted(true)\n setActiveId(itemId)\n }, [])\n\n const focusFirst = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n if (enabled.length > 0) {\n const first = enabled[0]\n setHasInteracted(true)\n setActiveId(first.id)\n first.ref.current?.focus()\n }\n }, [])\n\n const getFirstEnabledId = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n return enabled[0]?.id ?? null\n }, [])\n\n const reset = useCallback(() => {\n setHasInteracted(false)\n }, [])\n\n const handleGroupKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (!hasInteracted) {\n if (\n e.key === 'ArrowDown' ||\n e.key === 'ArrowRight' ||\n e.key === 'ArrowUp' ||\n e.key === 'ArrowLeft'\n ) {\n e.preventDefault()\n focusFirst()\n }\n }\n },\n [hasInteracted, focusFirst]\n )\n\n const handleItemKeyDown = useCallback(\n (\n e: React.KeyboardEvent,\n itemId: string,\n onSelect?: (value: string) => void,\n options?: {\n role?: 'radio' | 'option'\n allowHorizontal?: boolean\n }\n ) => {\n const role = options?.role ?? 'radio'\n const allowHorizontal = options?.allowHorizontal ?? true\n\n if (e.key === 'Tab' && e.shiftKey) {\n e.preventDefault()\n reset()\n if (role === 'radio') {\n const fieldset = (e.target as HTMLElement).closest('[role=\"radiogroup\"]') as HTMLElement\n fieldset?.focus()\n } else if (role === 'option') {\n const listbox = (e.target as HTMLElement).closest('[role=\"listbox\"]') as HTMLElement\n listbox?.focus()\n }\n return\n }\n\n if (e.key === 'ArrowDown' || (allowHorizontal && e.key === 'ArrowRight')) {\n e.preventDefault()\n move(1)\n return\n }\n\n if (e.key === 'ArrowUp' || (allowHorizontal && e.key === 'ArrowLeft')) {\n e.preventDefault()\n move(-1)\n return\n }\n\n if (e.key === ' ' || e.key === 'Enter') {\n e.preventDefault()\n onSelect?.(itemId)\n }\n },\n [move, reset]\n )\n\n const handleGroupBlur = useCallback(\n (e: React.FocusEvent) => {\n const currentTarget = e.currentTarget\n setTimeout(() => {\n if (!currentTarget.contains(document.activeElement)) {\n reset()\n }\n }, 0)\n },\n [reset]\n )\n\n return {\n activeId,\n hasInteracted,\n registerItem,\n unregisterItem,\n move,\n setActiveId,\n focusItem,\n focusFirst,\n getFirstEnabledId,\n reset,\n handleGroupKeyDown,\n handleItemKeyDown,\n handleGroupBlur\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":["useRef","useState","useCallback"],"mappings":"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","file":"index.js","sourcesContent":["import { useCallback, useRef, useState } from 'react'\n\nexport interface RovingFocusItem {\n id: string\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport interface UseRovingFocusOptions {\n wrap?: boolean\n allowHorizontal?: boolean\n containerRole?: string\n}\n\nexport function useRovingFocus(options: UseRovingFocusOptions = {}) {\n const { wrap = true, allowHorizontal = true, containerRole } = options\n\n const items = useRef<RovingFocusItem[]>([])\n const activeIdRef = useRef<string | null>(null)\n const [activeId, _setActiveId] = useState<string | null>(null)\n const hasInteractedRef = useRef(false)\n const [hasInteracted, _setHasInteracted] = useState(false)\n\n const setActiveId = useCallback((id: string | null) => {\n activeIdRef.current = id\n _setActiveId(id)\n }, [])\n\n const setHasInteracted = useCallback((value: boolean) => {\n hasInteractedRef.current = value\n _setHasInteracted(value)\n }, [])\n\n const registerItem = useCallback(\n (item: RovingFocusItem) => {\n const idx = items.current.findIndex((i) => i.id === item.id)\n if (idx === -1) {\n items.current.push(item)\n } else {\n items.current[idx] = item\n }\n if (!activeIdRef.current && !item.disabled) {\n setActiveId(item.id)\n }\n },\n [setActiveId]\n )\n\n const unregisterItem = useCallback(\n (id: string) => {\n items.current = items.current.filter((i) => i.id !== id)\n if (activeIdRef.current === id) {\n const next = items.current.find((i) => !i.disabled)\n setActiveId(next?.id ?? null)\n }\n },\n [setActiveId]\n )\n\n const move = useCallback(\n (dir: 1 | -1) => {\n setHasInteracted(true)\n const enabled = items.current.filter((i) => !i.disabled)\n if (!enabled.length) return\n const idx = enabled.findIndex((i) => i.id === activeIdRef.current)\n const nextIdx = wrap\n ? (idx + dir + enabled.length) % enabled.length\n : Math.min(Math.max(idx + dir, 0), enabled.length - 1)\n const next = enabled[nextIdx]\n setActiveId(next.id)\n next.ref.current?.focus()\n },\n [wrap, setActiveId, setHasInteracted]\n )\n\n const focusFirst = useCallback(() => {\n const first = items.current.find((i) => !i.disabled)\n if (!first) return\n setHasInteracted(true)\n setActiveId(first.id)\n first.ref.current?.focus()\n }, [setActiveId, setHasInteracted])\n\n const focusLast = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n const last = enabled[enabled.length - 1]\n if (!last) return\n setHasInteracted(true)\n setActiveId(last.id)\n last.ref.current?.focus()\n }, [setActiveId, setHasInteracted])\n\n const focusItem = useCallback(\n (id: string) => {\n setHasInteracted(true)\n setActiveId(id)\n },\n [setActiveId, setHasInteracted]\n )\n\n const getFirstEnabledId = useCallback(\n () => items.current.find((i) => !i.disabled)?.id ?? null,\n []\n )\n\n const reset = useCallback(() => {\n setHasInteracted(false)\n }, [setHasInteracted])\n\n const handleGroupKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n const isArrow = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)\n if (!isArrow) return\n if (!e.currentTarget.contains(document.activeElement)) return\n if (e.currentTarget !== document.activeElement) return\n e.preventDefault()\n focusFirst()\n },\n [focusFirst]\n )\n\n const handleGroupBlur = useCallback(\n (e: React.FocusEvent) => {\n const currentTarget = e.currentTarget\n setTimeout(() => {\n if (!currentTarget.contains(document.activeElement)) reset()\n }, 0)\n },\n [reset]\n )\n\n const handleItemKeyDown = useCallback(\n (\n e: React.KeyboardEvent,\n itemId: string,\n onSelect?: (id: string) => void,\n containerRef?: React.RefObject<HTMLElement>\n ) => {\n if (e.key === 'Tab') {\n if (e.shiftKey) {\n e.preventDefault()\n reset()\n const container: HTMLElement | null =\n containerRef?.current ??\n (containerRole\n ? ((e.target as HTMLElement).closest(\n `[role=\"${containerRole}\"]`\n ) as HTMLElement | null)\n : null)\n container?.focus()\n } else {\n reset()\n }\n return\n }\n\n if (e.key === 'ArrowDown' || (allowHorizontal && e.key === 'ArrowRight')) {\n e.preventDefault()\n move(1)\n return\n }\n if (e.key === 'ArrowUp' || (allowHorizontal && e.key === 'ArrowLeft')) {\n e.preventDefault()\n move(-1)\n return\n }\n if (e.key === 'Home') {\n e.preventDefault()\n focusFirst()\n return\n }\n if (e.key === 'End') {\n e.preventDefault()\n focusLast()\n return\n }\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n onSelect?.(itemId)\n }\n },\n [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]\n )\n\n return {\n activeId,\n hasInteracted,\n registerItem,\n unregisterItem,\n focusItem,\n focusFirst,\n focusLast,\n getFirstEnabledId,\n reset,\n handleGroupKeyDown,\n handleItemKeyDown,\n handleGroupBlur\n }\n}\n\nexport type RovingFocusReturn = ReturnType<typeof useRovingFocus>\n"]}
package/dist/index.mjs CHANGED
@@ -1,83 +1,119 @@
1
1
  import { useRef, useState, useCallback } from 'react';
2
2
 
3
3
  // src/index.ts
4
- function useRovingFocus() {
4
+ function useRovingFocus(options = {}) {
5
+ const { wrap = true, allowHorizontal = true, containerRole } = options;
5
6
  const items = useRef([]);
6
- const [activeId, setActiveId] = useState(null);
7
- const [hasInteracted, setHasInteracted] = useState(false);
7
+ const activeIdRef = useRef(null);
8
+ const [activeId, _setActiveId] = useState(null);
9
+ const hasInteractedRef = useRef(false);
10
+ const [hasInteracted, _setHasInteracted] = useState(false);
11
+ const setActiveId = useCallback((id) => {
12
+ activeIdRef.current = id;
13
+ _setActiveId(id);
14
+ }, []);
15
+ const setHasInteracted = useCallback((value) => {
16
+ hasInteractedRef.current = value;
17
+ _setHasInteracted(value);
18
+ }, []);
8
19
  const registerItem = useCallback(
9
20
  (item) => {
10
- const existingIndex = items.current.findIndex((i) => i.id === item.id);
11
- if (existingIndex === -1) {
21
+ const idx = items.current.findIndex((i) => i.id === item.id);
22
+ if (idx === -1) {
12
23
  items.current.push(item);
13
24
  } else {
14
- items.current[existingIndex] = item;
25
+ items.current[idx] = item;
15
26
  }
16
- if (!activeId && !item.disabled) {
27
+ if (!activeIdRef.current && !item.disabled) {
17
28
  setActiveId(item.id);
18
29
  }
19
30
  },
20
- [activeId]
31
+ [setActiveId]
32
+ );
33
+ const unregisterItem = useCallback(
34
+ (id) => {
35
+ items.current = items.current.filter((i) => i.id !== id);
36
+ if (activeIdRef.current === id) {
37
+ const next = items.current.find((i) => !i.disabled);
38
+ setActiveId(next?.id ?? null);
39
+ }
40
+ },
41
+ [setActiveId]
21
42
  );
22
- const unregisterItem = useCallback((id) => {
23
- items.current = items.current.filter((i) => i.id !== id);
24
- }, []);
25
43
  const move = useCallback(
26
44
  (dir) => {
27
45
  setHasInteracted(true);
28
46
  const enabled = items.current.filter((i) => !i.disabled);
29
47
  if (!enabled.length) return;
30
- const index = enabled.findIndex((i) => i.id === activeId);
31
- const next = enabled[(index + dir + enabled.length) % enabled.length];
48
+ const idx = enabled.findIndex((i) => i.id === activeIdRef.current);
49
+ const nextIdx = wrap ? (idx + dir + enabled.length) % enabled.length : Math.min(Math.max(idx + dir, 0), enabled.length - 1);
50
+ const next = enabled[nextIdx];
32
51
  setActiveId(next.id);
33
52
  next.ref.current?.focus();
34
53
  },
35
- [activeId]
54
+ [wrap, setActiveId, setHasInteracted]
36
55
  );
37
- const focusItem = useCallback((itemId) => {
38
- setHasInteracted(true);
39
- setActiveId(itemId);
40
- }, []);
41
56
  const focusFirst = useCallback(() => {
57
+ const first = items.current.find((i) => !i.disabled);
58
+ if (!first) return;
59
+ setHasInteracted(true);
60
+ setActiveId(first.id);
61
+ first.ref.current?.focus();
62
+ }, [setActiveId, setHasInteracted]);
63
+ const focusLast = useCallback(() => {
42
64
  const enabled = items.current.filter((i) => !i.disabled);
43
- if (enabled.length > 0) {
44
- const first = enabled[0];
65
+ const last = enabled[enabled.length - 1];
66
+ if (!last) return;
67
+ setHasInteracted(true);
68
+ setActiveId(last.id);
69
+ last.ref.current?.focus();
70
+ }, [setActiveId, setHasInteracted]);
71
+ const focusItem = useCallback(
72
+ (id) => {
45
73
  setHasInteracted(true);
46
- setActiveId(first.id);
47
- first.ref.current?.focus();
48
- }
49
- }, []);
50
- const getFirstEnabledId = useCallback(() => {
51
- const enabled = items.current.filter((i) => !i.disabled);
52
- return enabled[0]?.id ?? null;
53
- }, []);
74
+ setActiveId(id);
75
+ },
76
+ [setActiveId, setHasInteracted]
77
+ );
78
+ const getFirstEnabledId = useCallback(
79
+ () => items.current.find((i) => !i.disabled)?.id ?? null,
80
+ []
81
+ );
54
82
  const reset = useCallback(() => {
55
83
  setHasInteracted(false);
56
- }, []);
84
+ }, [setHasInteracted]);
57
85
  const handleGroupKeyDown = useCallback(
58
86
  (e) => {
59
- if (!hasInteracted) {
60
- if (e.key === "ArrowDown" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowLeft") {
61
- e.preventDefault();
62
- focusFirst();
63
- }
64
- }
87
+ const isArrow = ["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight"].includes(e.key);
88
+ if (!isArrow) return;
89
+ if (!e.currentTarget.contains(document.activeElement)) return;
90
+ if (e.currentTarget !== document.activeElement) return;
91
+ e.preventDefault();
92
+ focusFirst();
93
+ },
94
+ [focusFirst]
95
+ );
96
+ const handleGroupBlur = useCallback(
97
+ (e) => {
98
+ const currentTarget = e.currentTarget;
99
+ setTimeout(() => {
100
+ if (!currentTarget.contains(document.activeElement)) reset();
101
+ }, 0);
65
102
  },
66
- [hasInteracted, focusFirst]
103
+ [reset]
67
104
  );
68
105
  const handleItemKeyDown = useCallback(
69
- (e, itemId, onSelect, options) => {
70
- const role = options?.role ?? "radio";
71
- const allowHorizontal = options?.allowHorizontal ?? true;
72
- if (e.key === "Tab" && e.shiftKey) {
73
- e.preventDefault();
74
- reset();
75
- if (role === "radio") {
76
- const fieldset = e.target.closest('[role="radiogroup"]');
77
- fieldset?.focus();
78
- } else if (role === "option") {
79
- const listbox = e.target.closest('[role="listbox"]');
80
- listbox?.focus();
106
+ (e, itemId, onSelect, containerRef) => {
107
+ if (e.key === "Tab") {
108
+ if (e.shiftKey) {
109
+ e.preventDefault();
110
+ reset();
111
+ const container = containerRef?.current ?? (containerRole ? e.target.closest(
112
+ `[role="${containerRole}"]`
113
+ ) : null);
114
+ container?.focus();
115
+ } else {
116
+ reset();
81
117
  }
82
118
  return;
83
119
  }
@@ -91,33 +127,31 @@ function useRovingFocus() {
91
127
  move(-1);
92
128
  return;
93
129
  }
94
- if (e.key === " " || e.key === "Enter") {
130
+ if (e.key === "Home") {
131
+ e.preventDefault();
132
+ focusFirst();
133
+ return;
134
+ }
135
+ if (e.key === "End") {
136
+ e.preventDefault();
137
+ focusLast();
138
+ return;
139
+ }
140
+ if (e.key === "Enter" || e.key === " ") {
95
141
  e.preventDefault();
96
142
  onSelect?.(itemId);
97
143
  }
98
144
  },
99
- [move, reset]
100
- );
101
- const handleGroupBlur = useCallback(
102
- (e) => {
103
- const currentTarget = e.currentTarget;
104
- setTimeout(() => {
105
- if (!currentTarget.contains(document.activeElement)) {
106
- reset();
107
- }
108
- }, 0);
109
- },
110
- [reset]
145
+ [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]
111
146
  );
112
147
  return {
113
148
  activeId,
114
149
  hasInteracted,
115
150
  registerItem,
116
151
  unregisterItem,
117
- move,
118
- setActiveId,
119
152
  focusItem,
120
153
  focusFirst,
154
+ focusLast,
121
155
  getFirstEnabledId,
122
156
  reset,
123
157
  handleGroupKeyDown,
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import { useCallback, useRef, useState } from 'react'\n\ninterface Item {\n id: string\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport function useRovingFocus() {\n const items = useRef<Item[]>([])\n const [activeId, setActiveId] = useState<string | null>(null)\n const [hasInteracted, setHasInteracted] = useState(false)\n\n const registerItem = useCallback(\n (item: Item) => {\n const existingIndex = items.current.findIndex((i) => i.id === item.id)\n\n if (existingIndex === -1) {\n items.current.push(item)\n } else {\n items.current[existingIndex] = item\n }\n\n if (!activeId && !item.disabled) {\n setActiveId(item.id)\n }\n },\n [activeId]\n )\n\n const unregisterItem = useCallback((id: string) => {\n items.current = items.current.filter((i) => i.id !== id)\n }, [])\n\n const move = useCallback(\n (dir: 1 | -1) => {\n setHasInteracted(true)\n\n const enabled = items.current.filter((i) => !i.disabled)\n if (!enabled.length) return\n\n const index = enabled.findIndex((i) => i.id === activeId)\n const next = enabled[(index + dir + enabled.length) % enabled.length]\n\n setActiveId(next.id)\n next.ref.current?.focus()\n },\n [activeId]\n )\n\n const focusItem = useCallback((itemId: string) => {\n setHasInteracted(true)\n setActiveId(itemId)\n }, [])\n\n const focusFirst = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n if (enabled.length > 0) {\n const first = enabled[0]\n setHasInteracted(true)\n setActiveId(first.id)\n first.ref.current?.focus()\n }\n }, [])\n\n const getFirstEnabledId = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n return enabled[0]?.id ?? null\n }, [])\n\n const reset = useCallback(() => {\n setHasInteracted(false)\n }, [])\n\n const handleGroupKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (!hasInteracted) {\n if (\n e.key === 'ArrowDown' ||\n e.key === 'ArrowRight' ||\n e.key === 'ArrowUp' ||\n e.key === 'ArrowLeft'\n ) {\n e.preventDefault()\n focusFirst()\n }\n }\n },\n [hasInteracted, focusFirst]\n )\n\n const handleItemKeyDown = useCallback(\n (\n e: React.KeyboardEvent,\n itemId: string,\n onSelect?: (value: string) => void,\n options?: {\n role?: 'radio' | 'option'\n allowHorizontal?: boolean\n }\n ) => {\n const role = options?.role ?? 'radio'\n const allowHorizontal = options?.allowHorizontal ?? true\n\n if (e.key === 'Tab' && e.shiftKey) {\n e.preventDefault()\n reset()\n if (role === 'radio') {\n const fieldset = (e.target as HTMLElement).closest('[role=\"radiogroup\"]') as HTMLElement\n fieldset?.focus()\n } else if (role === 'option') {\n const listbox = (e.target as HTMLElement).closest('[role=\"listbox\"]') as HTMLElement\n listbox?.focus()\n }\n return\n }\n\n if (e.key === 'ArrowDown' || (allowHorizontal && e.key === 'ArrowRight')) {\n e.preventDefault()\n move(1)\n return\n }\n\n if (e.key === 'ArrowUp' || (allowHorizontal && e.key === 'ArrowLeft')) {\n e.preventDefault()\n move(-1)\n return\n }\n\n if (e.key === ' ' || e.key === 'Enter') {\n e.preventDefault()\n onSelect?.(itemId)\n }\n },\n [move, reset]\n )\n\n const handleGroupBlur = useCallback(\n (e: React.FocusEvent) => {\n const currentTarget = e.currentTarget\n setTimeout(() => {\n if (!currentTarget.contains(document.activeElement)) {\n reset()\n }\n }, 0)\n },\n [reset]\n )\n\n return {\n activeId,\n hasInteracted,\n registerItem,\n unregisterItem,\n move,\n setActiveId,\n focusItem,\n focusFirst,\n getFirstEnabledId,\n reset,\n handleGroupKeyDown,\n handleItemKeyDown,\n handleGroupBlur\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["import { useCallback, useRef, useState } from 'react'\n\nexport interface RovingFocusItem {\n id: string\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport interface UseRovingFocusOptions {\n wrap?: boolean\n allowHorizontal?: boolean\n containerRole?: string\n}\n\nexport function useRovingFocus(options: UseRovingFocusOptions = {}) {\n const { wrap = true, allowHorizontal = true, containerRole } = options\n\n const items = useRef<RovingFocusItem[]>([])\n const activeIdRef = useRef<string | null>(null)\n const [activeId, _setActiveId] = useState<string | null>(null)\n const hasInteractedRef = useRef(false)\n const [hasInteracted, _setHasInteracted] = useState(false)\n\n const setActiveId = useCallback((id: string | null) => {\n activeIdRef.current = id\n _setActiveId(id)\n }, [])\n\n const setHasInteracted = useCallback((value: boolean) => {\n hasInteractedRef.current = value\n _setHasInteracted(value)\n }, [])\n\n const registerItem = useCallback(\n (item: RovingFocusItem) => {\n const idx = items.current.findIndex((i) => i.id === item.id)\n if (idx === -1) {\n items.current.push(item)\n } else {\n items.current[idx] = item\n }\n if (!activeIdRef.current && !item.disabled) {\n setActiveId(item.id)\n }\n },\n [setActiveId]\n )\n\n const unregisterItem = useCallback(\n (id: string) => {\n items.current = items.current.filter((i) => i.id !== id)\n if (activeIdRef.current === id) {\n const next = items.current.find((i) => !i.disabled)\n setActiveId(next?.id ?? null)\n }\n },\n [setActiveId]\n )\n\n const move = useCallback(\n (dir: 1 | -1) => {\n setHasInteracted(true)\n const enabled = items.current.filter((i) => !i.disabled)\n if (!enabled.length) return\n const idx = enabled.findIndex((i) => i.id === activeIdRef.current)\n const nextIdx = wrap\n ? (idx + dir + enabled.length) % enabled.length\n : Math.min(Math.max(idx + dir, 0), enabled.length - 1)\n const next = enabled[nextIdx]\n setActiveId(next.id)\n next.ref.current?.focus()\n },\n [wrap, setActiveId, setHasInteracted]\n )\n\n const focusFirst = useCallback(() => {\n const first = items.current.find((i) => !i.disabled)\n if (!first) return\n setHasInteracted(true)\n setActiveId(first.id)\n first.ref.current?.focus()\n }, [setActiveId, setHasInteracted])\n\n const focusLast = useCallback(() => {\n const enabled = items.current.filter((i) => !i.disabled)\n const last = enabled[enabled.length - 1]\n if (!last) return\n setHasInteracted(true)\n setActiveId(last.id)\n last.ref.current?.focus()\n }, [setActiveId, setHasInteracted])\n\n const focusItem = useCallback(\n (id: string) => {\n setHasInteracted(true)\n setActiveId(id)\n },\n [setActiveId, setHasInteracted]\n )\n\n const getFirstEnabledId = useCallback(\n () => items.current.find((i) => !i.disabled)?.id ?? null,\n []\n )\n\n const reset = useCallback(() => {\n setHasInteracted(false)\n }, [setHasInteracted])\n\n const handleGroupKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n const isArrow = ['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight'].includes(e.key)\n if (!isArrow) return\n if (!e.currentTarget.contains(document.activeElement)) return\n if (e.currentTarget !== document.activeElement) return\n e.preventDefault()\n focusFirst()\n },\n [focusFirst]\n )\n\n const handleGroupBlur = useCallback(\n (e: React.FocusEvent) => {\n const currentTarget = e.currentTarget\n setTimeout(() => {\n if (!currentTarget.contains(document.activeElement)) reset()\n }, 0)\n },\n [reset]\n )\n\n const handleItemKeyDown = useCallback(\n (\n e: React.KeyboardEvent,\n itemId: string,\n onSelect?: (id: string) => void,\n containerRef?: React.RefObject<HTMLElement>\n ) => {\n if (e.key === 'Tab') {\n if (e.shiftKey) {\n e.preventDefault()\n reset()\n const container: HTMLElement | null =\n containerRef?.current ??\n (containerRole\n ? ((e.target as HTMLElement).closest(\n `[role=\"${containerRole}\"]`\n ) as HTMLElement | null)\n : null)\n container?.focus()\n } else {\n reset()\n }\n return\n }\n\n if (e.key === 'ArrowDown' || (allowHorizontal && e.key === 'ArrowRight')) {\n e.preventDefault()\n move(1)\n return\n }\n if (e.key === 'ArrowUp' || (allowHorizontal && e.key === 'ArrowLeft')) {\n e.preventDefault()\n move(-1)\n return\n }\n if (e.key === 'Home') {\n e.preventDefault()\n focusFirst()\n return\n }\n if (e.key === 'End') {\n e.preventDefault()\n focusLast()\n return\n }\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n onSelect?.(itemId)\n }\n },\n [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]\n )\n\n return {\n activeId,\n hasInteracted,\n registerItem,\n unregisterItem,\n focusItem,\n focusFirst,\n focusLast,\n getFirstEnabledId,\n reset,\n handleGroupKeyDown,\n handleItemKeyDown,\n handleGroupBlur\n }\n}\n\nexport type RovingFocusReturn = ReturnType<typeof useRovingFocus>\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@negative-space/roving-focus",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },