# Records list with filters

A list screen built from the patterns, with tabs, a filter panel, a table with selection and a detail sheet, all kept in the URL.

`records-list.tsx`

```tsx
import * as React from "react"
import {
  MoreHorizontalIcon,
  SearchIcon,
  SearchXIcon,
  ShieldAlertIcon,
} from "lucide-react"

import { Button } from "@zenoxai/ui/components/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@zenoxai/ui/components/dropdown-menu"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@zenoxai/ui/components/input-group"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from "@zenoxai/ui/components/sheet"
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@zenoxai/ui/components/tabs"
import {
  DataTable,
  DataTableColumnsMenu,
  DataTableText,
  actionsColumn,
  createColumnHelper,
  selectColumn,
  sortByRank,
  useDataTable,
} from "@zenoxai/ui/patterns/data-table"
import {
  DescriptionItem,
  DescriptionList,
} from "@zenoxai/ui/patterns/description-list"
import {
  FilterCheckboxList,
  FilterChips,
  FilterDate,
  FilterMultiSelect,
  FilterPanel,
  FilterPanelInset,
  FilterPanelProvider,
  FilterPanelToolbar,
  FilterPanelTrigger,
  FilterSection,
  filtersFromSearchParams,
  filtersToSearchParams,
  resolveFilterDate,
  useFilters,
} from "@zenoxai/ui/patterns/filter-panel"
import { ListEmptyState } from "@zenoxai/ui/patterns/list-empty-state"
import { PageHeader } from "@zenoxai/ui/patterns/page-header"
import { SeverityBadge } from "@zenoxai/ui/patterns/severity-badge"

import {
  ALERTS,
  OWNERS,
  SEVERITIES,
  SOURCES,
  TODAY,
  alertFields,
  matchAlert,
  type Alert,
  type Status,
} from "./alerts"
import { useSearchParams } from "./use-search-params"

const TABS = [
  { value: "open", label: "Open" },
  { value: "investigating", label: "Investigating" },
  { value: "resolved", label: "Resolved" },
  { value: "all", label: "All" },
] as const

const sourceLabel = (value: string) =>
  SOURCES.find((source) => source.value === value)?.label ?? value
const ownerLabel = (value: string | null) =>
  OWNERS.find((owner) => owner.value === (value ?? "none"))?.label ?? value

export function RecordsListBlock() {
  // The URL holds the tab, the search, the filters and the open alert, so
  // a link reproduces the screen.
  const [params, setParams] = useSearchParams()
  const tab = (params.get("tab") ?? "open") as Status | "all"
  const search = params.get("q") ?? ""
  const openId = params.get("alert") ?? undefined
  const setParam = (name: string, value: string | undefined) => {
    const next = new URLSearchParams(params)
    if (value) next.set(name, value)
    else next.delete(name)
    setParams(next)
  }

  const value = React.useMemo(
    () => filtersFromSearchParams(alertFields, params),
    [params]
  )
  const filters = useFilters({
    fields: alertFields,
    value,
    onValueChange: (next) =>
      setParams(filtersToSearchParams(alertFields, next, params)),
  })
  // Persist this in the app's preference store, not in the URL.
  const [panelOpen, setPanelOpen] = React.useState(true)

  const query = {
    filters: value,
    detected: resolveFilterDate(value.detected, {
      presets: alertFields[3].presets,
      today: TODAY,
    }),
    search,
    tab,
  }
  const rows = ALERTS.filter((alert) => matchAlert(alert, query))
  const countBy = (skip: "severity" | "source" | "tab", key: keyof Alert) => {
    const counts: Record<string, number> = {}
    for (const alert of ALERTS) {
      if (!matchAlert(alert, query, skip)) continue
      const bucket = String(alert[key])
      counts[bucket] = (counts[bucket] ?? 0) + 1
    }
    return counts
  }
  const tabCounts = countBy("tab", "status")
  const open = ALERTS.find((alert) => alert.id === openId)

  const columns = React.useMemo(() => {
    const column = createColumnHelper<Alert>()
    return [
      selectColumn<Alert>({ getRowLabel: (alert) => alert.title }),
      column.accessor("title", {
        header: "Alert",
        meta: { fluid: true, minWidth: 180 },
        cell: ({ row }) => (
          <DataTableText strong secondary={row.original.id}>
            {row.original.title}
          </DataTableText>
        ),
      }),
      column.accessor("severity", {
        header: "Severity",
        size: 110,
        sortFn: sortByRank(SEVERITIES),
        cell: (info) => <SeverityBadge level={info.getValue()} />,
      }),
      column.accessor("asset", {
        header: "Asset",
        size: 146,
        cell: (info) => <DataTableText mono>{info.getValue()}</DataTableText>,
      }),
      column.accessor("owner", {
        header: "Owner",
        size: 120,
        cell: (info) =>
          info.getValue() ? (
            <DataTableText>{ownerLabel(info.getValue())}</DataTableText>
          ) : (
            // The absence means something: say it.
            <span className="text-text-muted">Unassigned</span>
          ),
      }),
      column.accessor("detected", {
        header: "Detected",
        size: 124,
        sortDescFirst: true,
        meta: { description: "In your profile's time zone." },
        cell: (info) => (
          <DataTableText numeric secondary={info.getValue().slice(11)}>
            {info.getValue().slice(0, 10)}
          </DataTableText>
        ),
      }),
      actionsColumn<Alert>({
        cell: (alert) => (
          <DropdownMenu>
            <DropdownMenuTrigger
              render={
                <Button
                  variant="ghost"
                  size="icon-sm"
                  aria-label={`Actions for ${alert.id}`}
                />
              }
            >
              <MoreHorizontalIcon />
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuItem>Assign</DropdownMenuItem>
              <DropdownMenuItem>Resolve</DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
        ),
      }),
    ]
  }, [])
  const table = useDataTable({
    data: rows,
    columns,
    getRowId: (alert) => alert.id,
    defaultSorting: [
      { id: "severity", desc: false },
      { id: "detected", desc: true },
    ],
  })
  const selected = Object.keys(table.state.rowSelection).length

  // Zero results name what caused them, and clear that.
  const emptyState =
    search !== "" ? (
      <ListEmptyState
        icon={SearchXIcon}
        title={`No alerts match "${search}"`}
        description="Check the spelling, or search by ID or asset."
        action={
          <Button variant="outline" onClick={() => setParam("q", undefined)}>
            Clear search
          </Button>
        }
      />
    ) : filters.canClear ? (
      <ListEmptyState
        icon={SearchXIcon}
        title="No alerts match these filters"
        description="Remove a filter to see more alerts."
        action={
          <Button variant="outline" onClick={filters.clear}>
            Clear filters
          </Button>
        }
      />
    ) : (
      <ListEmptyState
        title={`No ${TABS.find((item) => item.value === tab)?.label.toLowerCase()} alerts`}
      />
    )

  return (
    <Tabs
      value={tab}
      onValueChange={(next) =>
        setParam("tab", next === "open" ? undefined : String(next))
      }
      className="flex min-h-0 w-full flex-1 flex-col gap-3"
    >
      <PageHeader
        title="Alerts"
        icon={<ShieldAlertIcon />}
        hint="Alerts raised by your detection rules in the last 30 days."
        tabs={
          <TabsList variant="line">
            {TABS.map((item) => (
              <TabsTrigger key={item.value} value={item.value}>
                {item.label}
                <span className="text-xs text-text-muted tabular-nums">
                  {item.value === "all"
                    ? Object.values(tabCounts).reduce((sum, n) => sum + n, 0)
                    : (tabCounts[item.value] ?? 0)}
                </span>
              </TabsTrigger>
            ))}
          </TabsList>
        }
      />
      <TabsContent value={tab} className="flex min-h-0 flex-1">
        <FilterPanelProvider
          filters={filters}
          open={panelOpen}
          onOpenChange={setPanelOpen}
          total={rows.length}
        >
          <FilterPanel>
            <FilterSection field="severity">
              <FilterCheckboxList
                field="severity"
                counts={countBy("severity", "severity")}
              />
            </FilterSection>
            <FilterSection field="source">
              <FilterCheckboxList
                field="source"
                order="count"
                counts={countBy("source", "source")}
              />
            </FilterSection>
            <FilterSection field="owner">
              <FilterMultiSelect field="owner" placeholder="Search owners" />
            </FilterSection>
            <FilterSection
              field="detected"
              description="In your profile's time zone."
            >
              <FilterDate field="detected" />
            </FilterSection>
          </FilterPanel>
          <FilterPanelInset>
            <FilterPanelToolbar>
              <FilterPanelTrigger />
              {selected > 0 ? (
                // The bulk bar takes the toolbar's place; the trigger stays.
                <>
                  <span className="px-1 text-sm font-medium">
                    {selected} selected
                  </span>
                  <Button variant="outline" size="sm">
                    Assign
                  </Button>
                  <Button variant="outline" size="sm">
                    Resolve
                  </Button>
                  <Button
                    variant="ghost"
                    size="sm"
                    onClick={() => table.resetRowSelection()}
                  >
                    Clear selection
                  </Button>
                </>
              ) : (
                <InputGroup size="sm" className="max-w-64">
                  <InputGroupAddon>
                    <SearchIcon />
                  </InputGroupAddon>
                  <InputGroupInput
                    type="search"
                    value={search}
                    onChange={(event) =>
                      setParam("q", event.target.value || undefined)
                    }
                    placeholder="Search alerts"
                    aria-label="Search alerts"
                  />
                </InputGroup>
              )}
              <span className="ms-auto text-sm text-text-secondary tabular-nums">
                {rows.length} alerts
              </span>
              <DataTableColumnsMenu table={table} />
            </FilterPanelToolbar>
            <FilterChips />
            <DataTable
              table={table}
              label="Alerts"
              lines={2}
              height="fill"
              total={rows.length}
              resultKey={`${tab}|${search}|${filtersToSearchParams(alertFields, value)}`}
              onRowClick={(alert) => setParam("alert", alert.id)}
              // No link or button names the row, so rows take focus and
              // open with Enter.
              focusableRows
              activeRowId={openId}
              emptyState={emptyState}
            />
          </FilterPanelInset>
        </FilterPanelProvider>
      </TabsContent>

      <Sheet
        open={Boolean(open)}
        onOpenChange={(next) => {
          if (!next) setParam("alert", undefined)
        }}
      >
        <SheetContent size="md">
          {open ? (
            <>
              <SheetHeader>
                <SheetTitle>{open.title}</SheetTitle>
                <SheetDescription>{open.id}</SheetDescription>
              </SheetHeader>
              <DescriptionList className="px-4">
                <DescriptionItem label="Severity">
                  <SeverityBadge level={open.severity} />
                </DescriptionItem>
                <DescriptionItem label="Status">
                  {TABS.find((item) => item.value === open.status)?.label}
                </DescriptionItem>
                <DescriptionItem label="Asset" mono>
                  {open.asset}
                </DescriptionItem>
                <DescriptionItem label="Source">
                  {sourceLabel(open.source)}
                </DescriptionItem>
                <DescriptionItem label="Owner">
                  {ownerLabel(open.owner)}
                </DescriptionItem>
                <DescriptionItem label="Detected">
                  {open.detected}
                </DescriptionItem>
              </DescriptionList>
            </>
          ) : null}
        </SheetContent>
      </Sheet>
    </Tabs>
  )
}
```

