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 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | 6x 6x 6x 21x 21x 8x 8x 14x 8x 8x 8x 2x 2x 6x 2x 2x 4x 2x 2x 2x 2x 2x 8x 8x 21x 33x 33x 4x 33x | import { useCallback, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
import { Code2, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { ICON_LG } from "../../utils/iconSize.js";
import { Tooltip } from "../display/Tooltip.js";
import styles from "./ContextNav.module.scss";
/** A single selectable context in the {@link ContextNav} rail. */
export interface ContextItem {
/** Stable identifier (e.g. `"code"`). */
id: string;
/** Display label. */
label: string;
/** Icon element rendered before the label. */
icon: ReactNode;
/** `data-testid` for the context's button (e.g. `"context-code"`). */
testId: string;
}
/** Identifier of the default `Code` context (the only context in Phase 0). */
export const DEFAULT_CONTEXT_ID: string = "code";
/**
* Accessible name shared by the `<nav>` landmark and its `tablist`. The landmark
* names the region; the tablist needs its own name so assistive tech announces a
* named tab list (and so `getByRole("tablist")` can find it by label).
*/
const CONTEXT_NAV_LABEL: string = "Context navigation";
/**
* Canonical list of contexts, co-located with the component like {@link TABS}
* so icons/ids/test-ids stay a single source of truth. Phase 0 ships only
* `Code` (#1414); Agent rows are appended dynamically in #1417.
*/
export const CONTEXTS: ContextItem[] = [
{
id: DEFAULT_CONTEXT_ID,
label: "Code",
icon: <Code2 size={ICON_LG} />,
testId: "context-code",
},
];
/** Props for the {@link ContextNav} component. */
export interface ContextNavProps {
/** Contexts to list, in display order. */
contexts: ContextItem[];
/** Identifier of the currently active context. */
activeContextId: string;
/** Called with a context id when the user selects it. */
onSelectContext: (id: string) => void;
/** When `true`, the rail shows icons only (labels move into tooltips). */
collapsed?: boolean;
/** Called when the user toggles the collapsed state. Omit to hide the toggle. */
onToggleCollapsed?: () => void;
}
/**
* Vertical left rail for the **context axis** (#1414) — the outermost level of
* the context → view → detail navigation spine. Lists the contexts the user can
* enter (just `Code` today; Agent rows arrive in #1417) and is purely
* presentational: it takes the active id plus selection/toggle callbacks and
* never touches the router or `useGrackle`.
*
* Implements a vertical `tablist` with automatic activation — arrow keys move
* focus and select in one step, mirroring {@link AppNav}'s horizontal behavior.
*/
export function ContextNav({
contexts,
activeContextId,
onSelectContext,
collapsed = false,
onToggleCollapsed,
}: ContextNavProps): JSX.Element {
const tabListRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLElement>) => {
const buttons = tabListRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]');
Iif (!buttons || buttons.length === 0) {
return;
}
const focusedIndex = Array.from(buttons).findIndex((b) => b === document.activeElement);
const currentIndex =
focusedIndex >= 0 ? focusedIndex : contexts.findIndex((c) => c.id === activeContextId);
let nextIndex = currentIndex;
if (e.key === "ArrowDown" || e.key === "j" || e.key === "J") {
e.preventDefault();
nextIndex = (currentIndex + 1) % contexts.length;
} else if (e.key === "ArrowUp" || e.key === "k" || e.key === "K") {
e.preventDefault();
nextIndex = (currentIndex - 1 + contexts.length) % contexts.length;
} else if (e.key === "Home") {
e.preventDefault();
nextIndex = 0;
} else if (e.key === "End") {
e.preventDefault();
nextIndex = contexts.length - 1;
} else E{
return;
}
onSelectContext(contexts[nextIndex].id);
buttons[nextIndex]?.focus(); // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- index may be out of bounds
},
[activeContextId, contexts, onSelectContext],
);
return (
<nav
className={styles.rail}
aria-label={CONTEXT_NAV_LABEL}
data-testid="context-nav"
data-collapsed={collapsed}
>
<div
className={styles.tabList}
ref={tabListRef}
role="tablist"
aria-label={CONTEXT_NAV_LABEL}
aria-orientation="vertical"
onKeyDown={handleKeyDown}
>
{contexts.map((context) => {
const isActive = context.id === activeContextId;
const button = (
<button
role="tab"
type="button"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
className={`${styles.tab} ${isActive ? styles.tabActive : ""}`}
onClick={() => onSelectContext(context.id)}
data-testid={context.testId}
aria-label={context.label}
>
<span className={styles.tabIcon} aria-hidden="true">
{context.icon}
</span>
{!collapsed && <span className={styles.tabLabel}>{context.label}</span>}
</button>
);
// When collapsed, labels live in a tooltip so the rail stays icon-only.
return collapsed ? (
<Tooltip key={context.id} text={context.label} placement="right" inline={false}>
{button}
</Tooltip>
) : (
<div key={context.id} className={styles.tabWrapper}>
{button}
</div>
);
})}
</div>
{onToggleCollapsed && (
<button
type="button"
className={styles.toggle}
onClick={onToggleCollapsed}
aria-label={collapsed ? "Expand context navigation" : "Collapse context navigation"}
aria-expanded={!collapsed}
data-testid="context-nav-toggle"
>
<span className={styles.tabIcon} aria-hidden="true">
{collapsed ? <PanelLeftOpen size={ICON_LG} /> : <PanelLeftClose size={ICON_LG} />}
</span>
{!collapsed && <span className={styles.tabLabel}>Collapse</span>}
</button>
)}
</nav>
);
}
|