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 | 5x 31x 31x 31x 31x 132x 132x 31x 31x 16x 16x 27x 16x 16x 16x 8x 8x 8x 2x 2x 6x 3x 3x 3x 3x 3x 16x 16x 16x 31x 186x 186x | 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"]');
Iif (!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 E{
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>
);
}
|