`alerts.ts`

```ts
import {
  defineFilterFields,
  type FilterValues,
} from "@zenoxai/ui/patterns/filter-panel"

export type Severity = "critical" | "high" | "medium" | "low"
export type Status = "open" | "investigating" | "resolved"

export type Alert = {
  id: string
  title: string
  severity: Severity
  status: Status
  source: string
  asset: string
  owner: string | null
  /** When the rule fired, `YYYY-MM-DD HH:mm:ss` in the profile's time zone. */
  detected: string
}

export const SEVERITIES: readonly Severity[] = [
  "critical",
  "high",
  "medium",
  "low",
]

export const SOURCES = [
  { value: "edr", label: "EDR" },
  { value: "email", label: "Email gateway" },
  { value: "firewall", label: "Firewall" },
  { value: "cloud", label: "Cloud audit" },
  { value: "identity", label: "Identity provider" },
]

export const OWNERS = [
  { value: "none", label: "Unassigned" },
  { value: "soc-1", label: "SOC tier 1" },
  { value: "soc-2", label: "SOC tier 2" },
  { value: "cloud", label: "Cloud security" },
  { value: "iam", label: "Identity team" },
]

/** Today in the profile's time zone; an app gets it from filterDateToday. */
export const TODAY = "2026-10-24"

/**
 * The filters of the list, declared once. An app that translates the labels
 * builds them in a useMemo with its t().
 */
export const alertFields = defineFilterFields([
  {
    id: "severity",
    label: "Severity",
    kind: "multi",
    options: SEVERITIES.map((level) => ({
      value: level,
      label: level[0]!.toUpperCase() + level.slice(1),
    })),
  },
  { id: "source", label: "Source", kind: "multi", options: SOURCES },
  { id: "owner", label: "Owner", kind: "multi", options: OWNERS },
  {
    id: "detected",
    label: "Detected",
    kind: "date",
    presets: [
      { value: "today", label: "Today", days: 1 },
      { value: "7d", label: "Last 7 days", days: 7 },
      { value: "30d", label: "Last 30 days", days: 30 },
    ],
  },
])

export type AlertFilters = FilterValues<typeof alertFields>

type Query = {
  filters: AlertFilters
  /** The detected window, resolved from a preset or custom dates. */
  detected?: { from?: string; to?: string }
  search: string
  tab: Status | "all"
}

/**
 * Whether an alert passes the query, ignoring `skip`: counts are
 * disjunctive, so a facet counts its values among the other filters' results.
 */
export function matchAlert(
  alert: Alert,
  { filters, detected, search, tab }: Query,
  skip?: "severity" | "source" | "tab"
) {
  const day = alert.detected.slice(0, 10)
  const needle = search.trim().toLowerCase()
  return (
    (skip === "tab" || tab === "all" || alert.status === tab) &&
    (skip === "severity" ||
      !filters.severity ||
      filters.severity.includes(alert.severity)) &&
    (skip === "source" ||
      !filters.source ||
      filters.source.includes(alert.source)) &&
    (!filters.owner || filters.owner.includes(alert.owner ?? "none")) &&
    (!detected?.from || day >= detected.from) &&
    (!detected?.to || day <= detected.to) &&
    (!needle ||
      alert.title.toLowerCase().includes(needle) ||
      alert.asset.toLowerCase().includes(needle) ||
      alert.id.toLowerCase().includes(needle))
  )
}

const TITLES = [
  "Credential stuffing against the VPN gateway",
  "Beacon to a known C2 server",
  "Phishing kit hosted on a lookalike domain",
  "Impossible travel for a privileged account",
  "Public bucket exposing backups",
  "Mass download from the file share",
  "New admin added outside change window",
  "Outbound SMB to the internet",
  "Malicious macro opened from an invoice",
  "MFA fatigue: repeated push denials",
]

const ASSETS = [
  "vpn-gw-01.acme.com",
  "wks-0193.corp.acme.com",
  "login-verify-acme.com",
  "okta: dana.whitfield",
  "s3://acme-backups",
  "fs-prod-02.corp.acme.com",
  "aws: 4421-0871-2290",
  "fw-edge-03.acme.com",
  "wks-0417.corp.acme.com",
  "okta: marcus.oyelaran",
]

// A seeded generator: the same fake alerts on the server and in the browser.
let seed = 20261024
function random() {
  seed = (seed * 16807) % 2147483647
  return (seed - 1) / 2147483646
}
function pick<T>(items: readonly T[], weights?: readonly number[]): T {
  if (!weights) return items[Math.floor(random() * items.length)]!
  let roll = random() * weights.reduce((sum, weight) => sum + weight, 0)
  for (let index = 0; index < items.length; index++) {
    roll -= weights[index]!
    if (roll < 0) return items[index]!
  }
  return items[items.length - 1]!
}
const pad = (value: number) => String(value).padStart(2, "0")

/** 96 fake alerts across the last 30 days, newest first. */
export const ALERTS: Alert[] = Array.from({ length: 96 }, (_, index) => {
  const kind = Math.floor(random() * TITLES.length)
  const daysAgo = Math.floor((index / 96) * 30)
  const date = new Date(2026, 9, 24 - daysAgo)
  return {
    id: `ALR-${2400 - index}`,
    title: TITLES[kind]!,
    severity: pick(SEVERITIES, [15, 30, 35, 20]),
    status: pick(["open", "investigating", "resolved"] as const, [50, 25, 25]),
    source: pick(SOURCES).value,
    asset: ASSETS[kind]!,
    owner: random() < 0.3 ? null : pick(OWNERS.slice(1)).value,
    detected: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
      23 - Math.floor(random() * 24)
    )}:${pad(Math.floor(random() * 60))}:${pad(Math.floor(random() * 60))}`,
  }
})
```

`use-search-params.ts`

```ts
import * as React from "react"

