UNPKG

7.94 kBPlain TextView Raw
1// Credits: https://github.com/stevejay/react-roving-tabindex
2import * as React from "react";
3import {
4 SealedInitialState,
5 useSealedState,
6} from "reakit-utils/useSealedState";
7import {
8 unstable_IdState,
9 unstable_IdActions,
10 unstable_IdInitialState,
11 unstable_useIdState,
12} from "../Id/IdState";
13
14type Stop = {
15 id: string;
16 ref: React.RefObject<HTMLElement>;
17};
18
19export type RoverState = unstable_IdState & {
20 /**
21 * Defines the orientation of the rover list.
22 */
23 orientation?: "horizontal" | "vertical";
24 /**
25 * A list of element refs and IDs of the roving items.
26 */
27 stops: Stop[];
28 /**
29 * The current focused element ID.
30 */
31 currentId: Stop["id"] | null;
32 /**
33 * The last focused element ID.
34 * @private
35 */
36 unstable_pastId: Stop["id"] | null;
37 /**
38 * Stores the number of moves that have been made by calling `move`, `next`,
39 * `previous`, `first` or `last`.
40 */
41 unstable_moves: number;
42 /**
43 * If enabled:
44 * - Jumps to the first item when moving next from the last item.
45 * - Jumps to the last item when moving previous from the first item.
46 */
47 loop: boolean;
48};
49
50export type RoverActions = unstable_IdActions & {
51 /**
52 * Registers the element ID and ref in the roving tab index list.
53 */
54 register: (id: Stop["id"], ref: Stop["ref"]) => void;
55 /**
56 * Unregisters the roving item.
57 */
58 unregister: (id: Stop["id"]) => void;
59 /**
60 * Moves focus to a given element ID.
61 */
62 move: (id: Stop["id"] | null, unstable_silent?: boolean) => void;
63 /**
64 * Moves focus to the next element.
65 */
66 next: () => void;
67 /**
68 * Moves focus to the previous element.
69 */
70 previous: () => void;
71 /**
72 * Moves focus to the first element.
73 */
74 first: () => void;
75 /**
76 * Moves focus to the last element.
77 */
78 last: () => void;
79 /**
80 * Resets `currentId` and `pastId` states.
81 * @private
82 */
83 unstable_reset: () => void;
84 /**
85 * Changes the `orientation` state of the roving tab index list.
86 * @private
87 */
88 unstable_orientate: (orientation: RoverState["orientation"]) => void;
89};
90
91export type RoverInitialState = unstable_IdInitialState &
92 Partial<Pick<RoverState, "orientation" | "currentId" | "loop">>;
93
94export type RoverStateReturn = RoverState & RoverActions;
95
96type RoverAction =
97 | { type: "register"; id: Stop["id"]; ref: Stop["ref"] }
98 | { type: "unregister"; id: Stop["id"] }
99 | { type: "move"; id: Stop["id"] | null; silent?: boolean }
100 | { type: "next" }
101 | { type: "previous" }
102 | { type: "first" }
103 | { type: "last" }
104 | { type: "reset" }
105 | {
106 type: "orientate";
107 orientation?: RoverState["orientation"];
108 };
109
110type RoverReducerState = Omit<RoverState, keyof unstable_IdState>;
111
112function reducer(
113 state: RoverReducerState,
114 action: RoverAction
115): RoverReducerState {
116 const {
117 stops,
118 currentId,
119 unstable_pastId: pastId,
120 unstable_moves: moves,
121 loop,
122 } = state;
123
124 switch (action.type) {
125 case "register": {
126 const { id, ref } = action;
127 if (stops.length === 0) {
128 return {
129 ...state,
130 stops: [{ id, ref }],
131 };
132 }
133
134 const index = stops.findIndex((stop) => stop.id === id);
135
136 if (index >= 0) {
137 return state;
138 }
139
140 const indexToInsertAt = stops.findIndex((stop) => {
141 if (!stop.ref.current || !ref.current) return false;
142 // Return true if the new rover element is located earlier in the DOM
143 // than stop's element, else false:
144 return Boolean(
145 stop.ref.current.compareDocumentPosition(ref.current) &
146 Node.DOCUMENT_POSITION_PRECEDING
147 );
148 });
149
150 // findIndex returns -1 when the new rover should be inserted
151 // at the end of stops (the compareDocumentPosition test
152 // always returns false in that case).
153 if (indexToInsertAt === -1) {
154 return {
155 ...state,
156 stops: [...stops, { id, ref }],
157 };
158 }
159 return {
160 ...state,
161 stops: [
162 ...stops.slice(0, indexToInsertAt),
163 { id, ref },
164 ...stops.slice(indexToInsertAt),
165 ],
166 };
167 }
168 case "unregister": {
169 const { id } = action;
170 const nextStops = stops.filter((stop) => stop.id !== id);
171 if (nextStops.length === stops.length) {
172 return state;
173 }
174
175 return {
176 ...state,
177 stops: nextStops,
178 unstable_pastId: pastId && pastId === id ? null : pastId,
179 currentId: currentId && currentId === id ? null : currentId,
180 };
181 }
182 case "move": {
183 const { id, silent } = action;
184 const nextMoves = silent ? moves : moves + 1;
185
186 if (id === null) {
187 return {
188 ...state,
189 currentId: null,
190 unstable_pastId: currentId,
191 unstable_moves: nextMoves,
192 };
193 }
194
195 const index = stops.findIndex((stop) => stop.id === id);
196
197 // Item doesn't exist, so we don't count a move
198 if (index === -1) {
199 return state;
200 }
201
202 if (stops[index].id === currentId) {
203 return { ...state, unstable_moves: nextMoves };
204 }
205
206 return {
207 ...state,
208 currentId: stops[index].id,
209 unstable_pastId: currentId,
210 unstable_moves: nextMoves,
211 };
212 }
213 case "next": {
214 if (currentId == null) {
215 return reducer(state, { type: "move", id: stops[0] && stops[0].id });
216 }
217 const index = stops.findIndex((stop) => stop.id === currentId);
218
219 // If loop is truthy, turns [0, currentId, 2, 3] into [currentId, 2, 3, 0]
220 // Otherwise turns into [currentId, 2, 3]
221 const reorderedStops = [
222 ...stops.slice(index + 1),
223 ...(loop ? stops.slice(0, index) : []),
224 ];
225
226 const nextIndex =
227 reorderedStops.findIndex((stop) => stop.id === currentId) + 1;
228
229 return reducer(state, {
230 type: "move",
231 id: reorderedStops[nextIndex] && reorderedStops[nextIndex].id,
232 });
233 }
234 case "previous": {
235 const { stops: _, ...nextState } = reducer(
236 { ...state, stops: stops.slice().reverse() },
237 { type: "next" }
238 );
239 return {
240 ...state,
241 ...nextState,
242 };
243 }
244 case "first": {
245 const stop = stops[0];
246 return reducer(state, { type: "move", id: stop && stop.id });
247 }
248 case "last": {
249 const stop = stops[stops.length - 1];
250 return reducer(state, { type: "move", id: stop && stop.id });
251 }
252 case "reset": {
253 return {
254 ...state,
255 currentId: null,
256 unstable_pastId: null,
257 };
258 }
259 case "orientate":
260 return { ...state, orientation: action.orientation };
261 default:
262 throw new Error();
263 }
264}
265
266export function useRoverState(
267 initialState: SealedInitialState<RoverInitialState> = {}
268): RoverStateReturn {
269 const {
270 orientation,
271 currentId = null,
272 loop = false,
273 ...sealed
274 } = useSealedState(initialState);
275 const [state, dispatch] = React.useReducer(reducer, {
276 orientation,
277 stops: [],
278 currentId,
279 unstable_pastId: null,
280 unstable_moves: 0,
281 loop,
282 });
283
284 const idState = unstable_useIdState(sealed);
285
286 return {
287 ...idState,
288 ...state,
289 register: useAction((id, ref) => dispatch({ type: "register", id, ref })),
290 unregister: useAction((id) => dispatch({ type: "unregister", id })),
291 move: useAction((id, silent) => dispatch({ type: "move", id, silent })),
292 next: useAction(() => dispatch({ type: "next" })),
293 previous: useAction(() => dispatch({ type: "previous" })),
294 first: useAction(() => dispatch({ type: "first" })),
295 last: useAction(() => dispatch({ type: "last" })),
296 unstable_reset: useAction(() => dispatch({ type: "reset" })),
297 unstable_orientate: useAction((o) =>
298 dispatch({ type: "orientate", orientation: o })
299 ),
300 };
301}
302
303function useAction<T extends (...args: any[]) => any>(fn: T) {
304 return React.useCallback(fn, []);
305}