@verdify/ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -17
- package/package.json +30 -24
- package/registry/accordion.json +33 -0
- package/registry/agent-badge.json +32 -0
- package/registry/alert.json +32 -0
- package/registry/avatar.json +34 -0
- package/registry/badge.json +32 -0
- package/registry/breadcrumb.json +33 -0
- package/registry/button.json +33 -0
- package/registry/card.json +33 -0
- package/registry/checkbox.json +32 -0
- package/registry/cn.json +19 -0
- package/registry/command-palette.json +33 -0
- package/registry/consent-toggle.json +34 -0
- package/registry/credential-card.json +35 -0
- package/registry/data-grid.json +33 -0
- package/registry/dialog.json +33 -0
- package/registry/identity-chip.json +36 -0
- package/registry/init.json +17 -0
- package/registry/input.json +32 -0
- package/registry/label.json +32 -0
- package/registry/menu.json +33 -0
- package/registry/pagination.json +33 -0
- package/registry/popover.json +32 -0
- package/registry/progress.json +32 -0
- package/registry/radio.json +32 -0
- package/registry/select.json +33 -0
- package/registry/separator.json +33 -0
- package/registry/sheet.json +33 -0
- package/registry/sidebar.json +33 -0
- package/registry/skeleton.json +32 -0
- package/registry/spinner.json +32 -0
- package/registry/switch.json +32 -0
- package/registry/table.json +33 -0
- package/registry/tabs.json +33 -0
- package/registry/textarea.json +33 -0
- package/registry/toast.json +33 -0
- package/registry/tooltip.json +32 -0
- package/registry/trust-score.json +33 -0
- package/registry/verified-badge.json +32 -0
- package/registry.json +159 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cn } from \"@/lib/cn\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n dataGridVariants,\n dataGridTableClass,\n dataGridHeaderRowClass,\n dataGridRowClass,\n dataGridCellVariants,\n dataGridColumnHeaderVariants,\n dataGridSortButtonClass,\n dataGridSortCaretClass,\n dataGridSelectionCellClass,\n dataGridBulkBarClass,\n dataGridBulkCountClass,\n dataGridBulkActionVariants,\n dataGridEmptyClass,\n dataGridStatusRegionClass,\n type DataGridCellVariantProps,\n} from \"./data-grid.variants\";\n\n/** Row density (spec §3): `comfortable` (default) for read-and-act screens, `compact` for dense consoles. */\nexport type DataGridDensity = \"comfortable\" | \"compact\";\n/** Selection model (spec §3): `none` (read-only) or `multiple` (adds the selection cell, select-all, and bulk bar). */\nexport type DataGridSelection = \"none\" | \"multiple\";\n/** A status cell's reported state (spec §3/§4): a status color appears only inside a cell that reports a real state, paired with text. */\nexport type DataGridCellStatus = NonNullable<DataGridCellVariantProps[\"status\"]>;\n/** A sort direction (spec §4/§7): reflected as `aria-sort` on the column header and a non-color caret. */\nexport type DataGridSortDirection = \"ascending\" | \"descending\" | \"none\";\n\n// ─────────────────────────────────────────────────────────────────────────────────────────────\n// Roving-cell focus over a native <table role=\"grid\"> (spec §6/§7). There is no Radix primitive for\n// the APG grid pattern's cell-by-cell arrow movement, so it is HAND-COMPOSED the same way Sidebar\n// hand-rolls roving over its native links — but in 2D, keyed by the cell's (row, col) coordinates\n// rather than DOM order, because grid movement is coordinate-based (Right/Left/Up/Down, row ends,\n// grid corners, page). Each cell registers its absolute (rowIndex, colIndex) + live DOM ref with the\n// root; the root holds the single active coordinate (exactly one cell is tabindex=0, the rest -1, so\n// the grid is a single tab stop, 2.1.1/2.4.3) and an onKeyDown on the grid that resolves the\n// destination coordinate and focuses the registered cell there. This is why the file is 'use client'.\ntype CellRegistration = { row: number; col: number; el: HTMLElement };\ntype ActiveCoord = { row: number; col: number };\ntype DataGridContextValue = {\n density: DataGridDensity;\n selection: DataGridSelection;\n active: ActiveCoord | null;\n register: (entry: CellRegistration) => () => void;\n // a cell reports its (row, col) so the root can set the initial active cell and answer isActive()\n claimInitial: (coord: ActiveCoord) => void;\n isActive: (coord: ActiveCoord) => boolean;\n // a cell asks the root to make it the active cell (on focus / pointer), so click + Tab agree with arrows\n setActive: (coord: ActiveCoord) => void;\n // the sibling DOM node, after the grid table, that grid CHROME (the bulk bar, the status-region\n // marker) portals itself into so it renders OUTSIDE role=\"grid\" (aria-required-children). A chrome\n // slot reads this from context and createPortal()s into it — so it lands outside the grid no matter\n // how a caller nests it (Fragment, wrapper, forwarded), with NO child.type introspection.\n chromeContainer: HTMLElement | null;\n};\nconst DataGridContext = React.createContext<DataGridContextValue | null>(null);\n\nfunction useDataGrid(): DataGridContextValue {\n const ctx = React.useContext(DataGridContext);\n if (!ctx) throw new Error(\"DataGrid slots must be rendered inside <DataGrid>.\");\n return ctx;\n}\n\nexport interface DataGridProps extends React.HTMLAttributes<HTMLDivElement> {\n /**\n * The TRUE total row count of the full set (spec §7), carried on the grid as `aria-rowcount` — NOT\n * the rendered window. So a screen reader announces \"row 4,210 of 12,000\" even when only thirty\n * rows are in the DOM. Reporting only the rendered window is the defining virtualization-a11y\n * defect (spec §8 Don't).\n */\n rowCount: number;\n /** The TRUE total column count of the full set (spec §7), carried on the grid as `aria-colcount`. */\n colCount: number;\n /** Row density (spec §3). `comfortable` (default) or `compact`. Applies to all cells via context. */\n density?: DataGridDensity;\n /**\n * Selection model (spec §3). `none` (default, read-only) or `multiple` — `multiple` sets\n * `aria-multiselectable` and is the model the selection cells + bulk bar belong to.\n */\n selection?: DataGridSelection;\n /** The rows are resolving (spec §4 Loading). Marks the grid `aria-busy` without stealing focus. */\n loading?: boolean;\n /**\n * The polite live-region message (spec §7, 4.1.3 Status Messages): the resolved row count after a\n * load or filter, the new sort direction, or the selection count — announced as TEXT so it never\n * rests on visual state alone. The caller owns the string (mirroring Table/CommandPalette).\n */\n announcement?: string;\n /**\n * A blocking row-load error (spec §4 Error / §7): announced through the ASSERTIVE region. State\n * what failed and what to do next, in text, without blaming the reader — for example \"These rows\n * didn't load. The request timed out — retry, or narrow the date range.\"\n */\n errorMessage?: string;\n /**\n * Select all rows that match the current filter (spec §6 Ctrl+A). The grid owns the keydown, so\n * Ctrl+A inside the grid calls this; the caller selects the full filtered set and announces the\n * resolved count through `announcement` (never silently — spec §6 \"announced as a count\"). Only\n * meaningful in a `multiple`-selection grid. Without it, Ctrl+A falls through to the browser.\n */\n onSelectAll?: () => void;\n}\n\n/**\n * A DataGrid shows many rows of structured records in a scrollable, operable, two-dimensional grid\n * you navigate one cell at a time (spec §1). Reach for it over a Table when rows run into the\n * thousands and must virtualize, when columns sort/filter/resize/freeze, when rows select for bulk\n * actions, or when each cell is its own focus stop — a trust-events log, an API-key inventory, an\n * AI-agent-identity roster. Use the Table for short, read-mostly lists.\n *\n * It is a NEUTRAL data surface (spec §1/§3): neutrals carry roughly 90% of it. Verification and trust\n * states inside it are STATUS, never brand — a verified row reads through the status aliases, never\n * the Sovereign Violet brand accent, and the brand accent never marks a row as verified. Selection\n * and the active cell are NEUTRAL action states, not Verified Green and not the brand violet\n * (brand != state, G-U2). For a first-class verified result in a cell, use the VerifiedBadge molecule.\n *\n * The grid is a SINGLE tab stop: Tab lands on the active cell and Tab again leaves; inside, the arrow\n * keys move the active cell (spec §6). It owns that roving-cell focus, so it is `'use client'`.\n */\nexport const DataGrid = React.forwardRef<HTMLDivElement, DataGridProps>(function DataGrid(\n {\n className,\n rowCount,\n colCount,\n density = \"comfortable\",\n selection = \"none\",\n loading = false,\n announcement,\n errorMessage,\n onSelectAll,\n children,\n \"aria-label\": ariaLabel,\n \"aria-labelledby\": ariaLabelledby,\n ...props\n },\n ref,\n) {\n // Cells register their absolute (row, col) + live DOM ref here so the root can move the active cell\n // to a coordinate (Sidebar's registry-in-context pattern, generalized to 2D).\n const registry = React.useRef<Set<CellRegistration>>(new Set());\n const [active, setActive] = React.useState<ActiveCoord | null>(null);\n // the lowest (row, col) any cell has claimed — the resting active cell before the user moves\n const initialRef = React.useRef<ActiveCoord | null>(null);\n // the sibling node, after the table, that grid chrome (bulk bar + status marker) portals into so it\n // renders OUTSIDE role=\"grid\"; tracked as state so a chrome slot re-portals once the node mounts.\n const [chromeContainer, setChromeContainer] = React.useState<HTMLElement | null>(null);\n // true once the user has moved the active cell into the grid (Tab/arrow/pointer) — so we restore\n // focus only for someone already operating the grid, never auto-focus the resting cell on mount.\n const userEngagedRef = React.useRef(false);\n // bumped on every (un)register so a window re-render re-runs the focus-restoration effect AFTER the\n // remounted cell has registered its live DOM ref (a cell registers in its own effect, which fires\n // before the root's effects — so the root must re-render to see the new ref, not read it in render).\n const [registryTick, setRegistryTick] = React.useState(0);\n\n const register = React.useCallback((entry: CellRegistration) => {\n registry.current.add(entry);\n setRegistryTick((t) => t + 1);\n return () => {\n registry.current.delete(entry);\n setRegistryTick((t) => t + 1);\n };\n }, []);\n\n const claimInitial = React.useCallback((coord: ActiveCoord) => {\n const current = initialRef.current;\n // the FIRST cell in reading order (lowest row, then lowest col) is the resting active cell\n if (!current || coord.row < current.row || (coord.row === current.row && coord.col < current.col)) {\n initialRef.current = coord;\n setActive((prev) => prev ?? coord);\n }\n }, []);\n\n const setActiveCoord = React.useCallback((coord: ActiveCoord) => {\n // a cell took focus (pointer / Tab / arrow) → the user is operating the grid, so a later\n // virtualized re-render should restore focus to the active cell rather than let it fall to <body>.\n userEngagedRef.current = true;\n setActive(coord);\n }, []);\n\n const resolvedActive = active ?? initialRef.current;\n const isActive = React.useCallback(\n (coord: ActiveCoord) =>\n resolvedActive !== null && resolvedActive.row === coord.row && resolvedActive.col === coord.col,\n [resolvedActive],\n );\n\n // Focus restoration across a virtualized re-render (spec §7 Focus management / §8 Don't — \"the\n // defining virtualization-a11y defect\"). The active cell's COORDINATE survives in state, so the\n // remounted cell at that (row, col) correctly regains tabindex=0 — but the BROWSER drops DOM focus\n // to <body> when the old node unmounts. So when the cell carrying the active coordinate is present\n // again (its window re-rendered) and focus has fallen OUT of the grid, restore focus to it, so a\n // keyboard / screen-reader user is never stranded mid-grid. We restore ONLY when focus was actually\n // lost — never steal it from another element, and never auto-focus the resting cell on first mount\n // (the resting active cell is a tab stop, not auto-focused; `userEngagedRef` gates that). The effect\n // re-runs on `registryTick`, which bumps once the remounted cell has registered its live DOM ref.\n React.useEffect(() => {\n if (!resolvedActive || !userEngagedRef.current) return;\n const activeCellEl = Array.from(registry.current).find(\n (c) => c.row === resolvedActive.row && c.col === resolvedActive.col,\n )?.el;\n if (!activeCellEl) return;\n const root = activeCellEl.ownerDocument;\n const focused = root.activeElement;\n // focus is \"lost from the grid\" when it is no longer ON a node inside THIS grid — the browser\n // drops it to <body> (or detaches it) when the previously-focused out-of-window cell unmounts.\n const focusInGrid =\n focused instanceof HTMLElement && root.contains(focused) && focused.closest('[role=\"grid\"]') !== null;\n if (!focusInGrid) activeCellEl.focus();\n }, [resolvedActive, registryTick]);\n\n // Resolve a destination coordinate against the registered cells and move focus there. Movement is\n // coordinate-based and clamps at the edges — Right/Left stop at the inline edge (no wrap, spec §6),\n // Up/Down at the row edges, page by a viewport of rows (clamped to what is rendered).\n const cells = () => Array.from(registry.current);\n const rowsOf = () => {\n const set = new Set<number>();\n cells().forEach((c) => set.add(c.row));\n return Array.from(set).sort((a, b) => a - b);\n };\n const colsInRow = (row: number) =>\n cells()\n .filter((c) => c.row === row)\n .map((c) => c.col)\n .sort((a, b) => a - b);\n const cellAt = (row: number, col: number) => cells().find((c) => c.row === row && c.col === col);\n\n // move to the nearest cell at (row, col), clamping col to the columns that exist in that row\n const moveTo = (row: number, col: number) => {\n const target = cellAt(row, col);\n if (target) {\n setActive({ row, col });\n target.el.focus();\n return;\n }\n // clamp the column to the nearest one present in the destination row (rows can differ in shape)\n const cols = colsInRow(row);\n if (cols.length === 0) return;\n const nearest = cols.reduce((best, c) => (Math.abs(c - col) < Math.abs(best - col) ? c : best), cols[0]);\n const fallback = cellAt(row, nearest);\n if (fallback) {\n setActive({ row, col: nearest });\n fallback.el.focus();\n }\n };\n\n const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n // Ctrl+A → select all rows that match the current filter (spec §6), handled BEFORE the movement\n // switch and independent of an active cell. The grid owns the keydown, so it intercepts the\n // browser's select-all; the caller selects the full filtered set and announces the count via\n // `announcement` (spec §6 \"announced as a count, not silently\"). Only a selection grid honors it.\n if ((event.ctrlKey || event.metaKey) && (event.key === \"a\" || event.key === \"A\")) {\n if (selection === \"multiple\" && onSelectAll) {\n event.preventDefault();\n onSelectAll();\n }\n return;\n }\n\n const current = resolvedActive;\n if (!current) return;\n const allRows = rowsOf();\n if (allRows.length === 0) return;\n const rowPos = allRows.indexOf(current.row);\n const cols = colsInRow(current.row);\n const colPos = cols.indexOf(current.col);\n const PAGE = 10; // a viewport of rows; clamped to what exists in this fixture / window\n\n switch (event.key) {\n case \"ArrowRight\": {\n event.preventDefault();\n // inline-axis next; logical (the visual mirror under RTL is the browser's, the index is logical)\n if (colPos < cols.length - 1) moveTo(current.row, cols[colPos + 1]); // stop at edge, no wrap\n break;\n }\n case \"ArrowLeft\": {\n event.preventDefault();\n if (colPos > 0) moveTo(current.row, cols[colPos - 1]); // stop at edge, no wrap\n break;\n }\n case \"ArrowDown\": {\n event.preventDefault();\n if (rowPos < allRows.length - 1) moveTo(allRows[rowPos + 1], current.col);\n break;\n }\n case \"ArrowUp\": {\n event.preventDefault();\n if (rowPos > 0) moveTo(allRows[rowPos - 1], current.col);\n break;\n }\n case \"Home\": {\n event.preventDefault();\n if (event.ctrlKey) {\n // Ctrl+Home → first cell of the first row\n const firstRow = allRows[0];\n moveTo(firstRow, colsInRow(firstRow)[0]);\n } else {\n // Home → first cell in the current row\n moveTo(current.row, cols[0]);\n }\n break;\n }\n case \"End\": {\n event.preventDefault();\n if (event.ctrlKey) {\n // Ctrl+End → last cell of the last row\n const lastRow = allRows[allRows.length - 1];\n const lastRowCols = colsInRow(lastRow);\n moveTo(lastRow, lastRowCols[lastRowCols.length - 1]);\n } else {\n // End → last cell in the current row\n moveTo(current.row, cols[cols.length - 1]);\n }\n break;\n }\n case \"PageDown\": {\n event.preventDefault();\n const dest = Math.min(rowPos + PAGE, allRows.length - 1);\n moveTo(allRows[dest], current.col);\n break;\n }\n case \"PageUp\": {\n event.preventDefault();\n const dest = Math.max(rowPos - PAGE, 0);\n moveTo(allRows[dest], current.col);\n break;\n }\n default:\n break;\n }\n };\n\n const ctx = React.useMemo<DataGridContextValue>(\n () => ({\n density,\n selection,\n active: resolvedActive,\n register,\n claimInitial,\n isActive,\n setActive: setActiveCoord,\n chromeContainer,\n }),\n [density, selection, resolvedActive, register, claimInitial, isActive, setActiveCoord, chromeContainer],\n );\n\n return (\n <DataGridContext.Provider value={ctx}>\n {/* The scroll container is a plain wrapper (the caller sizes the scroll viewport via className —\n the token set has no grid-height scale, the caller-owned-dimension precedent). The grid ROLE\n lives on the native <table> below, so its rows are the grid's direct row children\n (aria-required-children); the bulk bar + live regions are SIBLINGS of the table, OUTSIDE the\n grid. The arrow-key roving handler is on the wrapper so it catches the bubbling keydown from\n any cell. */}\n <div ref={ref} className={cn(dataGridVariants(), className)} onKeyDown={onKeyDown} {...props}>\n {/* The grid carries the FULL-set shape (spec §7): aria-rowcount / aria-colcount are the TRUE\n totals, not the rendered window, so the full shape survives virtualization. A selection\n grid is multiselectable. aria-busy reflects loading without stealing focus (spec §4\n Loading). A native <table> + role=\"grid\" keeps the row/column relationship native (1.3.1)\n while exposing the APG grid pattern. The caller's children render INSIDE the table; the\n chrome slots (bulk bar, status marker) portal THEMSELVES back OUT to the sibling node\n below via context, so a role=\"toolbar\" never lands inside role=\"grid\" (aria-required-\n children) no matter how the caller nests it. */}\n <table\n role=\"grid\"\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledby}\n aria-rowcount={rowCount}\n aria-colcount={colCount}\n aria-multiselectable={selection === \"multiple\" ? true : undefined}\n aria-busy={loading || undefined}\n className={dataGridTableClass}\n >\n {children}\n </table>\n {/* The sibling node, OUTSIDE the grid table, that grid chrome portals into. A role=\"toolbar\"\n (the bulk bar) and the live-region <span>s are not valid children of role=\"grid\", so they\n live here — the same way Table's pagination is a sibling control beside the table, not\n table content. Routed by context + createPortal (recipe Root→slot context), NOT by\n introspecting child.type, so wrapping a chrome slot in a Fragment / wrapper can't misroute\n it back into the grid. */}\n <div ref={setChromeContainer} />\n {/* The polite + assertive live regions, owned by the root and portaled to the chrome node, so\n the announcement contract holds even when the caller omits <DataGridStatusRegion/>. The\n grid's accessible name is carried by the <table>, so the wrapper is unnamed. */}\n {chromeContainer\n ? createPortal(\n <DataGridLiveRegions announcement={announcement} errorMessage={errorMessage} />,\n chromeContainer,\n )\n : null}\n </div>\n </DataGridContext.Provider>\n );\n});\n\n// The grid's live regions (spec §7, 4.1.3): a POLITE region for the row count / sort / filter /\n// selection changes, and an ASSERTIVE region for a blocking row-load error. Both always present (a\n// live region must exist before its text changes to be announced) and sr-only (the visual state\n// carries the same information for sighted users, so the region is text-only — never color alone).\n// Rendered by the root so the contract holds even when a caller omits <DataGridStatusRegion/>; the\n// explicit slot is a no-op marker for the anatomy (below). They are siblings of the <table>.\nfunction DataGridLiveRegions({\n announcement,\n errorMessage,\n}: {\n announcement?: string;\n errorMessage?: string;\n}) {\n return (\n <>\n <span role=\"status\" aria-live=\"polite\" className={dataGridStatusRegionClass}>\n {announcement}\n </span>\n <span role=\"alert\" aria-live=\"assertive\" className={dataGridStatusRegionClass}>\n {errorMessage}\n </span>\n </>\n );\n}\n\nexport type DataGridStatusRegionProps = Record<string, never>;\n\n/**\n * A no-op anatomy marker for the live status region (spec §2 status-region). The actual polite +\n * assertive regions are owned by the DataGrid root (fed by its `announcement` / `errorMessage`\n * props) and portaled outside the grid table, so the announcement contract holds even when this slot\n * is omitted; render this in the grid's children to document the anatomy explicitly. It renders\n * nothing itself — so it is harmless wherever the caller places it inside the grid.\n */\nexport function DataGridStatusRegion(_props: DataGridStatusRegionProps): null {\n return null;\n}\n\nexport interface DataGridHeaderRowProps extends React.HTMLAttributes<HTMLTableRowElement> {\n /** The header row's absolute 1-based `aria-rowindex` against the full set (spec §7). Conventionally 1. */\n rowIndex: number;\n}\n\n/**\n * The sticky column-header row (spec §2 column-header-row / §4). It is a `role=\"row\"` holding the\n * `columnheader` cells, pinned to the top of the scroll viewport on the raised surface so it reads\n * above the scrolling rows (spec §4/§5). Its absolute `aria-rowindex` is carried for the full-set\n * shape (spec §7); a header row is conventionally row 1.\n */\nexport const DataGridHeaderRow = React.forwardRef<HTMLTableRowElement, DataGridHeaderRowProps>(\n function DataGridHeaderRow({ className, rowIndex, children, ...props }, ref) {\n return (\n // a real <thead><tr> so the column-header relationship is native (1.3.1)\n <thead>\n <tr ref={ref} role=\"row\" aria-rowindex={rowIndex} className={cn(dataGridHeaderRowClass, className)} {...props}>\n {children}\n </tr>\n </thead>\n );\n },\n);\n\nexport type DataGridBodyProps = React.HTMLAttributes<HTMLTableSectionElement>;\n\n/**\n * The `<tbody>` holding the rendered window of data rows (spec §2). With virtualization only a window\n * of rows is in the DOM — the full shape is carried by the grid's `aria-rowcount` and each row's\n * absolute `aria-rowindex` (spec §7), not by the rendered count.\n */\nexport const DataGridBody = React.forwardRef<HTMLTableSectionElement, DataGridBodyProps>(\n function DataGridBody({ className, ...props }, ref) {\n return <tbody ref={ref} className={cn(className)} {...props} />;\n },\n);\n\nexport interface DataGridRowProps extends React.HTMLAttributes<HTMLTableRowElement> {\n /** The row's absolute 1-based `aria-rowindex` against the full set (spec §7) — e.g. 4210, not the rendered position. */\n rowIndex: number;\n /**\n * The row is selected (spec §4 Selected). Sets `aria-selected` and the NEUTRAL selection-accent\n * fill; selection is encoded by `aria-selected` + the selection checkbox, never by color alone, and\n * NEVER a brand or status tint (brand != state, G-U2). Selecting a row never implies it is verified.\n */\n selected?: boolean;\n}\n\n/**\n * One record row (spec §2 row / §4). A `role=\"row\"` carrying its absolute `aria-rowindex` (spec §7),\n * the restrained ghost row-hover affordance, and — when `selected` — the neutral selection accent +\n * `aria-selected`. In a `multiple`-selection grid every body row reflects `aria-selected` (true or\n * false) so selection state is never ambiguous to assistive tech.\n */\nexport const DataGridRow = React.forwardRef<HTMLTableRowElement, DataGridRowProps>(function DataGridRow(\n { className, rowIndex, selected, ...props },\n ref,\n) {\n const { selection } = useDataGrid();\n // in a selection grid, every row reports aria-selected (true/false); a read-only grid omits it\n const ariaSelected =\n selection === \"multiple\" ? (selected ? true : false) : selected ? true : undefined;\n return (\n <tr\n ref={ref}\n role=\"row\"\n aria-rowindex={rowIndex}\n aria-selected={ariaSelected}\n className={cn(dataGridRowClass, className)}\n {...props}\n />\n );\n});\n\n// A hook that wires a cell into the roving-focus registry: it registers the cell's (row, col) + ref,\n// reports the initial active coordinate, and returns the tabindex + focus handler the cell binds. The\n// shared seam for every roving cell (data cell, column header, selection cell) so they all agree.\nfunction useRovingCell(row: number, col: number) {\n const { register, claimInitial, isActive, setActive } = useDataGrid();\n const ref = React.useRef<HTMLTableCellElement | null>(null);\n React.useEffect(() => {\n const el = ref.current;\n if (!el) return;\n claimInitial({ row, col });\n return register({ row, col, el });\n }, [register, claimInitial, row, col]);\n const active = isActive({ row, col });\n // exactly one cell is tabindex=0 (the active cell); every other is -1 → the grid is one tab stop\n const tabIndex = active ? 0 : -1;\n // focusing a cell (pointer/Tab/arrow) makes it the active cell, so click + Tab agree with arrows\n const onFocus = () => setActive({ row, col });\n return { ref, tabIndex, onFocus };\n}\n\nexport interface DataGridColumnHeaderProps\n extends Omit<React.ThHTMLAttributes<HTMLTableCellElement>, \"onClick\"> {\n /** The header's absolute 1-based `aria-colindex` against the full set (spec §7). */\n colIndex: number;\n /** This column can be re-sorted from its header (spec §3 sortable): renders a real sort button + caret. */\n sortable?: boolean;\n /**\n * The current sort direction for this column (spec §4 Sorted), reflected as `aria-sort` on the\n * header and as the caret. Only one column is the sort column — set it here and `\"none\"` elsewhere.\n */\n sortDirection?: DataGridSortDirection;\n /** Fired when the sortable header is activated (click / Enter), so the caller re-sorts and updates `sortDirection`. */\n onSort?: () => void;\n /** The column name used in the sort control's accessible name, when it differs from the children. */\n sortLabel?: string;\n}\n\n// The sort-direction caret (spec §4 Sorted): decorative — aria-sort on the th + the glyph SHAPE\n// (data-direction) encode the direction, so it never rests on color alone (1.4.1). Inline SVG, no\n// icon dep; it points up for ascending, down for descending, and shows a neutral both-ways glyph when\n// the column is sortable but not the active sort column.\nfunction SortCaret({ direction }: { direction: DataGridSortDirection }) {\n return (\n <span data-testid=\"data-grid-sort-caret\" data-direction={direction} aria-hidden=\"true\" className={dataGridSortCaretClass}>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" focusable=\"false\" aria-hidden=\"true\">\n {direction === \"ascending\" ? (\n <path d=\"M4 10l4-4 4 4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n ) : direction === \"descending\" ? (\n <path d=\"M4 6l4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n ) : (\n <path d=\"M5 6.5l3-3 3 3M5 9.5l3 3 3-3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n )}\n </svg>\n </span>\n );\n}\n\nconst NEXT_DIRECTION_WORD: Record<DataGridSortDirection, string> = {\n none: \"ascending\",\n ascending: \"descending\",\n descending: \"ascending\",\n};\n\n/**\n * One column header cell (spec §2 column-header / §7). A `th role=\"columnheader\"` that is also a\n * roving active-cell target (it carries the cell focus ring + tabindex). A plain header is a quiet,\n * tracked label in the secondary text color. A `sortable` header wraps the label in a real `<button>`\n * carrying the ghost-action accent, a caret, and the target-size floor; the header reflects\n * `aria-sort` and Enter toggles the sort (spec §6) — so direction reaches assistive tech as data and\n * never rests on color alone (1.4.1).\n */\nexport const DataGridColumnHeader = React.forwardRef<HTMLTableCellElement, DataGridColumnHeaderProps>(\n function DataGridColumnHeader(\n { className, colIndex, sortable = false, sortDirection = \"none\", onSort, sortLabel, children, ...props },\n ref,\n ) {\n const { density } = useDataGrid();\n const { ref: rovingRef, tabIndex, onFocus } = useRovingCell(1, colIndex); // header is row 1\n React.useImperativeHandle(ref, () => rovingRef.current as HTMLTableCellElement);\n const ariaSort = sortable ? sortDirection : undefined;\n const label = sortLabel ?? (typeof children === \"string\" ? children : undefined);\n\n // Enter on the active header toggles the sort (spec §6 \"In a column header: toggle the column\n // sort\"). Activating the inner button also fires onSort; both routes call the same handler.\n const onKeyDown = (event: React.KeyboardEvent<HTMLTableCellElement>) => {\n if (sortable && event.key === \"Enter\") {\n event.preventDefault();\n onSort?.();\n }\n };\n\n return (\n <th\n ref={rovingRef}\n role=\"columnheader\"\n scope=\"col\"\n aria-colindex={colIndex}\n // exactly one header carries aria-sort at a time (spec §4 Sorted); the rest are \"none\"/absent\n aria-sort={ariaSort}\n tabIndex={tabIndex}\n onFocus={onFocus}\n onKeyDown={onKeyDown}\n className={cn(dataGridColumnHeaderVariants({ density }), className)}\n {...props}\n >\n {sortable ? (\n <button\n type=\"button\"\n onClick={onSort}\n // a roving cell owns the tab stop; the inner button is reachable via Enter on the cell,\n // not as its own tab stop (the cell is the single stop) — so it is tabindex=-1\n tabIndex={-1}\n aria-label={label ? `Sort by ${label}, ${NEXT_DIRECTION_WORD[sortDirection]}` : undefined}\n className={dataGridSortButtonClass}\n >\n {children}\n <SortCaret direction={sortDirection} />\n </button>\n ) : (\n children\n )}\n </th>\n );\n },\n);\n\n/**\n * How a cell relates to the interactive control(s) it holds (spec §2/§6 cell entry/exit model):\n * - `undefined` — a plain VALUE cell (or one static, non-interactive node). The cell itself is the\n * focus stop; arrow keys move the active cell as normal.\n * - `\"single\"` — the cell holds ONE interactive control (a link, a status chip, a row-action menu).\n * The cell DELEGATES focus to that control on entry, so the control is what you land on and operate\n * directly (spec §6 \"delegates focus to it on entry\").\n * - `\"multiple\"` — the cell holds SEVERAL controls. The cell is ENTERED with Enter (focus moves to the\n * first control, and arrow keys stay free for the inner controls rather than moving the active cell)\n * and EXITED with Escape (focus returns to the cell), so arrow keys stay free for grid movement\n * outside it (spec §6 \"entered with Enter and exited with Escape, so arrow keys stay free\").\n */\nexport type DataGridCellControls = \"single\" | \"multiple\";\n\nexport interface DataGridCellProps\n extends React.TdHTMLAttributes<HTMLTableCellElement>,\n Pick<DataGridCellVariantProps, \"mono\" | \"secondary\" | \"status\"> {\n /** The cell's absolute 1-based `aria-colindex` against the full set (spec §7). */\n colIndex: number;\n /** The cell belongs to a body row at this absolute `aria-rowindex`; supplied by DataGridRow context where omitted. */\n rowIndex?: number;\n /**\n * Whether this cell holds interactive control(s), and so how Enter/Escape behave for it (spec §6\n * cell entry/exit model). Omit for a plain value cell. See {@link DataGridCellControls}.\n */\n controls?: DataGridCellControls;\n}\n\n// Find the focusable descendants of a cell (a single/multi-control cell delegates / enters into\n// them). The roving-cell <td> itself is excluded — we want the controls it HOSTS, not the cell.\nconst FOCUSABLE_SELECTOR =\n 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex=\"-1\"]):not([role=\"gridcell\"]):not([role=\"columnheader\"]), [tabindex=\"-1\"]:not([role=\"gridcell\"]):not([role=\"columnheader\"])';\nfunction focusablesIn(cell: HTMLElement): HTMLElement[] {\n return Array.from(cell.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));\n}\n\n// A cell reads its owning row's aria-rowindex from the rendered <tr> so the caller does not repeat it\n// on every cell. The row sets it; the cell discovers it from its closest row on mount.\nfunction useOwningRowIndex(elRef: React.RefObject<HTMLElement | null>, explicit?: number): number {\n const [rowIndex, setRowIndex] = React.useState<number>(explicit ?? 0);\n React.useEffect(() => {\n if (explicit !== undefined) {\n setRowIndex(explicit);\n return;\n }\n const tr = elRef.current?.closest('[role=\"row\"]');\n const attr = tr?.getAttribute(\"aria-rowindex\");\n if (attr) setRowIndex(Number(attr));\n }, [elRef, explicit]);\n return rowIndex;\n}\n\n/**\n * One data cell — the focusable unit of the grid (spec §2 cell / §4/§7). A `td role=\"gridcell\"`\n * carrying its absolute `aria-colindex` (spec §7) and the roving active-cell tabindex + focus ring.\n * A plain cell is neutral primary text; a `mono` cell takes the monospace role and stays LTR in RTL\n * (an identifier/key/timestamp, G-U6); a `secondary` cell is de-emphasized; a `status` cell carries\n * the status fg paired with text (a verification/trust state) — the status color lives in the cell\n * only, never the row or header, and NEVER the brand (brand != state, G-U2). The cell hosts a value\n * or a single interactive control; density is read from the grid via context.\n */\nexport const DataGridCell = React.forwardRef<HTMLTableCellElement, DataGridCellProps>(function DataGridCell(\n { className, colIndex, rowIndex, mono = false, secondary = false, status = \"none\", controls, onKeyDown, ...props },\n ref,\n) {\n const { density } = useDataGrid();\n const localRef = React.useRef<HTMLTableCellElement | null>(null);\n const owningRow = useOwningRowIndex(localRef, rowIndex);\n const { ref: rovingRef, tabIndex, onFocus: rovingOnFocus } = useRovingCell(owningRow, colIndex);\n // whether the active cell has been ENTERED for its controls (a multi-control cell); while entered,\n // arrow keys are left for the inner controls and Escape returns to grid navigation (spec §6).\n const [entered, setEntered] = React.useState(false);\n // share one DOM node between the roving registry, the row-index discovery, and the forwarded ref\n React.useImperativeHandle(ref, () => localRef.current as HTMLTableCellElement);\n const setRefs = (node: HTMLTableCellElement | null) => {\n localRef.current = node;\n rovingRef.current = node;\n };\n\n // A SINGLE-control cell delegates focus to its one control on entry (spec §6): when the cell takes\n // focus, hand focus straight to the inner control so you land on and operate the control directly.\n const onFocus = (event: React.FocusEvent<HTMLTableCellElement>) => {\n rovingOnFocus();\n if (controls === \"single\" && event.target === localRef.current) {\n const first = focusablesIn(localRef.current)[0];\n if (first) first.focus();\n }\n };\n\n const handleKeyDown = (event: React.KeyboardEvent<HTMLTableCellElement>) => {\n onKeyDown?.(event);\n if (event.defaultPrevented) return;\n const cell = localRef.current;\n if (!cell) return;\n if (controls === \"multiple\") {\n // Enter ENTERS the cell: focus the first control; arrow keys then stay free for the inner\n // controls (we stop their propagation below so the grid's roving handler ignores them).\n if (event.key === \"Enter\" && event.target === cell && !entered) {\n const first = focusablesIn(cell)[0];\n if (first) {\n event.preventDefault();\n setEntered(true);\n first.focus();\n }\n return;\n }\n // Escape EXITS the cell: focus returns to the cell and arrow keys move the active cell again.\n if (event.key === \"Escape\" && entered) {\n event.preventDefault();\n event.stopPropagation();\n setEntered(false);\n cell.focus();\n return;\n }\n // while ENTERED, keep arrow keys for the inner controls — stop them reaching the grid's roving\n // handler on the wrapper, so they never move the active cell (spec §6 \"arrow keys stay free\").\n if (\n entered &&\n (event.key === \"ArrowRight\" ||\n event.key === \"ArrowLeft\" ||\n event.key === \"ArrowUp\" ||\n event.key === \"ArrowDown\" ||\n event.key === \"Home\" ||\n event.key === \"End\")\n ) {\n event.stopPropagation();\n }\n }\n };\n\n return (\n <td\n ref={setRefs}\n role=\"gridcell\"\n aria-colindex={colIndex}\n tabIndex={tabIndex}\n onFocus={onFocus}\n onKeyDown={handleKeyDown}\n className={cn(dataGridCellVariants({ density, mono, secondary, status }), className)}\n {...props}\n />\n );\n});\n\nexport interface DataGridSelectionCellProps\n extends Omit<React.TdHTMLAttributes<HTMLTableCellElement>, \"onChange\"> {\n /** The cell's absolute 1-based `aria-colindex` (spec §7). The selection cell is the leading column. */\n colIndex: number;\n /** Whether the row is selected (spec §4 Selected). */\n checked: boolean;\n /** Fired when the row's selection toggles (via the checkbox or Space on the cell). */\n onCheckedChange?: (checked: boolean) => void;\n /**\n * Extend a range from the last selected row to this one (spec §6 Shift+Space). The grid does not\n * track which row was selected last, so the caller owns the anchor and the resulting range; this\n * fires on Shift+Space on the cell so the range reaches the same handler keyboard and pointer\n * (Shift+click) would. Without it, Shift+Space falls back to toggling the single row.\n */\n onExtendSelection?: () => void;\n /** The checkbox's accessible name (spec §7) — e.g. \"Select sk_live_1\". Never the placeholder. */\n label: string;\n /** The cell belongs to a body row at this absolute `aria-rowindex`; discovered from the row when omitted. */\n rowIndex?: number;\n}\n\n/**\n * The leading selection cell (spec §2 selection-cell / §4/§6): a `gridcell` holding the row's\n * Checkbox. Space on the cell toggles the row's selection (spec §6). The checkbox is the committed\n * Checkbox component — a checked box is the brand action accent (never status-verified, G-U2):\n * selection is a neutral action state. It is a roving active-cell target like any other cell.\n */\nexport const DataGridSelectionCell = React.forwardRef<HTMLTableCellElement, DataGridSelectionCellProps>(\n function DataGridSelectionCell(\n { className, colIndex, checked, onCheckedChange, onExtendSelection, label, rowIndex, ...props },\n ref,\n ) {\n const localRef = React.useRef<HTMLTableCellElement | null>(null);\n const owningRow = useOwningRowIndex(localRef, rowIndex);\n const { ref: rovingRef, tabIndex, onFocus } = useRovingCell(owningRow, colIndex);\n React.useImperativeHandle(ref, () => localRef.current as HTMLTableCellElement);\n const setRefs = (node: HTMLTableCellElement | null) => {\n localRef.current = node;\n rovingRef.current = node;\n };\n // Space on the cell toggles the row's selection; Shift+Space extends a range from the last\n // selected row (spec §6). Shift+Space prefers the range handler when the caller wires one and\n // falls back to toggling the single row otherwise, so the key is never inert.\n const onKeyDown = (event: React.KeyboardEvent<HTMLTableCellElement>) => {\n if (event.key === \" \") {\n event.preventDefault();\n if (event.shiftKey && onExtendSelection) {\n onExtendSelection();\n } else {\n onCheckedChange?.(!checked);\n }\n }\n };\n return (\n <td\n ref={setRefs}\n role=\"gridcell\"\n aria-colindex={colIndex}\n tabIndex={tabIndex}\n onFocus={onFocus}\n onKeyDown={onKeyDown}\n className={cn(dataGridSelectionCellClass, className)}\n {...props}\n >\n <Checkbox label={label} checked={checked} onCheckedChange={onCheckedChange} />\n </td>\n );\n },\n);\n\nexport interface DataGridSelectAllCellProps\n extends Omit<React.ThHTMLAttributes<HTMLTableCellElement>, \"onChange\"> {\n /** The header cell's absolute 1-based `aria-colindex` (spec §7). The select-all is the leading column. */\n colIndex: number;\n /** Whether all rows are selected. */\n checked: boolean;\n /** Whether some-but-not-all rows are selected (the mixed/indeterminate state). */\n indeterminate?: boolean;\n /** Fired when the select-all toggles. */\n onCheckedChange?: (checked: boolean) => void;\n /** The select-all checkbox's accessible name (spec §7) — e.g. \"Select all rows\". */\n label: string;\n}\n\n/**\n * The select-all header cell (spec §2 selection-cell: \"its header holds the select-all checkbox\").\n * A `columnheader` holding the parent Checkbox (the `variant=\"parent\"` mixed model), so a partial\n * selection reads as mixed to assistive tech. A roving active-cell target like any other header.\n */\nexport const DataGridSelectAllCell = React.forwardRef<HTMLTableCellElement, DataGridSelectAllCellProps>(\n function DataGridSelectAllCell(\n { className, colIndex, checked, indeterminate = false, onCheckedChange, label, ...props },\n ref,\n ) {\n const { ref: rovingRef, tabIndex, onFocus } = useRovingCell(1, colIndex); // header is row 1\n React.useImperativeHandle(ref, () => rovingRef.current as HTMLTableCellElement);\n return (\n <th\n ref={rovingRef}\n role=\"columnheader\"\n scope=\"col\"\n aria-colindex={colIndex}\n tabIndex={tabIndex}\n onFocus={onFocus}\n className={cn(dataGridSelectionCellClass, className)}\n {...props}\n >\n <Checkbox\n variant=\"parent\"\n label={label}\n checked={checked}\n indeterminate={indeterminate}\n onCheckedChange={onCheckedChange}\n />\n </th>\n );\n },\n);\n\nexport interface DataGridBulkActionBarProps extends React.HTMLAttributes<HTMLDivElement> {\n /** How many rows are selected (spec §2/§7), announced in words so selection is never color alone. */\n selectedCount: number;\n /** Fired when Escape dismisses the bar (spec §6 \"dismiss the bulk-action bar\"). */\n onDismiss?: () => void;\n}\n\n/**\n * The bulk-action bar (spec §2 bulk-action-bar / §4/§6): a `role=\"toolbar\"` that appears when one or\n * more rows are selected, holding the selection actions and the selected-count label. It is a NEUTRAL\n * surface — the color lives on the action buttons it holds, never on the bar (spec §3/§8). Escape\n * dismisses it back to grid navigation (spec §6). Its accessible name names the selection count.\n */\nexport const DataGridBulkActionBar = React.forwardRef<HTMLDivElement, DataGridBulkActionBarProps>(\n function DataGridBulkActionBar({ className, selectedCount, onDismiss, children, ...props }, ref) {\n // A role=\"toolbar\" is not a valid child of role=\"grid\" (aria-required-children), so the bar\n // renders OUTSIDE the grid table — but it is authored as a child of <DataGrid>. It reads the\n // root's chrome container from context and portals itself there, so it lands outside the grid no\n // matter how the caller nests it (Fragment, wrapper, forwarded) — NO child.type introspection.\n const ctx = React.useContext(DataGridContext);\n const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onDismiss?.();\n }\n };\n const bar = (\n <div\n ref={ref}\n role=\"toolbar\"\n tabIndex={-1}\n aria-label={`${selectedCount} selected`}\n className={cn(dataGridBulkBarClass, className)}\n onKeyDown={onKeyDown}\n {...props}\n >\n <span className={dataGridBulkCountClass}>{selectedCount} selected</span>\n {children}\n </div>\n );\n // Inside a DataGrid: portal to the root's chrome node — and render NOTHING until that node\n // mounts, so the bar never lands inside the <table role=\"grid\"> for even one frame (the container\n // ref is set + a state bump re-renders this on the same commit). Outside any DataGrid (no\n // context): render in place so a standalone bar is never lost.\n if (ctx) return ctx.chromeContainer ? createPortal(bar, ctx.chromeContainer) : null;\n return bar;\n },\n);\n\nexport interface DataGridBulkActionProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n /** A destructive bulk action (spec §5) — a risk signal in the ACTION tier (never a status color). */\n destructive?: boolean;\n}\n\n/**\n * One action in the bulk-action bar (spec §2/§5): a real `<button>`. The default action is the\n * primary action accent; a `destructive` action (for example, revoke a key) is the destructive\n * action accent — a risk signal named in TEXT, never a status color (spec §5/§8).\n */\nexport const DataGridBulkAction = React.forwardRef<HTMLButtonElement, DataGridBulkActionProps>(\n function DataGridBulkAction({ className, destructive = false, type = \"button\", ...props }, ref) {\n return (\n <button\n ref={ref}\n type={type}\n className={cn(dataGridBulkActionVariants({ destructive }), className)}\n {...props}\n />\n );\n },\n);\n\nexport interface DataGridEmptyProps extends React.TdHTMLAttributes<HTMLTableCellElement> {\n /** How many columns the empty line spans, so it fills the grid's own width (spec §2/§4 Empty). */\n colSpan?: number;\n}\n\n/**\n * The empty-state row (spec §2/§4 Empty): a single full-width cell stating why the grid is empty and\n * what to do next, in plain words ending in a period — clear a filter, widen a date range. An empty\n * grid is NOT an error and never reads as one (no status color), and it never blames you or ends with\n * an exclamation mark (voice). Render it inside the body when a query returns nothing.\n */\nexport const DataGridEmpty = React.forwardRef<HTMLTableCellElement, DataGridEmptyProps>(\n function DataGridEmpty({ className, colSpan = 1, children, ...props }, ref) {\n return (\n <tr role=\"row\">\n <td ref={ref} role=\"gridcell\" colSpan={colSpan} className={cn(dataGridEmptyClass, className)} {...props}>\n {children}\n </td>\n </tr>\n );\n },\n);\n",
|
|
9
|
+
"path": "data-grid/data-grid.tsx",
|
|
10
|
+
"target": "@ui/data-grid/data-grid.tsx",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// A DataGrid shows many rows of structured records in a scrollable, operable, two-dimensional grid\n// you navigate one cell at a time (spec §1). It is a NEUTRAL data surface (spec §1/§3): neutrals\n// carry roughly 90% of it, and a dense grid earns its legibility from restraint. It paints from the\n// surface, text, and border roles; it reaches the --color-action-* tier only for the controls it\n// hosts (the sortable-header ghost accent, the row-hover affordance, the bulk-bar actions, and the\n// NEUTRAL selection accent) and the --color-status-* tier ONLY inside a cell that reports a real\n// state, paired with text — never as a row tint, a header fill, or the selection accent, and NEVER\n// a brand token as a status (brand != state, G-U2). Selection and the active cell are NEUTRAL action\n// states; a verified/trust state is a status cell (spec §3/§4/§8).\n\n// The scroll container <div role=\"grid\"> (spec §2 grid / §5). The neutral canvas surface and the\n// default cell text role, framed by the outer surface border at the md radius, with a fixed-height\n// scroll viewport (the caller sizes it via className — the token set has no grid-height scale, the\n// caller-owned-dimension precedent J). It NEVER wears the brand violet or a status fill (spec §3/§8)\n// — those belong to the controls and the status cells inside it. The active cell is kept scrolled\n// clear of the sticky header and pinned columns by scroll-margin (2.4.11 Focus Not Obscured).\nexport const dataGridVariants = cva([\n \"relative w-full overflow-auto\",\n \"bg-surface-canvas text-body text-text-primary\",\n \"border border-surface-border rounded-(--radius-md)\",\n]);\n\nexport type DataGridVariantProps = VariantProps<typeof dataGridVariants>;\n\n// The inner table element. The grid is a real <table> for the row/column relationship (1.3.1),\n// border-collapsed so the gridlines read as single hairlines, and start-aligned so it mirrors under\n// dir=\"rtl\" (G-U6).\nexport const dataGridTableClass = \"w-full border-collapse text-start\";\n\n// The column-header row <tr> (spec §2 column-header-row / §4 Default / §5). It is STICKY — it stays\n// pinned to the top of the scroll viewport while rows scroll under it — on the raised neutral surface\n// with the sm elevation so it reads ABOVE the scrolling rows (spec §4/§5). z on the sticky layer so a\n// scrolled cell never paints over the pinned header. A header row NEVER wears a status or brand tint\n// (spec §3/§8).\nexport const dataGridHeaderRowClass =\n \"sticky top-0 z-(--z-index-sticky) bg-surface-raised shadow-sm \" +\n \"border-b border-border-default\";\n\n// The shared cell padding by density (spec §3 density / §5 --space-*). Density tightens the VERTICAL\n// padding only, ABOVE the a11y floor — any in-cell control keeps its own --size-target-* floor\n// (DEC-B: never a fixed height below the floor). Horizontal inline padding is constant.\nconst cellPaddingVariants = {\n density: {\n comfortable: \"py-(--space-3)\",\n compact: \"py-(--space-1)\",\n },\n} as const;\n\n// A data row <tr> (spec §4 Default/Hover/Selected). RESTING: no fill, on the canvas. HOVER: a\n// restrained GHOST fill to track the eye across a wide row — an AFFORDANCE, never the sole carrier of\n// meaning, and never a selection (nothing is selected until you act, spec §4 Hover). SELECTED\n// (aria-selected): the NEUTRAL secondary-action selection accent — selection is a neutral action\n// state, NOT verified green and NOT the brand violet (selecting a row never implies it is verified;\n// brand != state, G-U2, spec §4/§8). Selection is encoded by the row checkbox + aria-selected, so a\n// grayscale reader reads it from the checkbox, not the fill (1.4.1). Motion is the fast token\n// transition on the verdify easing, instant under reduced motion — never the 350ms VerifiedBadge-only\n// theatre (a row hover/select is a plain transition, G-U3).\nexport const dataGridRowClass =\n \"border-b border-border-default \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"aria-selected:bg-action-secondary-bg-hover \" +\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\";\n\n// A data cell <td role=\"gridcell\"> (spec §2 cell, §4/§5). It is ONE focusable unit in the roving\n// grid: exactly one cell is the active cell (tabindex=0) and shows the visible 2px focus ring; every\n// other cell is tabindex=-1. The ring is part of the base and is NEVER removed (spec §4 Focus /\n// 2.4.7). scroll-margin keeps the active cell clear of the sticky header + pinned columns before it\n// takes focus (spec §7, 2.4.11 Focus Not Obscured). Default: the primary text color at the body type\n// role. A `mono` cell takes the monospace role and is isolated LTR so an identifier/key/timestamp\n// stays readable inside an RTL layout (spec §3/§5, G-U6). A `secondary` cell is de-emphasized\n// auxiliary text. A `status` cell carries the status fg paired with the cell's words — the status\n// color lives in the CELL only, never the row or header (spec §3), and NEVER a brand token (brand !=\n// state, G-U2); status-*-bg is the one neutral raised surface, so meaning is carried by the fg + the\n// text, not a saturated fill.\nexport const dataGridCellVariants = cva(\n [\n \"px-(--space-3) align-middle text-start text-body text-text-primary\",\n // the roving active cell's focus ring — always visible, never removed (2.4.7)\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n // keep the active cell clear of the sticky header + a pinned column before it takes focus (2.4.11)\n \"scroll-mt-(--space-12) scroll-ms-(--space-12)\",\n ],\n {\n variants: {\n ...cellPaddingVariants,\n mono: {\n // identifier/key/timestamp: the monospace role, isolated LTR inside RTL text (G-U6)\n true: \"text-mono [direction:ltr]\",\n false: \"\",\n },\n secondary: {\n // de-emphasized secondary cell text (spec §5 --color-text-secondary)\n true: \"text-text-secondary\",\n false: \"\",\n },\n status: {\n none: \"\",\n // each status is the fg only, paired with the cell's text — the words carry the meaning,\n // the fg reinforces it (spec §3/§5); never a saturated -bg fill, never the brand\n verified: \"text-status-verified-fg\",\n signal: \"text-status-signal-fg\",\n caution: \"text-status-caution-fg\",\n critical: \"text-status-critical-fg\",\n },\n },\n defaultVariants: {\n density: \"comfortable\",\n mono: false,\n secondary: false,\n status: \"none\",\n },\n },\n);\n\nexport type DataGridCellVariantProps = VariantProps<typeof dataGridCellVariants>;\n\n// A column-header cell <th role=\"columnheader\"> (spec §2 column-header / §4/§5). The header LABEL is\n// the SECONDARY text color at the label type role (the quiet, tracked column label) on the raised\n// header surface — a header NEVER wears a status or brand tint (spec §3/§8). It is also a roving\n// active-cell target, so it carries the same focus ring + scroll-margin as a data cell.\nexport const dataGridColumnHeaderVariants = cva(\n [\n \"px-(--space-3) align-middle text-start text-label text-text-secondary\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"scroll-mt-(--space-12) scroll-ms-(--space-12)\",\n ],\n {\n variants: { ...cellPaddingVariants },\n defaultVariants: { density: \"comfortable\" },\n },\n);\n\nexport type DataGridColumnHeaderVariantProps = VariantProps<typeof dataGridColumnHeaderVariants>;\n\n// The SORTABLE-header control (spec §2/§4 Sorted/§6/§7): a real <button> inside the columnheader, so\n// it reads as the control it is and toggles the sort on Enter. It is the GHOST action accent — the\n// label + caret in the ghost fg with the restrained ghost hover fill (spec §4 sortable-header hover /\n// §5) — the action tier is legitimate here because it is a control the grid HOSTS, not a header tint.\n// It carries the target-size floor (40px desktop / 44px touch, spec §7) and inherits the active\n// cell's focus ring from the columnheader. Motion is the fast token transition, never the deliberate\n// verified-check theatre (G-U3). aria-sort lives on the parent th, and the caret encodes direction\n// alongside it so it never rests on color alone (spec §4 Sorted / 1.4.1).\nexport const dataGridSortButtonClass =\n \"inline-flex items-center gap-(--space-1) -mx-(--space-1) px-(--space-1) rounded-(--radius-sm) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\";\n\n// The sort-direction caret (spec §4 Sorted / §5): the sm icon role, decorative (the direction is also\n// encoded by aria-sort on the th + the glyph's shape via data-direction, so it never rests on color\n// alone — 1.4.1). It inherits the ghost accent color from the button.\nexport const dataGridSortCaretClass =\n \"inline-flex h-(--size-icon-sm) w-(--size-icon-sm) shrink-0 items-center justify-center\";\n\n// The selection cell <td role=\"gridcell\"> and the select-all header cell (spec §2 selection-cell /\n// §5): the leading cell that holds the row's Checkbox, carrying the active-cell focus ring +\n// scroll-margin like any other cell. The checkbox is the committed Checkbox component (reused, not\n// re-rolled), which already binds the control surface + border tokens (spec §5) and the brand action\n// accent on the CHECKED box — a checked checkbox is the brand accent, never status-verified (G-U2):\n// selection is a neutral action state. This wrapper is just the cell padding + the roving focus ring.\nexport const dataGridSelectionCellClass =\n \"px-(--space-3) align-middle text-start \" +\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 \" +\n \"scroll-mt-(--space-12) scroll-ms-(--space-12)\";\n\n// The bulk-action bar (spec §2 bulk-action-bar / §4/§5): a toolbar that appears when rows are\n// selected, on the raised neutral surface with the sm elevation and a hairline top border, holding\n// the selection actions + a clear-selection control. It is a NEUTRAL surface — the COLOR lives on the\n// action buttons it holds, never on the bar (spec §3/§8). Motion is the fast token transition (the\n// bar's appear/transition), never the deliberate verified-check theatre (G-U3).\nexport const dataGridBulkBarClass =\n \"flex items-center gap-(--space-3) px-(--space-3) py-(--space-2) \" +\n \"bg-surface-raised border-t border-border-default \" +\n \"transition-opacity duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\";\n\n// The selected-count label in the bulk-action bar (spec §2): the secondary text at the label role —\n// the count of selected rows, in words, so selection is announced, never color alone (1.4.1 / 4.1.3).\nexport const dataGridBulkCountClass = \"text-label text-text-secondary\";\n\n// A bulk-action button (spec §2/§5): the PRIMARY selection action is the primary ACTION accent; a\n// `destructive` action (for example, revoke a key) is the destructive ACTION accent — a risk signal\n// named in TEXT, never a status color (spec §5/§8). Both carry the visible focus ring + target-size\n// floor. Motion is the fast token transition, never the deliberate verified-check theatre (G-U3).\nexport const dataGridBulkActionVariants = cva(\n [\n \"inline-flex items-center justify-center gap-(--space-1) rounded-(--radius-md) px-(--space-3)\",\n \"text-label font-medium cursor-pointer\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n ],\n {\n variants: {\n destructive: {\n // a destructive bulk action — a risk signal in the ACTION tier, NEVER a status token\n true: \"bg-action-destructive-bg text-action-destructive-fg border border-action-destructive-border\",\n // the default bulk action — the primary action accent\n false: \"bg-action-primary-bg text-action-primary-fg border border-action-primary-border hover:bg-action-primary-bg-hover\",\n },\n },\n defaultVariants: { destructive: false },\n },\n);\n\nexport type DataGridBulkActionVariantProps = VariantProps<typeof dataGridBulkActionVariants>;\n\n// The empty-state cell (spec §2/§4 Empty): a plain line spanning the full grid width, in the muted\n// text color — an empty grid is NOT an error and never reads as one (no status color). Its copy says\n// why it is empty and what to do next, in plain words ending in a period (spec §4 Empty / voice).\nexport const dataGridEmptyClass =\n \"px-(--space-3) py-(--space-6) text-center text-body text-text-muted\";\n\n// The off-screen-capable live region (spec §2 status-region / §7 4.1.3): announces the resolved row\n// count, sort + filter changes, and the selection count politely; a blocking row-load error\n// assertively. Always sr-only (it never paints — the visual state carries the same information for\n// sighted users), so it reaches assistive tech as TEXT, never color alone (1.4.1 / 4.1.3).\nexport const dataGridStatusRegionClass = \"sr-only\";\n",
|
|
15
|
+
"path": "data-grid/data-grid.variants.ts",
|
|
16
|
+
"target": "@ui/data-grid/data-grid.variants.ts",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "export {\n DataGrid,\n DataGridHeaderRow,\n DataGridBody,\n DataGridRow,\n DataGridColumnHeader,\n DataGridCell,\n DataGridSelectionCell,\n DataGridSelectAllCell,\n DataGridBulkActionBar,\n DataGridBulkAction,\n DataGridStatusRegion,\n DataGridEmpty,\n type DataGridProps,\n type DataGridHeaderRowProps,\n type DataGridBodyProps,\n type DataGridRowProps,\n type DataGridColumnHeaderProps,\n type DataGridCellProps,\n type DataGridSelectionCellProps,\n type DataGridSelectAllCellProps,\n type DataGridBulkActionBarProps,\n type DataGridBulkActionProps,\n type DataGridStatusRegionProps,\n type DataGridEmptyProps,\n type DataGridDensity,\n type DataGridSelection,\n type DataGridCellStatus,\n type DataGridCellControls,\n type DataGridSortDirection,\n} from \"./data-grid\";\nexport {\n dataGridVariants,\n dataGridTableClass,\n dataGridHeaderRowClass,\n dataGridRowClass,\n dataGridCellVariants,\n dataGridColumnHeaderVariants,\n dataGridSortButtonClass,\n dataGridSortCaretClass,\n dataGridSelectionCellClass,\n dataGridBulkBarClass,\n dataGridBulkCountClass,\n dataGridBulkActionVariants,\n dataGridEmptyClass,\n dataGridStatusRegionClass,\n type DataGridVariantProps,\n type DataGridCellVariantProps,\n type DataGridColumnHeaderVariantProps,\n type DataGridBulkActionVariantProps,\n} from \"./data-grid.variants\";\n",
|
|
21
|
+
"path": "data-grid/index.ts",
|
|
22
|
+
"target": "@ui/data-grid/index.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "data-grid",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn",
|
|
29
|
+
"@verdify/checkbox"
|
|
30
|
+
],
|
|
31
|
+
"title": "data-grid",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Dialog as DialogPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n dialogScrimVariants,\n dialogPanelVariants,\n dialogHeaderClass,\n dialogTitleClass,\n dialogDescriptionClass,\n dialogBodyClass,\n dialogFooterClass,\n dialogCloseVariants,\n dialogCloseGlyphClass,\n type DialogPanelVariantProps,\n} from \"./dialog.variants\";\n\ntype DialogVariant = \"standard\" | \"confirm\" | \"destructive\";\ntype DialogSize = NonNullable<DialogPanelVariantProps[\"size\"]>;\n\n// The presentation axes (spec §3) are set ONCE on the root and travel to the content via context,\n// so callers don't repeat `variant`/`size` on `DialogContent` (mirrors the Tabs/Accordion\n// precedent). `variant` decides the scrim-dismiss policy (standard dismisses on scrim click;\n// confirm/destructive do not — a decision is not lost to a stray click, spec §6); `size` sets the\n// panel's max width.\ntype DialogContextValue = {\n variant: DialogVariant;\n size: DialogSize;\n};\nconst DialogContext = React.createContext<DialogContextValue>({\n variant: \"standard\",\n size: \"md\",\n});\n\nexport interface DialogProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root> {\n /**\n * Intent (spec §3): `standard` (default — a short task or form that interrupts the context;\n * dismisses on scrim click), `confirm` (a single decision; does NOT dismiss on scrim click so a\n * choice is not lost to a stray click), or `destructive` (a confirm whose primary action is\n * irreversible — the destructive treatment lives on the confirm Button, never a red panel).\n */\n variant?: DialogVariant;\n /** Panel max width (spec §3): `sm` / `md` (default) / `lg`. The panel never exceeds the viewport. */\n size?: DialogSize;\n}\n\n/**\n * Dialog presents focused content or a decision in a layer above the page, holding attention\n * until you act or dismiss it (spec §1). Use it for a short task or confirmation that must\n * interrupt the current context; for non-blocking messages use Toast or Alert, and for a side\n * panel that does not demand a decision use Sheet. It is a NEUTRAL overlay surface: the panel and\n * scrim are neutral, brand violet appears only on a footer primary action through Button, and\n * Verified Green never appears here as decoration (spec §3 / §5 / §8, brand != state). Wraps the\n * Radix Dialog primitive (WAI-ARIA APG modal-dialog pattern) — a stateful primitive, so this file\n * is `'use client'`.\n */\nexport function Dialog({ variant = \"standard\", size = \"md\", children, ...rootProps }: DialogProps) {\n return (\n <DialogContext.Provider value={{ variant, size }}>\n <DialogPrimitive.Root {...rootProps}>{children}</DialogPrimitive.Root>\n </DialogContext.Provider>\n );\n}\n\nexport interface DialogTriggerProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger> {}\n\n/**\n * The control that opens the dialog (spec §7: focus returns here on close). Pass `asChild` to wrap\n * your own Button so the trigger inherits its role, keyboard, and focus ring rather than nesting a\n * second button.\n */\nexport const DialogTrigger = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Trigger>,\n DialogTriggerProps\n>(function DialogTrigger(props, ref) {\n return <DialogPrimitive.Trigger ref={ref} {...props} />;\n});\n\nexport interface DialogContentProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}\n\n/**\n * Renders the portal, the scrim, and the panel (spec §2 scrim + panel). The panel is\n * `role=\"dialog\"` with `aria-modal=\"true\"` (Radix), takes the focus trap, is named by its\n * `DialogTitle` via `aria-labelledby` and described by its `DialogDescription` via\n * `aria-describedby` (Radix wires both). On open, focus moves into the panel — to the first\n * meaningful control, or the panel itself when none exists — and returns to the trigger on close\n * (spec §7). Content behind the open dialog is inert. A `standard` dialog dismisses on a scrim\n * (outside) click the same way Escape does; a `confirm` or `destructive` dialog does NOT, so a\n * decision is never lost to a stray click (spec §6).\n */\nexport const DialogContent = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Content>,\n DialogContentProps\n>(function DialogContent({ className, children, ...props }, ref) {\n const { variant, size } = React.useContext(DialogContext);\n const dismissOnOutside = variant === \"standard\";\n\n // confirm/destructive: block scrim-click dismissal (and the focus-out auto-dismiss that would\n // follow a click landing outside), so a decision is only dismissed by Escape or an explicit\n // cancel (spec §6). preventDefault on the outside-pointer/interaction keeps the panel open.\n const guardOutside = dismissOnOutside\n ? undefined\n : (event: Event) => event.preventDefault();\n\n return (\n <DialogPrimitive.Portal>\n <DialogPrimitive.Overlay\n data-testid=\"dialog-scrim\"\n className={dialogScrimVariants()}\n />\n <DialogPrimitive.Content\n ref={ref}\n // The spec §7 ARIA contract names aria-modal=\"true\" explicitly. This Radix version makes\n // the rest of the page inert (pointer-events:none on body + aria-hidden on siblings) but\n // does not emit aria-modal, so we set it to honor the frozen contract literally; the panel\n // IS modal (the focus trap + inert siblings back the claim).\n aria-modal=\"true\"\n className={cn(dialogPanelVariants({ size }), className)}\n onPointerDownOutside={guardOutside}\n onInteractOutside={guardOutside}\n {...props}\n >\n {children}\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n});\n\nexport interface DialogHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/** The top region: the title and the optional close button on the inline-end (spec §2 header). */\nexport const DialogHeader = React.forwardRef<HTMLDivElement, DialogHeaderProps>(\n function DialogHeader({ className, ...props }, ref) {\n return <div ref={ref} className={cn(dialogHeaderClass, className)} {...props} />;\n },\n);\n\nexport interface DialogTitleProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {}\n\n/**\n * Names the dialog in one short statement — it IS the dialog's accessible name, wired to the panel\n * via `aria-labelledby` by Radix (spec §2 title, §7). Rendered as an `<h2>` by default at the\n * dialog's title type role.\n */\nexport const DialogTitle = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Title>,\n DialogTitleProps\n>(function DialogTitle({ className, ...props }, ref) {\n return <DialogPrimitive.Title ref={ref} className={cn(dialogTitleClass, className)} {...props} />;\n});\n\nexport interface DialogDescriptionProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> {}\n\n/**\n * Optional supporting text directly under the title, associated with the panel for screen readers\n * via `aria-describedby` (Radix) (spec §2 description, §7).\n */\nexport const DialogDescription = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Description>,\n DialogDescriptionProps\n>(function DialogDescription({ className, ...props }, ref) {\n return (\n <DialogPrimitive.Description\n ref={ref}\n className={cn(dialogDescriptionClass, className)}\n {...props}\n />\n );\n});\n\nexport interface DialogBodyProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/**\n * The scrollable content region between header and footer (spec §2 body, §3). The panel caps its\n * own height to the viewport; the body takes the remaining space and scrolls when content\n * overflows, so the panel never exceeds the viewport.\n */\nexport const DialogBody = React.forwardRef<HTMLDivElement, DialogBodyProps>(\n function DialogBody({ className, ...props }, ref) {\n return <div ref={ref} className={cn(dialogBodyClass, className)} {...props} />;\n },\n);\n\nexport interface DialogFooterProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/**\n * The action region holding the primary and any secondary/cancel action (spec §2 footer). The\n * actions are Buttons — the footer consumes the `--color-action-*` aliases THROUGH Button, which\n * the dialog spec does not restate (spec §5 note). A `destructive` dialog's confirm Button uses\n * the destructive action treatment; the panel stays neutral.\n */\nexport const DialogFooter = React.forwardRef<HTMLDivElement, DialogFooterProps>(\n function DialogFooter({ className, ...props }, ref) {\n return <div ref={ref} className={cn(dialogFooterClass, className)} {...props} />;\n },\n);\n\n// A neutral X glyph, --size-icon-md, drawn with currentColor so it inherits the close button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nfunction CloseGlyph() {\n return (\n <svg\n data-testid=\"dialog-close-glyph\"\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n className={dialogCloseGlyphClass}\n >\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nexport interface DialogCloseProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> {}\n\n/**\n * The dismiss control (spec §2 close): closing returns focus to the trigger (Radix). Two forms,\n * both proven by the tests:\n * - default (no children): the styled neutral-ghost icon-button for the header — pass an\n * `aria-label` (e.g. \"Close\"), since the glyph is decorative and the placeholder is never a name.\n * - `asChild`: wrap a footer Button (e.g. \"Cancel\") so the cancel action also dismisses without\n * nesting a second button.\n * A dialog with no in-panel dismiss must still be dismissible by Escape (spec §2) — Radix provides it.\n */\nexport const DialogClose = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Close>,\n DialogCloseProps\n>(function DialogClose({ className, children, asChild, ...props }, ref) {\n if (asChild) {\n return (\n <DialogPrimitive.Close ref={ref} asChild className={className} {...props}>\n {children}\n </DialogPrimitive.Close>\n );\n }\n return (\n <DialogPrimitive.Close\n ref={ref}\n className={cn(dialogCloseVariants(), className)}\n {...props}\n >\n {children ?? <CloseGlyph />}\n </DialogPrimitive.Close>\n );\n});\n",
|
|
10
|
+
"path": "dialog/dialog.tsx",
|
|
11
|
+
"target": "@ui/dialog/dialog.tsx",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Dialog is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents, neutrals carry the surface. The PANEL and the SCRIM are neutral; Sovereign Violet\n// appears only on a footer PRIMARY action — through Button, not here — and Verified Green never\n// appears on the dialog as decoration. A destructive confirm uses the destructive ACTION\n// treatment on its confirm button (Button), never a red panel. So NOTHING in this file binds an\n// --color-action-primary-* or --color-status-* fill (brand != state, G-U2). This is the ONLY\n// token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer behind the panel that separates the dialog from the page and\n// absorbs outside clicks (spec §2 scrim, §5 --color-scrim-*). It is a neutral dim on the modal\n// z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing, instant\n// under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre gate).\n// Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not arbitrary\n// values). On a light surface the dark scrim token applies (spec §5: scrim-dark on light).\nexport const dialogScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container holding the dialog content; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius, the lg elevation shadow above the scrim, centered on the modal z-layer.\n// It never exceeds the viewport and scrolls its BODY when content overflows (spec §3) — the\n// panel caps its own height to the viewport less the gutter; the DialogBody owns the scroll. The\n// open/close transition is the BASE duration + verdify easing, instant under reduced motion, and\n// rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). Panel padding/gaps come from --space-*.\n//\n// size = the panel's MAX WIDTH only (spec §3 sm/md/lg, md default), bound to the --container-*\n// scale; the panel is full-width up to that cap and centered. There is no fixed height — the\n// height emerges from the content up to the viewport cap.\nexport const dialogPanelVariants = cva(\n [\n // centered on the modal layer; full available width up to the size cap, with side gutters\n \"fixed left-1/2 top-1/2 z-(--z-index-modal) -translate-x-1/2 -translate-y-1/2\",\n \"flex w-[calc(100%-var(--space-8))] flex-col gap-(--space-4)\",\n // never taller than the viewport less the gutter; the body scrolls within (spec §3)\n \"max-h-[calc(100dvh-var(--space-8))]\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base open/close transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n \"data-[state=open]:scale-100 data-[state=closed]:scale-95\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n ],\n {\n variants: {\n // size = max width only (spec §3). md is the default. Bound to the --container-* scale.\n size: {\n sm: \"max-w-(--container-sm)\",\n md: \"max-w-(--container-md)\",\n lg: \"max-w-(--container-lg)\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The header: the top region holding the title and the optional close button on the inline-end.\n// Logical-property layout (G-U6); a neutral hairline divider under it continues the surface.\nexport const dialogHeaderClass =\n \"flex items-start justify-between gap-(--space-4) border-b border-border-default pb-(--space-4)\";\n\n// The title: names the dialog in one short statement; it is the accessible name (Radix wires\n// aria-labelledby). The h2 type role in primary text (spec §5 --text-h2 / --color-text-primary).\nexport const dialogTitleClass = \"text-h2 text-text-primary\";\n\n// The description: optional supporting text under the title, associated with the panel for\n// screen readers (Radix wires aria-describedby). Body type role in secondary text (spec §5).\nexport const dialogDescriptionClass = \"text-body text-text-secondary\";\n\n// The body: the scrollable content region between header and footer. The panel caps its height\n// to the viewport; the body takes the remaining space and scrolls when content overflows (spec\n// §3). Body text is the body type role in secondary text (spec §5 --text-body / text-secondary).\nexport const dialogBodyClass =\n \"min-h-0 flex-1 overflow-y-auto text-body text-text-secondary\";\n\n// The footer: the action region holding the primary and any secondary/cancel action, aligned to\n// the inline-end with a neutral hairline divider above it. The actions themselves are Buttons —\n// the dialog spec does not restate their --color-action-* bindings (spec §5 note). Logical-\n// property layout (G-U6): actions flow inline-end with a gap.\nexport const dialogFooterClass =\n \"flex items-center justify-end gap-(--space-2) border-t border-border-default pt-(--space-4)\";\n\n// The close button: the dismiss control in the header. A NEUTRAL ghost surface — the glyph in\n// --color-action-ghost-fg at rest, the restrained ghost hover fill (spec §5 ghost-fg /\n// ghost-bg-hover), the md radius, the persistent focus ring, the target-size floor (44px touch /\n// 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor, never fixed\n// below it. fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const dialogCloseVariants = cva([\n \"inline-flex items-center justify-center rounded-(--radius-md)\",\n // neutral ghost surface: glyph color at rest + restrained hover fill (no bg/border at rest)\n \"text-action-ghost-fg hover:bg-action-ghost-bg-hover\",\n // fast functional hover transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer; the close button is square at the floor (DEC-B)\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile)\",\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n]);\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the\n// button's ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const dialogCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n\nexport type DialogPanelVariantProps = VariantProps<typeof dialogPanelVariants>;\n",
|
|
16
|
+
"path": "dialog/dialog.variants.ts",
|
|
17
|
+
"target": "@ui/dialog/dialog.variants.ts",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"content": "export {\n Dialog,\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogBody,\n DialogFooter,\n DialogClose,\n type DialogProps,\n type DialogTriggerProps,\n type DialogContentProps,\n type DialogHeaderProps,\n type DialogTitleProps,\n type DialogDescriptionProps,\n type DialogBodyProps,\n type DialogFooterProps,\n type DialogCloseProps,\n} from \"./dialog\";\nexport {\n dialogPanelVariants,\n dialogScrimVariants,\n dialogCloseVariants,\n type DialogPanelVariantProps,\n} from \"./dialog.variants\";\n",
|
|
22
|
+
"path": "dialog/index.ts",
|
|
23
|
+
"target": "@ui/dialog/index.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "dialog",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "dialog",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Avatar, type AvatarSize } from \"@/components/ui/avatar\";\nimport { AgentBadge, type AgentBadgeProps } from \"@/components/ui/agent-badge\";\nimport { VerifiedBadge, type VerifiedBadgeProps } from \"@/components/ui/verified-badge\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n identityChipVariants,\n identityChipNameClass,\n identityChipSecondaryClass,\n identityChipTextClass,\n identityChipRemoveControlClass,\n identityChipRemoveGlyphClass,\n type IdentityChipVariantProps,\n} from \"./identity-chip.variants\";\n\n/** How an IdentityChip is used (spec §3). Defaults to `static`. */\nexport type IdentityChipVariant = NonNullable<IdentityChipVariantProps[\"variant\"]>;\n\n/**\n * The optional AgentBadge shown when, and only when, the identity is an AI agent (spec §2). Pass\n * the badge's own props — its `aria-label` (e.g. \"AI agent\") names the actor kind for assistive\n * technology, so an agent is never read as a human (spec §7/§8). The chip reserves the position\n * and never absorbs the badge's meaning into itself; a human identity passes nothing here.\n */\nexport type IdentityChipAgent = Omit<AgentBadgeProps, \"className\">;\n\n/**\n * The optional VerifiedBadge shown when the identity's verification is worth surfacing inline\n * (spec §2). Pass the badge's own props — it owns the verified-status treatment and the green\n * status color end to end, and carries its OWN accessible name (its visible `label`, or an\n * `aria-label` when shown check-only). The chip only reserves the position and never paints a\n * verified signal itself (spec §1/§2).\n */\nexport type IdentityChipVerified = Omit<VerifiedBadgeProps, \"className\">;\n\n// The default decorative remove glyph (spec §2/§5): a small ✕ at the sm icon role, inheriting the\n// remove-control's action-ghost foreground via currentColor. The accessible name lives on the\n// control (\"Remove {name}\"), so the glyph is aria-hidden.\nfunction RemoveGlyph() {\n return (\n <svg\n className={identityChipRemoveGlyphClass}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n >\n <path d=\"M18 6 6 18\" />\n <path d=\"m6 6 12 12\" />\n </svg>\n );\n}\n\nexport interface IdentityChipProps\n extends Omit<React.HTMLAttributes<HTMLSpanElement>, \"color\">,\n IdentityChipVariantProps {\n /**\n * The identity's display name (spec §2 `name`), sentence case, no all-caps. It carries the\n * identity in the accessibility tree as text and is the chip's accessible name; it truncates\n * with an ellipsis when the chip is width-constrained rather than being dropped (spec §2/§7).\n * Required.\n */\n name: string;\n /**\n * The text alternative for the composed Avatar (spec §2/§7) — normally the same as {@link name}.\n * The Avatar is marked DECORATIVE inside the chip so the identity is announced once (the visible\n * name), not twice; this value is the picture's alternative if the chip's name is ever absent.\n * Defaults to {@link name}.\n */\n avatarAlt?: string;\n /**\n * The picture for the identity (spec §2 `avatar`). Cropped to the Avatar shape when it loads;\n * falls back to the name initials, then a neutral glyph. The picture is never the only signal of\n * who the identity is — the visible name always carries it (spec §7).\n */\n src?: string;\n /**\n * One supporting line under the name (spec §2 `secondary`) — a handle, a role, or the profile\n * context — to disambiguate two identities that share a name. Supporting detail only: never a\n * credential value and never a status.\n */\n secondary?: React.ReactNode;\n /**\n * The AgentBadge props shown when, and only when, the identity is an AI agent (spec §2/§7/§8).\n * Its `aria-label` names the actor kind. When the chip is `interactive`, the actor kind is also\n * folded into the control's accessible name — \"Atlas (AI agent)\" — so a screen-reader user can\n * always tell an agent from a human. A human identity passes nothing here.\n */\n agent?: IdentityChipAgent;\n /**\n * The VerifiedBadge props shown when the identity's verification is worth surfacing inline (spec\n * §2). The badge owns the verified-status treatment and its own accessible name; the chip never\n * paints a verified signal itself (spec §1/§2/§5).\n */\n verified?: IdentityChipVerified;\n /**\n * Fires when a `removable` chip's trailing remove-control is activated (spec §2/§6). Required for\n * the `removable` variant — it is the chip's one focusable part. In a chip field this removes the\n * token; the surrounding field owns where focus moves next (spec §7).\n */\n onRemove?: () => void;\n /**\n * Override the remove-control's accessible name (spec §7). Defaults to `\"Remove {name}\"` so a\n * screen-reader user is never asked to remove an unnamed thing. `removable` variant only.\n */\n removeLabel?: string;\n /**\n * Disable the `interactive` / `removable` control (spec §4 Disabled). The identity stays named\n * and pictured so it remains legible; only the action is withdrawn. The `interactive` body sets\n * `aria-disabled` and suppresses activation; the `removable` control uses the native `disabled`.\n */\n disabled?: boolean;\n /**\n * The identity itself is still resolving (spec §4 Loading) — for example a chip rendered from an\n * ID before the profile loads. Shows a Skeleton in the chip's shape and marks the chip region\n * `aria-busy`, rather than a chip with a guessed name or a borrowed picture; the real chip\n * replaces it when the identity resolves (spec §4/§7).\n */\n loading?: boolean;\n}\n\n/**\n * An IdentityChip is a compact inline reference to a single identity — a person or an AI agent —\n * that names and pictures who or what an entry belongs to (spec §1). Its one job is to place an\n * identity in a flow: an actor in an audit row, a recipient in a share field, the current profile\n * in an account switcher. It carries the person or the agent, never their login and never their\n * proof.\n *\n * It encodes the platform invariant that identity is NOT credentials: the chip says *who*, while a\n * CredentialCard says *what an identity can prove* — the chip never asserts a verification result,\n * a key, or a document on its own (spec §1/§8). The verified status is the composed VerifiedBadge,\n * with its own meaning and accessible name; the chip only reserves the position and binds nothing\n * from the status tier. One identity can have many profiles, so the same person may appear as\n * different chips in different contexts; a chip pictures the profile in view, not the whole\n * identity behind it (spec §1).\n *\n * It COMPOSES the committed primitives rather than reinventing them — the Avatar for the picture\n * (marked decorative so the identity is announced once), the optional AgentBadge for the actor\n * kind, the optional VerifiedBadge for a surfaced verification, and a Skeleton in the chip's shape\n * while the identity is still resolving — so each composed part owns its own tokens, motion, and\n * accessible name. When the actor is an AI agent, the chip hosts the AgentBadge so the kind of\n * actor is explicit and an agent never reads as a human (spec §1/§7/§8).\n *\n * brand != state (spec §3/§5/§8): a chip carries no status of its own, so the body paints from\n * NEUTRAL surface / text / border roles — never the verified-status green (that lives in the\n * VerifiedBadge) and never the brand violet as a fill (the brand is not a status and a chip is not\n * an action). A chip is never made interactive by styling alone: the `interactive` and `removable`\n * variants are real controls with a real role, target size, focus ring, and keyboard model (spec\n * §3/§4/§6/§7).\n *\n * It is `'use client'` for the `React.useId()` that mints the id wiring an interactive chip's\n * folded accessible name (any hook makes the file a client component); the composed Avatar /\n * AgentBadge / VerifiedBadge own their own state independently.\n */\nexport const IdentityChip = React.forwardRef<HTMLSpanElement, IdentityChipProps>(\n function IdentityChip(\n {\n className,\n variant = \"static\",\n name,\n avatarAlt,\n src,\n secondary,\n agent,\n verified,\n onRemove,\n removeLabel,\n disabled = false,\n loading = false,\n \"aria-label\": ariaLabelProp,\n ...props\n },\n ref,\n ) {\n const reactId = React.useId();\n const nameId = `${reactId}-name`;\n const agentNameId = `${reactId}-agent`;\n const alt = avatarAlt ?? name;\n const avatarSize: AvatarSize = \"sm\";\n\n // LOADING (spec §4/§7): a Skeleton in the chip's shape while the identity resolves. The chip\n // REGION owns the wait (aria-busy on the chip), and the Skeleton is decorative (aria-hidden),\n // so a screen reader hears the wait once, not the placeholder shapes. Never a chip with a\n // guessed name or a borrowed picture — show the placeholder until the identity resolves.\n if (loading) {\n return (\n <span\n ref={ref}\n data-testid=\"identity-chip\"\n aria-busy=\"true\"\n className={cn(identityChipVariants({ variant: \"static\" }), \"min-h-(--space-9)\", className)}\n {...props}\n >\n <Skeleton\n variant=\"circle\"\n data-testid=\"identity-chip-skeleton\"\n className=\"h-(--space-7) w-(--space-7)\"\n />\n <Skeleton variant=\"text\" className=\"w-(--space-20)\" />\n </span>\n );\n }\n\n // The composed Avatar (spec §2/§7): pictures the profile in view. It is marked DECORATIVE\n // inside the chip so the identity is announced once — by the visible name — not twice. It\n // carries no verified or status signal of its own (identity != credentials).\n const avatar = (\n <Avatar\n data-testid=\"identity-chip-avatar\"\n decorative\n size={avatarSize}\n src={src}\n alt={alt}\n name={name}\n />\n );\n\n // The name + optional supporting line (spec §2/§5). The name is the LABEL role + primary\n // color, truncatable; the secondary line is the caption role + secondary color. The name\n // carries the identity in the tree as text (1.3.1) and is the chip's accessible name.\n const text = (\n <span className={identityChipTextClass}>\n <span id={nameId} className={identityChipNameClass}>\n {name}\n </span>\n {secondary != null ? (\n <span className={identityChipSecondaryClass}>{secondary}</span>\n ) : null}\n </span>\n );\n\n // The composed AgentBadge (spec §2/§7/§8): shown only for an AI agent. It owns its own meaning\n // and accessible name; the chip reserves the position and never absorbs the badge's meaning.\n const agentBadge =\n agent != null ? (\n <AgentBadge data-testid=\"identity-chip-agent\" {...agent} />\n ) : null;\n\n // The composed VerifiedBadge (spec §1/§2/§5): shown when verification is worth surfacing\n // inline. It owns the verified-status treatment and the green status color end to end and\n // carries its OWN accessible name — the chip only reserves the position and paints no green.\n const verifiedBadge =\n verified != null ? (\n <VerifiedBadge data-testid=\"identity-chip-verified\" {...verified} />\n ) : null;\n\n // INTERACTIVE (spec §3/§6/§7): the chip body is itself a control — a native <button> in place,\n // or a native <a> when it navigates to a profile by URL — so it exposes the button/link role\n // and is operable without extra wiring, and it gets the focus ring + target-size floor + ghost\n // hover/press fill from the variant. The accessible name is the display name, and — when an\n // AgentBadge is present — the actor kind is FOLDED IN (\"Atlas (AI agent)\") so a screen-reader\n // user can always tell an agent from a human (spec §7). A disabled interactive chip is\n // aria-disabled with activation suppressed; the identity stays named and pictured (spec §4).\n if (variant === \"interactive\") {\n // a navigating chip carries an href on the rest props; pull it out so the button branch\n // never receives it (href is only valid on the <a> branch).\n const { href, onClick: propOnClick, ...interactiveProps } = props as React.HTMLAttributes<HTMLElement> & {\n href?: string;\n };\n // fold the actor kind into the accessible name when an agent is present, so it is heard\n const agentName =\n agent != null && typeof agent[\"aria-label\"] === \"string\"\n ? agent[\"aria-label\"]\n : undefined;\n const accessibleName = agentName ? `${name} (${agentName})` : name;\n const classes = cn(identityChipVariants({ variant: \"interactive\" }), className);\n // the AgentBadge inside an interactive control is decorative — its meaning is folded into the\n // control's accessible name above, so it is not announced a second time from inside the button\n const innerAgent =\n agent != null ? (\n <span id={agentNameId} aria-hidden=\"true\">\n <AgentBadge data-testid=\"identity-chip-agent\" {...agent} aria-label={undefined} />\n </span>\n ) : null;\n\n // navigate by URL → native <a>; act in place → native <button> (spec §7).\n if (href !== undefined && !disabled) {\n return (\n <a\n ref={ref as React.Ref<HTMLAnchorElement>}\n data-testid=\"identity-chip\"\n href={href}\n aria-label={ariaLabelProp ?? accessibleName}\n className={classes}\n onClick={propOnClick as React.MouseEventHandler<HTMLAnchorElement>}\n {...(interactiveProps as React.AnchorHTMLAttributes<HTMLAnchorElement>)}\n >\n {avatar}\n {text}\n {innerAgent}\n {verifiedBadge}\n </a>\n );\n }\n return (\n <button\n ref={ref as React.Ref<HTMLButtonElement>}\n type=\"button\"\n data-testid=\"identity-chip\"\n aria-label={ariaLabelProp ?? accessibleName}\n aria-disabled={disabled || undefined}\n className={classes}\n // a disabled interactive chip is aria-disabled (still named/pictured) and inert — the\n // identity stays legible, only the action is withdrawn (spec §4). DEC-C dim via token.\n onClick={\n disabled\n ? (e: React.MouseEvent) => e.preventDefault()\n : (propOnClick as React.MouseEventHandler<HTMLButtonElement>)\n }\n {...(interactiveProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}\n >\n {avatar}\n {text}\n {innerAgent}\n {verifiedBadge}\n </button>\n );\n }\n\n // REMOVABLE (spec §2/§3/§4/§6): a token in an editable field. The chip BODY stays\n // PRESENTATIONAL (no focus ring / target floor of its own) — the trailing remove-control is the\n // one focusable part and owns its activation, target size, and focus ring. It is removed on\n // click, Backspace, or Delete when focused; a disabled control is native-disabled and inert.\n if (variant === \"removable\") {\n const removeName = removeLabel ?? `Remove ${name}`;\n const handleRemove = () => {\n if (!disabled) onRemove?.();\n };\n return (\n <span\n ref={ref}\n data-testid=\"identity-chip\"\n className={cn(identityChipVariants({ variant: \"removable\" }), className)}\n {...props}\n >\n {avatar}\n {text}\n {agentBadge}\n {verifiedBadge}\n <button\n type=\"button\"\n data-testid=\"identity-chip-remove\"\n aria-label={removeName}\n disabled={disabled}\n className={identityChipRemoveControlClass}\n onClick={handleRemove}\n onKeyDown={(e) => {\n // Backspace / Delete remove the chip when its control has focus (spec §6)\n if (e.key === \"Backspace\" || e.key === \"Delete\") {\n e.preventDefault();\n handleRemove();\n }\n }}\n >\n <RemoveGlyph />\n </button>\n </span>\n );\n }\n\n // STATIC (default, spec §3/§4/§6): a read-only inline reference. Non-interactive — no role, no\n // tabIndex, no focus ring, no target floor; skipped in the tab order. A native <span> grouping\n // the composed Avatar + the name text (+ any badges); the name is the accessible name.\n return (\n <span\n ref={ref}\n data-testid=\"identity-chip\"\n className={cn(identityChipVariants({ variant: \"static\" }), className)}\n {...props}\n >\n {avatar}\n {text}\n {agentBadge}\n {verifiedBadge}\n </span>\n );\n },\n);\n",
|
|
9
|
+
"path": "identity-chip/identity-chip.tsx",
|
|
10
|
+
"target": "@ui/identity-chip/identity-chip.tsx",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// An IdentityChip is a compact inline reference to a single identity — a person or an AI agent\n// (spec §1). It COMPOSES the committed primitives — the Avatar for the picture, the optional\n// AgentBadge for the actor kind, the optional VerifiedBadge for a surfaced verification, and a\n// Skeleton in the chip's shape while the identity is still resolving — rather than reinventing\n// any of them. This file binds ONLY the chip body's neutral surface + text-role + layout classes\n// and (for the interactive variants) the ghost hover/press fill, the focus ring, the target-size\n// floor, and the fast functional transition; every composed primitive owns its own tokens.\n//\n// brand != state (spec §3/§5/§8). A chip carries NO status of its own — it reports who, never a\n// result — so the chip body consumes NOTHING from the status tier: no --color-status-* token is\n// bound here, and the verified-status green lives entirely inside the composed VerifiedBadge,\n// never painted on the chip. The brand violet (Sovereign Violet, the action accent) is never a\n// chip FILL either: the brand is not a status and a chip is not an action, so the body paints\n// from neutral surface / text / border roles. The interactive variants are real controls, so\n// they legitimately bind the ACTION-GHOST hover/press fill and the focus ring — the restrained\n// neutral control treatment, never a brand-colored or status-colored chip.\n//\n// The motion is the functional fast transition on verdify easing, collapsing to the instant\n// endpoint under reduced motion — never the 350ms VerifiedBadge-only theatre duration: a chip\n// is not a verification (G-U3).\n\n// The chip container (spec §2/§3/§4/§5). A self-contained rounded unit that holds the avatar,\n// the name, and the reserved badge positions in a single inline row at the --space-1 gap with\n// --space-2 inline padding, on the NEUTRAL raised surface with the muted hairline that separates\n// it from a same-colored surface. `relative` so a loading-state Skeleton placed inside can sit\n// `absolute inset-0` (the committed Avatar/Skeleton pattern). `min-w-0` so the name can truncate\n// rather than overflow when the chip is width-constrained (spec §2 — the name is never dropped).\n//\n// The `variant` axis is about HOW the chip is used (spec §3), never status and never a brand\n// fill — so NONE of the variants recolors the body. `static` (default) is a passive frame: no\n// focus, no hit target, no hover (spec §3/§4/§6). `interactive` and `removable` are real controls\n// and add the ghost hover/press fill, the focus ring, the target-size floor, and the fast\n// functional transition — the difference is the keyboard model and which element takes focus\n// (the body for `interactive`, the trailing remove-control for `removable`), carried by the tsx,\n// not by recoloring here.\nexport const identityChipVariants = cva(\n [\n // shape / layout: a single inline row, never shrinking in a flex line, names truncatable\n \"inline-flex min-w-0 shrink-0 items-center gap-(--space-1) px-(--space-2)\",\n // full radius + the neutral raised surface + the muted separating hairline (spec §5)\n \"rounded-(--radius-full) bg-surface-raised border border-surface-border-muted\",\n // relative so a loading Skeleton can be positioned absolute inset-0 inside (Avatar pattern)\n \"relative\",\n // global-first: never wrap the row; logical alignment so it mirrors under dir=rtl (G-U6)\n \"whitespace-nowrap text-start\",\n ],\n {\n variants: {\n // STRUCTURAL axis = spec §3 (how the chip is USED, never status, never a brand fill).\n variant: {\n // static (default): a read-only inline reference — non-interactive, no focus, no hit\n // target, no hover. The common case (spec §3).\n static: \"\",\n // interactive: the chip body is itself a control (an account-switcher trigger, a chip\n // that opens the profile). It takes the focus ring, the target-size floor, the restrained\n // ghost hover/press fill, and the fast functional transition (spec §3/§4). DEC-C: a\n // disabled control dims the label/name via the disabled TOKEN, never a blanket opacity.\n interactive: [\n \"cursor-pointer\",\n // fast functional motion on verdify easing; instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer (spec §5/§7, WCAG 2.5.8)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // restrained ghost hover/press fill — quiet neutral, never a status or brand color (spec §4)\n \"hover:bg-action-ghost-bg-hover active:bg-action-ghost-bg-hover\",\n // focus ring — always visible, never removed (spec §4/§7)\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n // disabled — DEC-C: dim the label via the disabled TOKEN, inert, never opacity-60 (spec §4)\n \"aria-disabled:pointer-events-none aria-disabled:text-text-disabled\",\n ],\n // removable: a token in an editable field. The chip BODY stays presentational (no focus\n // ring / target floor of its own) — the trailing remove-control is the focusable part and\n // owns the target size + focus ring (set on removeControlClass). The body keeps the static\n // surface (spec §3/§4).\n removable: \"\",\n },\n },\n defaultVariants: { variant: \"static\" },\n },\n);\n\n// The display name (spec §2/§5): the identity's name in the LABEL type role + the PRIMARY text\n// color. It carries the identity in the accessibility tree as text, so it is never the only part\n// dropped at small sizes — it truncates with an ellipsis rather than disappearing (spec §2/§7).\nexport const identityChipNameClass = \"min-w-0 truncate text-label text-text-primary\";\n\n// The optional supporting line (spec §2/§5): one handle / role / profile-context line that\n// disambiguates two identities sharing a name, in the CAPTION type role + the SECONDARY text\n// color. Supporting detail only — never a credential value and never a status color.\nexport const identityChipSecondaryClass = \"min-w-0 truncate text-caption text-text-secondary\";\n\n// The text block (spec §2): stacks the name above the optional secondary line; takes the\n// remaining inline space between the avatar and the reserved badge positions and lets the name\n// truncate (min-w-0) rather than overflow.\nexport const identityChipTextClass = \"flex min-w-0 flex-col\";\n\n// The trailing remove-control of the `removable` variant (spec §2/§4/§5/§6). It is the ONE\n// focusable part of a removable chip and owns its own activation, so it carries the target-size\n// floor, the focus ring, and the ghost hover/press fill — the control treatment lives HERE, not\n// on the chip body. The glyph takes the action-ghost foreground at the sm icon role; under reduced\n// motion the transition collapses to the instant endpoint. DEC-C: a disabled remove dims via the\n// disabled TOKEN, never a blanket opacity.\nexport const identityChipRemoveControlClass =\n \"inline-flex shrink-0 items-center justify-center rounded-(--radius-full) \" +\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile) \" +\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop) \" +\n \"text-action-ghost-fg cursor-pointer \" +\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"hover:bg-action-ghost-bg-hover active:bg-action-ghost-bg-hover \" +\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The remove-control glyph (spec §5): one small decorative mark at the sm icon role, inheriting\n// the control's action-ghost foreground via currentColor.\nexport const identityChipRemoveGlyphClass = \"h-(--size-icon-sm) w-(--size-icon-sm)\";\n\nexport type IdentityChipVariantProps = VariantProps<typeof identityChipVariants>;\n",
|
|
15
|
+
"path": "identity-chip/identity-chip.variants.ts",
|
|
16
|
+
"target": "@ui/identity-chip/identity-chip.variants.ts",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "export {\n IdentityChip,\n type IdentityChipProps,\n type IdentityChipVariant,\n type IdentityChipAgent,\n type IdentityChipVerified,\n} from \"./identity-chip\";\nexport {\n identityChipVariants,\n identityChipNameClass,\n identityChipSecondaryClass,\n identityChipTextClass,\n identityChipRemoveControlClass,\n identityChipRemoveGlyphClass,\n type IdentityChipVariantProps,\n} from \"./identity-chip.variants\";\n",
|
|
21
|
+
"path": "identity-chip/index.ts",
|
|
22
|
+
"target": "@ui/identity-chip/index.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "identity-chip",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn",
|
|
29
|
+
"@verdify/agent-badge",
|
|
30
|
+
"@verdify/avatar",
|
|
31
|
+
"@verdify/skeleton",
|
|
32
|
+
"@verdify/verified-badge"
|
|
33
|
+
],
|
|
34
|
+
"title": "identity-chip",
|
|
35
|
+
"type": "registry:ui"
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"css": {
|
|
4
|
+
"@import \"@verdify/tokens/preset\"": {}
|
|
5
|
+
},
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@verdify/tokens@^0.6.0"
|
|
8
|
+
],
|
|
9
|
+
"extends": "none",
|
|
10
|
+
"files": [],
|
|
11
|
+
"name": "init",
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"@verdify/cn"
|
|
14
|
+
],
|
|
15
|
+
"title": "Verdify base",
|
|
16
|
+
"type": "registry:style"
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "export { Input, type InputProps } from \"./input\";\nexport {\n inputVariants,\n inputMessageVariants,\n type InputVariantProps,\n} from \"./input.variants\";\n",
|
|
9
|
+
"path": "input/index.ts",
|
|
10
|
+
"target": "@ui/input/index.ts",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "import * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport {\n inputVariants,\n inputMessageVariants,\n type InputVariantProps,\n} from \"./input.variants\";\n\nexport interface InputProps\n extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"size\">,\n Pick<InputVariantProps, \"size\"> {\n /** Required: the field is wired to a sibling <label for={id}> (or aria-label). */\n id: string;\n /** Icon/prefix inside the inline-start edge (search glyph, currency mark). */\n leading?: React.ReactNode;\n /** Icon/affordance inside the inline-end edge (clear, reveal, unit suffix). */\n trailing?: React.ReactNode;\n /** Neutral description below the field, wired by aria-describedby. */\n help?: React.ReactNode;\n /** Error message below the field: sets aria-invalid + critical border, wired\n * by aria-describedby so the reason is read with the field. */\n error?: React.ReactNode;\n}\n\n// Render-only native control: error/disabled/readOnly are props, not internal\n// state, and there is no hook, effect, or stateful Radix primitive — so NO\n// 'use client' directive (input.md §7: the native <input> role, no composite widget).\nexport const Input = React.forwardRef<HTMLInputElement, InputProps>(\n function Input(\n {\n id,\n className,\n size,\n leading,\n trailing,\n help,\n error,\n \"aria-describedby\": describedBy,\n ...props\n },\n ref,\n ) {\n const invalid = error != null && error !== false;\n const showMessage = help != null || invalid;\n const messageId = showMessage ? `${id}-message` : undefined;\n // caller-provided aria-describedby first, then the generated message id —\n // both are read with the field; collapse to undefined when neither is set.\n const describedByValue =\n [describedBy, messageId].filter(Boolean).join(\" \") || undefined;\n return (\n <div className=\"w-full\">\n <div className=\"relative flex items-center\">\n {leading ? (\n <span\n aria-hidden=\"true\"\n className={cn(\n \"pointer-events-none absolute start-0 flex items-center justify-center\",\n \"ps-(--space-3) text-control-placeholder text-(length:--size-icon-sm)\",\n )}\n >\n {leading}\n </span>\n ) : null}\n <input\n ref={ref}\n id={id}\n aria-invalid={invalid || undefined}\n aria-describedby={describedByValue}\n className={cn(\n inputVariants({\n size,\n leadingSlot: !!leading,\n trailingSlot: !!trailing,\n }),\n className,\n )}\n {...props}\n />\n {trailing ? (\n <span className=\"absolute end-0 flex items-center justify-center pe-(--space-2) text-(length:--size-icon-sm)\">\n {trailing}\n </span>\n ) : null}\n </div>\n {showMessage ? (\n <p\n id={messageId}\n className={inputMessageVariants({ tone: invalid ? \"error\" : \"help\" })}\n >\n {invalid ? error : help}\n </p>\n ) : null}\n </div>\n );\n },\n);\n",
|
|
15
|
+
"path": "input/input.tsx",
|
|
16
|
+
"target": "@ui/input/input.tsx",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// The text field. Token binding lives ONLY here. Native <input>, no Radix.\n// The closed state set for a text field is default·hover·focus·disabled·read-only·error\n// (input.md §4) — loading and pressed do NOT apply and are dropped.\nexport const inputVariants = cva(\n [\n // shape + resting field: control-* carries the field, neutrals not brand\n \"block w-full rounded-md border bg-control-bg text-control-fg\",\n \"border-control-border placeholder:text-control-placeholder\",\n // DEC-A — the value SIZE is text-base (16px) so iOS never zooms on focus; the\n // brand BODY line-height + letter-spacing ride along via the role-suffix vars.\n // text-body itself (0.9375rem / 15px) is NEVER bound on a form field: under the\n // role-aware cn it would collapse against text-base, and 15px would reintroduce\n // the iOS focus-zoom that the 16px reset exists to prevent.\n \"text-base leading-(--text-body--line-height) tracking-(--text-body--letter-spacing)\",\n // hover shows a text caret; the border does NOT change color (restraint)\n \"cursor-text\",\n // focus: visible 2px signal-blue ring at 2px offset + focused border, never\n // removed (2.4.7); border+ring meet 3:1 non-text contrast (1.4.11)\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"focus-visible:border-border-focus\",\n // colors transition functionally (no theatre); border + ring only\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n // disabled: muted value, non-interactive (native disabled drives tab skip)\n \"disabled:cursor-not-allowed disabled:text-text-disabled\",\n // read-only: editable-looking, selectable, stays in the tab order\n \"read-only:cursor-default\",\n // ERROR is the only colored field state — it borrows the STATUS color, never\n // the brand (§3, §8). Driven by the native aria-invalid attribute.\n \"aria-invalid:border-status-critical-border\",\n \"aria-invalid:focus-visible:ring-status-critical-border\",\n // 44px mobile / 40px desktop target floor, logical block-size. DEC-B: tokens\n // expose only target-size FLOORS, no height scale — every size anchors this\n // floor and never sets a fixed height below it (a11y). Resting height emerges\n // from the size variant's vertical padding above this floor.\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n ],\n {\n variants: {\n // DEC-B — the 16px no-zoom reset is a hard floor on every form-field size, so\n // (unlike a non-field control) the type role is held constant and the sizes\n // differ ONLY by vertical padding (density) ABOVE the shared target floor:\n // --space-1 (0.25rem) <= --space-2 (0.5rem) gives a coherent sm <= md height\n // progression, both >= the floor.\n size: {\n md: \"py-(--space-2)\",\n sm: \"py-(--space-1)\",\n },\n // logical inline padding; widened on the slot side to reserve room\n leadingSlot: { true: \"ps-(--space-9)\", false: \"ps-(--space-3)\" },\n trailingSlot: { true: \"pe-(--space-9)\", false: \"pe-(--space-3)\" },\n },\n defaultVariants: { size: \"md\", leadingSlot: false, trailingSlot: false },\n },\n);\n\nexport type InputVariantProps = VariantProps<typeof inputVariants>;\n\n// The message below the field. The error help text borrows the field's STATUS\n// color (the only colored field state); neutral help text is muted secondary.\nexport const inputMessageVariants = cva(\"mt-(--space-1) text-caption\", {\n variants: {\n tone: { help: \"text-text-secondary\", error: \"text-status-critical-fg\" },\n },\n defaultVariants: { tone: \"help\" },\n});\n",
|
|
21
|
+
"path": "input/input.variants.ts",
|
|
22
|
+
"target": "@ui/input/input.variants.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "input",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn"
|
|
29
|
+
],
|
|
30
|
+
"title": "input",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "export { Label, type LabelProps } from \"./label\";\nexport {\n labelVariants,\n requiredMarkVariants,\n optionalHintVariants,\n type LabelVariantProps,\n} from \"./label.variants\";\n",
|
|
9
|
+
"path": "label/index.ts",
|
|
10
|
+
"target": "@ui/label/index.ts",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "import * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport {\n labelVariants,\n requiredMarkVariants,\n optionalHintVariants,\n type LabelVariantProps,\n} from \"./label.variants\";\n\nexport interface LabelProps\n extends React.LabelHTMLAttributes<HTMLLabelElement>,\n Omit<LabelVariantProps, \"disabled\"> {\n /**\n * Show the required mark. Presentation only — the field's required state is\n * carried by the control's `required` / `aria-required`, not by this mark.\n * Mutually exclusive with `optional`; `required` wins.\n */\n required?: boolean;\n /** Show the optional hint instead of a mark. Ignored when `required` is set. */\n optional?: boolean;\n /** Reflect the associated control's disabled state (renders disabled text). */\n disabled?: boolean;\n}\n\nexport const Label = React.forwardRef<HTMLLabelElement, LabelProps>(\n function Label(\n { className, required = false, optional = false, disabled = false, children, ...props },\n ref,\n ) {\n return (\n // native <label>; associate with its control via the `htmlFor`/`id` props.\n // No role override, no tabIndex, no focus ring — not an interactive control.\n <label ref={ref} className={cn(labelVariants({ disabled }), className)} {...props}>\n {children}\n {required ? (\n <span data-testid=\"label-required-mark\" className={cn(requiredMarkVariants())}>\n {/* decorative glyph — the meaning is the word, not the color */}\n <span data-testid=\"label-required-glyph\" aria-hidden=\"true\">\n *\n </span>\n {/* visually hidden so the mark reads \"required\" to assistive tech */}\n <span className=\"sr-only\">required</span>\n </span>\n ) : optional ? (\n <span data-testid=\"label-optional-hint\" className={cn(optionalHintVariants())}>\n optional\n </span>\n ) : null}\n </label>\n );\n },\n);\n",
|
|
15
|
+
"path": "label/label.tsx",
|
|
16
|
+
"target": "@ui/label/label.tsx",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Resting label text: the label type role + primary text color, laid out inline\n// with its mark/hint at the --space-2 gap. No focus ring, no target-size floor —\n// a Label is not interactive. `disabled` reflects the control's state visually.\nexport const labelVariants = cva(\n [\n \"inline-flex items-center gap-(--space-2)\",\n \"text-label font-medium text-text-primary select-none\",\n ],\n {\n variants: {\n disabled: {\n // reflects the associated control's disabled state; stays in the DOM\n true: \"text-text-disabled\",\n false: \"\",\n },\n },\n defaultVariants: { disabled: false },\n },\n);\n\n// The required mark — meaning carried by shape + text, never color alone. The\n// critical color is permitted ONLY here (paired with the asterisk glyph and the\n// visually-hidden \"required\" word), never on resting label text.\nexport const requiredMarkVariants = cva([\n \"inline-flex items-center gap-(--space-1) text-status-critical-fg\",\n]);\n\n// The optional hint — a short secondary note in the caption role + secondary color.\nexport const optionalHintVariants = cva([\"text-caption text-text-secondary\"]);\n\nexport type LabelVariantProps = VariantProps<typeof labelVariants>;\n",
|
|
21
|
+
"path": "label/label.variants.ts",
|
|
22
|
+
"target": "@ui/label/label.variants.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "label",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn"
|
|
29
|
+
],
|
|
30
|
+
"title": "label",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "export {\n Menu,\n MenuTrigger,\n MenuContent,\n MenuItem,\n MenuGroup,\n MenuLabel,\n MenuSeparator,\n MenuSub,\n MenuSubTrigger,\n MenuSubContent,\n type MenuProps,\n type MenuTriggerProps,\n type MenuContentProps,\n type MenuItemProps,\n type MenuGroupProps,\n type MenuLabelProps,\n type MenuSeparatorProps,\n type MenuSubProps,\n type MenuSubTriggerProps,\n type MenuSubContentProps,\n} from \"./menu\";\nexport {\n menuTriggerClass,\n menuPopupClass,\n menuItemVariants,\n menuItemIconClass,\n menuItemShortcutClass,\n menuSubChevronClass,\n menuLabelClass,\n menuSeparatorClass,\n type MenuItemVariantProps,\n} from \"./menu.variants\";\n",
|
|
10
|
+
"path": "menu/index.ts",
|
|
11
|
+
"target": "@ui/menu/index.ts",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n menuTriggerClass,\n menuPopupClass,\n menuItemVariants,\n menuItemIconClass,\n menuItemShortcutClass,\n menuSubChevronClass,\n menuLabelClass,\n menuSeparatorClass,\n type MenuItemVariantProps,\n} from \"./menu.variants\";\n\nexport interface MenuProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root> {}\n\n/**\n * Menu is a popup list of ACTIONS that a trigger opens — the row of commands behind a button, an\n * avatar, or a row's overflow control (spec §1). Reach for it when you want to fire an action (open,\n * rename, sign out, revoke a key), not pick a value: use Select to choose one option from a list, and\n * use the Sidebar for page-level navigation. Each item runs a command and then the menu closes.\n *\n * It is a NEUTRAL surface (spec §3): the popup, items, and separators are neutral, and the brand\n * violet never marks an item as \"the special one.\" The one colored item is the destructive item,\n * which takes the destructive ACTION treatment because the command it runs is irreversible — a risk\n * signal, not a status result; a verified result is never reported by a menu item (brand != state).\n *\n * Wraps the Radix DropdownMenu primitive (WAI-ARIA APG menu-button + menu pattern), which provides\n * the portal, roving tabindex, type-ahead, submenu, and Escape/arrow keyboard model — a stateful\n * primitive, so this file is `'use client'`.\n */\nexport function Menu(props: MenuProps) {\n return <DropdownMenuPrimitive.Root {...props} />;\n}\n\nexport interface MenuTriggerProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> {}\n\n/**\n * The control that opens the menu (spec §2 trigger): the one stop in the page tab order for this\n * control, carrying the focus ring. Radix sets `aria-haspopup=\"menu\"`, `aria-expanded`, and\n * `aria-controls` (pointing at the popup) for you. Pass `asChild` to wrap your own Button so the\n * trigger inherits its role, keyboard, and focus ring rather than nesting a second button; the bare\n * (non-`asChild`) form renders the default neutral-ghost trigger.\n */\nexport const MenuTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,\n MenuTriggerProps\n>(function MenuTrigger({ className, asChild, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Trigger\n ref={ref}\n asChild={asChild}\n className={asChild ? className : cn(menuTriggerClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {}\n\n/**\n * Renders the portal and the popup (spec §2 popup): the floating `role=\"menu\"` surface that opens on\n * activation, raised above the page and anchored to the trigger. On open, focus moves into the popup\n * (first item, or last on Up) and roving tabindex tracks the active item; on close — by Escape,\n * activation, or click-away — focus returns to the trigger (Radix, spec §6/§7). The menu is NOT a\n * modal dialog: focus is not trapped, and Tab leaves the menu rather than stepping through items.\n * A neutral raised surface; brand violet and Verified Green never appear here (spec §3/§5/§8).\n */\nexport const MenuContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n MenuContentProps\n>(function MenuContent({ className, sideOffset = 4, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends (spec §6: \"wrapping at the ends\") — Radix leaves\n // it OFF by default, so we default it ON to honor the frozen keyboard model. Disabled items\n // are skipped by the roving handler for free (Radix, spec §4/§6).\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\nexport interface MenuItemProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`; the item names itself by its label text. */\n icon?: React.ReactNode;\n /** A trailing shortcut hint (spec §2): text such as \"⌘K\", in the muted label role; never a focus stop. */\n shortcut?: React.ReactNode;\n}\n\n/**\n * One command row (spec §2 item, §4 states): a `role=\"menuitem\"` whose activation runs its command\n * and closes the menu, returning focus to the trigger (Radix). It holds an optional leading icon, a\n * label, and an optional trailing shortcut hint. Pointer hover and keyboard arrow movement share ONE\n * highlight (Radix `data-highlighted`), so the active item is the same for both (spec §4 Hover).\n *\n * `destructive` (spec §3 `item=destructive`) marks the ONE colored item — a command that is\n * irreversible (revoke a key, delete a profile). It takes the destructive ACTION treatment and must\n * name the consequence in its TEXT, never by color alone (spec §7/§8). A `disabled` item stays in the\n * menu and readable to assistive technology (`aria-disabled`), is skipped by arrow movement, and does\n * not fire on activation (spec §4 Disabled / §7).\n */\nexport const MenuItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n MenuItemProps\n>(function MenuItem({ className, destructive, icon, shortcut, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Item\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {shortcut ? <span className={menuItemShortcutClass}>{shortcut}</span> : null}\n </DropdownMenuPrimitive.Item>\n );\n});\n\nexport interface MenuGroupProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group> {\n /**\n * The non-interactive heading that partitions the popup (spec §2 group / group-label). It names the\n * group for assistive technology via `aria-labelledby` (Radix `Label`) and is NEVER a focus stop.\n */\n label?: React.ReactNode;\n}\n\n/**\n * A set of related items under a non-interactive `group-label` that partitions the popup (spec §2\n * group). The items read as a related set (`role=\"group\"` named by the label); the label is never a\n * menuitem and never a focus stop.\n */\nexport const MenuGroup = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Group>,\n MenuGroupProps\n>(function MenuGroup({ label, children, ...props }, ref) {\n // Radix Group + Label do NOT auto-wire aria-labelledby (the Label renders a bare div with no id),\n // so the group would read as an unnamed role=group. We hand-wire it the same way Separator\n // hand-rolls a named anatomy when the primitive can't carry it (skill: compose the role by hand\n // when Radix can't express the spec's anatomy): generate a stable id on the Label and point the\n // Group's aria-labelledby at it, so the items read as a related set named by the label (spec §2\n // group / §7 group named by its label).\n const labelId = React.useId();\n return (\n <DropdownMenuPrimitive.Group\n ref={ref}\n aria-labelledby={label ? labelId : undefined}\n {...props}\n >\n {label ? (\n <DropdownMenuPrimitive.Label id={labelId} className={menuLabelClass}>\n {label}\n </DropdownMenuPrimitive.Label>\n ) : null}\n {children}\n </DropdownMenuPrimitive.Group>\n );\n});\n\nexport interface MenuLabelProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {}\n\n/**\n * A standalone non-interactive section label (spec §2 group-label) for a label that is not wrapped in\n * a `MenuGroup`. Like the group label it is the muted label-role heading and is never a focus stop.\n */\nexport const MenuLabel = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n MenuLabelProps\n>(function MenuLabel({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Label ref={ref} className={cn(menuLabelClass, className)} {...props} />\n );\n});\n\nexport interface MenuSeparatorProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> {}\n\n/**\n * A thin neutral divider between groups (spec §2 separator): decorative (`role=\"separator\"`), never a\n * focus stop.\n */\nexport const MenuSeparator = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n MenuSeparatorProps\n>(function MenuSeparator({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Separator\n ref={ref}\n className={cn(menuSeparatorClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuSubProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Sub> {}\n\n/**\n * A submenu: an item that opens a nested popup of its own items (spec §2 submenu). Keep nesting\n * shallow — deep trees are hard to operate by keyboard (spec §2). Wraps `MenuSubTrigger` +\n * `MenuSubContent`.\n */\nexport function MenuSub(props: MenuSubProps) {\n return <DropdownMenuPrimitive.Sub {...props} />;\n}\n\nexport interface MenuSubTriggerProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`. */\n icon?: React.ReactNode;\n}\n\n/**\n * The item that opens a submenu (spec §2/§6): a `role=\"menuitem\"` with `aria-haspopup=\"menu\"` and its\n * own `aria-expanded` (Radix). Right opens the submenu and focuses its first item; Left closes it and\n * returns focus here. It carries the same row treatment as a `MenuItem`, plus a trailing chevron\n * pointing to the inline-end.\n */\nexport const MenuSubTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n MenuSubTriggerProps\n>(function MenuSubTrigger({ className, destructive, icon, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n <ChevronGlyph />\n </DropdownMenuPrimitive.SubTrigger>\n );\n});\n\nexport interface MenuSubContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> {}\n\n/**\n * The nested popup of a submenu (spec §2 submenu): the same neutral raised `role=\"menu\"` surface as\n * `MenuContent`, anchored to its `MenuSubTrigger`.\n */\nexport const MenuSubContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n MenuSubContentProps\n>(function MenuSubContent({ className, sideOffset = 2, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.SubContent\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends inside the submenu too (spec §6), matching the\n // parent popup; Radix leaves it OFF by default.\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\n// The submenu chevron — inline SVG (no icon dep), --size-icon-md, pointing to the inline-end to\n// signal the nested popup. Decorative (aria-hidden); aria-haspopup/aria-expanded carry the state, not\n// the glyph (spec §2/§7). Drawn with currentColor so it inherits the row's color.\nfunction ChevronGlyph() {\n return (\n <span data-testid=\"menu-sub-chevron\" aria-hidden=\"true\" className={menuSubChevronClass}>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" focusable=\"false\">\n <path d=\"M6 4l4 4-4 4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n );\n}\n",
|
|
16
|
+
"path": "menu/menu.tsx",
|
|
17
|
+
"target": "@ui/menu/menu.tsx",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Menu is a popup list of ACTIONS a trigger opens (spec §1). It is a NEUTRAL surface (spec §3): its\n// items, popup, and separators are neutral, and the brand violet NEVER marks an item as \"the\n// special one.\" The ONE colored item is the DESTRUCTIVE item, which takes the destructive ACTION\n// treatment because the command it runs is irreversible — a RISK signal, not a status result. A\n// verified meaning is never reported by a menu item (that is VerifiedBadge's job), so NOTHING here\n// binds a --color-status-* token and the brand action-primary tier never appears (brand != state,\n// G-U2). This is the ONLY token-binding site (skill §5 hard rule). All open/close motion is the\n// FAST token transition on the verdify easing, instant under reduced motion — never the 350ms\n// VerifiedBadge-only theatre duration (G-U3).\n\n// The trigger: the one stop in the page tab order for this control (spec §2 trigger). A NEUTRAL\n// ghost surface — the label/glyph in the ghost action fg at rest, the restrained ghost hover fill,\n// the md radius, the persistent 2px focus ring (never removed, spec §4 Focus), and the target-size\n// floor (44px touch / 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor.\n// A disabled trigger dims via the disabled TOKEN (DEC-C), never a blanket opacity. fast functional\n// hover motion + verdify easing, instant under reduced motion (G-U3). This styles the DEFAULT\n// (non-asChild) trigger; when a Button is passed via `asChild` it carries its own treatment.\nexport const menuTriggerClass =\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-3) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The popup (spec §2 popup, §5): the floating surface that opens on activation, raised above the\n// page and anchored to the trigger. A NEUTRAL raised surface (--color-surface-raised) with the\n// outer surface border, the md corner radius, and the md elevation shadow above the page, on the\n// POPOVER z-layer (a menu is a non-modal popover layer, not the modal layer). It NEVER wears a brand\n// or status fill (spec §3/§8). The open/close fade is a PLAIN fast transition + verdify easing,\n// instant under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3). Enter/exit ride\n// Radix's data-state on the content (attribute-selector variants, not arbitrary values). Inset\n// padding from --space-*; a SubContent shares the same surface treatment.\nexport const menuPopupClass =\n \"z-(--z-index-popover) min-w-(--container-sm) overflow-hidden p-(--space-1) \" +\n \"bg-surface-raised border border-surface-border rounded-(--radius-md) shadow-(--shadow-md) \" +\n \"transition-opacity duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\";\n\n// One command row (spec §2 item, §4 states). A neutral row at rest; the active/hovered item shares\n// ONE highlight (pointer and keyboard both drive Radix's data-highlighted, spec §4 Hover) painted\n// with the ghost hover fill.\n//\n// RESTING (default): the LABEL in the PRIMARY text color (spec §5 --color-text-primary) at the BODY\n// type role (spec §5 --text-body), on the popup surface with NO fill (spec §4 Default).\n// HIGHLIGHTED (data-highlighted): the restrained ghost-action hover fill (spec §5\n// --color-action-ghost-bg-hover) — pointer hover AND keyboard arrow movement set the same\n// data-highlighted, so the two share one highlight (spec §4 Hover/Active). No motion beyond the\n// token transition.\n// DISABLED (data-disabled): dims via the disabled TOKEN (DEC-C), never a blanket opacity; Radix\n// keeps it readable to AT (aria-disabled) and skips it in arrow movement (spec §4 Disabled / §7).\n// FOCUS: the open popup tracks the active item by ROVING FOCUS and shows the active fill, not a\n// second ring (spec §4 Focus) — so an item does NOT paint its own focus-visible ring; the active\n// fill is the focus affordance inside the menu.\n// Motion: fast token transition + verdify easing, instant under reduced motion (NEVER the check\n// theatre, G-U3). Target-size floor on every row (44px touch / 40px pointer, spec §7 2.5.8), never a\n// fixed height below the floor.\n//\n// `destructive` is the spec §3 `item=destructive` axis — the ONE colored item: the destructive\n// ACTION treatment (label/icon in the destructive fg; the highlight fill is the destructive bg). It\n// is a RISK signal, not the brand and NEVER status-verified (G-U2); the risk is also named in the\n// item's text + icon, never carried by color alone (spec §7/§8, 1.4.1).\nexport const menuItemVariants = cva(\n [\n // shape + the icon-to-label gap + logical inline padding so it mirrors under RTL (G-U6)\n \"relative flex items-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n // type ROLE + the resting (neutral) label color, no fill, pointer cursor\n \"text-body text-text-primary no-underline cursor-pointer select-none\",\n // the shared pointer+keyboard highlight: the restrained ghost-action fill (spec §4 Hover/Active)\n \"data-[highlighted]:bg-action-ghost-bg-hover\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre, G-U3)\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every row (spec §7, 2.5.8), never a fixed\n // height below the floor\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // the open popup tracks the active item by ROVING focus + the active fill, not a second ring\n // (spec §4 Focus) — no per-item focus-visible ring inside the menu\n \"outline-none\",\n // disabled (non-operable) row — DEC-C: dim via the disabled TOKEN, never opacity. Radix drives\n // it via data-disabled and keeps the label readable to AT (aria-disabled), skipping arrow focus.\n \"data-[disabled]:pointer-events-none data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n // the spec §3 `item=destructive` axis — the ONE colored item: the destructive ACTION\n // treatment, a RISK signal (not the brand, NEVER status-verified — G-U2). At rest the label +\n // icon take the destructive fg; the shared highlight fill is the destructive bg.\n destructive: {\n true: [\n \"text-action-destructive-fg\",\n \"data-[highlighted]:bg-action-destructive-bg\",\n ],\n false: \"\",\n },\n },\n defaultVariants: { destructive: false },\n },\n);\n\nexport type MenuItemVariantProps = VariantProps<typeof menuItemVariants>;\n\n// The leading item icon (spec §2/§5): the md icon role, decorative (the item names itself by its\n// label text, not the glyph). It inherits the row's color via currentColor, so a destructive row's\n// icon is the destructive fg and a disabled row's icon dims with the disabled token. shrink-0 so it\n// never collapses.\nexport const menuItemIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center\";\n\n// The trailing shortcut hint on an item (spec §2/§5): the keyboard hint pushed to the inline-end, in\n// the MUTED text color at the LABEL type role (spec §5 --color-text-muted / --text-label).\n// Decorative wayfinding, never a focus stop; logical inline-end placement (G-U6).\nexport const menuItemShortcutClass =\n \"ms-auto ps-(--space-4) text-label text-text-muted\";\n\n// The submenu chevron on a SubTrigger (spec §2 submenu, §5): the md icon role, decorative; it\n// points to the inline-end to signal the nested popup. Inherits the row color via currentColor.\nexport const menuSubChevronClass =\n \"ms-auto inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center\";\n\n// The group label (spec §2 group-label, §5): the non-interactive heading that partitions the popup;\n// it is NEVER a focus stop. The MUTED text color at the LABEL type role (spec §5 --color-text-muted\n// / --text-label). Logical inline padding so it mirrors under RTL (G-U6).\nexport const menuLabelClass =\n \"px-(--space-2) py-(--space-1) text-label text-text-muted select-none\";\n\n// The separator (spec §2 separator, §5): a thin neutral divider between groups. It is decorative and\n// never takes focus. A neutral hairline in the default border color (spec §5 --color-border-default),\n// with a little vertical breathing room. Negated logical inline margins keep the rule flush to the\n// popup's inner padding edge (it spans the popup inset, mirrors under RTL — G-U6).\nexport const menuSeparatorClass =\n \"-mx-(--space-1) my-(--space-1) h-px bg-border-default\";\n",
|
|
22
|
+
"path": "menu/menu.variants.ts",
|
|
23
|
+
"target": "@ui/menu/menu.variants.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "menu",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "menu",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|