Records list with filters
betaA list screen built from the patterns, with tabs, a filter panel, a table with selection and a detail sheet, all kept in the URL.
Alerts
Filters
45 alerts
| Actions | ||||||
|---|---|---|---|---|---|---|
Phishing kit hosted on a lookalike domain ALR-2396 | Critical | login-verify-acme.com | Identity team | 2026-10-23 06:39:28 | ||
Impossible travel for a privileged account ALR-2364 | Critical | okta: dana.whitfield | SOC tier 2 | 2026-10-13 10:01:27 | ||
MFA fatigue: repeated push denials ALR-2356 | Critical | okta: marcus.oyelaran | Identity team | 2026-10-11 01:10:57 | ||
Malicious macro opened from an invoice ALR-2355 | Critical | wks-0417.corp.acme.com | Identity team | 2026-10-10 06:14:28 | ||
Phishing kit hosted on a lookalike domain ALR-2349 | Critical | login-verify-acme.com | Identity team | 2026-10-09 11:50:08 | ||
Malicious macro opened from an invoice ALR-2319 | Critical | wks-0417.corp.acme.com | Unassigned | 2026-09-29 11:40:03 | ||
Outbound SMB to the internet ALR-2311 | Critical | fw-edge-03.acme.com | SOC tier 1 | 2026-09-27 08:58:09 | ||
Outbound SMB to the internet ALR-2398 | High | fw-edge-03.acme.com | SOC tier 1 | 2026-10-24 07:36:51 | ||
Impossible travel for a privileged account ALR-2394 | High | okta: dana.whitfield | Unassigned | 2026-10-23 16:00:28 | ||
Public bucket exposing backups ALR-2395 | High | s3://acme-backups | SOC tier 1 | 2026-10-23 05:30:05 | ||
Public bucket exposing backups ALR-2392 | High | s3://acme-backups | SOC tier 1 | 2026-10-22 13:47:44 | ||
Outbound SMB to the internet ALR-2384 | High | fw-edge-03.acme.com | Unassigned | 2026-10-19 05:47:43 | ||
Beacon to a known C2 server ALR-2376 | High | wks-0193.corp.acme.com | Unassigned | 2026-10-17 03:15:27 | ||
Phishing kit hosted on a lookalike domain ALR-2373 | High | login-verify-acme.com | Identity team | 2026-10-16 09:06:10 | ||
New admin added outside change window ALR-2372 | High | aws: 4421-0871-2290 | SOC tier 1 | 2026-10-16 00:33:10 | ||
Impossible travel for a privileged account ALR-2366 | High | okta: dana.whitfield | Unassigned | 2026-10-14 16:46:47 | ||
Beacon to a known C2 server ALR-2363 | High | wks-0193.corp.acme.com | Unassigned | 2026-10-13 20:37:38 | ||
New admin added outside change window ALR-2353 | High | aws: 4421-0871-2290 | Identity team | 2026-10-10 22:54:48 | ||
Outbound SMB to the internet ALR-2352 | High | fw-edge-03.acme.com | SOC tier 1 | 2026-10-09 19:01:35 | ||
MFA fatigue: repeated push denials ALR-2350 | High | okta: marcus.oyelaran | SOC tier 1 | 2026-10-09 11:59:52 | ||
Public bucket exposing backups ALR-2346 | High | s3://acme-backups | SOC tier 1 | 2026-10-08 00:27:10 | ||
Public bucket exposing backups ALR-2338 | High | s3://acme-backups | Cloud security | 2026-10-05 16:40:47 | ||
Public bucket exposing backups ALR-2334 | High | s3://acme-backups | SOC tier 2 | 2026-10-04 21:37:48 | ||
Beacon to a known C2 server ALR-2329 | High | wks-0193.corp.acme.com | Identity team | 2026-10-02 06:57:24 | ||
Phishing kit hosted on a lookalike domain ALR-2318 | High | login-verify-acme.com | Unassigned | 2026-09-29 16:52:30 | ||
Outbound SMB to the internet ALR-2307 | High | fw-edge-03.acme.com | Unassigned | 2026-09-25 19:55:33 | ||
Phishing kit hosted on a lookalike domain ALR-2389 | Medium | login-verify-acme.com | Unassigned | 2026-10-21 05:53:08 | ||
Mass download from the file share ALR-2379 | Medium | fs-prod-02.corp.acme.com | SOC tier 2 | 2026-10-18 20:39:47 | ||
Mass download from the file share ALR-2375 | Medium | fs-prod-02.corp.acme.com | Cloud security | 2026-10-17 13:02:37 | ||
New admin added outside change window ALR-2368 | Medium | aws: 4421-0871-2290 | SOC tier 2 | 2026-10-14 21:57:03 | ||
MFA fatigue: repeated push denials ALR-2362 | Medium | okta: marcus.oyelaran | Unassigned | 2026-10-13 09:48:37 | ||
Mass download from the file share ALR-2354 | Medium | fs-prod-02.corp.acme.com | SOC tier 2 | 2026-10-10 09:11:05 | ||
Mass download from the file share ALR-2351 | Medium | fs-prod-02.corp.acme.com | Cloud security | 2026-10-09 03:55:36 | ||
Phishing kit hosted on a lookalike domain ALR-2348 | Medium | login-verify-acme.com | Cloud security | 2026-10-08 15:15:54 | ||
Malicious macro opened from an invoice ALR-2343 | Medium | wks-0417.corp.acme.com | SOC tier 2 | 2026-10-07 22:39:47 | ||
Phishing kit hosted on a lookalike domain ALR-2336 | Medium | login-verify-acme.com | Unassigned | 2026-10-04 16:58:06 | ||
Public bucket exposing backups ALR-2332 | Medium | s3://acme-backups | Identity team | 2026-10-03 15:30:08 | ||
Phishing kit hosted on a lookalike domain ALR-2325 | Medium | login-verify-acme.com | Cloud security | 2026-10-01 05:14:32 | ||
Malicious macro opened from an invoice ALR-2322 | Medium | wks-0417.corp.acme.com | Unassigned | 2026-09-30 07:49:01 | ||
MFA fatigue: repeated push denials ALR-2323 | Medium | okta: marcus.oyelaran | SOC tier 1 | 2026-09-30 03:06:25 | ||
Outbound SMB to the internet ALR-2397 | Low | fw-edge-03.acme.com | SOC tier 2 | 2026-10-24 08:43:31 | ||
Credential stuffing against the VPN gateway ALR-2390 | Low | vpn-gw-01.acme.com | Identity team | 2026-10-21 12:41:22 | ||
Outbound SMB to the internet ALR-2367 | Low | fw-edge-03.acme.com | SOC tier 1 | 2026-10-14 17:18:51 | ||
Credential stuffing against the VPN gateway ALR-2312 | Low | vpn-gw-01.acme.com | Unassigned | 2026-09-27 22:18:31 | ||
Public bucket exposing backups ALR-2305 | Low | s3://acme-backups | Identity team | 2026-09-25 10:01:16 |
records-list.tsx412 lines
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.ts174 lines
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.ts37 lines
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 with status tabs whose counts follow the filters and the search.
- 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 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 with the alert’s details when a row opens, marked as the active row.
Wiring it to your app
- The URL.
use-search-params.tsreads and writes the page’s URL withreplaceState. In an app, use your router’s hook instead (React Router’suseSearchParams, with{ replace: true }); the rest of the block does not change. - The data.
alerts.tsholds fake alerts and filters them in the browser. ReplacematchAlertand 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
resolveFilterDateand today. Get today fromfilterDateTodaywith the profile’s time zone, never a fixed date. - Texts. The fields and the screen are in English. Build the fields in a
useMemowith your translations, and passlabelsto the provider and touseFilters. - 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
(
totalandresultKey).