Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | import { useCallback, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react"; import { useLocation } from "react-router"; import { Github, Info, Key, Keyboard, Palette, Puzzle } from "lucide-react"; import { SETTINGS_URL, useAppNavigate } from "../../utils/navigation.js"; import { ICON_LG } from "../../utils/iconSize.js"; import styles from "./SettingsNav.module.scss"; /** Tab definition for the settings navigation rail. */ interface SettingsTab { /** URL path segment (appended to /settings/). */ path: string; /** Display label for the tab. */ label: string; /** Icon element displayed before the label. */ icon: ReactNode; } /** Ordered list of settings tabs. */ const TABS: SettingsTab[] = [ { path: "credentials", label: "Credentials", icon: <Key size={ICON_LG} /> }, { path: "github-accounts", label: "GitHub Accounts", icon: <Github size={ICON_LG} /> }, { path: "appearance", label: "Appearance", icon: <Palette size={ICON_LG} /> }, { path: "shortcuts", label: "Shortcuts", icon: <Keyboard size={ICON_LG} /> }, { path: "plugins", label: "Plugins", icon: <Puzzle size={ICON_LG} /> }, { path: "about", label: "About", icon: <Info size={ICON_LG} /> }, ]; /** Vertical tab navigation rail for the settings hub. */ export function SettingsNav(): JSX.Element { const location = useLocation(); const navigate = useAppNavigate(); const tabListRef = useRef<HTMLElement>(null); const activeTab = TABS.find((tab) => { const tabPath = `${SETTINGS_URL}/${tab.path}`; return location.pathname === tabPath || location.pathname.startsWith(`${tabPath}/`); })?.path ?? TABS[0].path; const handleClick = useCallback( (path: string) => { navigate(`${SETTINGS_URL}/${path}`); }, [navigate], ); const handleKeyDown = useCallback( (e: KeyboardEvent<HTMLDivElement>) => { // Derive current index from the focused element rather than location // to avoid stale closures during rapid keyboard navigation. const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]'); if (!buttons) { return; } const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement); const currentIndex = focusedIndex >= 0 ? focusedIndex : TABS.findIndex((t) => t.path === activeTab); let nextIndex = currentIndex; if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") { e.preventDefault(); nextIndex = (currentIndex + 1) % TABS.length; } else if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") { e.preventDefault(); nextIndex = (currentIndex - 1 + TABS.length) % TABS.length; } else if (e.key === "Home") { e.preventDefault(); nextIndex = 0; } else if (e.key === "End") { e.preventDefault(); nextIndex = TABS.length - 1; } else { return; } const nextPath = TABS[nextIndex].path; navigate(`${SETTINGS_URL}/${nextPath}`); buttons[nextIndex]?.focus(); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- index may be out of bounds }, [activeTab, navigate], ); return ( <nav className={styles.nav} ref={tabListRef} role="tablist" aria-orientation="vertical" aria-label="Settings" onKeyDown={handleKeyDown} > {TABS.map((tab) => { const isActive = tab.path === activeTab; return ( <button key={tab.path} role="tab" type="button" aria-selected={isActive} tabIndex={isActive ? 0 : -1} className={`${styles.tab} ${isActive ? styles.tabActive : ""}`} onClick={() => handleClick(tab.path)} > <span className={styles.tabIcon} aria-hidden="true"> {tab.icon} </span> {tab.label} </button> ); })} </nav> ); } |