@negative-space/roving-focus 1.0.0 → 1.1.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,18 +1,27 @@
1
1
  import * as react from 'react';
2
2
 
3
- type RovingFocusItem<Id extends string = string> = {
4
- id: Id;
3
+ interface Item {
4
+ id: string;
5
5
  ref: React.RefObject<HTMLElement>;
6
6
  disabled?: boolean;
7
- };
8
- declare const useRovingFocus: <Item extends RovingFocusItem<Id>, Id extends string = string>() => {
7
+ }
8
+ declare function useRovingFocus(): {
9
+ activeId: string | null;
10
+ hasInteracted: boolean;
9
11
  registerItem: (item: Item) => void;
10
- unregisterItem: (id: Id) => void;
11
- activeId: Id | null;
12
- setActiveId: react.Dispatch<react.SetStateAction<Id | null>>;
13
- onKeyDown: (event: React.KeyboardEvent) => void;
14
- onFocus: (event: React.FocusEvent) => void;
15
- onBlur: (event: React.FocusEvent) => void;
12
+ unregisterItem: (id: string) => void;
13
+ move: (dir: 1 | -1) => void;
14
+ setActiveId: react.Dispatch<react.SetStateAction<string | null>>;
15
+ focusItem: (itemId: string) => void;
16
+ focusFirst: () => void;
17
+ getFirstEnabledId: () => string;
18
+ reset: () => void;
19
+ 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;
24
+ handleGroupBlur: (e: React.FocusEvent) => void;
16
25
  };
17
26
 
18
- export { type RovingFocusItem, useRovingFocus };
27
+ export { useRovingFocus };
package/dist/index.d.ts CHANGED
@@ -1,18 +1,27 @@
1
1
  import * as react from 'react';
2
2
 
3
- type RovingFocusItem<Id extends string = string> = {
4
- id: Id;
3
+ interface Item {
4
+ id: string;
5
5
  ref: React.RefObject<HTMLElement>;
6
6
  disabled?: boolean;
7
- };
8
- declare const useRovingFocus: <Item extends RovingFocusItem<Id>, Id extends string = string>() => {
7
+ }
8
+ declare function useRovingFocus(): {
9
+ activeId: string | null;
10
+ hasInteracted: boolean;
9
11
  registerItem: (item: Item) => void;
10
- unregisterItem: (id: Id) => void;
11
- activeId: Id | null;
12
- setActiveId: react.Dispatch<react.SetStateAction<Id | null>>;
13
- onKeyDown: (event: React.KeyboardEvent) => void;
14
- onFocus: (event: React.FocusEvent) => void;
15
- onBlur: (event: React.FocusEvent) => void;
12
+ unregisterItem: (id: string) => void;
13
+ move: (dir: 1 | -1) => void;
14
+ setActiveId: react.Dispatch<react.SetStateAction<string | null>>;
15
+ focusItem: (itemId: string) => void;
16
+ focusFirst: () => void;
17
+ getFirstEnabledId: () => string;
18
+ reset: () => void;
19
+ 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;
24
+ handleGroupBlur: (e: React.FocusEvent) => void;
16
25
  };
17
26
 
18
- export { type RovingFocusItem, useRovingFocus };
27
+ export { useRovingFocus };
package/dist/index.js CHANGED
@@ -3,64 +3,130 @@
3
3
  var react = require('react');
4
4
 
5
5
  // src/index.ts
6
- var useRovingFocus = () => {
6
+ function useRovingFocus() {
7
7
  const items = react.useRef([]);
8
8
  const [activeId, setActiveId] = react.useState(null);
9
- const registerItem = react.useCallback((item) => {
10
- if (items.current.some((i) => i.id === item.id)) return;
11
- items.current = [...items.current, item];
12
- }, []);
9
+ const [hasInteracted, setHasInteracted] = react.useState(false);
10
+ const registerItem = react.useCallback(
11
+ (item) => {
12
+ const existingIndex = items.current.findIndex((i) => i.id === item.id);
13
+ if (existingIndex === -1) {
14
+ items.current.push(item);
15
+ } else {
16
+ items.current[existingIndex] = item;
17
+ }
18
+ if (!activeId && !item.disabled) {
19
+ setActiveId(item.id);
20
+ }
21
+ },
22
+ [activeId]
23
+ );
13
24
  const unregisterItem = react.useCallback((id) => {
14
25
  items.current = items.current.filter((i) => i.id !== id);
15
26
  }, []);
16
- const focusItem = react.useCallback((item) => {
17
- if (!item || item.disabled) return;
18
- setActiveId(item.id);
19
- item.ref.current?.focus();
20
- }, []);
21
- const moveFocus = react.useCallback(
22
- (offset) => {
27
+ const move = react.useCallback(
28
+ (dir) => {
29
+ setHasInteracted(true);
23
30
  const enabled = items.current.filter((i) => !i.disabled);
24
31
  if (!enabled.length) return;
25
32
  const index = enabled.findIndex((i) => i.id === activeId);
26
- const nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length;
27
- focusItem(enabled[nextIndex]);
33
+ const next = enabled[(index + dir + enabled.length) % enabled.length];
34
+ setActiveId(next.id);
35
+ next.ref.current?.focus();
36
+ },
37
+ [activeId]
38
+ );
39
+ const focusItem = react.useCallback((itemId) => {
40
+ setHasInteracted(true);
41
+ setActiveId(itemId);
42
+ }, []);
43
+ const focusFirst = react.useCallback(() => {
44
+ const enabled = items.current.filter((i) => !i.disabled);
45
+ if (enabled.length > 0) {
46
+ const first = enabled[0];
47
+ 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
+ }, []);
56
+ const reset = react.useCallback(() => {
57
+ setHasInteracted(false);
58
+ }, []);
59
+ const handleGroupKeyDown = react.useCallback(
60
+ (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
+ }
28
67
  },
29
- [activeId, focusItem]
68
+ [hasInteracted, focusFirst]
30
69
  );
31
- const onKeyDown = react.useCallback(
32
- (event) => {
33
- if (event.key === "ArrowDown") {
34
- event.preventDefault();
35
- moveFocus(1);
70
+ 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();
83
+ }
84
+ return;
85
+ }
86
+ if (e.key === "ArrowDown" || allowHorizontal && e.key === "ArrowRight") {
87
+ e.preventDefault();
88
+ move(1);
89
+ return;
36
90
  }
37
- if (event.key === "ArrowUp") {
38
- event.preventDefault();
39
- moveFocus(-1);
91
+ if (e.key === "ArrowUp" || allowHorizontal && e.key === "ArrowLeft") {
92
+ e.preventDefault();
93
+ move(-1);
94
+ return;
95
+ }
96
+ if (e.key === " " || e.key === "Enter") {
97
+ e.preventDefault();
98
+ onSelect?.(itemId);
40
99
  }
41
100
  },
42
- [moveFocus]
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]
43
113
  );
44
- const onFocus = react.useCallback((event) => {
45
- if (event.target === event.currentTarget) {
46
- setActiveId(null);
47
- }
48
- }, []);
49
- const onBlur = react.useCallback((event) => {
50
- if (!event.currentTarget.contains(event.relatedTarget)) {
51
- setActiveId(null);
52
- }
53
- }, []);
54
114
  return {
115
+ activeId,
116
+ hasInteracted,
55
117
  registerItem,
56
118
  unregisterItem,
57
- activeId,
119
+ move,
58
120
  setActiveId,
59
- onKeyDown,
60
- onFocus,
61
- onBlur
121
+ focusItem,
122
+ focusFirst,
123
+ getFirstEnabledId,
124
+ reset,
125
+ handleGroupKeyDown,
126
+ handleItemKeyDown,
127
+ handleGroupBlur
62
128
  };
63
- };
129
+ }
64
130
 
65
131
  exports.useRovingFocus = useRovingFocus;
66
132
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
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 type RovingFocusItem<Id extends string = string> = {\n id: Id\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport const useRovingFocus = <Item extends RovingFocusItem<Id>, Id extends string = string>() => {\n const items = useRef<Item[]>([])\n const [activeId, setActiveId] = useState<Id | null>(null)\n\n const registerItem = useCallback((item: Item) => {\n if (items.current.some((i) => i.id === item.id)) return\n items.current = [...items.current, item]\n }, [])\n\n const unregisterItem = useCallback((id: Id) => {\n items.current = items.current.filter((i) => i.id !== id)\n }, [])\n\n const focusItem = useCallback((item?: Item) => {\n if (!item || item.disabled) return\n setActiveId(item.id)\n item.ref.current?.focus()\n }, [])\n\n const moveFocus = useCallback(\n (offset: number) => {\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 nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length\n\n focusItem(enabled[nextIndex])\n },\n [activeId, focusItem]\n )\n\n const onKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n moveFocus(1)\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n moveFocus(-1)\n }\n },\n [moveFocus]\n )\n\n const onFocus = useCallback((event: React.FocusEvent) => {\n if (event.target === event.currentTarget) {\n setActiveId(null)\n }\n }, [])\n\n const onBlur = useCallback((event: React.FocusEvent) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n setActiveId(null)\n }\n }, [])\n\n return {\n registerItem,\n unregisterItem,\n activeId,\n setActiveId,\n onKeyDown,\n onFocus,\n onBlur\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\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"]}
package/dist/index.mjs CHANGED
@@ -1,64 +1,130 @@
1
1
  import { useRef, useState, useCallback } from 'react';
2
2
 
3
3
  // src/index.ts
4
- var useRovingFocus = () => {
4
+ function useRovingFocus() {
5
5
  const items = useRef([]);
6
6
  const [activeId, setActiveId] = useState(null);
7
- const registerItem = useCallback((item) => {
8
- if (items.current.some((i) => i.id === item.id)) return;
9
- items.current = [...items.current, item];
10
- }, []);
7
+ const [hasInteracted, setHasInteracted] = useState(false);
8
+ const registerItem = useCallback(
9
+ (item) => {
10
+ const existingIndex = items.current.findIndex((i) => i.id === item.id);
11
+ if (existingIndex === -1) {
12
+ items.current.push(item);
13
+ } else {
14
+ items.current[existingIndex] = item;
15
+ }
16
+ if (!activeId && !item.disabled) {
17
+ setActiveId(item.id);
18
+ }
19
+ },
20
+ [activeId]
21
+ );
11
22
  const unregisterItem = useCallback((id) => {
12
23
  items.current = items.current.filter((i) => i.id !== id);
13
24
  }, []);
14
- const focusItem = useCallback((item) => {
15
- if (!item || item.disabled) return;
16
- setActiveId(item.id);
17
- item.ref.current?.focus();
18
- }, []);
19
- const moveFocus = useCallback(
20
- (offset) => {
25
+ const move = useCallback(
26
+ (dir) => {
27
+ setHasInteracted(true);
21
28
  const enabled = items.current.filter((i) => !i.disabled);
22
29
  if (!enabled.length) return;
23
30
  const index = enabled.findIndex((i) => i.id === activeId);
24
- const nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length;
25
- focusItem(enabled[nextIndex]);
31
+ const next = enabled[(index + dir + enabled.length) % enabled.length];
32
+ setActiveId(next.id);
33
+ next.ref.current?.focus();
34
+ },
35
+ [activeId]
36
+ );
37
+ const focusItem = useCallback((itemId) => {
38
+ setHasInteracted(true);
39
+ setActiveId(itemId);
40
+ }, []);
41
+ const focusFirst = useCallback(() => {
42
+ const enabled = items.current.filter((i) => !i.disabled);
43
+ if (enabled.length > 0) {
44
+ const first = enabled[0];
45
+ 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
+ }, []);
54
+ const reset = useCallback(() => {
55
+ setHasInteracted(false);
56
+ }, []);
57
+ const handleGroupKeyDown = useCallback(
58
+ (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
+ }
26
65
  },
27
- [activeId, focusItem]
66
+ [hasInteracted, focusFirst]
28
67
  );
29
- const onKeyDown = useCallback(
30
- (event) => {
31
- if (event.key === "ArrowDown") {
32
- event.preventDefault();
33
- moveFocus(1);
68
+ 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();
81
+ }
82
+ return;
83
+ }
84
+ if (e.key === "ArrowDown" || allowHorizontal && e.key === "ArrowRight") {
85
+ e.preventDefault();
86
+ move(1);
87
+ return;
34
88
  }
35
- if (event.key === "ArrowUp") {
36
- event.preventDefault();
37
- moveFocus(-1);
89
+ if (e.key === "ArrowUp" || allowHorizontal && e.key === "ArrowLeft") {
90
+ e.preventDefault();
91
+ move(-1);
92
+ return;
93
+ }
94
+ if (e.key === " " || e.key === "Enter") {
95
+ e.preventDefault();
96
+ onSelect?.(itemId);
38
97
  }
39
98
  },
40
- [moveFocus]
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]
41
111
  );
42
- const onFocus = useCallback((event) => {
43
- if (event.target === event.currentTarget) {
44
- setActiveId(null);
45
- }
46
- }, []);
47
- const onBlur = useCallback((event) => {
48
- if (!event.currentTarget.contains(event.relatedTarget)) {
49
- setActiveId(null);
50
- }
51
- }, []);
52
112
  return {
113
+ activeId,
114
+ hasInteracted,
53
115
  registerItem,
54
116
  unregisterItem,
55
- activeId,
117
+ move,
56
118
  setActiveId,
57
- onKeyDown,
58
- onFocus,
59
- onBlur
119
+ focusItem,
120
+ focusFirst,
121
+ getFirstEnabledId,
122
+ reset,
123
+ handleGroupKeyDown,
124
+ handleItemKeyDown,
125
+ handleGroupBlur
60
126
  };
61
- };
127
+ }
62
128
 
63
129
  export { useRovingFocus };
64
130
  //# sourceMappingURL=index.mjs.map
@@ -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\nexport type RovingFocusItem<Id extends string = string> = {\n id: Id\n ref: React.RefObject<HTMLElement>\n disabled?: boolean\n}\n\nexport const useRovingFocus = <Item extends RovingFocusItem<Id>, Id extends string = string>() => {\n const items = useRef<Item[]>([])\n const [activeId, setActiveId] = useState<Id | null>(null)\n\n const registerItem = useCallback((item: Item) => {\n if (items.current.some((i) => i.id === item.id)) return\n items.current = [...items.current, item]\n }, [])\n\n const unregisterItem = useCallback((id: Id) => {\n items.current = items.current.filter((i) => i.id !== id)\n }, [])\n\n const focusItem = useCallback((item?: Item) => {\n if (!item || item.disabled) return\n setActiveId(item.id)\n item.ref.current?.focus()\n }, [])\n\n const moveFocus = useCallback(\n (offset: number) => {\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 nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length\n\n focusItem(enabled[nextIndex])\n },\n [activeId, focusItem]\n )\n\n const onKeyDown = useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === 'ArrowDown') {\n event.preventDefault()\n moveFocus(1)\n }\n if (event.key === 'ArrowUp') {\n event.preventDefault()\n moveFocus(-1)\n }\n },\n [moveFocus]\n )\n\n const onFocus = useCallback((event: React.FocusEvent) => {\n if (event.target === event.currentTarget) {\n setActiveId(null)\n }\n }, [])\n\n const onBlur = useCallback((event: React.FocusEvent) => {\n if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n setActiveId(null)\n }\n }, [])\n\n return {\n registerItem,\n unregisterItem,\n activeId,\n setActiveId,\n onKeyDown,\n onFocus,\n onBlur\n }\n}\n"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAQO,SAAS,cAAA,GAAiB;AAC/B,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAe,EAAE,CAAA;AAC/B,EAAA,MAAM,CAAC,QAAA,EAAU,WAAW,CAAA,GAAI,SAAwB,IAAI,CAAA;AAC5D,EAAA,MAAM,CAAC,aAAA,EAAe,gBAAgB,CAAA,GAAI,SAAS,KAAK,CAAA;AAExD,EAAA,MAAM,YAAA,GAAe,WAAA;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,GAAiB,WAAA,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,GAAO,WAAA;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,GAAY,WAAA,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,GAAa,YAAY,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,GAAoB,YAAY,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,GAAQ,YAAY,MAAM;AAC9B,IAAA,gBAAA,CAAiB,KAAK,CAAA;AAAA,EACxB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,kBAAA,GAAqB,WAAA;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,GAAoB,WAAA;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,GAAkB,WAAA;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.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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@negative-space/roving-focus",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },