# Panel

A bordered box with a header, content and footer, whose content can fold away.

- **Import:** `import { Panel, PanelHeader, PanelTitle, PanelActions, PanelTrigger, PanelContent, PanelFooter } from "@booleanpress/ui/panel"`
- **Radix Collapsible:** <https://www.radix-ui.com/primitives/docs/components/collapsible>
- **APG Disclosure:** <https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/>
- **Page:** <https://ui.booleanpress.com/components/panel> · @booleanpress/ui 0.2.0

## Usage

A panel frames one block of a page under a title: a summary, a connection's settings, a ticket. For a stack of sections that open one at a time, use an [accordion](/components/accordion); for a box with no header bar, a [card](/components/card).

```tsx
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

export function Connection() {
  return (
    <Panel toggleable>
      <PanelHeader>
        <PanelTitle>SMTP connection</PanelTitle>
        <PanelTrigger />
      </PanelHeader>
      <PanelContent>Host smtp.example.com, port 587.</PanelContent>
    </Panel>
  )
}
```

Without `toggleable` the content always shows and `PanelTrigger` renders nothing. With it, `PanelTrigger` folds the content away and back: uncontrolled with `defaultOpen` (`true` by default), or controlled with `open` and `onOpenChange`. The trigger is named "Show or hide" and the title, from the provider's `toggleContent` string, through `aria-labelledby` on the title's id, so the name is right from the first render (server HTML included); give `PanelTitle` your own `id` there, not on an `asChild` element. `indicator` replaces its chevron.

`PanelActions` holds the header's buttons, such as a [dropdown menu](/components/dropdown-menu) trigger, before the toggle. `PanelFooter` placed after `PanelContent` stays in view when the content folds; placed inside it, it folds with the content. `PanelTitle` is a `div`: give it `asChild` and a heading element where the page's outline needs one. The content's height animates from Radix's measured `--radix-collapsible-content-height`.

## Examples

### Basic

A title over content that always shows.

```tsx
import { Panel, PanelContent, PanelHeader, PanelTitle } from "@booleanpress/ui/panel"
import { Separator } from "@booleanpress/ui/separator"

const LINES = [
  { label: "Growth plan", amount: "$29.00" },
  { label: "10,000 extra emails", amount: "$8.00" },
  { label: "Dedicated IP", amount: "$5.99" },
]

export default function PanelBasic() {
  return (
    <Panel className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle>October invoice</PanelTitle>
      </PanelHeader>
      <PanelContent>
        <div className="flex flex-col gap-3">
          {LINES.map((line) => (
            <div key={line.label} className="flex justify-between">
              <span className="text-muted-foreground">{line.label}</span>
              <span className="font-medium">{line.amount}</span>
            </div>
          ))}
        </div>
        <Separator className="my-3.5" />
        <div className="flex justify-between font-semibold">
          <span>Total</span>
          <span>$42.99</span>
        </div>
      </PanelContent>
    </Panel>
  )
}
```

### Toggleable

`toggleable` and a `PanelTrigger`: the chevron folds the content away and back.

```tsx
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"
import { Separator } from "@booleanpress/ui/separator"

const LINES = [
  { label: "Growth plan", amount: "$29.00" },
  { label: "10,000 extra emails", amount: "$8.00" },
  { label: "Dedicated IP", amount: "$5.99" },
]

export default function PanelToggleable() {
  return (
    <Panel toggleable className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle>October invoice</PanelTitle>
        <PanelTrigger />
      </PanelHeader>
      <PanelContent>
        <div className="flex flex-col gap-3">
          {LINES.map((line) => (
            <div key={line.label} className="flex justify-between">
              <span className="text-muted-foreground">{line.label}</span>
              <span className="font-medium">{line.amount}</span>
            </div>
          ))}
        </div>
        <Separator className="my-3.5" />
        <div className="flex justify-between font-semibold">
          <span>Total</span>
          <span>$42.99</span>
        </div>
      </PanelContent>
    </Panel>
  )
}
```

### Controlled

`open` and `onOpenChange` keep the state outside, so other buttons can open and close it.

