Skip to the content

Capsid menu

components/tabs/tabs.react.tsx

172 lines and 13 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of tabs.react.tsx
FunctionLinesComplexityCoveredCRAPMutation
useTabs18 to 222No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
Tabs35 to 526No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
choose40 to 442No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
TabsList62 to 966No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
keys64 to 778No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:68>68 to 682No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:69>69 to 691No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:87>87 to 902No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
TabsTrigger105 to 1285No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:118>118 to 1213No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
TabsContent130 to 1372No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
TabsNav157 to 1646No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
TabLink175 to 1866No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports components/tabs/tabs.ts. Imported by components/media-library/media-library.react.tsx, components/mentions-list/mentions-list.react.tsx, components/posts-list/posts-list.react.tsx, components/tabs/examples.tsx.

Source

import { createContext, useContext, useId, useState, type ComponentProps, type KeyboardEvent, type ReactNode } from "react";
import { nextTab, type Activation, type Orientation } from "./tabs.ts";

// Renders the HTML contract in tabs.md. The chosen tab, the roving tabindex and the hidden
// panels are React state, so the markup a server renders is the markup a client hydrates.
// Every panel is rendered (the inactive ones `hidden`), so the page's content is in the HTML.
// Tab values are short tokens (letters, digits, dashes): they become part of the ids.
interface TabsContext {
  base: string;
  value: string;
  stop: string;
  orientation: Orientation;
  activation: Activation;
  choose: (value: string) => void;
  setStop: (value: string) => void;
}
const Ctx = createContext<TabsContext | null>(null);
function useTabs(): TabsContext {
  const c = useContext(Ctx);
  if (!c) throw new Error("Tabs parts go inside <Tabs>");
  return c;
}

export interface TabsProps extends Omit<ComponentProps<"div">, "onChange" | "defaultValue"> {
  // Controlled: the chosen tab, with onValueChange. Or uncontrolled: defaultValue.
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  orientation?: Orientation;
  // "automatic" (default): an arrow key chooses the tab it lands on. "manual": it only moves
  // focus, and Enter or Space chooses (for panels that are slow to show).
  activation?: Activation;
}

export function Tabs({ value, defaultValue = "", onValueChange, orientation = "horizontal", activation = "automatic", className, children, ...props }: TabsProps) {
  const base = useId();
  const [own, setOwn] = useState(defaultValue);
  const chosen = value ?? own;
  const [moved, setMoved] = useState<string | null>(null);
  const choose = (v: string) => {
    setOwn(v);
    setMoved(null);
    if (v !== chosen) onValueChange?.(v);
  };
  return (
    <Ctx.Provider value={{ base, value: chosen, stop: moved ?? chosen, orientation, activation, choose, setStop: setMoved }}>
      <div className={className ? `cap-tabs ${className}` : "cap-tabs"} data-orientation={orientation === "vertical" ? "vertical" : undefined} data-activation={activation === "manual" ? "manual" : undefined} {...props}>
        {children}
      </div>
    </Ctx.Provider>
  );
}

export interface TabsListProps extends ComponentProps<"div"> {
  // The list needs a name: "Posts", "Settings sections".
  "aria-label": string;
  variant?: "default" | "line";
  size?: "sm" | "default" | "lg";
  fill?: boolean;
}

export function TabsList({ variant = "default", size = "default", fill, className, onKeyDown, onBlur, children, ...props }: TabsListProps) {
  const c = useTabs();
  const keys = (e: KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e);
    if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
    const tabs = [...e.currentTarget.querySelectorAll<HTMLElement>(":scope > [role='tab']")];
    const enabled = tabs.flatMap((t, i) => (t.getAttribute("aria-disabled") === "true" ? [] : [i]));
    const current = tabs.findIndex((t) => t === document.activeElement);
    const to = nextTab(enabled, current, e.key, c.orientation, getComputedStyle(e.currentTarget).direction === "rtl");
    const target = to === null ? undefined : tabs[to];
    if (!target) return;
    e.preventDefault();
    target.focus();
    if (c.activation === "automatic") target.click();
    else c.setStop(target.id.slice(`${c.base}-tab-`.length));
  };
  return (
    <div
      role="tablist"
      aria-orientation={c.orientation === "vertical" ? "vertical" : undefined}
      className={className ? `cap-tabs-list ${className}` : "cap-tabs-list"}
      data-variant={variant === "line" ? "line" : undefined}
      data-size={size === "default" ? undefined : size}
      data-fill={fill ? "" : undefined}
      onKeyDown={keys}
      onBlur={(e) => {
        onBlur?.(e);
        if (!e.currentTarget.contains(e.relatedTarget)) c.setStop(c.value);
      }}
      {...props}
    >
      {children}
    </div>
  );
}

