356 lines · 12.2 KB · TypeScript
RawDownload
import {  lazy,  Suspense,  useCallback,  useEffect,  useMemo,  useRef,  useState,  type ComponentType,  type KeyboardEvent,} from "react";import {  ArchiveIcon,  BlocksIcon,  BotIcon,  createLucideIcon,  GitBranchIcon,  PanelsTopLeftIcon,  KeyboardIcon,  Link2Icon,  PaletteIcon,  SearchIcon,  Settings2Icon,  XIcon,} from "lucide-react";import { 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 { SidebarUtilityMenu } from "../sidebar/SidebarChrome";import { scrollToSettingsTarget } from "./settingsLayout";import {  searchSettings,  SETTINGS_SECTION_LABELS,  type SettingsPath,  type SettingsSearchItem,} from "./settingsSearch";import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems"; const SnapShotIcon = createLucideIcon("snap-shot", [  [    "path",    {      d: "M8 3H6a3 3 0 0 0-3 3v2M16 3h2a3 3 0 0 1 3 3v2M21 16v2a3 3 0 0 1-3 3h-2M8 21H6a3 3 0 0 1-3-3v-2",      key: "capture-frame",    },  ],  ["rect", { width: "10", height: "8", x: "7", y: "8", rx: "2", key: "window" }],  ["circle", { cx: "12", cy: "12", r: "1.5", key: "lens" }],]); const T3ConnectSidebarSignIn = lazy(() =>  import("../clerk/T3ConnectSidebarSignIn").then((module) => ({    default: module.T3ConnectSidebarSignIn,  })),);const T3ConnectSidebarAvatar = lazy(() =>  import("../clerk/T3ConnectSidebarSignIn").then((module) => ({    default: module.T3ConnectSidebarAvatar,  })),); const SETTINGS_SECTION_ICONS: Readonly<  Record<SettingsPath, ComponentType<{ className?: string }>>> = {  "/settings/general": Settings2Icon,  "/settings/appearance": PaletteIcon,  "/settings/projects": PanelsTopLeftIcon,  "/settings/keybindings": KeyboardIcon,  "/settings/snap-shot": SnapShotIcon,  "/settings/providers": BotIcon,  "/settings/integrations": BlocksIcon,  "/settings/source-control": GitBranchIcon,  "/settings/connections": Link2Icon,  "/settings/archived": ArchiveIcon,}; 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 { isMobile, setOpenMobile, open, setOpen } = useSidebar();  const searchInputRef = useRef<HTMLInputElement>(null);  const [query, setQuery] = useState("");  const [activeResultIndex, setActiveResultIndex] = useState(0);  const searchableItems = useAvailableSettingsSearchItems();  const results = useMemo(() => searchSettings(query, searchableItems), [query, searchableItems]);  const isSearching = query.trim().length > 0;  const hasResults = results.length > 0;   useEffect(() => {    setActiveResultIndex((index) => Math.min(index, Math.max(results.length - 1, 0)));  }, [results.length]);   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 (        item.to !== "/settings/projects" &&        pathname === item.to &&        currentHash.replace(/^#/, "") === targetId      ) {        scrollToSettingsTarget(targetId);        return;      }      void navigate({        to: item.to,        search: (previous) =>          item.to === "/settings/projects" ? { ...previous, project: undefined } : previous,        hash: targetId,        replace: true,        hashScrollIntoView: false,        state: { settingsTargetHighlight: true },      });    },    [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],  );  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}          {isSearching ? (            <SidebarMenu              className="ps-px"              id={hasResults ? "settings-search-results" : undefined}              role={hasResults ? "listbox" : undefined}              aria-label={hasResults ? "Settings search results" : undefined}            >              {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>              ))}            </SidebarMenu>          ) : (            <SidebarMenu className="ps-px">              {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)]">        <Suspense fallback={null}>          <T3ConnectSidebarSignIn />        </Suspense>        <div className="flex items-center gap-1">          <div className="min-w-0 flex-1">            <SidebarUtilityMenu />          </div>          <Suspense fallback={null}>            <T3ConnectSidebarAvatar />          </Suspense>        </div>      </SidebarFooter>    </>  );}