# Record Sheet

The sheet that opens one record from a list, in four bands every module reads the same way.

```tsx
import * as React from "react"
import { CalendarIcon, CircleDotIcon, FileKeyIcon, GlobeIcon } from "lucide-react"
import { Button } from "@zenoxai/ui/components/button"
import { Field, FieldLabel } from "@zenoxai/ui/components/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select"
import { Sheet, SheetTrigger } from "@zenoxai/ui/components/sheet"
import { TabsContent, TabsTrigger } from "@zenoxai/ui/components/tabs"
import { Textarea } from "@zenoxai/ui/components/textarea"
import { CopyButton } from "@zenoxai/ui/patterns/copy-button"
import { DescriptionItem, DescriptionList } from "@zenoxai/ui/patterns/description-list"
import { ExternalLink } from "@zenoxai/ui/patterns/external-link"
import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state"
import { RecordSheetBody, RecordSheetContent, RecordSheetHeader, RecordSheetSection, RecordSheetTabs, RecordSheetTabsList, RecordSheetTriage } from "@zenoxai/ui/patterns/record-sheet"
import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge"
import { StatusBadge } from "@zenoxai/ui/patterns/status-badge"

function RecordSheetDemo() {
  const [index, setIndex] = React.useState(0)
  const statuses = [
    { value: "new", label: "New" },
    { value: "pending", label: "Pending" },
    { value: "resolved", label: "Resolved" },
    { value: "false-positive", label: "False positive" },
  ]

  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open the leaked credential
      </SheetTrigger>
      <RecordSheetContent>
        <RecordSheetHeader
          severity="high"
          icon={<FileKeyIcon className="text-sev-high-fg" />}
          title="Email domain leak monitoring"
          meta={
            <>
              <StatusBadge tone="neutral" icon={CircleDotIcon} label="New" />
              <SeverityBadge level="high" />
              <span className="inline-flex items-center gap-1.5">
                <CalendarIcon />
                10/14/2026 21:36:26
              </span>
            </>
          }
          position={`${index + 1} of 4`}
          onPrevious={index > 0 ? () => setIndex(index - 1) : undefined}
          onNext={index < 3 ? () => setIndex(index + 1) : undefined}
          link="https://app.zenox.example/identity/credentials/fff187d3"
        />
        <RecordSheetTriage>
          <Field>
            <FieldLabel htmlFor="credential-status">Status</FieldLabel>
            <Select items={statuses} defaultValue="new">
              <SelectTrigger id="credential-status" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {statuses.map((status) => (
                  <SelectItem key={status.value} value={status.value}>
                    {status.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
          <Field>
            <FieldLabel htmlFor="credential-assignee">Assignee</FieldLabel>
            <Select
              items={[{ value: "none", label: "Unassigned" }]}
              defaultValue="none"
            >
              <SelectTrigger id="credential-assignee" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="none">Unassigned</SelectItem>
              </SelectContent>
            </Select>
          </Field>
        </RecordSheetTriage>
        <RecordSheetTabs defaultValue="overview">
          <RecordSheetTabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="similar">Similar</TabsTrigger>
          </RecordSheetTabsList>
          <TabsContent value="overview">
            <RecordSheetBody
              aside={
                <>
                  <RecordSheetSection title="Details">
                    <DescriptionList>
                      <DescriptionItem label="Reference ID" block mono>
                        <span className="inline-flex items-center gap-1">
                          fff187d3-be80-48bb-8bfc-1bdd1595a640
                          <CopyButton value="fff187d3-be80-48bb-8bfc-1bdd1595a640" />
                        </span>
                      </DescriptionItem>
                      <DescriptionItem label="Historic event" block>
                        No
                      </DescriptionItem>
                    </DescriptionList>
                  </RecordSheetSection>
                  <RecordSheetSection title="Entities">
                    <div className="flex items-center gap-2 text-sm">
                      <GlobeIcon className="size-4 text-text-secondary" />
                      <div>
                        <p>acme.com</p>
                        <p className="font-mono text-xs text-text-muted">
                          INT-4704677299365174950
                        </p>
                      </div>
                    </div>
                  </RecordSheetSection>
                  <RecordSheetSection title="History">
                    <ListEmptyState size="sm" title="No changes recorded" />
                  </RecordSheetSection>
                  <RecordSheetSection title="Comments">
                    <ListEmptyState size="sm" title="No comments yet" />
                    <Textarea
                      aria-label="Comment"
                      placeholder="Write a comment"
                    />
                    <Button variant="outline" size="sm" className="self-end">
                      Send
                    </Button>
                  </RecordSheetSection>
                </>
              }
            >
              <RecordSheetSection title="Credential">
                <DescriptionList>
                  <DescriptionItem label="Source file name">
                    urllogin pass[ulp]@cloud9base 09.10.(3).txt
                  </DescriptionItem>
                  <DescriptionItem label="URL">
                    <ExternalLink
                      href="https://portswigger.net"
                      underline="hover"
                    >
                      portswigger.net
                    </ExternalLink>
                  </DescriptionItem>
                  <DescriptionItem label="Email">
                    marcio.cruz@acme.com
                  </DescriptionItem>
                  <DescriptionItem label="Severity">
                    <span className="inline-flex items-center gap-2">
                      100 <SeverityBadge level="critical" />
                    </span>
                  </DescriptionItem>
                  <DescriptionItem label="Date added">
                    10/14/2026 21:38:16
                  </DescriptionItem>
                  <DescriptionItem label="Domain">
                    portswigger.net
                  </DescriptionItem>
                  <DescriptionItem label="Email domain">
                    acme.com
                  </DescriptionItem>
                  <DescriptionItem label="Date indexed">
                    10/14/2026 21:36:24
                  </DescriptionItem>
                  <DescriptionItem label="Password">••••••••</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="similar">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No similar events" />
            </RecordSheetBody>
          </TabsContent>
        </RecordSheetTabs>
      </RecordSheetContent>
    </Sheet>
  )
}
```

