@negative-space/roving-focus 1.0.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,18 +1,27 @@
1
- import * as react from 'react';
2
-
3
- type RovingFocusItem<Id extends string = string> = {
4
- id: Id;
1
+ interface RovingFocusItem {
2
+ id: string;
5
3
  ref: React.RefObject<HTMLElement>;
6
4
  disabled?: boolean;
5
+ }
6
+ interface UseRovingFocusOptions {
7
+ wrap?: boolean;
8
+ allowHorizontal?: boolean;
9
+ containerRole?: string;
10
+ }
11
+ declare function useRovingFocus(options?: UseRovingFocusOptions): {
12
+ activeId: string | null;
13
+ hasInteracted: boolean;
14
+ registerItem: (item: RovingFocusItem) => void;
15
+ unregisterItem: (id: string) => void;
16
+ focusItem: (id: string) => void;
17
+ focusFirst: () => void;
18
+ focusLast: () => void;
19
+ getFirstEnabledId: () => string | null;
20
+ reset: () => void;
21
+ handleGroupKeyDown: (e: React.KeyboardEvent) => void;
22
+ handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (id: string) => void, containerRef?: React.RefObject<HTMLElement>) => void;
23
+ handleGroupBlur: (e: React.FocusEvent) => void;
7
24
  };
8
- declare const useRovingFocus: <Item extends RovingFocusItem<Id>, Id extends string = string>() => {
9
- 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;
16
- };
25
+ type RovingFocusReturn = ReturnType<typeof useRovingFocus>;
17
26
 
18
- export { type RovingFocusItem, useRovingFocus };
27
+ export { type RovingFocusItem, type RovingFocusReturn, type UseRovingFocusOptions, useRovingFocus };
package/dist/index.d.ts CHANGED
@@ -1,18 +1,27 @@
1
- import * as react from 'react';
2
-
3
- type RovingFocusItem<Id extends string = string> = {
4
- id: Id;
1
+ interface RovingFocusItem {
2
+ id: string;
5
3
  ref: React.RefObject<HTMLElement>;
6
4
  disabled?: boolean;
5
+ }
6
+ interface UseRovingFocusOptions {
7
+ wrap?: boolean;
8
+ allowHorizontal?: boolean;
9
+ containerRole?: string;
10
+ }
11
+ declare function useRovingFocus(options?: UseRovingFocusOptions): {
12
+ activeId: string | null;
13
+ hasInteracted: boolean;
14
+ registerItem: (item: RovingFocusItem) => void;
15
+ unregisterItem: (id: string) => void;
16
+ focusItem: (id: string) => void;
17
+ focusFirst: () => void;
18
+ focusLast: () => void;
19
+ getFirstEnabledId: () => string | null;
20
+ reset: () => void;
21
+ handleGroupKeyDown: (e: React.KeyboardEvent) => void;
22
+ handleItemKeyDown: (e: React.KeyboardEvent, itemId: string, onSelect?: (id: string) => void, containerRef?: React.RefObject<HTMLElement>) => void;
23
+ handleGroupBlur: (e: React.FocusEvent) => void;
7
24
  };
8
- declare const useRovingFocus: <Item extends RovingFocusItem<Id>, Id extends string = string>() => {
9
- 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;
16
- };
25
+ type RovingFocusReturn = ReturnType<typeof useRovingFocus>;
17
26
 
18
- export { type RovingFocusItem, useRovingFocus };
27
+ export { type RovingFocusItem, type RovingFocusReturn, type UseRovingFocusOptions, useRovingFocus };
package/dist/index.js CHANGED
@@ -3,64 +3,164 @@
3
3
  var react = require('react');
4
4
 
5
5
  // src/index.ts
6
- var 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 registerItem = react.useCallback((item) => {
10
- if (items.current.some((i) => i.id === item.id)) return;
11
- items.current = [...items.current, item];
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);
12
16
  }, []);
