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>
);
}
|