## Usage

```tsx
import {
  RecordSheetBody,
  RecordSheetContent,
  RecordSheetHeader,
  RecordSheetSection,
  RecordSheetTabs,
  RecordSheetTabsList,
  RecordSheetTriage,
} from "@zenoxai/ui/patterns/record-sheet"

<Sheet open={Boolean(id)} onOpenChange={(open) => !open && close()}>
  <RecordSheetContent>
    <RecordSheetHeader title={useCaseName} severity="high" meta={badges} />
    <RecordSheetTriage>{statusField}{assigneeField}</RecordSheetTriage>
    <RecordSheetTabs defaultValue="overview">
      <RecordSheetTabsList>...</RecordSheetTabsList>
      <TabsContent value="overview">
        <RecordSheetBody aside={details}>{datasets}</RecordSheetBody>
      </TabsContent>
    </RecordSheetTabs>
  </RecordSheetContent>
</Sheet>
```

A record sheet is the record's file, in four bands:

1. **Header**: who the record is (an optional breadcrumb, icon, title, subtitle, badges and dates)
   and the sheet's controls, always in one order: the position, previous, next, Copy link, the
   record's own actions, Close. `severity` draws a 3px rule along the top in the record's severity
   or priority color.
2. **Triage**, only for a record with a workflow: the controls the tenant decides with (Status,
   Assignee, an asset's State), each a [Field](/components/field) with its label above, and the
   workflow action on the right ("Request takedown"). A decision bar takes this band too.
3. **Tabs**: the record's views, "Overview" first. A record with one view has no tabs.
4. **Body**: the collected content in the main column, and the record's context in `aside`, in this
   order: Details, Entities, History, Comments. With `aside` the sheet is `xl` and the columns
   split 7/5; without it, `size="lg"` and one column.

The rest of the standard:

- Sections are [Record Sheet Section](#api-recordsheetsection)s, flat with a compact heading; no
  card inside the sheet (`zx/card-nesting`).
- Rows are a [Description List](/patterns/description-list): label beside the value in the main
  column, `block` (label above) in the side column.
- A value already in the header or the triage band is not repeated in Details.
- Empty values are [Empty Value](/patterns/empty-value); optional dataset fields with no value are
  left out. An empty section is a [List Empty State](/patterns/list-empty-state) with `size="sm"`.
- Copy is [Copy Button](/patterns/copy-button); links that leave the platform are
  [External Link](/patterns/external-link).
- History is always named "History"; the comment button is "Send".

## Examples

### An asset, with its actions

The record's own actions ("Rescan", a More menu) sit in the header between the navigation and
Close. The asset's State is the triage band; its other properties are Details in the side column.

```tsx
import { CircleIcon, MoreHorizontalIcon, RadarIcon } from "lucide-react"
import { Badge } from "@zenoxai/ui/components/badge"
import { Button } from "@zenoxai/ui/components/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@zenoxai/ui/components/dropdown-menu"
import { Field, FieldLabel } from "@zenoxai/ui/components/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@zenoxai/ui/components/select"
import { Sheet, SheetTrigger } from "@zenoxai/ui/components/sheet"
import { TabsContent, TabsTrigger } from "@zenoxai/ui/components/tabs"
import { DescriptionItem, DescriptionList } from "@zenoxai/ui/patterns/description-list"
import { EmptyValue } from "@zenoxai/ui/patterns/empty-value"
import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state"
import { RecordSheetBody, RecordSheetContent, RecordSheetHeader, RecordSheetSection, RecordSheetTabs, RecordSheetTabsList, RecordSheetTriage } from "@zenoxai/ui/patterns/record-sheet"
import { StatusBadge } from "@zenoxai/ui/patterns/status-badge"

function RecordSheetAsset() {
  const states = [
    { value: "approved", label: "Approved" },
    { value: "dependency", label: "Dependency" },
    { value: "dismissed", label: "Dismissed" },
  ]

  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open the asset
      </SheetTrigger>
      <RecordSheetContent>
        <RecordSheetHeader
          title="www.acme.com"
          mono
          copyValue="www.acme.com"
          meta={
            <>
              <Badge variant="outline">Host</Badge>
              <StatusBadge tone="success" icon={CircleIcon} label="Approved" />
            </>
          }
          position="3 of 28"
          onPrevious={() => {}}
          onNext={() => {}}
          actions={
            <>
              <Button variant="outline" size="sm">
                <RadarIcon data-icon="inline-start" />
                Rescan
              </Button>
              <DropdownMenu>
                <DropdownMenuTrigger
                  render={
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      aria-label="More actions"
                    />
                  }
                >
                  <MoreHorizontalIcon />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end">
                  <DropdownMenuItem variant="destructive">
                    Remove asset
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </>
          }
        />
        <RecordSheetTriage>
          <Field>
            <FieldLabel htmlFor="asset-state">State</FieldLabel>
            <Select items={states} defaultValue="approved">
              <SelectTrigger id="asset-state" className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {states.map((state) => (
                  <SelectItem key={state.value} value={state.value}>
                    {state.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
        </RecordSheetTriage>
        <RecordSheetTabs defaultValue="overview">
          <RecordSheetTabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="chain">Discovery chain</TabsTrigger>
            <TabsTrigger value="history">History</TabsTrigger>
          </RecordSheetTabsList>
          <TabsContent value="overview">
            <RecordSheetBody
              aside={
                <RecordSheetSection title="Details">
                  <DescriptionList>
                    <DescriptionItem label="UUID" block mono>
                      999def3a-d2ae-4e6d-9f7f-e2f81bc27416
                    </DescriptionItem>
                    <DescriptionItem label="State set by" block>
                      System
                    </DescriptionItem>
                    <DescriptionItem label="External ID" block>
                      <EmptyValue />
                    </DescriptionItem>
                    <DescriptionItem label="Confidence" block>
                      81%
                    </DescriptionItem>
                    <DescriptionItem label="Last scan" block>
                      10/25/2026 10:11:47
                    </DescriptionItem>
                    <DescriptionItem label="IPs" block mono>
                      104.248.111.57
                    </DescriptionItem>
                  </DescriptionList>
                </RecordSheetSection>
              }
            >
              <RecordSheetSection title="HTTP">
                <DescriptionList>
                  <DescriptionItem label="content_type">
                    text/html; charset=UTF-8
                  </DescriptionItem>
                  <DescriptionItem label="server">
                    Apache/2.4.52 (Ubuntu)
                  </DescriptionItem>
                  <DescriptionItem label="status_code">302</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
              <RecordSheetSection title="TLS">
                <DescriptionList>
                  <DescriptionItem label="cipher_suite" mono>
                    TLS_AES_128_GCM_SHA256
                  </DescriptionItem>
                  <DescriptionItem label="version">TLS 1.3</DescriptionItem>
                </DescriptionList>
              </RecordSheetSection>
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="chain">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No hops recorded" />
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="history">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No changes recorded" />
            </RecordSheetBody>
          </TabsContent>
        </RecordSheetTabs>
      </RecordSheetContent>
    </Sheet>
  )
}
```

### A catalog record, one column

A record without a workflow or context, such as a CVE in the catalog, has no triage band and no
side column: `size="lg"`.

```tsx
import { Button } from "@zenoxai/ui/components/button"
import { Sheet, SheetTrigger } from "@zenoxai/ui/components/sheet"
import { TabsContent, TabsTrigger } from "@zenoxai/ui/components/tabs"
import { DescriptionItem, DescriptionList } from "@zenoxai/ui/patterns/description-list"
import { EmptyValue } from "@zenoxai/ui/patterns/empty-value"
import { ExternalLink } from "@zenoxai/ui/patterns/external-link"
import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state"
import { RecordSheetBody, RecordSheetContent, RecordSheetHeader, RecordSheetSection, RecordSheetTabs, RecordSheetTabsList } from "@zenoxai/ui/patterns/record-sheet"
import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge"

function RecordSheetCatalog() {
  return (
    <Sheet>
      <SheetTrigger render={<Button variant="outline" />}>
        Open the CVE
      </SheetTrigger>
      <RecordSheetContent size="lg">
        <RecordSheetHeader
          title="CVE-2025-39946"
          mono
          copyValue="CVE-2025-39946"
          subtitle="tls: make sure to abort the stream if headers are bogus"
          meta={<SeverityBadge level="medium" />}
        />
        <RecordSheetTabs defaultValue="overview">
          <RecordSheetTabsList>
            <TabsTrigger value="overview">Overview</TabsTrigger>
            <TabsTrigger value="products">Affected products</TabsTrigger>
          </RecordSheetTabsList>
          <TabsContent value="overview">
            <RecordSheetBody>
              <RecordSheetSection title="Description">
                <p className="text-sm text-text-secondary">
                  In the Linux kernel, the following vulnerability has been
                  resolved: tls: make sure to abort the stream if headers are
                  bogus.
                </p>
              </RecordSheetSection>
              <RecordSheetSection title="CVE details">
                <DescriptionList>
                  <DescriptionItem label="CVSS">5.5</DescriptionItem>
                  <DescriptionItem label="EPSS">10.3%</DescriptionItem>
                  <DescriptionItem label="Exploited">No</DescriptionItem>
                  <DescriptionItem label="Published">
                    10/04/2025 04:31:07
                  </DescriptionItem>
                  <DescriptionItem label="Weaknesses (CWE)">
                    <EmptyValue />
                  </DescriptionItem>
                </DescriptionList>
                <ExternalLink
                  href="https://nvd.nist.gov/vuln/detail/CVE-2025-39946"
                  confirm={false}
                  underline="hover"
                  className="self-start text-sm"
                >
                  View on NVD
                </ExternalLink>
              </RecordSheetSection>
            </RecordSheetBody>
          </TabsContent>
          <TabsContent value="products">
            <RecordSheetBody>
              <ListEmptyState size="sm" title="No affected products" />
            </RecordSheetBody>
          </TabsContent>
        </RecordSheetTabs>
      </RecordSheetContent>
    </Sheet>
  )
}
```

## Accessibility

- The title names the sheet. Previous, Next, Copy link and Close are named buttons with tooltips;
  previous or next is disabled when there is no record on that side.
- Each section is a region named by its `h3` heading.
- Pass translated texts through `labels`.

## API reference

`import { ... } from "@zenoxai/ui/patterns/record-sheet"`

### RecordSheetBody

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| aside | `ReactNode` |  | The record's context in the side column, in this order: Details, Entities, History, Comments. Without it the body is one column. |

### RecordSheetContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| closeLabel | `string` |  | Already-translated accessible name of the X button. Defaults to "Close". |
| finalFocus | `boolean \| RefObject<HTMLElement \| null> \| ((closeType: InteractionType) => boolean \| void \| HTMLElement \| null)` |  | Determines the element to focus when the dialog is closed.  - `false`: Do not move focus. - `true`: Move focus based on the default behavior (trigger or previously focused element). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`).   Return an element to focus, `true` to use the default behavior, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing. |
| initialFocus | `boolean \| RefObject<HTMLElement \| null> \| ((openType: InteractionType) => boolean \| void \| HTMLElement \| null)` |  | Determines the element to focus when the dialog is opened. By default, focus moves to the first tabbable element inside the popup, except when the dialog is opened by touch — then the popup itself is focused to avoid opening the virtual keyboard.  - `false`: Do not move focus. - `true`: Move focus based on the default behavior (first tabbable element or popup). - `RefObject`: Move focus to the ref element. - `function`: Called with the interaction type (`mouse`, `touch`, `pen`, or `keyboard`).   Return an element to focus, `true` to use the default behavior, `null` to fall back to the default behavior, or `false`/`undefined` to do nothing. |
| render | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, DialogPopupState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component.  Accepts a `ReactElement` or a function that returns the element to render. |
| showOverlay | `boolean` |  | Renders the dimmed backdrop. Turning it off does not make the sheet non-modal; set `modal={false}` on Sheet for that. |
| size | `"lg" \| "xl"` | `xl` | `xl` (1100px) for a record with a side column, `lg` (800px) for one column. |

### RecordSheetHeader

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| actions | `ReactNode` |  | The record's own actions, between the navigation and Close: "View in catalog", "Scan now", "Reveal", a More menu. |
| breadcrumb | `ReactNode` |  | A Breadcrumb above the title, when the sheet moved from another record. |
| copyValue | `string` |  | The value Copy writes; renders a Copy button beside the title. |
| icon | `ReactNode` |  | A glyph before the title, such as the dataset or source icon. |
| labels | `Partial<RecordSheetLabels>` |  | Already-translated texts. English by default. |
| link | `string` |  | The record's link; renders a Copy link button. |
| meta | `ReactNode` |  | The badges, then the dates with their icon. |
| mono | `boolean` | `false` | Sets the title in the monospace face, for identifiers. |
| onNext | `(() => void)` |  | Opens the next record; omit when there is none. |
| onPrevious | `(() => void)` |  | Opens the previous record; omit when there is none. |
| position | `string` |  | The position in the list, already formatted: "1 de 4". |
| severity | `"info" \| "high" \| "low" \| "critical" \| "medium"` |  | A 3px rule along the top, in the record's severity or priority color. |
| subtitle | `ReactNode` |  | One line under the title, such as a CVE's catalog title. |
| title (required) | `ReactNode` |  | The record, as each module names it: a use case, a CVE id, an asset. |

### RecordSheetSection

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| action | `ReactNode` |  | One action on the right of the title. |
| title (required) | `ReactNode` |  |  |

### RecordSheetTabs

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| defaultValue | `any` | `0` | The default value. Use when the component is not controlled. When the value is `null`, no Tab will be active. |
| onValueChange | `((value: any, eventDetails: TabsRootChangeEventDetails) => void)` |  | Callback invoked when new value is being set.  The event `reason` is `'none'` for user-initiated changes, such as a click or keyboard navigation; `'initial'` for the first automatic selection or fallback in uncontrolled roots when `defaultValue` is omitted or `undefined`, including when the implicit initial value is disabled or missing; `'disabled'` for automatic fallback when the selected tab becomes disabled in uncontrolled roots; or `'missing'` for automatic fallback when the selected tab is removed, or when an explicit `defaultValue` never matches a mounted tab in uncontrolled roots.  For automatic changes, the selected value can be `null` when no enabled Tab is available as a fallback.  Automatic changes cannot be canceled; calling `eventDetails.cancel()` for `'initial'`, `'disabled'`, or `'missing'` has no effect. |
| orientation | `"horizontal" \| "vertical"` | `horizontal` | The component orientation (layout flow direction). |
| render | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, TabsRootState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component.  Accepts a `ReactElement` or a function that returns the element to render. |
| value | `any` |  | The value of the currently active `Tab`. Use when the component is controlled. When the value is `null`, no Tab will be active. |

### RecordSheetTabsList

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| activateOnFocus | `boolean` | `false` | Whether to automatically change the active tab on arrow key focus. Otherwise, tabs will be activated using <kbd>Enter</kbd> or <kbd>Space</kbd> key press. |
| loopFocus | `boolean` | `true` | Whether to loop keyboard focus back to the first item when the end of the list is reached while using the arrow keys. |
| render | `ReactElement<unknown, string \| JSXElementConstructor<any>> \| ComponentRenderFn<HTMLProps, TabsListState>` |  | Allows you to replace the component's HTML element with a different tag, or compose it with another component.  Accepts a `ReactElement` or a function that returns the element to render. |
| variant | `"line" \| "default" \| null` |  | `default` is a bordered track whose active tab takes the selection colors, the same control as a segmented ToggleGroup; `line` is a full-width bar on a bottom rule, with the active tab underlined, for page headers, sheets and status pivots. |

### RecordSheetTriage

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| action | `ReactNode` |  | The workflow action on the right, such as "Request takedown". |

