Skip to content
File

Blob: src/client/components/editor/controllers/menu/navigation.ts

typescript157 lines
1import { useCallback, useEffect, useState } from "react";
2 
3export function getInitialMenuIndex(itemCount: number, initialIndex = 0): number {
4 if (itemCount === 0) return -1;
5 return Math.max(0, Math.min(initialIndex, itemCount - 1));
6}
7 
8export function moveMenuIndex(currentIndex: number, itemCount: number, direction: -1 | 1): number {
9 if (itemCount === 0) return -1;
10 if (currentIndex < 0) return direction > 0 ? 0 : itemCount - 1;
11 if (direction > 0) return currentIndex >= itemCount - 1 ? 0 : currentIndex + 1;
12 return currentIndex <= 0 ? itemCount - 1 : currentIndex - 1;
13}
14 
15function moveMenuIndexByStep(currentIndex: number, itemCount: number, step: number): number {
16 if (itemCount === 0) return -1;
17 if (currentIndex < 0) return step > 0 ? 0 : itemCount - 1;
18 const next = currentIndex + step;
19 if (next < 0) return Math.max(0, itemCount + next);
20 if (next >= itemCount) return Math.min(itemCount - 1, next - itemCount);
21 return next;
22}
23 
24interface UseMenuNavigationOptions<T> {
25 items: T[];
26 initialIndex?: number;
27 listRef: React.RefObject<HTMLElement | null>;
28 onSelect: (item: T) => void;
29 columns?: number;
30}
31 
32interface MenuSelectionState<T> {
33 items: T[];
34 initialIndex: number;
35 index: number;
36}
37 
38function createMenuSelectionState<T>(items: T[], initialIndex: number): MenuSelectionState<T> {
39 return {
40 items,
41 initialIndex,
42 index: getInitialMenuIndex(items.length, initialIndex),
43 };
44}
45 
46function resolveSelectedIndex<T>(state: MenuSelectionState<T>, items: T[], initialIndex: number): number {
47 if (state.items !== items || state.initialIndex !== initialIndex) {
48 return getInitialMenuIndex(items.length, initialIndex);
49 }
50 return getInitialMenuIndex(items.length, state.index);
51}
52 
53export function useMenuNavigation<T>({
54 items,
55 initialIndex = 0,
56 listRef,
57 onSelect,
58 columns,
59}: UseMenuNavigationOptions<T>) {
60 const [selectedIndexState, setSelectedIndexState] = useState(() => createMenuSelectionState(items, initialIndex));
61 const selectedIndex = resolveSelectedIndex(selectedIndexState, items, initialIndex);
62 
63 const setSelectedIndex = useCallback(
64 (index: number) => {
65 setSelectedIndexState({
66 items,
67 initialIndex,
68 index: getInitialMenuIndex(items.length, index),
69 });
70 },
71 [initialIndex, items],
72 );
73 
74 useEffect(() => {
75 if (selectedIndex < 0) return;
76 const item = listRef.current?.querySelector<HTMLElement>(`[data-menu-index="${selectedIndex}"]`);
77 item?.scrollIntoView({ block: "nearest" });
78 }, [listRef, selectedIndex]);
79 
80 const onKeyDown = useCallback(
81 (event: KeyboardEvent) => {
82 if (items.length === 0) return false;
83 
84 const gridColumns = columns && columns > 1 ? columns : 0;
85 const vertical = gridColumns > 0 ? gridColumns : 1;
86 
87 if (event.key === "ArrowUp") {
88 event.preventDefault();
89 setSelectedIndexState((state) => ({
90 items,
91 initialIndex,
92 index: moveMenuIndexByStep(resolveSelectedIndex(state, items, initialIndex), items.length, -vertical),
93 }));
94 return true;
95 }
96 
97 if (event.key === "ArrowDown") {
98 event.preventDefault();
99 setSelectedIndexState((state) => ({
100 items,
101 initialIndex,
102 index: moveMenuIndexByStep(resolveSelectedIndex(state, items, initialIndex), items.length, vertical),
103 }));
104 return true;
105 }
106 
107 if (gridColumns > 0 && event.key === "ArrowLeft") {
108 event.preventDefault();
109 setSelectedIndexState((state) => ({
110 items,
111 initialIndex,
112 index: moveMenuIndex(resolveSelectedIndex(state, items, initialIndex), items.length, -1),
113 }));
114 return true;
115 }
116 
117 if (gridColumns > 0 && event.key === "ArrowRight") {
118 event.preventDefault();
119 setSelectedIndexState((state) => ({
120 items,
121 initialIndex,
122 index: moveMenuIndex(resolveSelectedIndex(state, items, initialIndex), items.length, 1),
123 }));
124 return true;
125 }
126 
127 if (event.key === "Home") {
128 event.preventDefault();
129 setSelectedIndex(0);
130 return true;
131 }
132 
133 if (event.key === "End") {
134 event.preventDefault();
135 setSelectedIndex(items.length - 1);
136 return true;
137 }
138 
139 if (event.key === "Enter") {
140 event.preventDefault();
141 const item = items[selectedIndex >= 0 ? selectedIndex : 0];
142 if (item) onSelect(item);
143 return true;
144 }
145 
146 return false;
147 },
148 [columns, initialIndex, items, onSelect, selectedIndex, setSelectedIndex],
149 );
150 
151 return {
152 onKeyDown,
153 selectedIndex,
154 setSelectedIndex,
155 };
156}