1import {
2 lazy,
3 Suspense,
4 useCallback,
5 useEffect,
6 useMemo,
7 useRef,
8 useState,
9 type KeyboardEvent,
10} from "react";
11import { SearchIcon, XIcon } from "lucide-react";
12import { useLocation, useNavigate } from "@tanstack/react-router";
13
14import { Button } from "../ui/button";
15import { Kbd } from "../ui/kbd";
16import {
17 SidebarContent,
18 SidebarFooter,
19 SidebarGroup,
20 SidebarMenu,
21 SidebarMenuButton,
22 SidebarMenuItem,
23 useSidebar,
24 SidebarInput,
25} from "../ui/sidebar";
26import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
27import { scrollToSettingsTarget } from "./settingsLayout";
28import {
29 searchSettings,
30 SETTINGS_SECTION_LABELS,
31 type SettingsPath,
32 type SettingsSearchItem,
33} from "./settingsSearch";
34import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
35import { validateSettingsScopeSearch } from "./settingsScope";
36import { getSettingsNavigationItems, SETTINGS_SECTION_ICONS } from "./settingsNavigation";
37
38const T3ConnectSidebarSignIn = lazy(() =>
39 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
40 default: module.T3ConnectSidebarSignIn,
41 })),
42);
43const T3ConnectSidebarAvatar = lazy(() =>
44 import("../clerk/T3ConnectSidebarSignIn").then((module) => ({
45 default: module.T3ConnectSidebarAvatar,
46 })),
47);
48
49function SettingsSectionIcon({ to }: { to: SettingsPath }) {
50 const Icon = SETTINGS_SECTION_ICONS[to];
51 return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
52}
53
54export function SettingsSidebarNav({ pathname }: { pathname: string }) {
55 const navigate = useNavigate();
56 const currentHash = useLocation({ select: (location) => location.hash });
57 const currentSearch = useLocation({ select: (location) => location.search });
58 const scopeSearch = useMemo(() => validateSettingsScopeSearch(currentSearch), [currentSearch]);
59 const navItems = getSettingsNavigationItems(scopeSearch);
60 const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
61 const searchInputRef = useRef<HTMLInputElement>(null);
62 const [query, setQuery] = useState("");
63 const [activeResultIndex, setActiveResultIndex] = useState(0);
64 const searchableItems = useAvailableSettingsSearchItems(scopeSearch);
65 const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);
66 const isSearching = query.trim().length > 0;
67 const hasResults = results.length > 0;
68
69 useEffect(() => {
70 setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));
71 }, [results.length]);
72
73 useEffect(() => {
74 const result = results[activeResultIndex];
75 if (!result) return;
76 document
77 .getElementById(`settings-search-result-${result.id}`)
78 ?.scrollIntoView({ block: "nearest" });
79 }, [activeResultIndex, results]);
80
81 useEffect(() => {
82 const handleKeyDown = (event: globalThis.KeyboardEvent) => {
83 if (event.defaultPrevented || event.isComposing) return;
84 const isFindShortcut =
85 event.key.toLowerCase() === "f" &&
86 (event.metaKey || event.ctrlKey) &&
87 !event.altKey &&
88 !event.shiftKey;
89 const isSlashShortcut =
90 event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey;
91 if (!isFindShortcut && !isSlashShortcut) return;
92
93 const target = event.target;
94 if (
95 target instanceof HTMLElement &&
96 ((isSlashShortcut &&
97 (target.tagName === "INPUT" ||
98 target.tagName === "TEXTAREA" ||
99 target.isContentEditable)) ||
100 // Preserve dialog and popup focus traps, and let shortcut recorders
101 // capture the keypress without moving focus into the sidebar search.
102 target.closest(
103 '[role="dialog"], [aria-modal="true"], [data-slot$="popup"], [data-keybinding-capture]',
104 ) !== null)
105 ) {
106 return;
107 }
108
109 event.preventDefault();
110 if (isMobile) {
111 setOpenMobile(true);
112 } else if (!open) {
113 setOpen(true);
114 }
115 requestAnimationFrame(() => {
116 searchInputRef.current?.focus();
117 searchInputRef.current?.select();
118 });
119 };
120 window.addEventListener("keydown", handleKeyDown);
121 return () => window.removeEventListener("keydown", handleKeyDown);
122 }, [isMobile, open, setOpen, setOpenMobile]);
123
124 const handleSectionClick = useCallback(
125 (to: SettingsPath) => {
126 if (isMobile) {
127 setOpenMobile(false);
128 }
129 void navigate({
130 to,
131 hash: "",
132 replace: true,
133 hashScrollIntoView: false,
134 });
135 },
136 [isMobile, navigate, setOpenMobile],
137 );
138 const clearSearch = useCallback(() => {
139 setQuery("");
140 setActiveResultIndex(0);
141 }, []);
142 const handleSearchResultClick = useCallback(
143 (item: SettingsSearchItem) => {
144 clearSearch();
145 if (isMobile) {
146 setOpenMobile(false);
147 }
148 const targetId = item.targetId ?? item.id;
149 if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
150 scrollToSettingsTarget(targetId);
151 return;
152 }
153 void navigate({
154 to: item.to,
155 hash: targetId,
156 replace: true,
157 hashScrollIntoView: false,
158 state: { settingsTargetHighlight: true },
159 });
160 },
161 [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile],
162 );
163 const handleSearchKeyDown = useCallback(
164 (event: KeyboardEvent<HTMLInputElement>) => {
165 if (event.key === "Escape" && isSearching) {
166 event.preventDefault();
167 event.stopPropagation();
168 clearSearch();
169 return;
170 }
171 if (results.length === 0) return;
172 if (event.key === "ArrowDown") {
173 event.preventDefault();
174 setActiveResultIndex((index) => (index + 1) % results.length);
175 return;
176 }
177 if (event.key === "ArrowUp") {
178 event.preventDefault();
179 setActiveResultIndex((index) => (index - 1 + results.length) % results.length);
180 return;
181 }
182 if (event.key === "Enter") {
183 event.preventDefault();
184 const result = results[activeResultIndex];
185 if (result) handleSearchResultClick(result);
186 }
187 },
188 [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
189 );
190 return (
191 <>
192 <SidebarContent className="overflow-x-hidden">
193 <SidebarGroup>
194 <div className="flex flex-col gap-2">
195 <div className="flex h-8 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground">
196 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
197 <SidebarInput
198 ref={searchInputRef}
199 nativeInput
200 type="search"
201 value={query}
202 onChange={(event) => {
203 setQuery(event.currentTarget.value);
204 setActiveResultIndex(0);
205 }}
206 onKeyDown={handleSearchKeyDown}
207 placeholder="Search"
208 aria-label="Search settings"
209 role="combobox"
210 aria-autocomplete="list"
211 aria-expanded={isSearching && hasResults}
212 aria-controls={isSearching && hasResults ? "settings-search-results" : undefined}
213 aria-activedescendant={
214 isSearching && results[activeResultIndex]
215 ? `settings-search-result-${results[activeResultIndex].id}`
216 : undefined
217 }
218 className="min-w-0 flex-1"
219 />
220 {isSearching ? (
221 <Button
222 type="button"
223 size="icon-micro"
224 variant="ghost-muted"
225 className="shrink-0"
226 aria-label="Clear settings search"
227 onClick={() => {
228 clearSearch();
229 searchInputRef.current?.focus();
230 }}
231 >
232 <XIcon className="size-3" />
233 </Button>
234 ) : (
235 <Kbd>/</Kbd>
236 )}
237 </div>
238 {isSearching && results.length === 0 ? (
239 <p
240 role="status"
241 className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
242 >
243 No settings found
244 </p>
245 ) : null}
246 {isSearching ? (
247 <SidebarMenu
248 id={hasResults ? "settings-search-results" : undefined}
249 role={hasResults ? "listbox" : undefined}
250 aria-label={hasResults ? "Settings search results" : undefined}
251 >
252 {results.map((item, index) => (
253 <SidebarMenuItem key={item.id} role="presentation">
254 <SidebarMenuButton
255 id={`settings-search-result-${item.id}`}
256 role="option"
257 aria-selected={index === activeResultIndex}
258 tabIndex={-1}
259 size="sm"
260 isActive={index === activeResultIndex}
261 className="h-auto min-h-10 items-start"
262 onMouseMove={() => setActiveResultIndex(index)}
263 onClick={() => handleSearchResultClick(item)}
264 >
265 <SettingsSectionIcon to={item.to} />
266 <span className="min-w-0 flex-1">
267 <span className="block truncate text-sm font-medium text-sidebar-foreground">
268 {item.title}
269 </span>
270 <span className="block truncate text-2xs text-sidebar-muted-foreground/75">
271 {SETTINGS_SECTION_LABELS[item.to]}
272 </span>
273 </span>
274 </SidebarMenuButton>
275 </SidebarMenuItem>
276 ))}
277 </SidebarMenu>
278 ) : (
279 <SidebarMenu>
280 {navItems.map((item) => {
281 const Icon = item.icon;
282 const isGeneralDetailPage =
283 item.to === "/settings/general" &&
284 pathname === "/settings/open-source-licenses";
285 const isActive =
286 isGeneralDetailPage ||
287 pathname === item.to ||
288 pathname.startsWith(`${item.to}/`);
289 return (
290 <SidebarMenuItem key={item.to}>
291 <SidebarMenuButton
292 isActive={isActive}
293 onClick={() => handleSectionClick(item.to)}
294 >
295 <Icon />
296 <span className="truncate">{item.label}</span>
297 </SidebarMenuButton>
298 </SidebarMenuItem>
299 );
300 })}
301 </SidebarMenu>
302 )}
303 </div>
304 </SidebarGroup>
305 </SidebarContent>
306 <SidebarFooter>
307 <Suspense fallback={null}>
308 <T3ConnectSidebarSignIn />
309 </Suspense>
310 <div className="flex items-center gap-1">
311 <div className="min-w-0 flex-1">
312 <SidebarUtilityMenu />
313 </div>
314 <Suspense fallback={null}>
315 <T3ConnectSidebarAvatar />
316 </Suspense>
317 </div>
318 </SidebarFooter>
319 </>
320 );
321}