# All files / src/components/settings SettingsNav.tsx

0% Statements 0/87
0% Branches 0/1
0% Functions 0/1
0% Lines 0/87

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