import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type KeyboardEvent,} from "react";import { ArchiveIcon, ArrowLeftIcon, BlocksIcon, BotIcon, GitBranchIcon, KeyboardIcon, Link2Icon, PaletteIcon, SearchIcon, Settings2Icon, XIcon,} from "lucide-react";import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { Button } from "../ui/button";import { Input } from "../ui/input";import { Kbd } from "../ui/kbd";import { SidebarContent, SidebarFooter, SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar,} from "../ui/sidebar";import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";import { scrollToSettingsTarget } from "./settingsLayout";import { searchSettings, SETTINGS_SECTION_LABELS, type SettingsPath, type SettingsSearchItem,} from "./settingsSearch"; const SETTINGS_SECTION_ICONS: Readonly< Record<SettingsPath, ComponentType<{ className?: string }>>> = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, "/settings/archived": ArchiveIcon,}; export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsPath; icon: ComponentType<{ className?: string }>;}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({ to, label: SETTINGS_SECTION_LABELS[to], icon: SETTINGS_SECTION_ICONS[to],})); function SettingsSectionIcon({ to }: { to: SettingsPath }) { const Icon = SETTINGS_SECTION_ICONS[to]; return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;} export function SettingsSidebarNav({ pathname }: { pathname: string }) { const navigate = useNavigate(); const currentHash = useLocation({ select: (location) => location.hash }); const canGoBack = useCanGoBack(); const { isMobile, setOpenMobile, open, setOpen } = useSidebar(); const searchInputRef = useRef<HTMLInputElement>(null); const [query, setQuery] = useState(""); const [activeResultIndex, setActiveResultIndex] = useState(0); const results = useMemo(() => searchSettings(query), [query]); const isSearching = query.trim().length > 0; const hasResults = results.length > 0; useEffect(() => { const result = results[activeResultIndex]; if (!result) return; document .getElementById(`settings-search-result-${result.id}`) ?.scrollIntoView({ block: "nearest" }); }, [activeResultIndex, results]); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return; const target = event.target; if ( target instanceof HTMLElement && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || // Keep focus inside open dialogs and popups instead of escaping // their focus trap into the sidebar search. target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]') !== null) ) { return; } event.preventDefault(); if (isMobile) { setOpenMobile(true); } else if (!open) { setOpen(true); } requestAnimationFrame(() => { searchInputRef.current?.focus(); searchInputRef.current?.select(); }); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isMobile, open, setOpen, setOpenMobile]); const handleSectionClick = useCallback( (to: SettingsPath) => { if (isMobile) { setOpenMobile(false); } void navigate({ to, hash: "", replace: true, hashScrollIntoView: false }); }, [isMobile, navigate, setOpenMobile], ); const clearSearch = useCallback(() => { setQuery(""); setActiveResultIndex(0); }, []); const handleSearchResultClick = useCallback( (item: SettingsSearchItem) => { clearSearch(); if (isMobile) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, hash: targetId, replace: true, hashScrollIntoView: false }); }, [clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile], ); const handleSearchKeyDown = useCallback( (event: KeyboardEvent<HTMLInputElement>) => { if (event.key === "Escape" && isSearching) { event.preventDefault(); event.stopPropagation(); clearSearch(); return; } if (results.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveResultIndex((index) => (index + 1) % results.length); return; } if (event.key === "ArrowUp") { event.preventDefault(); setActiveResultIndex((index) => (index - 1 + results.length) % results.length); return; } if (event.key === "Enter") { event.preventDefault(); const result = results[activeResultIndex]; if (result) handleSearchResultClick(result); } }, [activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results], ); const handleBackClick = useCallback(() => { if (isMobile) { setOpenMobile(false); } if (canGoBack) { window.history.back(); return; } void navigate({ to: "/" }); }, [canGoBack, isMobile, navigate, setOpenMobile]); return ( <> <SidebarContent className="overflow-x-hidden"> <SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]"> <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"> <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> <Input ref={searchInputRef} nativeInput unstyled type="search" value={query} onChange={(event) => { setQuery(event.currentTarget.value); setActiveResultIndex(0); }} onKeyDown={handleSearchKeyDown} placeholder="Search" aria-label="Search settings" role="combobox" aria-autocomplete="list" aria-expanded={isSearching && hasResults} aria-controls={isSearching && hasResults ? "settings-search-results" : undefined} aria-activedescendant={ isSearching && results[activeResultIndex] ? `settings-search-result-${results[activeResultIndex].id}` : undefined } className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" /> {isSearching ? ( <Button type="button" size="icon-micro" variant="ghost" className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground" aria-label="Clear settings search" onClick={() => { clearSearch(); searchInputRef.current?.focus(); }} > <XIcon className="size-3" /> </Button> ) : ( <Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">/</Kbd> )} </div> {isSearching && results.length === 0 ? ( <p role="status" className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground" > No settings found </p> ) : null} <SidebarMenu className="ps-px" id={isSearching && hasResults ? "settings-search-results" : undefined} role={isSearching && hasResults ? "listbox" : undefined} aria-label={isSearching && hasResults ? "Settings search results" : undefined} > {isSearching ? results.map((item, index) => ( <SidebarMenuItem key={item.id} role="presentation"> <SidebarMenuButton id={`settings-search-result-${item.id}`} role="option" aria-selected={index === activeResultIndex} tabIndex={-1} size="sm" isActive={index === activeResultIndex} className="h-auto min-h-10 items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-row-hover hover:text-sidebar-foreground" onMouseMove={() => setActiveResultIndex(index)} onClick={() => handleSearchResultClick(item)} > <SettingsSectionIcon to={item.to} /> <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-medium text-sidebar-foreground"> {item.title} </span> <span className="block truncate text-[11px] text-sidebar-muted-foreground/75"> {SETTINGS_SECTION_LABELS[item.to]} </span> </span> </SidebarMenuButton> </SidebarMenuItem> )) : SETTINGS_NAV_ITEMS.map((item) => { const Icon = item.icon; const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`); return ( <SidebarMenuItem key={item.to}> <SidebarMenuButton isActive={isActive} onClick={() => handleSectionClick(item.to)} > <Icon /> <span className="truncate">{item.label}</span> </SidebarMenuButton> </SidebarMenuItem> ); })} </SidebarMenu> </SidebarGroup> </SidebarContent> <SidebarFooter className="p-[var(--sidebar-content-inset)]"> <T3ConnectSidebarSignIn /> <div className="flex items-center gap-1"> <SidebarMenu className="min-w-0 flex-1"> <SidebarMenuItem> <SidebarMenuButton onClick={handleBackClick}> <ArrowLeftIcon /> <span>Back</span> </SidebarMenuButton> </SidebarMenuItem> </SidebarMenu> <T3ConnectSidebarAvatar /> </div> </SidebarFooter> </> );}
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type KeyboardEvent,
} from "react";
import {
ArchiveIcon,
ArrowLeftIcon,
BlocksIcon,
BotIcon,
GitBranchIcon,
KeyboardIcon,
Link2Icon,
PaletteIcon,
SearchIcon,
Settings2Icon,
XIcon,
} from "lucide-react";
import { useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Kbd } from "../ui/kbd";
import {
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "../ui/sidebar";
import { T3ConnectSidebarAvatar, T3ConnectSidebarSignIn } from "../clerk/T3ConnectSidebarSignIn";
import { scrollToSettingsTarget } from "./settingsLayout";
import {
searchSettings,
SETTINGS_SECTION_LABELS,
type SettingsPath,
type SettingsSearchItem,
} from "./settingsSearch";
const SETTINGS_SECTION_ICONS: Readonly<
Record<SettingsPath, ComponentType<{ className?: string }>>
> = {
"/settings/general": Settings2Icon,
"/settings/appearance": PaletteIcon,
"/settings/keybindings": KeyboardIcon,
"/settings/providers": BotIcon,
"/settings/integrations": BlocksIcon,
"/settings/source-control": GitBranchIcon,
"/settings/connections": Link2Icon,
"/settings/archived": ArchiveIcon,
};
export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
label: string;
to: SettingsPath;
icon: ComponentType<{ className?: string }>;
}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({
to,
label: SETTINGS_SECTION_LABELS[to],
icon: SETTINGS_SECTION_ICONS[to],
}));
function SettingsSectionIcon({ to }: { to: SettingsPath }) {
const Icon = SETTINGS_SECTION_ICONS[to];
return <Icon className="mt-0.5 size-3.5 shrink-0 text-sidebar-muted-foreground/60" />;
}
export function SettingsSidebarNav({ pathname }: { pathname: string }) {
const navigate = useNavigate();
const currentHash = useLocation({ select: (location) => location.hash });
const canGoBack = useCanGoBack();
const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
const searchInputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState("");
const [activeResultIndex, setActiveResultIndex] = useState(0);
const results = useMemo(() => searchSettings(query), [query]);
const isSearching = query.trim().length > 0;
const hasResults = results.length > 0;
useEffect(() => {
const result = results[activeResultIndex];
if (!result) return;
document
.getElementById(`settings-search-result-${result.id}`)
?.scrollIntoView({ block: "nearest" });
}, [activeResultIndex, results]);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLElement &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
// Keep focus inside open dialogs and popups instead of escaping
// their focus trap into the sidebar search.
target.closest('[role="dialog"], [aria-modal="true"], [data-slot$="popup"]') !== null)
) {
return;
}
event.preventDefault();
if (isMobile) {
setOpenMobile(true);
} else if (!open) {
setOpen(true);
}
requestAnimationFrame(() => {
searchInputRef.current?.focus();
searchInputRef.current?.select();
});
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isMobile, open, setOpen, setOpenMobile]);
const handleSectionClick = useCallback(
(to: SettingsPath) => {
if (isMobile) {
setOpenMobile(false);
}
void navigate({ to, hash: "", replace: true, hashScrollIntoView: false });
},
[isMobile, navigate, setOpenMobile],
);
const clearSearch = useCallback(() => {
setQuery("");
setActiveResultIndex(0);
}, []);
const handleSearchResultClick = useCallback(
(item: SettingsSearchItem) => {
clearSearch();
if (isMobile) {
setOpenMobile(false);
}
const targetId = item.targetId ?? item.id;
if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) {
scrollToSettingsTarget(targetId);
return;
}
void navigate({ to: item.to, hash: targetId, replace: true, hashScrollIntoView: false });
},
[clearSearch, currentHash, isMobile, navigate, pathname, setOpenMobile],
);
const handleSearchKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Escape" && isSearching) {
event.preventDefault();
event.stopPropagation();
clearSearch();
return;
}
if (results.length === 0) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveResultIndex((index) => (index + 1) % results.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveResultIndex((index) => (index - 1 + results.length) % results.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const result = results[activeResultIndex];
if (result) handleSearchResultClick(result);
}
},
[activeResultIndex, clearSearch, handleSearchResultClick, isSearching, results],
);
const handleBackClick = useCallback(() => {
if (isMobile) {
setOpenMobile(false);
}
if (canGoBack) {
window.history.back();
return;
}
void navigate({ to: "/" });
}, [canGoBack, isMobile, navigate, setOpenMobile]);
return (
<>
<SidebarContent className="overflow-x-hidden">
<SidebarGroup className="gap-2 p-[var(--sidebar-content-inset)]">
<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">
<SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
<Input
ref={searchInputRef}
nativeInput
unstyled
type="search"
value={query}
onChange={(event) => {
setQuery(event.currentTarget.value);
setActiveResultIndex(0);
}}
onKeyDown={handleSearchKeyDown}
placeholder="Search"
aria-label="Search settings"
role="combobox"
aria-autocomplete="list"
aria-expanded={isSearching && hasResults}
aria-controls={isSearching && hasResults ? "settings-search-results" : undefined}
aria-activedescendant={
isSearching && results[activeResultIndex]
? `settings-search-result-${results[activeResultIndex].id}`
: undefined
}
className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground"
/>
{isSearching ? (
<Button
type="button"
size="icon-micro"
variant="ghost"
className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
aria-label="Clear settings search"
onClick={() => {
clearSearch();
searchInputRef.current?.focus();
}}
>
<XIcon className="size-3" />
</Button>
) : (
<Kbd className="h-4 min-w-0 rounded-sm px-1.5 text-[10px]">/</Kbd>
)}
</div>
{isSearching && results.length === 0 ? (
<p
role="status"
className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
>
No settings found
</p>
) : null}
<SidebarMenu
className="ps-px"
id={isSearching && hasResults ? "settings-search-results" : undefined}
role={isSearching && hasResults ? "listbox" : undefined}
aria-label={isSearching && hasResults ? "Settings search results" : undefined}
>
{isSearching
? results.map((item, index) => (
<SidebarMenuItem key={item.id} role="presentation">
<SidebarMenuButton
id={`settings-search-result-${item.id}`}
role="option"
aria-selected={index === activeResultIndex}
tabIndex={-1}
size="sm"
isActive={index === activeResultIndex}
className="h-auto min-h-10 items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-sidebar-row-hover hover:text-sidebar-foreground"
onMouseMove={() => setActiveResultIndex(index)}
onClick={() => handleSearchResultClick(item)}
>
<SettingsSectionIcon to={item.to} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-sidebar-foreground">
{item.title}
</span>
<span className="block truncate text-[11px] text-sidebar-muted-foreground/75">
{SETTINGS_SECTION_LABELS[item.to]}
</span>
</span>
</SidebarMenuButton>
</SidebarMenuItem>
))
: SETTINGS_NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`);
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
isActive={isActive}
onClick={() => handleSectionClick(item.to)}
>
<Icon />
<span className="truncate">{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="p-[var(--sidebar-content-inset)]">
<T3ConnectSidebarSignIn />
<div className="flex items-center gap-1">
<SidebarMenu className="min-w-0 flex-1">
<SidebarMenuItem>
<SidebarMenuButton onClick={handleBackClick}>
<ArrowLeftIcon />
<span>Back</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<T3ConnectSidebarAvatar />
</div>
</SidebarFooter>
</>
);
}