All files / src/components/layout ContextNav.tsx

92.68% Statements 38/41
83.92% Branches 47/56
88.88% Functions 8/9
94.73% Lines 36/38

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 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266                                      8x             8x             8x                                                                                                                           36x 36x   36x   16x 16x     28x   16x 16x   16x 4x 4x 12x 4x 4x 8x 4x 4x 4x 4x 4x         16x 16x         36x                       3x   3x       2x                       3x                                               56x 56x 56x   56x           6x                     56x                                                                                                                      
import { useCallback, useRef, type JSX, type KeyboardEvent, type ReactNode } from "react";
import { Code2, PanelLeftClose, PanelLeftOpen, Plus } 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;
  /** Called when the user clicks "Create Agent". Omit to hide the affordance. */
  onCreateAgent?: () => void;
  /**
   * Fleet/overview items rendered in a section **above** the contexts — the
   * cross-context altitude (e.g. Coordination, #1415). Unlike contexts, these
   * navigate to a route, so the parent maps each id back to its destination.
   * Omit or pass an empty array to render no Fleet section.
   */
  fleetItems?: ContextItem[];
  /** Identifier of the active fleet item, if any (route-derived by the parent). */
  activeFleetId?: string;
  /** Called with a fleet item id when selected (the parent navigates to its route). */
  onSelectFleet?: (id: string) => 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`.
 *
 * An optional **Fleet** section sits above the contexts for cross-context
 * overview surfaces (Coordination, #1415). Those items navigate to a route, so
 * they render as plain buttons (not `tab`s) and stay out of the context
 * `tablist`'s roving-tabindex group.
 *
 * 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,
  onCreateAgent,
  fleetItems,
  activeFleetId,
  onSelectFleet,
}: ContextNavProps): JSX.Element {
  const createAgentLabel = "Create Agent";
  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}
    >
      {fleetItems && fleetItems.length > 0 && (
        <div className={styles.fleetSection}>
          {!collapsed && <span className={styles.sectionLabel}>Fleet</span>}
          <div className={styles.fleetList} role="list" aria-label="Fleet overview">
            {fleetItems.map((item) => {
              const isActive = item.id === activeFleetId;
              const button = (
                <button
                  type="button"
                  aria-current={isActive ? "page" : undefined}
                  className={`${styles.tab} ${isActive ? styles.tabActive : ""}`}
                  onClick={() => onSelectFleet?.(item.id)}
                  data-testid={item.testId}
                  aria-label={item.label}
                >
                  <span className={styles.tabIcon} aria-hidden="true">
                    {item.icon}
                  </span>
                  {!collapsed && <span className={styles.tabLabel}>{item.label}</span>}
                </button>
              );
              // Each item is a `listitem` so the `role="list"` has valid children.
              // When collapsed, labels live in a tooltip so the rail stays icon-only.
              return (
                <div key={item.id} role="listitem" className={styles.tabWrapper}>
                  {collapsed ? (
                    <Tooltip text={item.label} placement="right" inline={false}>
                      {button}
                    </Tooltip>
                  ) : (
                    button
                  )}
                </div>
              );
            })}
          </div>
        </div>
      )}
      <div
        className={styles.tabList}
        ref={tabListRef}
        role="tablist"
        aria-label={CONTEXT_NAV_LABEL}
        aria-orientation="vertical"
        onKeyDown={handleKeyDown}
      >
        {contexts.map((context, index) => {
          const isActive = context.id === activeContextId;
          const hasActiveContext = contexts.some((c) => c.id === activeContextId);
          const isFocusable = isActive || (!hasActiveContext && index === 0);
          const button = (
            <button
              role="tab"
              type="button"
              aria-selected={isActive}
              tabIndex={isFocusable ? 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>
          );
        })}
        {onCreateAgent &&
          (collapsed ? (
            <Tooltip text={createAgentLabel} placement="right" inline={false}>
              <button
                type="button"
                className={styles.createAgent}
                onClick={onCreateAgent}
                aria-label={createAgentLabel}
                data-testid="context-nav-create-agent"
              >
                <span className={styles.tabIcon} aria-hidden="true">
                  <Plus size={ICON_LG} />
                </span>
              </button>
            </Tooltip>
          ) : (
            <button
              type="button"
              className={styles.createAgent}
              onClick={onCreateAgent}
              aria-label={createAgentLabel}
              data-testid="context-nav-create-agent"
            >
              <span className={styles.tabIcon} aria-hidden="true">
                <Plus size={ICON_LG} />
              </span>
              <span className={styles.tabLabel}>{createAgentLabel}</span>
            </button>
          ))}
      </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>
  );
}