13
- const unregisterItem = react.useCallback((id) => {
14
- items.current = items.current.filter((i) => i.id !== id);
17
+ const setHasInteracted = react.useCallback((value) => {
18
+ hasInteractedRef.current = value;
19
+ _setHasInteracted(value);
15
20
  }, []);
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) => {
21
+ const registerItem = react.useCallback(
22
+ (item) => {
23
+ const idx = items.current.findIndex((i) => i.id === item.id);
24
+ if (idx === -1) {
25
+ items.current.push(item);
26
+ } else {
27
+ items.current[idx] = item;
28
+ }
29
+ if (!activeIdRef.current && !item.disabled) {
30
+ setActiveId(item.id);
31
+ }
32
+ },
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]
44
+ );
45
+ const move = react.useCallback(
46
+ (dir) => {
47
+ setHasInteracted(true);
23
48
  const enabled = items.current.filter((i) => !i.disabled);
24
49
  if (!enabled.length) return;
25
- const index = enabled.findIndex((i) => i.id === activeId);
26
- const nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length;
27
- focusItem(enabled[nextIndex]);
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];
53
+ setActiveId(next.id);
54
+ next.ref.current?.focus();
55
+ },
56
+ [wrap, setActiveId, setHasInteracted]
57
+ );
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(() => {
66
+ const enabled = items.current.filter((i) => !i.disabled);
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) => {
75
+ setHasInteracted(true);
76
+ setActiveId(id);
28
77
  },
29
- [activeId, focusItem]
78
+ [setActiveId, setHasInteracted]
79
+ );
80
+ const getFirstEnabledId = react.useCallback(
81
+ () => items.current.find((i) => !i.disabled)?.id ?? null,
82
+ []
30
83
  );
31
- const onKeyDown = react.useCallback(
32
- (event) => {
33
- if (event.key === "ArrowDown") {
34
- event.preventDefault();
35
- moveFocus(1);
84
+ const reset = react.useCallback(() => {
85
+ setHasInteracted(false);
86
+ }, [setHasInteracted]);
87
+ const handleGroupKeyDown = react.useCallback(
88
+ (e) => {
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);
104
+ },
105
+ [reset]
106
+ );
107
+ const handleItemKeyDown = react.useCallback(
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();
119
+ }
120
+ return;
121
+ }
122
+ if (e.key === "ArrowDown" || allowHorizontal && e.key === "ArrowRight") {
123
+ e.preventDefault();
124
+ move(1);
125
+ return;
126
+ }
127
+ if (e.key === "ArrowUp" || allowHorizontal && e.key === "ArrowLeft") {
128
+ e.preventDefault();
129
+ move(-1);
130
+ return;
131
+ }
132
+ if (e.key === "Home") {
133
+ e.preventDefault();
134
+ focusFirst();
135
+ return;
36
136
  }
37
- if (event.key === "ArrowUp") {
38
- event.preventDefault();
39
- moveFocus(-1);
137
+ if (e.key === "End") {
138
+ e.preventDefault();
139
+ focusLast();
140
+ return;
141
+ }
142
+ if (e.key === "Enter" || e.key === " ") {
143
+ e.preventDefault();
144
+ onSelect?.(itemId);
40
145
  }
41
146
  },
42
- [moveFocus]
147
+ [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]
43
148
  );
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
149
  return {
150
+ activeId,
151
+ hasInteracted,
55
152
  registerItem,
56
153
  unregisterItem,
57
- activeId,
58
- setActiveId,
59
- onKeyDown,
60
- onFocus,
61
- onBlur
154
+ focusItem,
155
+ focusFirst,
156
+ focusLast,
157
+ getFirstEnabledId,
158
+ reset,
159
+ handleGroupKeyDown,
160
+ handleItemKeyDown,
161
+ handleGroupBlur
62
162
  };
63
- };
163
+ }
64
164
 
65
165
  exports.useRovingFocus = useRovingFocus;