const listeners = new Set<() => void>()

function subscribe(listener: () => void) {
  listeners.add(listener)
  window.addEventListener("popstate", listener)
  return () => {
    listeners.delete(listener)
    window.removeEventListener("popstate", listener)
  }
}

/**
 * The page's URL params as state, read on every change and written with
 * `replaceState`, so a filter change does not add a history entry. It stands
 * in for your router's hook: with React Router, use its `useSearchParams` and
 * pass `{ replace: true }` when you set them.
 */
export function useSearchParams() {
  const search = React.useSyncExternalStore(
    subscribe,
    () => window.location.search,
    () => ""
  )
  const params = React.useMemo(() => new URLSearchParams(search), [search])
  const setParams = React.useCallback((next: URLSearchParams) => {
    const query = next.toString()
    window.history.replaceState(
      window.history.state,
      "",
      `${window.location.pathname}${query ? `?${query}` : ""}${window.location.hash}`
    )
    for (const listener of listeners) listener()
  }, [])
  return [params, setParams] as const
}
```

The reference for every list of records: alerts, findings, events, assets. Copy the files under
the preview into your app, replace the fake data with your queries, and keep the structure. The
filters, the tab, the search and the open alert live in the URL of this page: change them and
reload, or share the link.

## What it is made of

- [Page Header](/patterns/page-header) with status tabs whose counts follow the filters and the
  search.
- [Filter Panel](/patterns/filter-panel) beside the results: severity and source with
  disjunctive counts, owner as a search with chips, detected as presets or custom dates. Below
  60rem of width it becomes a sheet.
- The toolbar: the panel trigger, the search, the result count and the Columns menu. With rows
  selected, the bulk actions take the place of the search; the trigger stays.
- FilterChips under the toolbar while the panel is closed.
- [Data Table](/patterns/data-table) with two-line rows, a selection column, row actions, a
  default sort by severity then newest, and an empty state that names what caused zero results.
- A [Sheet](/components/sheet) with the alert's details when a row opens, marked as the active
  row.

## Wiring it to your app

- **The URL.** `use-search-params.ts` reads and writes the page's URL with `replaceState`. In an
  app, use your router's hook instead (React Router's `useSearchParams`, with
  `{ replace: true }`); the rest of the block does not change.
- **The data.** `alerts.ts` holds fake alerts and filters them in the browser. Replace
  `matchAlert` and the counts with your API: send the params as the query, read the rows, the
  total and the facet counts from the response. Facet counts are disjunctive: each facet's
  counts ignore its own selection.
- **Dates.** The detected presets resolve with `resolveFilterDate` and today. Get today from
  `filterDateToday` with the profile's time zone, never a fixed date.
- **Texts.** The fields and the screen are in English. Build the fields in a `useMemo` with your
  translations, and pass `labels` to the provider and to `useFilters`.
- **The panel's open state** is a preference: keep it in your app's preference store, not in the
  URL.

## Accessibility

- The tabs, the panel, the table and the sheet keep their own contracts: see each page.
- Rows have no link or button that names them, so the table makes them focusable
  (`focusableRows`): Tab reaches a row, Enter opens it.
- The table announces the result count when a filter, the search or the tab changes
  (`total` and `resultKey`).