```tsx
import { useState } from "react"
import { Button } from "@booleanpress/ui/button"
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

const ROWS = [
  { label: "Host", value: "smtp.example.com" },
  { label: "Port", value: "587 (STARTTLS)" },
  { label: "Signed in as", value: "mailer@example.com" },
]

export default function PanelControlled() {
  const [open, setOpen] = useState(true)

  return (
    <div className="flex w-full max-w-xs flex-col gap-4">
      <div className="flex justify-center gap-2">
        <Button onClick={() => setOpen(true)}>Open</Button>
        <Button variant="secondary" onClick={() => setOpen(false)}>
          Close
        </Button>
      </div>
      <Panel toggleable open={open} onOpenChange={setOpen}>
        <PanelHeader>
          <PanelTitle>SMTP connection</PanelTitle>
          <PanelTrigger />
        </PanelHeader>
        <PanelContent>
          <dl className="flex flex-col gap-3">
            {ROWS.map((row) => (
              <div key={row.label} className="flex justify-between gap-4">
                <dt className="text-muted-foreground">{row.label}</dt>
                <dd className="font-medium">{row.value}</dd>
              </div>
            ))}
          </dl>
        </PanelContent>
      </Panel>
    </div>
  )
}
```

### Custom indicator

`indicator` replaces the chevron with a minus that becomes a plus when the content folds.

```tsx
import { MinusIcon, PlusIcon } from "lucide-react"
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

// The trigger is the Tailwind group `panel-trigger`: each icon shows in one state.
const indicator = (
  <>
    <MinusIcon className="size-3.5 group-data-[state=closed]/panel-trigger:hidden" />
    <PlusIcon className="size-3.5 group-data-[state=open]/panel-trigger:hidden" />
  </>
)

export default function PanelCustomIndicator() {
  return (
    <Panel toggleable className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle>Retry policy</PanelTitle>
        <PanelTrigger indicator={indicator} />
      </PanelHeader>
      <PanelContent>
        <p>
          A failed email is retried after 5 minutes, 30 minutes and 2 hours. After the third failure it is marked failed in
          the delivery log and an alert goes to ops@example.com.
        </p>
      </PanelContent>
    </Panel>
  )
}
```

### Header actions

An avatar in the title, and a menu button in `PanelActions` before the toggle.

```tsx
import { EllipsisVerticalIcon, PencilIcon, PowerIcon, Trash2Icon } from "lucide-react"
import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { Button } from "@booleanpress/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@booleanpress/ui/dropdown-menu"
import { Panel, PanelActions, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

export default function PanelHeaderActions() {
  return (
    <Panel toggleable className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle className="flex items-center gap-2">
          <Avatar>
            <AvatarFallback>MG</AvatarFallback>
          </Avatar>
          Mailgun
        </PanelTitle>
        <PanelActions>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="ghost" size="icon" rounded className="text-muted-foreground" aria-label="Mailgun actions">
                <EllipsisVerticalIcon />
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem>
                <PencilIcon /> Edit connection
              </DropdownMenuItem>
              <DropdownMenuItem>
                <PowerIcon /> Turn off
              </DropdownMenuItem>
              <DropdownMenuSeparator />
              <DropdownMenuItem variant="destructive">
                <Trash2Icon /> Delete
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
          <PanelTrigger />
        </PanelActions>
      </PanelHeader>
      <PanelContent>
        <p>
          The fallback mailer: when Amazon SES refuses an email, it is sent through Mailgun&apos;s EU region instead. 214
          emails took this path in October.
        </p>
      </PanelContent>
    </Panel>
  )
}
```

### Footer

A `PanelFooter` inside the content, with buttons and a timestamp, folds away with it.

```tsx
import { BookmarkIcon, UserIcon } from "lucide-react"
import { Button } from "@booleanpress/ui/button"
import { Panel, PanelContent, PanelFooter, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

export default function PanelWithFooter() {
  return (
    <Panel toggleable className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle>Ticket #4821</PanelTitle>
        <PanelTrigger />
      </PanelHeader>
      <PanelContent>
        <p>
          Password reset emails reach Outlook addresses an hour late. The delivery log shows the mail server deferring
          them with a 421 reply, then accepting them on the second attempt.
        </p>
        {/* Inside PanelContent the footer folds away with the content. */}
        <PanelFooter className="justify-between">
          <div className="flex items-center gap-2">
            <Button variant="ghost" size="icon" rounded aria-label="Assign to me">
              <UserIcon />
            </Button>
            <Button variant="ghost" size="icon" rounded className="text-muted-foreground" aria-label="Bookmark">
              <BookmarkIcon />
            </Button>
          </div>
          <span className="text-muted-foreground">Updated 2 hours ago</span>
        </PanelFooter>
      </PanelContent>
    </Panel>
  )
}
```