66
166
  //# 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":";;;;;AAcO,SAAS,cAAA,CAAe,OAAA,GAAiC,EAAC,EAAG;AAClE,EAAA,MAAM,EAAE,IAAA,GAAO,IAAA,EAAM,eAAA,GAAkB,IAAA,EAAM,eAAc,GAAI,OAAA;AAE/D,EAAA,MAAM,KAAA,GAAQA,YAAA,CAA0B,EAAE,CAAA;AAC1C,EAAA,MAAM,WAAA,GAAcA,aAAsB,IAAI,CAAA;AAC9C,EAAA,MAAM,CAAC,QAAA,EAAU,YAAY,CAAA,GAAIC,eAAwB,IAAI,CAAA;AAC7D,EAAA,MAAM,gBAAA,GAAmBD,aAAO,KAAK,CAAA;AACrC,EAAA,MAAM,CAAC,aAAA,EAAe,iBAAiB,CAAA,GAAIC,eAAS,KAAK,CAAA;AAEzD,EAAA,MAAM,WAAA,GAAcC,iBAAA,CAAY,CAAC,EAAA,KAAsB;AACrD,IAAA,WAAA,CAAY,OAAA,GAAU,EAAA;AACtB,IAAA,YAAA,CAAa,EAAE,CAAA;AAAA,EACjB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,gBAAA,GAAmBA,iBAAA,CAAY,CAAC,KAAA,KAAmB;AACvD,IAAA,gBAAA,CAAiB,OAAA,GAAU,KAAA;AAC3B,IAAA,iBAAA,CAAkB,KAAK,CAAA;AAAA,EACzB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,YAAA,GAAeA,iBAAA;AAAA,IACnB,CAAC,IAAA,KAA0B;AACzB,MAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ,SAAA,CAAU,CAAC,CAAA,KAAM,CAAA,CAAE,EAAA,KAAO,IAAA,CAAK,EAAE,CAAA;AAC3D,MAAA,IAAI,QAAQ,EAAA,EAAI;AACd,QAAA,KAAA,CAAM,OAAA,CAAQ,KAAK,IAAI,CAAA;AAAA,MACzB,CAAA,MAAO;AACL,QAAA,KAAA,CAAM,OAAA,CAAQ,GAAG,CAAA,GAAI,IAAA;AAAA,MACvB;AACA,MAAA,IAAI,CAAC,WAAA,CAAY,OAAA,IAAW,CAAC,KAAK,QAAA,EAAU;AAC1C,QAAA,WAAA,CAAY,KAAK,EAAE,CAAA;AAAA,MACrB;AAAA,IACF,CAAA;AAAA,IACA,CAAC,WAAW;AAAA,GACd;AAEA,EAAA,MAAM,cAAA,GAAiBA,iBAAA;AAAA,IACrB,CAAC,EAAA,KAAe;AACd,MAAA,KAAA,CAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,OAAO,EAAE,CAAA;AACvD,MAAA,IAAI,WAAA,CAAY,YAAY,EAAA,EAAI;AAC9B,QAAA,MAAM,IAAA,GAAO,MAAM,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AAClD,QAAA,WAAA,CAAY,IAAA,EAAM,MAAM,IAAI,CAAA;AAAA,MAC9B;AAAA,IACF,CAAA;AAAA,IACA,CAAC,WAAW;AAAA,GACd;AAEA,EAAA,MAAM,IAAA,GAAOA,iBAAA;AAAA,IACX,CAAC,GAAA,KAAgB;AACf,MAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,MAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACvD,MAAA,IAAI,CAAC,QAAQ,MAAA,EAAQ;AACrB,MAAA,MAAM,GAAA,GAAM,QAAQ,SAAA,CAAU,CAAC,MAAM,CAAA,CAAE,EAAA,KAAO,YAAY,OAAO,CAAA;AACjE,MAAA,MAAM,UAAU,IAAA,GAAA,CACX,GAAA,GAAM,MAAM,OAAA,CAAQ,MAAA,IAAU,QAAQ,MAAA,GACvC,IAAA,CAAK,GAAA,CAAI,IAAA,CAAK,IAAI,GAAA,GAAM,GAAA,EAAK,CAAC,CAAA,EAAG,OAAA,CAAQ,SAAS,CAAC,CAAA;AACvD,MAAA,MAAM,IAAA,GAAO,QAAQ,OAAO,CAAA;AAC5B,MAAA,WAAA,CAAY,KAAK,EAAE,CAAA;AACnB,MAAA,IAAA,CAAK,GAAA,CAAI,SAAS,KAAA,EAAM;AAAA,IAC1B,CAAA;AAAA,IACA,CAAC,IAAA,EAAM,WAAA,EAAa,gBAAgB;AAAA,GACtC;AAEA,EAAA,MAAM,UAAA,GAAaA,kBAAY,MAAM;AACnC,IAAA,MAAM,KAAA,GAAQ,MAAM,OAAA,CAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACnD,IAAA,IAAI,CAAC,KAAA,EAAO;AACZ,IAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,IAAA,WAAA,CAAY,MAAM,EAAE,CAAA;AACpB,IAAA,KAAA,CAAM,GAAA,CAAI,SAAS,KAAA,EAAM;AAAA,EAC3B,CAAA,EAAG,CAAC,WAAA,EAAa,gBAAgB,CAAC,CAAA;AAElC,EAAA,MAAM,SAAA,GAAYA,kBAAY,MAAM;AAClC,IAAA,MAAM,OAAA,GAAU,MAAM,OAAA,CAAQ,MAAA,CAAO,CAAC,CAAA,KAAM,CAAC,EAAE,QAAQ,CAAA;AACvD,IAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,OAAA,CAAQ,MAAA,GAAS,CAAC,CAAA;AACvC,IAAA,IAAI,CAAC,IAAA,EAAM;AACX,IAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,IAAA,WAAA,CAAY,KAAK,EAAE,CAAA;AACnB,IAAA,IAAA,CAAK,GAAA,CAAI,SAAS,KAAA,EAAM;AAAA,EAC1B,CAAA,EAAG,CAAC,WAAA,EAAa,gBAAgB,CAAC,CAAA;AAElC,EAAA,MAAM,SAAA,GAAYA,iBAAA;AAAA,IAChB,CAAC,EAAA,KAAe;AACd,MAAA,gBAAA,CAAiB,IAAI,CAAA;AACrB,MAAA,WAAA,CAAY,EAAE,CAAA;AAAA,IAChB,CAAA;AAAA,IACA,CAAC,aAAa,gBAAgB;AAAA,GAChC;AAEA,EAAA,MAAM,iBAAA,GAAoBA,iBAAA;AAAA,IACxB,MAAM,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,CAAC,MAAM,CAAC,CAAA,CAAE,QAAQ,CAAA,EAAG,EAAA,IAAM,IAAA;AAAA,IACpD;AAAC,GACH;AAEA,EAAA,MAAM,KAAA,GAAQA,kBAAY,MAAM;AAC9B,IAAA,gBAAA,CAAiB,KAAK,CAAA;AAAA,EACxB,CAAA,EAAG,CAAC,gBAAgB,CAAC,CAAA;AAErB,EAAA,MAAM,kBAAA,GAAqBA,iBAAA;AAAA,IACzB,CAAC,CAAA,KAA2B;AAC1B,MAAA,MAAM,OAAA,GAAU,CAAC,WAAA,EAAa,SAAA,EAAW,aAAa,YAAY,CAAA,CAAE,QAAA,CAAS,CAAA,CAAE,GAAG,CAAA;AAClF,MAAA,IAAI,CAAC,OAAA,EAAS;AACd,MAAA,IAAI,CAAC,CAAA,CAAE,aAAA,CAAc,QAAA,CAAS,QAAA,CAAS,aAAa,CAAA,EAAG;AACvD,MAAA,IAAI,CAAA,CAAE,aAAA,KAAkB,QAAA,CAAS,aAAA,EAAe;AAChD,MAAA,CAAA,CAAE,cAAA,EAAe;AACjB,MAAA,UAAA,EAAW;AAAA,IACb,CAAA;AAAA,IACA,CAAC,UAAU;AAAA,GACb;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,GAAG,KAAA,EAAM;AAAA,MAC7D,GAAG,CAAC,CAAA;AAAA,IACN,CAAA;AAAA,IACA,CAAC,KAAK;AAAA,GACR;AAEA,EAAA,MAAM,iBAAA,GAAoBA,iBAAA;AAAA,IACxB,CACE,CAAA,EACA,MAAA,EACA,QAAA,EACA,YAAA,KACG;AACH,MAAA,IAAI,CAAA,CAAE,QAAQ,KAAA,EAAO;AACnB,QAAA,IAAI,EAAE,QAAA,EAAU;AACd,UAAA,CAAA,CAAE,cAAA,EAAe;AACjB,UAAA,KAAA,EAAM;AACN,UAAA,MAAM,SAAA,GACJ,YAAA,EAAc,OAAA,KACb,aAAA,GACK,EAAE,MAAA,CAAuB,OAAA;AAAA,YACzB,UAAU,aAAa,CAAA,EAAA;AAAA,WACzB,GACA,IAAA,CAAA;AACN,UAAA,SAAA,EAAW,KAAA,EAAM;AAAA,QACnB,CAAA,MAAO;AACL,UAAA,KAAA,EAAM;AAAA,QACR;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;AACA,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;AACA,MAAA,IAAI,CAAA,CAAE,QAAQ,MAAA,EAAQ;AACpB,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,UAAA,EAAW;AACX,QAAA;AAAA,MACF;AACA,MAAA,IAAI,CAAA,CAAE,QAAQ,KAAA,EAAO;AACnB,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,SAAA,EAAU;AACV,QAAA;AAAA,MACF;AAEA,MAAA,IAAI,CAAA,CAAE,GAAA,KAAQ,OAAA,IAAW,CAAA,CAAE,QAAQ,GAAA,EAAK;AACtC,QAAA,CAAA,CAAE,cAAA,EAAe;AACjB,QAAA,QAAA,GAAW,MAAM,CAAA;AAAA,MACnB;AAAA,IACF,CAAA;AAAA,IACA,CAAC,eAAA,EAAiB,IAAA,EAAM,UAAA,EAAY,SAAA,EAAW,OAAO,aAAa;AAAA,GACrE;AAEA,EAAA,OAAO;AAAA,IACL,QAAA;AAAA,IACA,aAAA;AAAA,IACA,YAAA;AAAA,IACA,cAAA;AAAA,IACA,SAAA;AAAA,IACA,UAAA;AAAA,IACA,SAAA;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\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,64 +1,164 @@
