# Side panel

The side panel's contents: one tab per open item (a diff, an approval, a Know document), each closable. Closing the last tab closes the panel.

Arrow keys move between tabs. With `onClose`, each tab has a close button for the pointer and Delete closes the focused tab, moving focus to its neighbour. The tab's button stays out of the tab order so the tab list holds only tabs; the panel starts with a "Close" button that shows when focused, so screen reader and touch users have a way too. Without `activeId`, the panel keeps track of the chosen tab itself. A tab can carry a `count`, and `actions` puts buttons at the end of the tab row, outside the tab list.

With `keepTabs`, switching tabs keeps each one as it was (its scroll, a half-filled form) instead of starting it again. On React 19.2 and later a hidden tab's effects pause until it's shown again.

- Registry name: `side-panel` (`@candor/side-panel`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/side-panel

## Install

```bash
npx candor-ui add side-panel
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/side-panel`.
It brings in: locale, utils.
npm packages: lucide-react@^1.67.0, radix-ui@^1.8.1.

## Example

```tsx
"use client"

import * as React from "react"
import { FileDiff, Maximize2, ShieldCheck } from "lucide-react"
import { SidePanel, type PanelTab } from "@/components/side-panel"

const allTabs: PanelTab[] = [
  {
    id: "approval",
    label: "Run migration on Production",
    Icon: ShieldCheck,
    // A count on the tab; "attention" for things waiting on the person, with words for screen readers.
    count: 1,
    countLabel: "1 needs you",
    countTone: "attention",
    content: (
      <div className="flex flex-col gap-2 text-sm">
        <p className="font-medium">Dev agent wants to run 0042_add_region on Production.</p>
        <p className="text-muted-foreground">Adds a nullable region column to orders. It ran on Staging in 1.8s.</p>
      </div>
    ),
  },
  {
    id: "diff",
    label: "Diff · 2 files",
    Icon: FileDiff,
    content: (
      <pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
        {`+ ALTER TABLE orders\n+   ADD COLUMN region text;\n+ CREATE INDEX orders_region_idx\n+   ON orders (region);`}
      </pre>
    ),
  },
]

export default function Example() {
  const [open, setOpen] = React.useState(allTabs.map((t) => t.id))
  const [active, setActive] = React.useState("approval")
  const tabs = allTabs.filter((t) => open.includes(t.id))
  return (
    <div className="h-80 max-w-md overflow-hidden rounded-lg border bg-chrome">
      {tabs.length > 0 ? (
        <SidePanel
          tabs={tabs}
          activeId={active}
          onActivate={setActive}
          actions={
            <button type="button" aria-label="Open full width" className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground">
              <Maximize2 className="size-3.5" />
            </button>
          }
          onClose={(id) => {
            const next = open.filter((t) => t !== id)
            setOpen(next)
            if (id === active && next[0]) setActive(next[0])
          }}
        />
      ) : (
        <div className="flex h-full items-center justify-center">
          <button type="button" onClick={() => setOpen(allTabs.map((t) => t.id))} className="text-sm text-muted-foreground underline underline-offset-2">
            Reopen the panel
          </button>
        </div>
      )}
    </div>
  )
}
```

## <SidePanel>

Props:

- `tabs: PanelTab[]`: The open items, one tab each, with their content.
- `activeId?: string | undefined`: The id of the tab showing; without one (or an unknown id), the first tab.
- `onActivate?: ((id: string) => void) | undefined`: Called with a tab's id when it's chosen.
- `onClose?: ((id: string) => void) | undefined`: Shows a close button on each tab, and lets Delete close the focused tab; called with the tab's id.
- `actions?: React.ReactNode | undefined`: Buttons at the end of the tab row (hide the panel, open full width), kept out of the tab list's arrow keys.
- `keepTabs?: boolean | undefined` (default `false`): Keeps tabs that aren't showing mounted, so each keeps its state and scroll position. Defaults to false: only the showing tab is rendered.
- `className?: string | undefined`

## Strings (`sidePanel`)

The fixed words, English by default. Override any of them under `sidePanel` in `<LocaleProvider strings={{ sidePanel: { … } }}>`; the table is exported as `sidePanelStrings`.

- `deleteHint`: Press Delete to close.
- `close`: Close {label}
- `count`: { one: "{count} item", other: "{count} items" }
- `countAfterName`: , {label}

## Types

### PanelTab

```ts
{ id: string label: string Icon?: React.ComponentType<{ className?: string | undefined }> | undefined content: React.ReactNode /** A number shown on the tab, such as what needs the person there. Nothing is shown for 0 or when left out. */ count?: number | undefined /** What `count` means, read by screen readers after the tab's name ("2 need you"). Defaults to "{count} items". */ countLabel?: strin
```

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider` (fixed strings) and `TermsProvider` (nouns); entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