### Disabled

`disabled` on a `toggleable` panel stops the toggle; the content stays as it is.

```tsx
import { Panel, PanelContent, PanelHeader, PanelTitle, PanelTrigger } from "@booleanpress/ui/panel"

export default function PanelDisabled() {
  return (
    <Panel toggleable disabled className="w-full max-w-xs">
      <PanelHeader>
        <PanelTitle>Billing details</PanelTitle>
        <PanelTrigger />
      </PanelHeader>
      <PanelContent>
        <p className="text-muted-foreground">Invoices go to billing@example.com. Your plan is managed by the account owner.</p>
      </PanelContent>
    </Panel>
  )
}
```

## Accessibility

**Semantics.** The panel is a plain `div`. The toggle is a `button` with `aria-expanded` and `aria-controls` pointing at the content; the closed content is removed from the page.

**Labels.** The toggle's name is the provider's `toggleContent` string with the title in the place of `{title}`: "Show or hide SMTP connection". It is built with `aria-labelledby` (the words round `{title}` in hidden spans, then the title's id), so a translation can put the title anywhere and the name never waits for the page to run. Pass `aria-label` to `PanelTrigger` to name it yourself. Name icon buttons in `PanelActions`.

**Focus.** The toggle and the header's buttons are tab stops, in order, then the content's controls. Focus is a 1px `--ring` outline 2px outside the toggle's 24px circle.

**Known limits.**

- The title is a `div`: use `asChild` with an `h2` or `h3` where the panel starts a section of the page.
- Closed content is removed from the page, so find-in-page and screen readers do not see it, and fields in it lose what was typed. Use `forceMount` on `PanelContent` to keep it in the page, hidden while folded.
- The indicator is decoration (`aria-hidden`); the state is in `aria-expanded`.

### Keyboard

| Key | Behaviour |
| --- | --- |
| Enter or Space | On the toggle: shows or hides the content. |

## API

### Panel

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultOpen` | `boolean` | `true` | Whether the content shows at the start, when the panel controls itself. `true` by default. |
| `disabled` | `boolean` |  | Stops the toggle from working; the content keeps its state. |
| `onOpenChange` | `((open: boolean) => void)` |  | Called with the new state when the content is shown or hidden. |
| `open` | `boolean` |  | Whether the content shows, when you control it. Pair it with `onOpenChange`. Needs `toggleable`. |
| `toggleable` | `boolean` | `false` | Adds a PanelTrigger's toggle: the content folds away and back. `false` by default. |

### PanelHeader

Renders a `div` and passes it every other prop.

### PanelTitle

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` | `false` | Render the child element instead (an `h2`, an `h3`), with the title's classes merged onto it. |

### PanelActions

Renders a `div` and passes it every other prop.

### PanelTrigger

Renders Radix Collapsible.Trigger and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  | Render the child element instead, with the toggle's behaviour merged onto it. |
| `indicator` | `ReactNode` |  | Replaces the chevron, which turns when the content opens. The trigger is the Tailwind group `panel-trigger`. |

### PanelContent

Renders Radix Collapsible.Content and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `asChild` | `boolean` |  |  |
| `forceMount` | `true` |  | Used to force mounting when more control is needed. Useful when controlling animation with React animation libraries. |

### PanelFooter

Renders a `div` and passes it every other prop.

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="panel"` (Panel), `data-slot="panel-header"` (PanelHeader), `data-slot="panel-title"` (PanelTitle), `data-slot="panel-actions"` (PanelActions), `data-slot="panel-trigger"` (PanelTrigger), `data-slot="panel-content"` (PanelContent), `data-slot="panel-footer"` (PanelFooter).

**Provider strings:** `toggleContent` (`BooleanUIProvider`'s `strings`).

## Theming

The panel is `--card` with a 1px `--border` edge and a 6px radius; the title is 14px semibold. The toggle's icon is `--foreground`, with an `--accent` circle under the pointer; focus is `--ring`.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--accent-foreground` | text |
| `--card` | background |
| `--card-foreground` | text |
| `--foreground` | text |
| `--ring` | outline |