1
1
  import { useRef, useState, useCallback } from 'react';
2
2
 
3
3
  // src/index.ts
4
- var 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 registerItem = useCallback((item) => {
8
- if (items.current.some((i) => i.id === item.id)) return;
9
- items.current = [...items.current, item];
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);
10
14
  }, []);
11
- const unregisterItem = useCallback((id) => {
12
- items.current = items.current.filter((i) => i.id !== id);
15
+ const setHasInteracted = useCallback((value) => {
16
+ hasInteractedRef.current = value;
17
+ _setHasInteracted(value);
13
18
  }, []);
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) => {
19
+ const registerItem = useCallback(
20
+ (item) => {
21
+ const idx = items.current.findIndex((i) => i.id === item.id);
22
+ if (idx === -1) {
23
+ items.current.push(item);
24
+ } else {
25
+ items.current[idx] = item;
26
+ }
27
+ if (!activeIdRef.current && !item.disabled) {
28
+ setActiveId(item.id);
29
+ }
30
+ },
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]
42
+ );
43
+ const move = useCallback(
44
+ (dir) => {
45
+ setHasInteracted(true);
21
46
  const enabled = items.current.filter((i) => !i.disabled);
22
47
  if (!enabled.length) return;
23
- const index = enabled.findIndex((i) => i.id === activeId);
24
- const nextIndex = index === -1 ? 0 : (index + offset + enabled.length) % enabled.length;
25
- focusItem(enabled[nextIndex]);
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];
51
+ setActiveId(next.id);
52
+ next.ref.current?.focus();
53
+ },
54
+ [wrap, setActiveId, setHasInteracted]
55
+ );
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(() => {
64
+ const enabled = items.current.filter((i) => !i.disabled);
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) => {
73
+ setHasInteracted(true);
74
+ setActiveId(id);
26
75
  },
27
- [activeId, focusItem]
76
+ [setActiveId, setHasInteracted]
77
+ );
78
+ const getFirstEnabledId = useCallback(
79
+ () => items.current.find((i) => !i.disabled)?.id ?? null,
80
+ []
28
81
  );