export interface TabsTriggerProps extends Omit<ComponentProps<"button">, "value"> {
  value: string;
  // A number beside the label. The accessible name reads "Open 12".
  count?: number;
  disabled?: boolean;
}

export function TabsTrigger({ value, count, disabled, className, onClick, children, ...props }: TabsTriggerProps) {
  const c = useTabs();
  const on = c.value === value;
  return (
    <button
      type="button"
      role="tab"
      id={`${c.base}-tab-${value}`}
      aria-selected={on}
      aria-controls={`${c.base}-panel-${value}`}
      aria-disabled={disabled ? true : undefined}
      tabIndex={c.stop === value ? 0 : -1}
      className={className ? `cap-tab ${className}` : "cap-tab"}
      onClick={(e) => {
        onClick?.(e);
        if (!e.defaultPrevented && !disabled) c.choose(value);
      }}
      {...props}
    >
      {children}
      {count != null ? <span className="cap-tab-count">{count}</span> : null}
    </button>
  );
}

export function TabsContent({ value, className, children, ...props }: ComponentProps<"div"> & { value: string }) {
  const c = useTabs();
  return (
    <div role="tabpanel" id={`${c.base}-panel-${value}`} aria-labelledby={`${c.base}-tab-${value}`} tabIndex={0} hidden={c.value !== value} className={className ? `cap-tabs-panel ${className}` : "cap-tabs-panel"} {...props}>
      {children}
    </div>
  );
}

// Tabs that are pages: a nav of links, the current one `aria-current="page"`. No tab roles,
// no arrow keys: each link is a tab stop, as links are.
// What a router's link component is given: a plain `<a>`'s props with `href` always set, the
// tab's class and `aria-current` already on it. Return the router's `<Link to={props.href} {...props} />`.
export type RenderTabLink = (props: ComponentProps<"a"> & { href: string }) => ReactNode;

const NavLinks = createContext<RenderTabLink | undefined>(undefined);

export interface TabsNavProps extends ComponentProps<"nav"> {
  "aria-label": string;
  variant?: "default" | "line";
  size?: "sm" | "default" | "lg";
  fill?: boolean;
  // The link slot for every `TabLink` inside: how an app's router draws a link (a client-side
  // navigation), so a tab link is a router link and still carries the tab's look and state.
  renderLink?: RenderTabLink;
}

export function TabsNav({ variant = "default", size = "default", fill, className, children, renderLink, ...props }: TabsNavProps) {
  const nav = (
    <nav className={className ? `cap-tabs-list ${className}` : "cap-tabs-list"} data-variant={variant === "line" ? "line" : undefined} data-size={size === "default" ? undefined : size} data-fill={fill ? "" : undefined} {...props}>
      {children}
    </nav>
  );
  return renderLink ? <NavLinks.Provider value={renderLink}>{nav}</NavLinks.Provider> : nav;
}

export interface TabLinkProps extends Omit<ComponentProps<"a">, "href"> {
  href: string;
  current?: boolean;
  count?: number;
  children: ReactNode;
  // This link only: how the router draws it. Wins over the nav's `renderLink`.
  renderLink?: RenderTabLink;
}

export function TabLink({ current, count, className, children, renderLink, ...props }: TabLinkProps) {
  const inherited = useContext(NavLinks);
  const draw = renderLink ?? inherited;
  const inner = (
    <>
      {children}
      {count != null ? <span className="cap-tab-count">{count}</span> : null}
    </>
  );
  const linkProps = { className: className ? `cap-tab ${className}` : "cap-tab", "aria-current": current ? ("page" as const) : undefined, ...props, children: inner };
  return draw ? <>{draw(linkProps)}</> : <a {...linkProps} />;
}