29
- const onKeyDown = useCallback(
30
- (event) => {
31
- if (event.key === "ArrowDown") {
32
- event.preventDefault();
33
- moveFocus(1);
82
+ const reset = useCallback(() => {
83
+ setHasInteracted(false);
84
+ }, [setHasInteracted]);
85
+ const handleGroupKeyDown = useCallback(
86
+ (e) => {
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);
102
+ },
103
+ [reset]
104
+ );
105
+ const handleItemKeyDown = useCallback(
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();
117
+ }
118
+ return;
119
+ }
120
+ if (e.key === "ArrowDown" || allowHorizontal && e.key === "ArrowRight") {
121
+ e.preventDefault();
122
+ move(1);
123
+ return;
124
+ }
125
+ if (e.key === "ArrowUp" || allowHorizontal && e.key === "ArrowLeft") {
126
+ e.preventDefault();
127
+ move(-1);
128
+ return;
129
+ }
130
+ if (e.key === "Home") {
131
+ e.preventDefault();
132
+ focusFirst();
133
+ return;
34
134
  }
35
- if (event.key === "ArrowUp") {
36
- event.preventDefault();
37
- moveFocus(-1);
135
+ if (e.key === "End") {
136
+ e.preventDefault();
137
+ focusLast();
138
+ return;
139
+ }
140
+ if (e.key === "Enter" || e.key === " ") {
141
+ e.preventDefault();
142
+ onSelect?.(itemId);
38
143
  }
39
144
  },
40
- [moveFocus]
145
+ [allowHorizontal, move, focusFirst, focusLast, reset, containerRole]
41
146
  );
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
147
  return {
148
+ activeId,
149
+ hasInteracted,
53
150
  registerItem,
54
151
  unregisterItem,
55
- activeId,
56
- setActiveId,
57
- onKeyDown,
58
- onFocus,
59
- onBlur
152
+ focusItem,
153
+ focusFirst,
154
+ focusLast,
155
+ getFirstEnabledId,
156
+ reset,
157
+ handleGroupKeyDown,
158
+ handleItemKeyDown,
159
+ handleGroupBlur
60
160
  };
61
- };
161
+ }
62
162
 
63
163
  export { useRovingFocus };
64
164
  //# 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":"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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.0.0",
3
+ "version": "1.2.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },