@verdify/ui 0.2.1 → 0.2.2

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.
Files changed (47) hide show
  1. package/LICENSE +12 -0
  2. package/dist/components/agent-badge/agent-badge.variants.d.ts.map +1 -1
  3. package/dist/components/agent-badge/agent-badge.variants.js.map +1 -1
  4. package/dist/components/checkbox/checkbox.js +1 -1
  5. package/dist/components/checkbox/checkbox.js.map +1 -1
  6. package/dist/components/consent-toggle/consent-toggle.variants.d.ts +1 -1
  7. package/dist/components/consent-toggle/consent-toggle.variants.d.ts.map +1 -1
  8. package/dist/components/consent-toggle/consent-toggle.variants.js +1 -1
  9. package/dist/components/consent-toggle/consent-toggle.variants.js.map +1 -1
  10. package/dist/components/credential-card/credential-card.variants.d.ts +1 -1
  11. package/dist/components/credential-card/credential-card.variants.d.ts.map +1 -1
  12. package/dist/components/credential-card/credential-card.variants.js +1 -1
  13. package/dist/components/credential-card/credential-card.variants.js.map +1 -1
  14. package/dist/components/data-grid/data-grid.variants.js +4 -4
  15. package/dist/components/data-grid/data-grid.variants.js.map +1 -1
  16. package/dist/components/input/input.variants.js +1 -1
  17. package/dist/components/input/input.variants.js.map +1 -1
  18. package/dist/components/label/label.variants.js +1 -1
  19. package/dist/components/label/label.variants.js.map +1 -1
  20. package/dist/components/progress/progress.variants.d.ts +1 -1
  21. package/dist/components/progress/progress.variants.d.ts.map +1 -1
  22. package/dist/components/progress/progress.variants.js +1 -1
  23. package/dist/components/progress/progress.variants.js.map +1 -1
  24. package/dist/components/select/select.variants.d.ts +2 -2
  25. package/dist/components/select/select.variants.d.ts.map +1 -1
  26. package/dist/components/select/select.variants.js +2 -2
  27. package/dist/components/select/select.variants.js.map +1 -1
  28. package/dist/components/table/table.d.ts.map +1 -1
  29. package/dist/components/table/table.js +1 -1
  30. package/dist/components/table/table.js.map +1 -1
  31. package/dist/components/table/table.variants.js +4 -4
  32. package/dist/components/table/table.variants.js.map +1 -1
  33. package/dist/components/textarea/textarea.js +1 -1
  34. package/dist/components/textarea/textarea.js.map +1 -1
  35. package/package.json +18 -19
  36. package/registry/agent-badge.json +1 -1
  37. package/registry/checkbox.json +1 -1
  38. package/registry/consent-toggle.json +1 -1
  39. package/registry/credential-card.json +1 -1
  40. package/registry/data-grid.json +1 -1
  41. package/registry/init.json +1 -1
  42. package/registry/input.json +1 -1
  43. package/registry/label.json +1 -1
  44. package/registry/progress.json +1 -1
  45. package/registry/select.json +1 -1
  46. package/registry/table.json +2 -2
  47. package/registry/textarea.json +1 -1
@@ -4,7 +4,7 @@
4
4
  "@import \"@verdify/tokens/preset\"": {}
5
5
  },
6
6
  "dependencies": [
7
- "@verdify/tokens@^0.7.0"
7
+ "@verdify/tokens@^0.8.0"
8
8
  ],
9
9
  "extends": "none",
10
10
  "files": [],
@@ -17,7 +17,7 @@
17
17
  "type": "registry:ui"
18
18
  },
19
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",
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-on-surface\" },\n },\n defaultVariants: { tone: \"help\" },\n});\n",
21
21
  "path": "input/input.variants.ts",
22
22
  "target": "@ui/input/input.variants.ts",
23
23
  "type": "registry:ui"
@@ -17,7 +17,7 @@
17
17
  "type": "registry:ui"
18
18
  },
19
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",
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-on-surface\",\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
21
  "path": "label/label.variants.ts",
22
22
  "target": "@ui/label/label.variants.ts",
23
23
  "type": "registry:ui"
@@ -17,7 +17,7 @@
17
17
  "type": "registry:ui"
18
18
  },
19
19
  {
20
- "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Progress reports how far a task has advanced — a status OUTPUT, not a control\n// (spec §1/§4/§6). It takes no focus, binds no keys, and renders no focus ring or\n// target-size floor: the value reaches assistive technology through role=\"progressbar\"\n// + aria-value* and a polite live region, never motion (spec §7).\n//\n// Brand != state (spec §3/§5/§8): the fill is a plain control affordance, NOT a\n// verification result. Neutrals carry the track and the fill takes the primary ACTION\n// accent (the visible action color on the surface) — NEVER --color-status-verified-*,\n// because verified green is the in-product verified status and is never a generic\n// progress affordance. There is no \"verified\" Progress; surfacing a verified outcome is\n// the job of VerifiedBadge, whose deliberate check animation is never borrowed here.\n\n// The track (spec §2/§5): the full-length rail that holds the fill, in the neutral\n// control surface with the control border, clipped so the fill's rounded end and the\n// traveling indeterminate indicator never bleed past the rail. Rounded on the full radius.\n// In the error state the rail edge takes the critical status border, driven by\n// aria-invalid on the bar so the failure is identified BY THE FIELD, in text (spec §4/§7).\nexport const progressTrackClass =\n \"relative block h-(--space-2) w-full overflow-hidden \" +\n \"rounded-(--radius-full) bg-control-bg border border-control-border \" +\n \"aria-invalid:border-status-critical-border\";\n\n// The fill / indeterminate indicator keyframe (spec §3/§4): a TRAVELLING indicator. A\n// narrow indicator (`w-2/5`, set in the variant below) slides along the track's INLINE\n// axis from before the inline-start edge to past the inline-end edge, then loops. Travel\n// is driven on the LOGICAL `inset-inline-start` property (not a physical `translateX`), so\n// the indicator mirrors automatically under `dir=\"rtl\"` (G-U6 global-first). This keyframe\n// is pure geometry — it binds NO design token (the duration and easing tokens are bound on\n// the variant className, the single §5 binding site), so it is emitted from `progress.tsx`\n// as a component-scoped <style>, not from this binding file.\nexport const PROGRESS_INDETERMINATE_KEYFRAME = \"progress-indeterminate-travel\";\n\n// The fill (spec §2/§4/§5): the portion of the track that is done, in the primary action\n// accent, rounded on the full radius.\n//\n// DETERMINATE: its inline length is the value (set as an inline width in tsx — a data\n// percentage, not a design token). The length CHANGES on the FAST duration with verdify\n// easing, collapsing to the instant endpoint under reduced motion (spec §4/§5).\n//\n// INDETERMINATE: no measured length exists, so a moving indicator TRAVELS the track on a\n// continuous AMBIENT loop with verdify easing — restrained activity, never the 350ms\n// verified-check theatre duration (spec §3/§4/§8). The indicator is positioned `absolute`\n// on the track and its `inset-inline-start` is animated by the `progress-indeterminate-travel`\n// keyframe (defined in progress.tsx). The loop length is driven onto the ambient token via\n// the arbitrary `animation-duration` PROPERTY, the easing onto the verdify token, and the\n// loop is set to repeat — all keyword arbitrary properties or paren-shorthand tokens, not\n// raw values, so the token-binding gate does not flag them. Under prefers-reduced-motion the\n// travel is suppressed (`motion-reduce:animate-none`) and the busy state is carried by the\n// live region + name, not motion alone (spec §4/§7).\nexport const progressFillVariants = cva(\"block h-full rounded-(--radius-full) bg-action-primary-bg\", {\n variants: {\n // STRUCTURAL axis = spec §3: the determinate fill is a measured length; the\n // indeterminate indicator is a looping travel. Neither recolors the fill (brand !=\n // state) — they differ only by motion and how the length is set.\n indeterminate: {\n // a measured length set inline; the length transition runs on the fast duration\n false:\n \"transition-[inline-size] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // a narrow indicator that TRAVELS the inline axis on a continuous ambient loop;\n // static under reduced motion. Absolutely positioned so it slides past both edges\n // of the clipped track (overflow-hidden on the track hides the off-track portion).\n true:\n \"absolute w-2/5 [animation-name:progress-indeterminate-travel] \" +\n \"[animation-iteration-count:infinite] \" +\n \"[animation-duration:var(--motion-duration-ambient)] \" +\n \"ease-(--motion-easing-verdify) motion-reduce:animate-none\",\n },\n },\n defaultVariants: { indeterminate: false },\n});\n\n// The bar root (spec §2): a layout column stacking the label/value-text row, the track,\n// and the optional description/error message at the --space-2 gap. Non-interactive: no\n// focus ring, no tab stop, no target-size floor (spec §4/§6/§7).\nexport const progressRootClass = \"flex w-full flex-col gap-(--space-2)\";\n\n// The label + value-text header row (spec §2): the label sits inline-start, the optional\n// value-text inline-end, spread across the bar's width.\nexport const progressHeaderClass = \"flex items-baseline justify-between gap-(--space-2)\";\n\n// The label (spec §2/§5): the text naming the task, in the primary text color at the\n// caption type role.\nexport const progressLabelClass = \"text-caption text-text-primary\";\n\n// The optional value-text readout (spec §2/§5): a plain progress readout (\"40%\",\n// \"Step 2 of 4\") in the secondary text color at the caption role. Determinate only.\nexport const progressValueTextClass = \"text-caption text-text-secondary\";\n\n// The optional one-line description (spec §2/§5): a statement clarifying what is running,\n// in the secondary text color at the caption role.\nexport const progressDescriptionClass = \"text-caption text-text-secondary\";\n\n// The error message (spec §4/§5/§8): states what failed and the next step, naming the\n// failure without blaming the reader. The critical status FOREGROUND marks the text at the\n// caption role; the field edge (track) takes the critical border via aria-invalid. Error\n// is shown in TEXT, never by color alone.\nexport const progressErrorClass = \"text-caption text-status-critical-fg\";\n\nexport type ProgressFillVariantProps = VariantProps<typeof progressFillVariants>;\n",
20
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Progress reports how far a task has advanced — a status OUTPUT, not a control\n// (spec §1/§4/§6). It takes no focus, binds no keys, and renders no focus ring or\n// target-size floor: the value reaches assistive technology through role=\"progressbar\"\n// + aria-value* and a polite live region, never motion (spec §7).\n//\n// Brand != state (spec §3/§5/§8): the fill is a plain control affordance, NOT a\n// verification result. Neutrals carry the track and the fill takes the primary ACTION\n// accent (the visible action color on the surface) — NEVER --color-status-verified-*,\n// because verified green is the in-product verified status and is never a generic\n// progress affordance. There is no \"verified\" Progress; surfacing a verified outcome is\n// the job of VerifiedBadge, whose deliberate check animation is never borrowed here.\n\n// The track (spec §2/§5): the full-length rail that holds the fill, in the neutral\n// control surface with the control border, clipped so the fill's rounded end and the\n// traveling indeterminate indicator never bleed past the rail. Rounded on the full radius.\n// In the error state the rail edge takes the critical status border, driven by\n// aria-invalid on the bar so the failure is identified BY THE FIELD, in text (spec §4/§7).\nexport const progressTrackClass =\n \"relative block h-(--space-2) w-full overflow-hidden \" +\n \"rounded-(--radius-full) bg-control-bg border border-control-border \" +\n \"aria-invalid:border-status-critical-border\";\n\n// The fill / indeterminate indicator keyframe (spec §3/§4): a TRAVELLING indicator. A\n// narrow indicator (`w-2/5`, set in the variant below) slides along the track's INLINE\n// axis from before the inline-start edge to past the inline-end edge, then loops. Travel\n// is driven on the LOGICAL `inset-inline-start` property (not a physical `translateX`), so\n// the indicator mirrors automatically under `dir=\"rtl\"` (G-U6 global-first). This keyframe\n// is pure geometry — it binds NO design token (the duration and easing tokens are bound on\n// the variant className, the single §5 binding site), so it is emitted from `progress.tsx`\n// as a component-scoped <style>, not from this binding file.\nexport const PROGRESS_INDETERMINATE_KEYFRAME = \"progress-indeterminate-travel\";\n\n// The fill (spec §2/§4/§5): the portion of the track that is done, in the primary action\n// accent, rounded on the full radius.\n//\n// DETERMINATE: its inline length is the value (set as an inline width in tsx — a data\n// percentage, not a design token). The length CHANGES on the FAST duration with verdify\n// easing, collapsing to the instant endpoint under reduced motion (spec §4/§5).\n//\n// INDETERMINATE: no measured length exists, so a moving indicator TRAVELS the track on a\n// continuous AMBIENT loop with verdify easing — restrained activity, never the 350ms\n// verified-check theatre duration (spec §3/§4/§8). The indicator is positioned `absolute`\n// on the track and its `inset-inline-start` is animated by the `progress-indeterminate-travel`\n// keyframe (defined in progress.tsx). The loop length is driven onto the ambient token via\n// the arbitrary `animation-duration` PROPERTY, the easing onto the verdify token, and the\n// loop is set to repeat — all keyword arbitrary properties or paren-shorthand tokens, not\n// raw values, so the token-binding gate does not flag them. Under prefers-reduced-motion the\n// travel is suppressed (`motion-reduce:animate-none`) and the busy state is carried by the\n// live region + name, not motion alone (spec §4/§7).\nexport const progressFillVariants = cva(\"block h-full rounded-(--radius-full) bg-action-primary-bg\", {\n variants: {\n // STRUCTURAL axis = spec §3: the determinate fill is a measured length; the\n // indeterminate indicator is a looping travel. Neither recolors the fill (brand !=\n // state) — they differ only by motion and how the length is set.\n indeterminate: {\n // a measured length set inline; the length transition runs on the fast duration\n false:\n \"transition-[inline-size] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // a narrow indicator that TRAVELS the inline axis on a continuous ambient loop;\n // static under reduced motion. Absolutely positioned so it slides past both edges\n // of the clipped track (overflow-hidden on the track hides the off-track portion).\n true:\n \"absolute w-2/5 [animation-name:progress-indeterminate-travel] \" +\n \"[animation-iteration-count:infinite] \" +\n \"[animation-duration:var(--motion-duration-ambient)] \" +\n \"ease-(--motion-easing-verdify) motion-reduce:animate-none\",\n },\n },\n defaultVariants: { indeterminate: false },\n});\n\n// The bar root (spec §2): a layout column stacking the label/value-text row, the track,\n// and the optional description/error message at the --space-2 gap. Non-interactive: no\n// focus ring, no tab stop, no target-size floor (spec §4/§6/§7).\nexport const progressRootClass = \"flex w-full flex-col gap-(--space-2)\";\n\n// The label + value-text header row (spec §2): the label sits inline-start, the optional\n// value-text inline-end, spread across the bar's width.\nexport const progressHeaderClass = \"flex items-baseline justify-between gap-(--space-2)\";\n\n// The label (spec §2/§5): the text naming the task, in the primary text color at the\n// caption type role.\nexport const progressLabelClass = \"text-caption text-text-primary\";\n\n// The optional value-text readout (spec §2/§5): a plain progress readout (\"40%\",\n// \"Step 2 of 4\") in the secondary text color at the caption role. Determinate only.\nexport const progressValueTextClass = \"text-caption text-text-secondary\";\n\n// The optional one-line description (spec §2/§5): a statement clarifying what is running,\n// in the secondary text color at the caption role.\nexport const progressDescriptionClass = \"text-caption text-text-secondary\";\n\n// The error message (spec §4/§5/§8): states what failed and the next step, naming the\n// failure without blaming the reader. The critical status FOREGROUND marks the text at the\n// caption role; the field edge (track) takes the critical border via aria-invalid. Error\n// is shown in TEXT, never by color alone.\nexport const progressErrorClass = \"text-caption text-status-critical-on-surface\";\n\nexport type ProgressFillVariantProps = VariantProps<typeof progressFillVariants>;\n",
21
21
  "path": "progress/progress.variants.ts",
22
22
  "target": "@ui/progress/progress.variants.ts",
23
23
  "type": "registry:ui"
@@ -18,7 +18,7 @@
18
18
  "type": "registry:ui"
19
19
  },
20
20
  {
21
- "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Trigger: control-* tier surface, secondary hover fill, focus ring that persists\n// while open, strong border in error, target-size floor, base+verdify motion.\nexport const triggerVariants = cva(\n [\n \"inline-flex items-center justify-between gap-2 rounded-(--radius-md) px-3\",\n \"bg-control-bg text-control-fg border border-control-border\",\n \"hover:bg-action-secondary-bg-hover cursor-pointer select-none\",\n // DEC-A — the trigger is a form field, so its value SIZE is text-base (16px): the\n // iOS no-zoom reset is a hard floor that holds on EVERY size. The brand type ROLE\n // (line-height + letter-spacing) rides along via the role-suffix vars set per size\n // below. text-body itself (a 15px font-size) is NEVER bound on the trigger — under\n // the role-aware cn it collapses against text-base, and 15px reintroduces the iOS\n // focus zoom the reset exists to prevent. So the font-size stays text-base across\n // sizes; only the leading/tracking role and the padding density shift.\n \"text-base\",\n // open/close transition — base duration + verdify easing, never deliberate theatre\n \"transition-colors duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n // target-size floor: 44px touch / 40px pointer\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // visible 2px signal-blue ring at 2px offset; persists while the listbox is open\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"data-[state=open]:ring-2 data-[state=open]:ring-border-focus data-[state=open]:ring-offset-2\",\n // error: strong border treatment, driven by aria-invalid\n \"aria-invalid:border-border-strong\",\n // disabled: out of tab order (Radix sets data-disabled), reduced emphasis\n \"data-[disabled]:pointer-events-none data-[disabled]:cursor-default\",\n \"disabled:text-text-disabled data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n // DEC-B: @verdify/tokens exposes only target-size FLOORS (44px / 40px), no\n // height scale. Every size anchors the shared floor (min-h-, in the base) and\n // NEVER sets a fixed height below it (a11y). Each size is TYPE-ROLE + vertical\n // padding (density) ABOVE the floor; the resting height EMERGES from the padding\n // and grows monotonically sm <= md <= lg. Because the trigger is a form field\n // (DEC-A), the value font-SIZE is pinned to text-base in the base — so here the\n // type role shifts only through the brand line-height + letter-spacing role\n // suffix (NOT the font-size), tightening at sm (caption metrics) and loosening at\n // lg (body-lg metrics). The padding ladder is --space-1 (.25rem) <= --space-2\n // (.5rem) <= --space-3 (.75rem), all >= the floor. (An earlier build set fixed\n // h-(--size-target-*), which made lg SHORTER than sm/md on desktop — the inverse\n // of the requirement — and is removed.)\n size: {\n sm: \"leading-(--text-caption--line-height) tracking-(--text-caption--letter-spacing) py-(--space-1)\",\n md: \"leading-(--text-body--line-height) tracking-(--text-body--letter-spacing) py-(--space-2)\",\n lg: \"leading-(--text-body-lg--line-height) tracking-(--text-body-lg--letter-spacing) py-(--space-3)\",\n },\n width: {\n auto: \"w-auto\",\n full: \"w-full\",\n },\n },\n defaultVariants: { size: \"md\", width: \"auto\" },\n },\n);\n\n// Placeholder text: de-emphasised, never the accessible name.\nexport const placeholderClass = \"text-control-placeholder\";\n\n// Listbox: raised surface, outer border, md elevation; opens with base motion only.\nexport const listboxClass = [\n \"z-50 overflow-hidden rounded-(--radius-md) p-1\",\n \"bg-surface-raised border border-surface-border shadow-(--shadow-md)\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:transition-none\",\n].join(\" \");\n\n// Option row: primary label, secondary hover/active fill, target-row floor.\n// DEC-B: the option row is parameterized by the SAME size the trigger is, so the\n// listbox density tracks the trigger. It anchors the shared target-row floor\n// (min-h-, in the base) and NEVER sets a fixed height below it; each size is\n// TYPE-ROLE + vertical padding (density) ABOVE the floor, the row height emerging\n// from the padding. Unlike the trigger, an option row is NOT a focused text field —\n// the iOS no-zoom reset does not apply — so here the type role shifts through the\n// actual font-SIZE role: caption (sm) < body (md) < body-lg (lg), paired with the\n// identical --space-1 <= --space-2 <= --space-3 padding ladder as the trigger. Both\n// type role and density therefore climb monotonically sm <= md <= lg, all >= floor.\nexport const optionVariants = cva(\n [\n \"relative flex items-center gap-2 rounded-(--radius-md) pe-8 ps-3\",\n \"text-text-primary outline-none cursor-pointer select-none\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // active (highlighted) option uses the secondary hover fill\n \"data-[highlighted]:bg-action-secondary-bg-hover data-[highlighted]:outline-none\",\n // disabled option: reduced emphasis, not operable\n \"data-[disabled]:text-text-disabled data-[disabled]:pointer-events-none\",\n ],\n {\n variants: {\n size: {\n sm: \"text-caption py-(--space-1)\",\n md: \"text-body py-(--space-2)\",\n lg: \"text-body-lg py-(--space-3)\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The selected-option check — a NEUTRAL mark (text-text-primary), never a status color.\nexport const checkClass = \"absolute end-2 inline-flex text-text-primary\";\n\n// Group heading: non-selectable, muted.\nexport const groupLabelClass = \"px-3 py-1 text-label text-text-muted select-none\";\n\n// Listbox/option dividers.\nexport const separatorClass = \"my-1 h-px bg-border-default\";\n\n// Error-slot text — status critical foreground.\nexport const errorTextClass = \"mt-1 text-label text-status-critical-fg\";\n// Non-error helper text — muted.\nexport const descriptionClass = \"mt-1 text-label text-text-muted\";\n// The visible, associated label.\nexport const labelClass = \"text-label text-text-primary\";\n\nexport type TriggerVariantProps = VariantProps<typeof triggerVariants>;\n",
21
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Trigger: control-* tier surface, secondary hover fill, focus ring that persists\n// while open, strong border in error, target-size floor, base+verdify motion.\nexport const triggerVariants = cva(\n [\n \"inline-flex items-center justify-between gap-2 rounded-(--radius-md) px-3\",\n \"bg-control-bg text-control-fg border border-control-border\",\n \"hover:bg-action-secondary-bg-hover cursor-pointer select-none\",\n // DEC-A — the trigger is a form field, so its value SIZE is text-base (16px): the\n // iOS no-zoom reset is a hard floor that holds on EVERY size. The brand type ROLE\n // (line-height + letter-spacing) rides along via the role-suffix vars set per size\n // below. text-body itself (a 15px font-size) is NEVER bound on the trigger — under\n // the role-aware cn it collapses against text-base, and 15px reintroduces the iOS\n // focus zoom the reset exists to prevent. So the font-size stays text-base across\n // sizes; only the leading/tracking role and the padding density shift.\n \"text-base\",\n // open/close transition — base duration + verdify easing, never deliberate theatre\n \"transition-colors duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n // target-size floor: 44px touch / 40px pointer\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // visible 2px signal-blue ring at 2px offset; persists while the listbox is open\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"data-[state=open]:ring-2 data-[state=open]:ring-border-focus data-[state=open]:ring-offset-2\",\n // error: strong border treatment, driven by aria-invalid\n \"aria-invalid:border-border-strong\",\n // disabled: out of tab order (Radix sets data-disabled), reduced emphasis\n \"data-[disabled]:pointer-events-none data-[disabled]:cursor-default\",\n \"disabled:text-text-disabled data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n // DEC-B: @verdify/tokens exposes only target-size FLOORS (44px / 40px), no\n // height scale. Every size anchors the shared floor (min-h-, in the base) and\n // NEVER sets a fixed height below it (a11y). Each size is TYPE-ROLE + vertical\n // padding (density) ABOVE the floor; the resting height EMERGES from the padding\n // and grows monotonically sm <= md <= lg. Because the trigger is a form field\n // (DEC-A), the value font-SIZE is pinned to text-base in the base — so here the\n // type role shifts only through the brand line-height + letter-spacing role\n // suffix (NOT the font-size), tightening at sm (caption metrics) and loosening at\n // lg (body-lg metrics). The padding ladder is --space-1 (.25rem) <= --space-2\n // (.5rem) <= --space-3 (.75rem), all >= the floor. (An earlier build set fixed\n // h-(--size-target-*), which made lg SHORTER than sm/md on desktop — the inverse\n // of the requirement — and is removed.)\n size: {\n sm: \"leading-(--text-caption--line-height) tracking-(--text-caption--letter-spacing) py-(--space-1)\",\n md: \"leading-(--text-body--line-height) tracking-(--text-body--letter-spacing) py-(--space-2)\",\n lg: \"leading-(--text-body-lg--line-height) tracking-(--text-body-lg--letter-spacing) py-(--space-3)\",\n },\n width: {\n auto: \"w-auto\",\n full: \"w-full\",\n },\n },\n defaultVariants: { size: \"md\", width: \"auto\" },\n },\n);\n\n// Placeholder text: de-emphasised, never the accessible name.\nexport const placeholderClass = \"text-control-placeholder\";\n\n// Listbox: raised surface, outer border, md elevation; opens with base motion only.\nexport const listboxClass = [\n \"z-50 overflow-hidden rounded-(--radius-md) p-1\",\n \"bg-surface-raised border border-surface-border shadow-(--shadow-md)\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:transition-none\",\n].join(\" \");\n\n// Option row: primary label, secondary hover/active fill, target-row floor.\n// DEC-B: the option row is parameterized by the SAME size the trigger is, so the\n// listbox density tracks the trigger. It anchors the shared target-row floor\n// (min-h-, in the base) and NEVER sets a fixed height below it; each size is\n// TYPE-ROLE + vertical padding (density) ABOVE the floor, the row height emerging\n// from the padding. Unlike the trigger, an option row is NOT a focused text field —\n// the iOS no-zoom reset does not apply — so here the type role shifts through the\n// actual font-SIZE role: caption (sm) < body (md) < body-lg (lg), paired with the\n// identical --space-1 <= --space-2 <= --space-3 padding ladder as the trigger. Both\n// type role and density therefore climb monotonically sm <= md <= lg, all >= floor.\nexport const optionVariants = cva(\n [\n \"relative flex items-center gap-2 rounded-(--radius-md) pe-8 ps-3\",\n \"text-text-primary outline-none cursor-pointer select-none\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // active (highlighted) option uses the secondary hover fill\n \"data-[highlighted]:bg-action-secondary-bg-hover data-[highlighted]:outline-none\",\n // disabled option: reduced emphasis, not operable\n \"data-[disabled]:text-text-disabled data-[disabled]:pointer-events-none\",\n ],\n {\n variants: {\n size: {\n sm: \"text-caption py-(--space-1)\",\n md: \"text-body py-(--space-2)\",\n lg: \"text-body-lg py-(--space-3)\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The selected-option check — a NEUTRAL mark (text-text-primary), never a status color.\nexport const checkClass = \"absolute end-2 inline-flex text-text-primary\";\n\n// Group heading: non-selectable, muted.\nexport const groupLabelClass = \"px-3 py-1 text-label text-text-muted select-none\";\n\n// Listbox/option dividers.\nexport const separatorClass = \"my-1 h-px bg-border-default\";\n\n// Error-slot text — status critical foreground.\nexport const errorTextClass = \"mt-1 text-label text-status-critical-on-surface\";\n// Non-error helper text — secondary (informational; mirrors Input's help tone, text-caption per\n// the message scale, text-text-secondary rather than text-muted so it reads at the same legibility\n// floor as Input help text — both carry information the user needs to complete the field).\nexport const descriptionClass = \"mt-1 text-caption text-text-secondary\";\n// The visible, associated label.\nexport const labelClass = \"text-label text-text-primary\";\n\nexport type TriggerVariantProps = VariantProps<typeof triggerVariants>;\n",
22
22
  "path": "select/select.variants.ts",
23
23
  "target": "@ui/select/select.variants.ts",
24
24
  "type": "registry:ui"
@@ -11,13 +11,13 @@
11
11
  "type": "registry:ui"
12
12
  },
13
13
  {
14
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n tableVariants,\n tableCaptionClass,\n tableHeaderVariants,\n tableBodyVariants,\n tableRowVariants,\n tableCellVariants,\n tableRowHeaderVariants,\n tableHeadVariants,\n tableSortButtonClass,\n tableSortCaretClass,\n tableEmptyClass,\n tableSkeletonCellClass,\n type TableCellVariantProps,\n} from \"./table.variants\";\n\n/** The row density (spec §3): `comfortable` (default) for general reading, `compact` for dense console views. */\nexport type TableDensity = \"comfortable\" | \"compact\";\n/** The rule treatment (spec §3): row hairlines (`horizontal`, default), `grid` (rows + columns), or `zebra` (alternating tint). */\nexport type TableRule = \"horizontal\" | \"grid\" | \"zebra\";\n/** A 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 TableCellStatus = NonNullable<TableCellVariantProps[\"status\"]>;\n/** A sort direction (spec §4/§7): reflected as `aria-sort` on the header and a non-color caret. */\nexport type TableSortDirection = \"ascending\" | \"descending\" | \"none\";\n\n// The presentation axes set ONCE on the root <table> travel to every cell via context (the proven\n// Dialog/Sheet/Tabs root-context pattern), so callers set density/rule on the Table and never repeat\n// them on each cell. `sticky` is read by the header. This is why the file is `'use client'` — it\n// uses React context (a hook). No cell roving is wired: a static Table's cells are read, not\n// operated; focus belongs to the interactive controls only (spec §6) — cell-by-cell arrow movement\n// is DataGrid behavior, not Table behavior, so there is no hand-rolled roving layer here.\ntype TableContextValue = { density: TableDensity; rule: TableRule };\nconst TableContext = React.createContext<TableContextValue>({\n density: \"comfortable\",\n rule: \"horizontal\",\n});\n\nexport interface TableProps extends React.TableHTMLAttributes<HTMLTableElement> {\n /** Row density (spec §3). `comfortable` (default) or `compact`. Applies to all cells via context. */\n density?: TableDensity;\n /** Rule treatment (spec §3). `horizontal` (default), `grid`, or `zebra`. Applies to rows/cells via context. */\n rule?: TableRule;\n /**\n * Pin the header row while the body scrolls (spec §3 sticky-header). The header gets the stronger\n * border-strong divider where a heavier separation reads better. Selection and sort are unchanged.\n */\n stickyHeader?: boolean;\n /**\n * The polite live-region message (spec §7/§8, WCAG 4.1.3 Status Messages). When a sort or a filter\n * changes the visible rows, set this to the result so it is announced — for example\n * \"Sorted by status, ascending. 42 rows.\" The visual reorder is not announced on its own, so a\n * silent re-sort breaks §8 (\"Don't re-sort silently\"). The caller owns the count (mirroring\n * CommandPalette): pass the new string whenever the visible rows change. Rendered into an sr-only\n * `role=\"status\" aria-live=\"polite\"` node, so it reaches assistive tech as text, never color alone.\n */\n announcement?: string;\n}\n\n/**\n * A Table presents structured data in rows and columns so you can read, compare, and sort records —\n * a list of API keys, verification events, or registered agents (spec §1). It is a SEMANTIC\n * `<table>`: the data has a real row-and-column relationship and the native markup carries that\n * relationship into the accessibility tree (1.3.1), not just the pixels. Reach for a DataGrid when\n * the data needs virtualized rows, in-cell editing, column resizing, or roving-focus cell navigation.\n *\n * Neutrals carry the table — it is a reading surface, not an accent surface (spec §3): most of it is\n * neutral text and hairline borders. A status color appears ONLY inside a cell that reports a real\n * state, paired with text (never a header, a whole row, or a decoration); the brand violet is NEVER\n * a Table variant — the brand is not a status, so it never tints a row, header, sort, or selection\n * (brand != state). For a first-class verified result in a cell, use the VerifiedBadge molecule.\n *\n * Name the table with a `<caption>` (preferred) or `aria-labelledby`/`aria-label`. It owns no\n * keyboard model of its own — focus belongs to the controls it hosts (a sortable header, a row\n * Checkbox, a row action), which keep their native tab stops in reading order (spec §6).\n */\nexport const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(\n { className, density = \"comfortable\", rule = \"horizontal\", stickyHeader = false, announcement, children, ...props },\n ref,\n) {\n const ctx = React.useMemo<TableContextValue>(() => ({ density, rule }), [density, rule]);\n // sticky is read by the header through a second, header-only context so the <thead> can pin\n // without the cell context carrying a presentation flag it does not use.\n const stickyCtx = React.useMemo(() => ({ sticky: stickyHeader }), [stickyHeader]);\n return (\n <TableContext.Provider value={ctx}>\n <TableStickyContext.Provider value={stickyCtx}>\n <table ref={ref} className={cn(tableVariants(), className)} {...props}>\n {children}\n </table>\n {/* The polite live region (spec §7/§8, WCAG 4.1.3): the result of a sort or filter — the new\n order and row count — announced as text, since the visual reorder is not announced on its\n own (a silent re-sort is the §8 \"Don't\"). Always present (the live region must exist before\n its text changes to be announced); sr-only so it never paints, and a sibling of the <table>,\n not a child, since a <span> is not valid table content. The caller feeds `announcement`. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {announcement}\n </span>\n </TableStickyContext.Provider>\n </TableContext.Provider>\n );\n});\n\nconst TableStickyContext = React.createContext<{ sticky: boolean }>({ sticky: false });\n\nexport type TableCaptionProps = React.HTMLAttributes<HTMLTableCaptionElement>;\n\n/**\n * The table's accessible name (spec §2/§7) — a `<caption>` naming what the data is, for example\n * \"Verification events, last 30 days\". Prefer it over a detached heading; add `className=\"sr-only\"`\n * to keep the name in the accessibility tree while hiding it visually.\n */\nexport const TableCaption = React.forwardRef<HTMLTableCaptionElement, TableCaptionProps>(\n function TableCaption({ className, ...props }, ref) {\n return <caption ref={ref} className={cn(tableCaptionClass, className)} {...props} />;\n },\n);\n\nexport type TableHeaderProps = React.HTMLAttributes<HTMLTableSectionElement>;\n\n/**\n * The `<thead>` holding the column-header row (spec §2). When the table's `stickyHeader` is set, the\n * header pins to the top of the scroll container with the stronger divider (spec §3/§5). The pinning\n * is read from the Table via context, so callers set it once on the Table.\n */\nexport const TableHeader = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>(\n function TableHeader({ className, ...props }, ref) {\n const { sticky } = React.useContext(TableStickyContext);\n return <thead ref={ref} className={cn(tableHeaderVariants({ sticky }), className)} {...props} />;\n },\n);\n\nexport interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {\n /**\n * The body is resolving (spec §4 Loading). Renders `skeletonRows` × `columns` Skeleton cells in the\n * table's own column layout — keeping the header and column widths stable so the table does not\n * reflow when data arrives — and marks the body `aria-busy=\"true\"`. A wait is a plain wait, not\n * theatre: the deliberate verified-check duration is never spent here.\n */\n loading?: boolean;\n /** How many skeleton rows to show while loading (spec §4 Loading). Default `3`. */\n skeletonRows?: number;\n /** How many columns the skeleton rows span, so the placeholder matches the real column layout. Default `1`. */\n columns?: number;\n}\n\n/**\n * The `<tbody>` holding the data rows (spec §2). In the `zebra` rule it alternates a neutral\n * raised-surface tint on even rows (read from the Table via context). While `loading`, it is\n * `aria-busy` and shows Skeleton rows in the column layout; the skeletons are decorative and the\n * body owns the wait (the Skeleton itself announces nothing, spec §4 Loading / §7).\n */\nexport const TableBody = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(\n function TableBody({ className, loading = false, skeletonRows = 3, columns = 1, children, ...props }, ref) {\n const { rule } = React.useContext(TableContext);\n return (\n <tbody\n ref={ref}\n aria-busy={loading || undefined}\n className={cn(tableBodyVariants({ rule }), className)}\n {...props}\n >\n {loading\n ? Array.from({ length: Math.max(1, skeletonRows) }, (_, r) => (\n <TableRow key={`skeleton-${r}`}>\n {Array.from({ length: Math.max(1, columns) }, (_, c) => (\n // a placeholder data cell in the column layout; decorative, so the wait is owned by\n // the body's aria-busy, not the skeleton (spec §4 Loading / §7).\n <td key={c} data-testid=\"table-skeleton-cell\" className={tableSkeletonCellClass}>\n <Skeleton variant=\"text\" />\n </td>\n ))}\n </TableRow>\n ))\n : children}\n </tbody>\n );\n },\n);\n\nexport type TableFooterProps = React.HTMLAttributes<HTMLTableSectionElement>;\n\n/**\n * The `<tfoot>` summary row (spec §2) — a total or an aggregate. It is DATA, not pagination;\n * pagination is a separate Pagination control beside the table.\n */\nexport const TableFooter = React.forwardRef<HTMLTableSectionElement, TableFooterProps>(\n function TableFooter({ className, ...props }, ref) {\n return (\n <tfoot ref={ref} className={cn(\"border-t border-border-default\", className)} {...props} />\n );\n },\n);\n\nexport interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {\n /**\n * A selectable row is currently selected (spec §4 Selected). Sets `aria-selected=\"true\"` and the\n * restrained neutral raised-surface fill. Selection is encoded by the row Checkbox's checked state\n * AND `aria-selected`, never by the fill alone, and NEVER a brand or status tint (brand != state).\n */\n selected?: boolean;\n}\n\n/**\n * A `<tr>` (spec §2/§4). A body row gets the restrained raised-surface hover fill (an affordance, to\n * track the eye across a wide row — nothing is selected until you act) and, when `selected`, the same\n * neutral fill plus `aria-selected`. The rule treatment (hairline / grid / zebra) is read from the\n * Table via context. Used inside `<thead>`, `<tbody>`, and `<tfoot>`.\n */\nexport const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(\n function TableRow({ className, selected = false, ...props }, ref) {\n const { rule } = React.useContext(TableContext);\n return (\n <tr\n ref={ref}\n aria-selected={selected || undefined}\n className={cn(tableRowVariants({ rule }), className)}\n {...props}\n />\n );\n },\n);\n\nexport interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {\n /**\n * This column can be re-sorted from its header (spec §3 sortable). Renders the header label as a\n * real `<button>` (the control it is) with a direction caret, and reflects `aria-sort` on the\n * `<th>`. Enable per column, not table-wide, so only meaningfully sortable columns advertise it.\n */\n sortable?: boolean;\n /**\n * The current sort direction for this column (spec §4 Sorted), reflected as `aria-sort` on the\n * `<th>` and as the caret glyph. Only one column is the sort column at a time — set `\"ascending\"`\n * or `\"descending\"` on it and `\"none\"` (the default) on the rest.\n */\n sortDirection?: TableSortDirection;\n /** Fired when the sortable header is activated (click / Enter / Space), so the caller re-sorts and updates `sortDirection`. */\n onSort?: () => void;\n /**\n * The column name used in the sort control's accessible name (\"Sort by {label}, {direction}\"),\n * when it differs from the visible children. Defaults to the children's text.\n */\n sortLabel?: string;\n}\n\n// The 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\n// when the column is sortable but not the active sort column.\nfunction SortCaret({ direction }: { direction: TableSortDirection }) {\n return (\n <span\n data-testid=\"table-sort-caret\"\n data-direction={direction}\n aria-hidden=\"true\"\n className={tableSortCaretClass}\n >\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 // sortable but not the active column: a quiet both-directions glyph\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<TableSortDirection, string> = {\n none: \"ascending\",\n ascending: \"descending\",\n descending: \"ascending\",\n};\n\n/**\n * A `<th scope=\"col\">` column header (spec §2/§7). A plain header is a quiet, tracked label in the\n * secondary text color. A `sortable` header wraps that label in a real `<button>` carrying the\n * ghost-action accent, a visible focus ring, the target-size floor, and a direction caret, and the\n * `<th>` reflects `aria-sort` — so sort direction reaches assistive tech as data and never rests on\n * color alone (1.4.1). The sort button's accessible name names the action and the column (\"Sort by\n * status, ascending\"). Density + grid rule are read from the Table via context.\n */\nexport const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(function TableHead(\n { className, sortable = false, sortDirection = \"none\", onSort, sortLabel, children, ...props },\n ref,\n) {\n const { density, rule } = React.useContext(TableContext);\n const ariaSort = sortable ? sortDirection : undefined;\n const label = sortLabel ?? (typeof children === \"string\" ? children : undefined);\n return (\n <th\n ref={ref}\n scope=\"col\"\n // exactly one header carries aria-sort at a time (spec §4 Sorted); the rest are \"none\"/absent\n aria-sort={ariaSort}\n className={cn(tableHeadVariants({ density, rule }), className)}\n {...props}\n >\n {sortable ? (\n <button\n type=\"button\"\n onClick={onSort}\n // the control's name includes the ACTION and the COLUMN, plus the NEXT direction it will\n // sort to — so a screen-reader user knows what activating it does (spec §7). aria-sort on\n // the th already exposes the CURRENT state.\n aria-label={label ? `Sort by ${label}, ${NEXT_DIRECTION_WORD[sortDirection]}` : undefined}\n className={tableSortButtonClass}\n >\n {children}\n <SortCaret direction={sortDirection} />\n </button>\n ) : (\n children\n )}\n </th>\n );\n});\n\nexport type TableRowHeaderProps = React.ThHTMLAttributes<HTMLTableCellElement>;\n\n/**\n * A `<th scope=\"row\">` row-header cell (spec §2/§7) — the row's natural label (an identifier, a\n * name), tying the row's cells to it. It reads at the same body weight as a data cell but is promoted\n * to a header for the relationship (1.3.1). Density + grid rule are read from the Table via context.\n */\nexport const TableRowHeader = React.forwardRef<HTMLTableCellElement, TableRowHeaderProps>(\n function TableRowHeader({ className, ...props }, ref) {\n const { density, rule } = React.useContext(TableContext);\n return (\n <th\n ref={ref}\n scope=\"row\"\n className={cn(tableRowHeaderVariants({ density, rule }), className)}\n {...props}\n />\n );\n },\n);\n\nexport interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {\n /**\n * The cell reports a real STATE (spec §3/§4): `verified`, `signal`, `caution`, or `critical`. The\n * status color lives in the CELL only (never the row or header), paired with text — so a grayscale\n * reader still reads the state from the words. NEVER a brand token. For a first-class verified\n * result use the VerifiedBadge molecule inside a plain cell, not a hand-tinted cell.\n */\n status?: TableCellStatus;\n /** A numeric cell (spec §4/§5): tabular figures so digits align down the column, end-aligned, in the PRIMARY data text role. */\n numeric?: boolean;\n /** De-emphasized AUXILIARY cell text (spec §5) — a timestamp, a unit. Takes the muted role; independent of `numeric`. */\n auxiliary?: boolean;\n}\n\n/**\n * A `<td>` data cell (spec §2 cell). Holds text, a number, a Badge, an Avatar, or a small inline\n * control. A plain cell is neutral primary text. A `numeric` cell uses tabular figures and stays in\n * the PRIMARY data text role (the muted role is reserved for `auxiliary` text — a timestamp, a unit —\n * per spec §5); a cell that reports a real state takes a `status` treatment (the status fg paired with\n * text). It is NOT a focus stop — focus belongs to any interactive control inside it (spec §6).\n * Density + grid rule are read from the Table via context.\n */\nexport const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function TableCell(\n { className, status = \"none\", numeric = false, auxiliary = false, ...props },\n ref,\n) {\n const { density, rule } = React.useContext(TableContext);\n return (\n <td\n ref={ref}\n className={cn(tableCellVariants({ density, rule, numeric, auxiliary, status }), className)}\n {...props}\n />\n );\n});\n\nexport interface TableEmptyProps extends React.TdHTMLAttributes<HTMLTableCellElement> {\n /** How many columns the empty line spans, so it fills the table'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 there is nothing yet and\n * what to do next, in plain words ending in a period. An empty table is NOT an error and never reads\n * as one — no status color. Render it inside the `<tbody>` when there are zero rows after loading.\n */\nexport const TableEmpty = React.forwardRef<HTMLTableCellElement, TableEmptyProps>(\n function TableEmpty({ className, colSpan = 1, children, ...props }, ref) {\n return (\n <tr>\n <td ref={ref} colSpan={colSpan} className={cn(tableEmptyClass, className)} {...props}>\n {children}\n </td>\n </tr>\n );\n },\n);\n",
14
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport {\n tableVariants,\n tableCaptionClass,\n tableHeaderVariants,\n tableBodyVariants,\n tableRowVariants,\n tableCellVariants,\n tableRowHeaderVariants,\n tableHeadVariants,\n tableSortButtonClass,\n tableSortCaretClass,\n tableEmptyClass,\n tableSkeletonCellClass,\n type TableCellVariantProps,\n} from \"./table.variants\";\n\n/** The row density (spec §3): `comfortable` (default) for general reading, `compact` for dense console views. */\nexport type TableDensity = \"comfortable\" | \"compact\";\n/** The rule treatment (spec §3): row hairlines (`horizontal`, default), `grid` (rows + columns), or `zebra` (alternating tint). */\nexport type TableRule = \"horizontal\" | \"grid\" | \"zebra\";\n/** A 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 TableCellStatus = NonNullable<TableCellVariantProps[\"status\"]>;\n/** A sort direction (spec §4/§7): reflected as `aria-sort` on the header and a non-color caret. */\nexport type TableSortDirection = \"ascending\" | \"descending\" | \"none\";\n\n// The presentation axes set ONCE on the root <table> travel to every cell via context (the proven\n// Dialog/Sheet/Tabs root-context pattern), so callers set density/rule on the Table and never repeat\n// them on each cell. `sticky` is read by the header. This is why the file is `'use client'` — it\n// uses React context (a hook). No cell roving is wired: a static Table's cells are read, not\n// operated; focus belongs to the interactive controls only (spec §6) — cell-by-cell arrow movement\n// is DataGrid behavior, not Table behavior, so there is no hand-rolled roving layer here.\ntype TableContextValue = { density: TableDensity; rule: TableRule };\nconst TableContext = React.createContext<TableContextValue>({\n density: \"comfortable\",\n rule: \"horizontal\",\n});\n\nexport interface TableProps extends React.TableHTMLAttributes<HTMLTableElement> {\n /** Row density (spec §3). `comfortable` (default) or `compact`. Applies to all cells via context. */\n density?: TableDensity;\n /** Rule treatment (spec §3). `horizontal` (default), `grid`, or `zebra`. Applies to rows/cells via context. */\n rule?: TableRule;\n /**\n * Pin the header row while the body scrolls (spec §3 sticky-header). The header gets the stronger\n * border-strong divider where a heavier separation reads better. Selection and sort are unchanged.\n */\n stickyHeader?: boolean;\n /**\n * The polite live-region message (spec §7/§8, WCAG 4.1.3 Status Messages). When a sort or a filter\n * changes the visible rows, set this to the result so it is announced — for example\n * \"Sorted by status, ascending. 42 rows.\" The visual reorder is not announced on its own, so a\n * silent re-sort breaks §8 (\"Don't re-sort silently\"). The caller owns the count (mirroring\n * CommandPalette): pass the new string whenever the visible rows change. Rendered into an sr-only\n * `role=\"status\" aria-live=\"polite\"` node, so it reaches assistive tech as text, never color alone.\n */\n announcement?: string;\n}\n\n/**\n * A Table presents structured data in rows and columns so you can read, compare, and sort records —\n * a list of API keys, verification events, or registered agents (spec §1). It is a SEMANTIC\n * `<table>`: the data has a real row-and-column relationship and the native markup carries that\n * relationship into the accessibility tree (1.3.1), not just the pixels. Reach for a DataGrid when\n * the data needs virtualized rows, in-cell editing, column resizing, or roving-focus cell navigation.\n *\n * Neutrals carry the table — it is a reading surface, not an accent surface (spec §3): most of it is\n * neutral text and hairline borders. A status color appears ONLY inside a cell that reports a real\n * state, paired with text (never a header, a whole row, or a decoration); the brand violet is NEVER\n * a Table variant — the brand is not a status, so it never tints a row, header, sort, or selection\n * (brand != state). For a first-class verified result in a cell, use the VerifiedBadge molecule.\n *\n * Name the table with a `<caption>` (preferred) or `aria-labelledby`/`aria-label`. It owns no\n * keyboard model of its own — focus belongs to the controls it hosts (a sortable header, a row\n * Checkbox, a row action), which keep their native tab stops in reading order (spec §6).\n */\nexport const Table = React.forwardRef<HTMLTableElement, TableProps>(function Table(\n { className, density = \"comfortable\", rule = \"horizontal\", stickyHeader = false, announcement, children, ...props },\n ref,\n) {\n const ctx = React.useMemo<TableContextValue>(() => ({ density, rule }), [density, rule]);\n // sticky is read by the header through a second, header-only context so the <thead> can pin\n // without the cell context carrying a presentation flag it does not use.\n const stickyCtx = React.useMemo(() => ({ sticky: stickyHeader }), [stickyHeader]);\n return (\n <TableContext.Provider value={ctx}>\n <TableStickyContext.Provider value={stickyCtx}>\n {/* overflow-x-auto: the table may be wider than the viewport on 320–414px screens.\n The scroll container keeps the page body from overflowing while letting a wide\n table scroll horizontally within its own bounds — the same pattern DataGrid uses\n for its `overflow-auto` scroll container (spec §5). The sticky header (if any)\n pins within this scroll container, which is correct behavior. */}\n <div className=\"w-full overflow-x-auto\">\n <table ref={ref} className={cn(tableVariants(), className)} {...props}>\n {children}\n </table>\n </div>\n {/* The polite live region (spec §7/§8, WCAG 4.1.3): the result of a sort or filter — the new\n order and row count — announced as text, since the visual reorder is not announced on its\n own (a silent re-sort is the §8 \"Don't\"). Always present (the live region must exist before\n its text changes to be announced); sr-only so it never paints, and a sibling of the <table>,\n not a child, since a <span> is not valid table content. The caller feeds `announcement`. */}\n <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n {announcement}\n </span>\n </TableStickyContext.Provider>\n </TableContext.Provider>\n );\n});\n\nconst TableStickyContext = React.createContext<{ sticky: boolean }>({ sticky: false });\n\nexport type TableCaptionProps = React.HTMLAttributes<HTMLTableCaptionElement>;\n\n/**\n * The table's accessible name (spec §2/§7) — a `<caption>` naming what the data is, for example\n * \"Verification events, last 30 days\". Prefer it over a detached heading; add `className=\"sr-only\"`\n * to keep the name in the accessibility tree while hiding it visually.\n */\nexport const TableCaption = React.forwardRef<HTMLTableCaptionElement, TableCaptionProps>(\n function TableCaption({ className, ...props }, ref) {\n return <caption ref={ref} className={cn(tableCaptionClass, className)} {...props} />;\n },\n);\n\nexport type TableHeaderProps = React.HTMLAttributes<HTMLTableSectionElement>;\n\n/**\n * The `<thead>` holding the column-header row (spec §2). When the table's `stickyHeader` is set, the\n * header pins to the top of the scroll container with the stronger divider (spec §3/§5). The pinning\n * is read from the Table via context, so callers set it once on the Table.\n */\nexport const TableHeader = React.forwardRef<HTMLTableSectionElement, TableHeaderProps>(\n function TableHeader({ className, ...props }, ref) {\n const { sticky } = React.useContext(TableStickyContext);\n return <thead ref={ref} className={cn(tableHeaderVariants({ sticky }), className)} {...props} />;\n },\n);\n\nexport interface TableBodyProps extends React.HTMLAttributes<HTMLTableSectionElement> {\n /**\n * The body is resolving (spec §4 Loading). Renders `skeletonRows` × `columns` Skeleton cells in the\n * table's own column layout — keeping the header and column widths stable so the table does not\n * reflow when data arrives — and marks the body `aria-busy=\"true\"`. A wait is a plain wait, not\n * theatre: the deliberate verified-check duration is never spent here.\n */\n loading?: boolean;\n /** How many skeleton rows to show while loading (spec §4 Loading). Default `3`. */\n skeletonRows?: number;\n /** How many columns the skeleton rows span, so the placeholder matches the real column layout. Default `1`. */\n columns?: number;\n}\n\n/**\n * The `<tbody>` holding the data rows (spec §2). In the `zebra` rule it alternates a neutral\n * raised-surface tint on even rows (read from the Table via context). While `loading`, it is\n * `aria-busy` and shows Skeleton rows in the column layout; the skeletons are decorative and the\n * body owns the wait (the Skeleton itself announces nothing, spec §4 Loading / §7).\n */\nexport const TableBody = React.forwardRef<HTMLTableSectionElement, TableBodyProps>(\n function TableBody({ className, loading = false, skeletonRows = 3, columns = 1, children, ...props }, ref) {\n const { rule } = React.useContext(TableContext);\n return (\n <tbody\n ref={ref}\n aria-busy={loading || undefined}\n className={cn(tableBodyVariants({ rule }), className)}\n {...props}\n >\n {loading\n ? Array.from({ length: Math.max(1, skeletonRows) }, (_, r) => (\n <TableRow key={`skeleton-${r}`}>\n {Array.from({ length: Math.max(1, columns) }, (_, c) => (\n // a placeholder data cell in the column layout; decorative, so the wait is owned by\n // the body's aria-busy, not the skeleton (spec §4 Loading / §7).\n <td key={c} data-testid=\"table-skeleton-cell\" className={tableSkeletonCellClass}>\n <Skeleton variant=\"text\" />\n </td>\n ))}\n </TableRow>\n ))\n : children}\n </tbody>\n );\n },\n);\n\nexport type TableFooterProps = React.HTMLAttributes<HTMLTableSectionElement>;\n\n/**\n * The `<tfoot>` summary row (spec §2) — a total or an aggregate. It is DATA, not pagination;\n * pagination is a separate Pagination control beside the table.\n */\nexport const TableFooter = React.forwardRef<HTMLTableSectionElement, TableFooterProps>(\n function TableFooter({ className, ...props }, ref) {\n return (\n <tfoot ref={ref} className={cn(\"border-t border-border-default\", className)} {...props} />\n );\n },\n);\n\nexport interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {\n /**\n * A selectable row is currently selected (spec §4 Selected). Sets `aria-selected=\"true\"` and the\n * restrained neutral raised-surface fill. Selection is encoded by the row Checkbox's checked state\n * AND `aria-selected`, never by the fill alone, and NEVER a brand or status tint (brand != state).\n */\n selected?: boolean;\n}\n\n/**\n * A `<tr>` (spec §2/§4). A body row gets the restrained raised-surface hover fill (an affordance, to\n * track the eye across a wide row — nothing is selected until you act) and, when `selected`, the same\n * neutral fill plus `aria-selected`. The rule treatment (hairline / grid / zebra) is read from the\n * Table via context. Used inside `<thead>`, `<tbody>`, and `<tfoot>`.\n */\nexport const TableRow = React.forwardRef<HTMLTableRowElement, TableRowProps>(\n function TableRow({ className, selected = false, ...props }, ref) {\n const { rule } = React.useContext(TableContext);\n return (\n <tr\n ref={ref}\n aria-selected={selected || undefined}\n className={cn(tableRowVariants({ rule }), className)}\n {...props}\n />\n );\n },\n);\n\nexport interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {\n /**\n * This column can be re-sorted from its header (spec §3 sortable). Renders the header label as a\n * real `<button>` (the control it is) with a direction caret, and reflects `aria-sort` on the\n * `<th>`. Enable per column, not table-wide, so only meaningfully sortable columns advertise it.\n */\n sortable?: boolean;\n /**\n * The current sort direction for this column (spec §4 Sorted), reflected as `aria-sort` on the\n * `<th>` and as the caret glyph. Only one column is the sort column at a time — set `\"ascending\"`\n * or `\"descending\"` on it and `\"none\"` (the default) on the rest.\n */\n sortDirection?: TableSortDirection;\n /** Fired when the sortable header is activated (click / Enter / Space), so the caller re-sorts and updates `sortDirection`. */\n onSort?: () => void;\n /**\n * The column name used in the sort control's accessible name (\"Sort by {label}, {direction}\"),\n * when it differs from the visible children. Defaults to the children's text.\n */\n sortLabel?: string;\n}\n\n// The 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\n// when the column is sortable but not the active sort column.\nfunction SortCaret({ direction }: { direction: TableSortDirection }) {\n return (\n <span\n data-testid=\"table-sort-caret\"\n data-direction={direction}\n aria-hidden=\"true\"\n className={tableSortCaretClass}\n >\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 // sortable but not the active column: a quiet both-directions glyph\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<TableSortDirection, string> = {\n none: \"ascending\",\n ascending: \"descending\",\n descending: \"ascending\",\n};\n\n/**\n * A `<th scope=\"col\">` column header (spec §2/§7). A plain header is a quiet, tracked label in the\n * secondary text color. A `sortable` header wraps that label in a real `<button>` carrying the\n * ghost-action accent, a visible focus ring, the target-size floor, and a direction caret, and the\n * `<th>` reflects `aria-sort` — so sort direction reaches assistive tech as data and never rests on\n * color alone (1.4.1). The sort button's accessible name names the action and the column (\"Sort by\n * status, ascending\"). Density + grid rule are read from the Table via context.\n */\nexport const TableHead = React.forwardRef<HTMLTableCellElement, TableHeadProps>(function TableHead(\n { className, sortable = false, sortDirection = \"none\", onSort, sortLabel, children, ...props },\n ref,\n) {\n const { density, rule } = React.useContext(TableContext);\n const ariaSort = sortable ? sortDirection : undefined;\n const label = sortLabel ?? (typeof children === \"string\" ? children : undefined);\n return (\n <th\n ref={ref}\n scope=\"col\"\n // exactly one header carries aria-sort at a time (spec §4 Sorted); the rest are \"none\"/absent\n aria-sort={ariaSort}\n className={cn(tableHeadVariants({ density, rule }), className)}\n {...props}\n >\n {sortable ? (\n <button\n type=\"button\"\n onClick={onSort}\n // the control's name includes the ACTION and the COLUMN, plus the NEXT direction it will\n // sort to — so a screen-reader user knows what activating it does (spec §7). aria-sort on\n // the th already exposes the CURRENT state.\n aria-label={label ? `Sort by ${label}, ${NEXT_DIRECTION_WORD[sortDirection]}` : undefined}\n className={tableSortButtonClass}\n >\n {children}\n <SortCaret direction={sortDirection} />\n </button>\n ) : (\n children\n )}\n </th>\n );\n});\n\nexport type TableRowHeaderProps = React.ThHTMLAttributes<HTMLTableCellElement>;\n\n/**\n * A `<th scope=\"row\">` row-header cell (spec §2/§7) — the row's natural label (an identifier, a\n * name), tying the row's cells to it. It reads at the same body weight as a data cell but is promoted\n * to a header for the relationship (1.3.1). Density + grid rule are read from the Table via context.\n */\nexport const TableRowHeader = React.forwardRef<HTMLTableCellElement, TableRowHeaderProps>(\n function TableRowHeader({ className, ...props }, ref) {\n const { density, rule } = React.useContext(TableContext);\n return (\n <th\n ref={ref}\n scope=\"row\"\n className={cn(tableRowHeaderVariants({ density, rule }), className)}\n {...props}\n />\n );\n },\n);\n\nexport interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {\n /**\n * The cell reports a real STATE (spec §3/§4): `verified`, `signal`, `caution`, or `critical`. The\n * status color lives in the CELL only (never the row or header), paired with text — so a grayscale\n * reader still reads the state from the words. NEVER a brand token. For a first-class verified\n * result use the VerifiedBadge molecule inside a plain cell, not a hand-tinted cell.\n */\n status?: TableCellStatus;\n /** A numeric cell (spec §4/§5): tabular figures so digits align down the column, end-aligned, in the PRIMARY data text role. */\n numeric?: boolean;\n /** De-emphasized AUXILIARY cell text (spec §5) — a timestamp, a unit. Takes the muted role; independent of `numeric`. */\n auxiliary?: boolean;\n}\n\n/**\n * A `<td>` data cell (spec §2 cell). Holds text, a number, a Badge, an Avatar, or a small inline\n * control. A plain cell is neutral primary text. A `numeric` cell uses tabular figures and stays in\n * the PRIMARY data text role (the muted role is reserved for `auxiliary` text — a timestamp, a unit —\n * per spec §5); a cell that reports a real state takes a `status` treatment (the status fg paired with\n * text). It is NOT a focus stop — focus belongs to any interactive control inside it (spec §6).\n * Density + grid rule are read from the Table via context.\n */\nexport const TableCell = React.forwardRef<HTMLTableCellElement, TableCellProps>(function TableCell(\n { className, status = \"none\", numeric = false, auxiliary = false, ...props },\n ref,\n) {\n const { density, rule } = React.useContext(TableContext);\n return (\n <td\n ref={ref}\n className={cn(tableCellVariants({ density, rule, numeric, auxiliary, status }), className)}\n {...props}\n />\n );\n});\n\nexport interface TableEmptyProps extends React.TdHTMLAttributes<HTMLTableCellElement> {\n /** How many columns the empty line spans, so it fills the table'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 there is nothing yet and\n * what to do next, in plain words ending in a period. An empty table is NOT an error and never reads\n * as one — no status color. Render it inside the `<tbody>` when there are zero rows after loading.\n */\nexport const TableEmpty = React.forwardRef<HTMLTableCellElement, TableEmptyProps>(\n function TableEmpty({ className, colSpan = 1, children, ...props }, ref) {\n return (\n <tr>\n <td ref={ref} colSpan={colSpan} className={cn(tableEmptyClass, className)} {...props}>\n {children}\n </td>\n </tr>\n );\n },\n);\n",
15
15
  "path": "table/table.tsx",
16
16
  "target": "@ui/table/table.tsx",
17
17
  "type": "registry:ui"
18
18
  },
19
19
  {
20
- "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// A Table presents structured data in rows and columns (spec §1). Neutrals carry the table —\n// restraint over volume (spec §3): it is a READING surface, not an accent surface. It paints from\n// the surface, text, and border roles only; it reaches the --color-action-* tier ONLY for the\n// controls it hosts (a sortable header's ghost accent + focus affordances) and the --color-status-*\n// tier ONLY for a cell that reports a real state, paired with text — never for a row, a header, or\n// a selected state, and NEVER a brand token at all (brand != state, G-U2). The status meaning lives\n// in the cell's words + the status fg, so a grayscale or color-blind reader still reads it (1.4.1).\n\n// The <table> container (spec §2): the neutral canvas surface and the default cell text role. It\n// collapses its borders so the row/column hairline rules read as single lines, and aligns text on\n// the logical start edge so it mirrors under dir=\"rtl\" (G-U6). The table NEVER wears the brand\n// violet or a status fill (spec §3/§8) — those belong to the controls and badges inside cells.\nexport const tableVariants = cva([\n \"w-full border-collapse text-start\",\n \"bg-surface-canvas text-body text-text-primary\",\n]);\n\nexport type TableVariantProps = VariantProps<typeof tableVariants>;\n\n// The <caption> (spec §2/§7): the table's accessible name, in the secondary text color at the\n// label type role. A reading caption, not an accent — never the brand or a status color.\nexport const tableCaptionClass = \"text-start text-label text-text-secondary mb-(--space-2)\";\n\n// The <thead> (spec §2). The header divider is a hairline by default; on a sticky-header table the\n// header pins to the top of the scroll container and the divider strengthens to border-strong,\n// where a heavier separation reads better as the body scrolls under it (spec §4/§5). z on the\n// sticky layer so a scrolled body cell never paints over the pinned header.\nexport const tableHeaderVariants = cva(\"border-b border-border-default\", {\n variants: {\n sticky: {\n true: \"sticky top-0 z-(--z-index-sticky) bg-surface-canvas border-b-border-strong\",\n false: \"\",\n },\n },\n defaultVariants: { sticky: false },\n});\n\nexport type TableHeaderVariantProps = VariantProps<typeof tableHeaderVariants>;\n\n// The <tbody> (spec §3 rule). The `zebra` rule replaces row hairlines with an alternating NEUTRAL\n// surface step on even rows, for a long table — the tint is a surface step, NEVER a status or brand\n// color (spec §3/§8). The arbitrary selector `[&>tr:nth-child(even)]` is a SELECTOR (its body\n// starts with `&`, not a raw value or a bare `--token`), so it is gate-legitimate, not an arbitrary\n// VALUE. While loading, the body is aria-busy (set on the element, not bound here) and shows\n// skeleton rows in the column layout (spec §4 Loading).\nexport const tableBodyVariants = cva(\"\", {\n variants: {\n rule: {\n // hairlines/grid carry their rules on the rows + cells, so the body adds nothing\n horizontal: \"\",\n grid: \"\",\n // zebra: the neutral raised surface step on even rows (no rules)\n zebra: \"[&>tr:nth-child(even)]:bg-surface-raised\",\n },\n },\n defaultVariants: { rule: \"horizontal\" },\n});\n\nexport type TableBodyVariantProps = VariantProps<typeof tableBodyVariants>;\n\n// A <tr> body row (spec §4 Default/Hover/Selected). RESTING: no fill, on the canvas. HOVER: a\n// restrained raised-surface fill to track the eye across a wide row — an AFFORDANCE, not a\n// selection (nothing is selected until you act, spec §4 Hover). SELECTED (aria-selected): the same\n// restrained raised-surface fill, encoded by the Checkbox state + aria-selected, NEVER a brand or\n// status tint (brand != state, G-U2) — so a grayscale reader reads selection from the checkbox, not\n// the fill. Motion is the fast token transition on the verdify easing, instant under reduced motion\n// — never the 350ms VerifiedBadge-only theatre (a row hover/select is a plain transition, G-U3).\n// The `zebra` rule turns off the per-row hairline; `horizontal`/`grid` keep the bottom hairline.\nexport const tableRowVariants = cva(\n [\n \"hover:bg-surface-raised\",\n \"aria-selected:bg-surface-raised\",\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n ],\n {\n variants: {\n rule: {\n horizontal: \"border-b border-border-default\",\n grid: \"border-b border-border-default\",\n zebra: \"\",\n },\n },\n defaultVariants: { rule: \"horizontal\" },\n },\n);\n\nexport type TableRowVariantProps = VariantProps<typeof tableRowVariants>;\n\n// The shared cell padding by density (spec §3 density / §5 --space-*). Density tightens the VERTICAL\n// padding only, ABOVE the a11y floor — the row controls keep their own --size-target-* floor (DEC-B:\n// never a fixed height below the floor). The grid rule adds a logical inline-end column rule so it\n// mirrors under dir=\"rtl\" (G-U6). Horizontal inline padding is constant.\nconst cellPaddingVariants = {\n density: {\n comfortable: \"py-(--space-3)\",\n compact: \"py-(--space-1)\",\n },\n rule: {\n horizontal: \"\",\n // a logical column rule on each cell, for a wide numeric table's column guide (spec §3 grid)\n grid: \"border-e border-border-default last:border-e-0\",\n zebra: \"\",\n },\n} as const;\n\n// A <td> data cell (spec §2 cell, §4/§5). Default: the primary text color at the body type role.\n// A `numeric` cell uses TABULAR figures so digits align down the column and end-aligns them (spec §4\n// Default/§5) — numeric DATA is primary text, NOT muted (spec §4 assigns cell text the primary role;\n// the muted role is reserved by spec §5 for de-emphasized AUXILIARY text, a timestamp or a unit). An\n// `auxiliary` cell takes the muted role explicitly (spec §5 --color-text-muted), independent of\n// `numeric`. A cell that reports a real STATE carries the status fg paired with text — 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// cell's words, not a saturated fill.\nexport const tableCellVariants = cva(\n [\"px-(--space-3) align-middle text-start text-body text-text-primary\"],\n {\n variants: {\n ...cellPaddingVariants,\n numeric: {\n // tabular figures + end-aligned numbers down the column — primary data text, not muted\n true: \"text-end tabular-nums\",\n false: \"\",\n },\n auxiliary: {\n // de-emphasized auxiliary cell text — a timestamp, a unit (spec §5 --color-text-muted)\n true: \"text-text-muted\",\n false: \"\",\n },\n status: {\n none: \"\",\n // each status is the fg only, paired with the cell's text — the cell's words carry the\n // meaning, 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 rule: \"horizontal\",\n numeric: false,\n auxiliary: false,\n status: \"none\",\n },\n },\n);\n\nexport type TableCellVariantProps = VariantProps<typeof tableCellVariants>;\n\n// A <th scope=\"row\"> row-header cell (spec §2/§7): the row's natural label (an identifier, a name),\n// tying its cells to the row. The primary text color at the body type role — the same reading weight\n// as a data cell, just promoted to a header for the relationship (1.3.1). Density + grid rule apply.\nexport const tableRowHeaderVariants = cva(\n [\"px-(--space-3) align-middle text-start font-normal text-body text-text-primary\"],\n {\n variants: { ...cellPaddingVariants },\n defaultVariants: { density: \"comfortable\", rule: \"horizontal\" },\n },\n);\n\nexport type TableRowHeaderVariantProps = VariantProps<typeof tableRowHeaderVariants>;\n\n// A <th scope=\"col\"> column header (spec §2/§4/§5). The header LABEL is the SECONDARY text color at\n// the label type role (the quiet, tracked column label), on the canvas — a header NEVER wears a\n// status or brand tint (spec §3/§8). Density + grid rule apply to the cell padding.\nexport const tableHeadVariants = cva(\n [\"px-(--space-3) align-middle text-start text-label text-text-secondary\"],\n {\n variants: { ...cellPaddingVariants },\n defaultVariants: { density: \"comfortable\", rule: \"horizontal\" },\n },\n);\n\nexport type TableHeadVariantProps = VariantProps<typeof tableHeadVariants>;\n\n// The SORTABLE-header control (spec §2/§4/§6/§7): a real <button> inside the <th>, so it reads as\n// the control it is and re-sorts on Enter/Space. It is the GHOST action accent — the label + caret\n// in the ghost fg with the restrained ghost hover fill (spec §4 Sortable-header hover / §5) — the\n// action tier is legitimate here because it is a control the table HOSTS, not a row/header tint. It\n// carries the visible 2px focus ring (never removed) and the target-size floor (40px desktop / 44px\n// touch, spec §7). Motion is the fast token transition, never the deliberate verified-check theatre\n// (G-U3). aria-sort lives on the parent <th>, and the direction caret encodes direction alongside\n// it so it never rests on color alone (spec §4 Sorted / 1.4.1).\nexport const tableSortButtonClass =\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\n// also encoded by aria-sort on the th + the glyph's shape via data-direction, so it never rests on\n// color alone — 1.4.1). It inherits the ghost accent color from the button.\nexport const tableSortCaretClass =\n \"inline-flex h-(--size-icon-sm) w-(--size-icon-sm) shrink-0 items-center justify-center\";\n\n// The empty-state cell (spec §2/§4 Empty): a plain line spanning the full table width, in the\n// secondary text color — an empty table is not an error and never reads as one (no status color).\nexport const tableEmptyClass =\n \"px-(--space-3) py-(--space-4) text-center text-body text-text-secondary\";\n\n// One skeleton placeholder cell while the body resolves (spec §4 Loading): keeps the column layout\n// stable so the table does not reflow when data arrives. The Skeleton itself is decorative + neutral\n// (it binds no brand/status — that invariant lives in the Skeleton component); this is just the cell\n// padding wrapping it.\nexport const tableSkeletonCellClass = \"px-(--space-3) py-(--space-3)\";\n",
20
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// A Table presents structured data in rows and columns (spec §1). Neutrals carry the table —\n// restraint over volume (spec §3): it is a READING surface, not an accent surface. It paints from\n// the surface, text, and border roles only; it reaches the --color-action-* tier ONLY for the\n// controls it hosts (a sortable header's ghost accent + focus affordances) and the --color-status-*\n// tier ONLY for a cell that reports a real state, paired with text — never for a row, a header, or\n// a selected state, and NEVER a brand token at all (brand != state, G-U2). The status meaning lives\n// in the cell's words + the status fg, so a grayscale or color-blind reader still reads it (1.4.1).\n\n// The <table> container (spec §2): the neutral canvas surface and the default cell text role. It\n// collapses its borders so the row/column hairline rules read as single lines, and aligns text on\n// the logical start edge so it mirrors under dir=\"rtl\" (G-U6). The table NEVER wears the brand\n// violet or a status fill (spec §3/§8) — those belong to the controls and badges inside cells.\nexport const tableVariants = cva([\n \"w-full border-collapse text-start\",\n \"bg-surface-canvas text-body text-text-primary\",\n]);\n\nexport type TableVariantProps = VariantProps<typeof tableVariants>;\n\n// The <caption> (spec §2/§7): the table's accessible name, in the secondary text color at the\n// label type role. A reading caption, not an accent — never the brand or a status color.\nexport const tableCaptionClass = \"text-start text-label text-text-secondary mb-(--space-2)\";\n\n// The <thead> (spec §2). The header divider is a hairline by default; on a sticky-header table the\n// header pins to the top of the scroll container and the divider strengthens to border-strong,\n// where a heavier separation reads better as the body scrolls under it (spec §4/§5). z on the\n// sticky layer so a scrolled body cell never paints over the pinned header.\nexport const tableHeaderVariants = cva(\"border-b border-border-default\", {\n variants: {\n sticky: {\n true: \"sticky top-0 z-(--z-index-sticky) bg-surface-canvas border-b-border-strong\",\n false: \"\",\n },\n },\n defaultVariants: { sticky: false },\n});\n\nexport type TableHeaderVariantProps = VariantProps<typeof tableHeaderVariants>;\n\n// The <tbody> (spec §3 rule). The `zebra` rule replaces row hairlines with an alternating NEUTRAL\n// surface step on even rows, for a long table — the tint is a surface step, NEVER a status or brand\n// color (spec §3/§8). The arbitrary selector `[&>tr:nth-child(even)]` is a SELECTOR (its body\n// starts with `&`, not a raw value or a bare `--token`), so it is gate-legitimate, not an arbitrary\n// VALUE. While loading, the body is aria-busy (set on the element, not bound here) and shows\n// skeleton rows in the column layout (spec §4 Loading).\nexport const tableBodyVariants = cva(\"\", {\n variants: {\n rule: {\n // hairlines/grid carry their rules on the rows + cells, so the body adds nothing\n horizontal: \"\",\n grid: \"\",\n // zebra: the neutral raised surface step on even rows (no rules)\n zebra: \"[&>tr:nth-child(even)]:bg-surface-raised\",\n },\n },\n defaultVariants: { rule: \"horizontal\" },\n});\n\nexport type TableBodyVariantProps = VariantProps<typeof tableBodyVariants>;\n\n// A <tr> body row (spec §4 Default/Hover/Selected). RESTING: no fill, on the canvas. HOVER: a\n// restrained raised-surface fill to track the eye across a wide row — an AFFORDANCE, not a\n// selection (nothing is selected until you act, spec §4 Hover). SELECTED (aria-selected): the same\n// restrained raised-surface fill, encoded by the Checkbox state + aria-selected, NEVER a brand or\n// status tint (brand != state, G-U2) — so a grayscale reader reads selection from the checkbox, not\n// the fill. Motion is the fast token transition on the verdify easing, instant under reduced motion\n// — never the 350ms VerifiedBadge-only theatre (a row hover/select is a plain transition, G-U3).\n// The `zebra` rule turns off the per-row hairline; `horizontal`/`grid` keep the bottom hairline.\nexport const tableRowVariants = cva(\n [\n \"hover:bg-surface-raised\",\n \"aria-selected:bg-surface-raised\",\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n ],\n {\n variants: {\n rule: {\n horizontal: \"border-b border-border-default\",\n grid: \"border-b border-border-default\",\n zebra: \"\",\n },\n },\n defaultVariants: { rule: \"horizontal\" },\n },\n);\n\nexport type TableRowVariantProps = VariantProps<typeof tableRowVariants>;\n\n// The shared cell padding by density (spec §3 density / §5 --space-*). Density tightens the VERTICAL\n// padding only, ABOVE the a11y floor — the row controls keep their own --size-target-* floor (DEC-B:\n// never a fixed height below the floor). The grid rule adds a logical inline-end column rule so it\n// mirrors under dir=\"rtl\" (G-U6). Horizontal inline padding is constant.\nconst cellPaddingVariants = {\n density: {\n comfortable: \"py-(--space-3)\",\n compact: \"py-(--space-1)\",\n },\n rule: {\n horizontal: \"\",\n // a logical column rule on each cell, for a wide numeric table's column guide (spec §3 grid)\n grid: \"border-e border-border-default last:border-e-0\",\n zebra: \"\",\n },\n} as const;\n\n// A <td> data cell (spec §2 cell, §4/§5). Default: the primary text color at the body type role.\n// A `numeric` cell uses TABULAR figures so digits align down the column and end-aligns them (spec §4\n// Default/§5) — numeric DATA is primary text, NOT muted (spec §4 assigns cell text the primary role;\n// the muted role is reserved by spec §5 for de-emphasized AUXILIARY text, a timestamp or a unit). An\n// `auxiliary` cell takes the muted role explicitly (spec §5 --color-text-muted), independent of\n// `numeric`. A cell that reports a real STATE carries the status fg paired with text — 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// cell's words, not a saturated fill.\nexport const tableCellVariants = cva(\n [\"px-(--space-3) align-middle text-start text-body text-text-primary\"],\n {\n variants: {\n ...cellPaddingVariants,\n numeric: {\n // tabular figures + end-aligned numbers down the column — primary data text, not muted\n true: \"text-end tabular-nums\",\n false: \"\",\n },\n auxiliary: {\n // de-emphasized auxiliary cell text — a timestamp, a unit (spec §5 --color-text-muted)\n true: \"text-text-muted\",\n false: \"\",\n },\n status: {\n none: \"\",\n // each status is the fg only, paired with the cell's text — the cell's words carry the\n // meaning, the fg reinforces it (spec §3/§5); never a saturated -bg fill, never the brand\n verified: \"text-status-verified-on-surface\",\n signal: \"text-status-signal-on-surface\",\n caution: \"text-status-caution-on-surface\",\n critical: \"text-status-critical-on-surface\",\n },\n },\n defaultVariants: {\n density: \"comfortable\",\n rule: \"horizontal\",\n numeric: false,\n auxiliary: false,\n status: \"none\",\n },\n },\n);\n\nexport type TableCellVariantProps = VariantProps<typeof tableCellVariants>;\n\n// A <th scope=\"row\"> row-header cell (spec §2/§7): the row's natural label (an identifier, a name),\n// tying its cells to the row. The primary text color at the body type role — the same reading weight\n// as a data cell, just promoted to a header for the relationship (1.3.1). Density + grid rule apply.\nexport const tableRowHeaderVariants = cva(\n [\"px-(--space-3) align-middle text-start font-normal text-body text-text-primary\"],\n {\n variants: { ...cellPaddingVariants },\n defaultVariants: { density: \"comfortable\", rule: \"horizontal\" },\n },\n);\n\nexport type TableRowHeaderVariantProps = VariantProps<typeof tableRowHeaderVariants>;\n\n// A <th scope=\"col\"> column header (spec §2/§4/§5). The header LABEL is the SECONDARY text color at\n// the label type role (the quiet, tracked column label), on the canvas — a header NEVER wears a\n// status or brand tint (spec §3/§8). Density + grid rule apply to the cell padding.\nexport const tableHeadVariants = cva(\n [\"px-(--space-3) align-middle text-start text-label text-text-secondary\"],\n {\n variants: { ...cellPaddingVariants },\n defaultVariants: { density: \"comfortable\", rule: \"horizontal\" },\n },\n);\n\nexport type TableHeadVariantProps = VariantProps<typeof tableHeadVariants>;\n\n// The SORTABLE-header control (spec §2/§4/§6/§7): a real <button> inside the <th>, so it reads as\n// the control it is and re-sorts on Enter/Space. It is the GHOST action accent — the label + caret\n// in the ghost fg with the restrained ghost hover fill (spec §4 Sortable-header hover / §5) — the\n// action tier is legitimate here because it is a control the table HOSTS, not a row/header tint. It\n// carries the visible 2px focus ring (never removed) and the target-size floor (40px desktop / 44px\n// touch, spec §7). Motion is the fast token transition, never the deliberate verified-check theatre\n// (G-U3). aria-sort lives on the parent <th>, and the direction caret encodes direction alongside\n// it so it never rests on color alone (spec §4 Sorted / 1.4.1).\nexport const tableSortButtonClass =\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\n// also encoded by aria-sort on the th + the glyph's shape via data-direction, so it never rests on\n// color alone — 1.4.1). It inherits the ghost accent color from the button.\nexport const tableSortCaretClass =\n \"inline-flex h-(--size-icon-sm) w-(--size-icon-sm) shrink-0 items-center justify-center\";\n\n// The empty-state cell (spec §2/§4 Empty): a plain line spanning the full table width, in the\n// secondary text color — an empty table is not an error and never reads as one (no status color).\nexport const tableEmptyClass =\n \"px-(--space-3) py-(--space-4) text-center text-body text-text-secondary\";\n\n// One skeleton placeholder cell while the body resolves (spec §4 Loading): keeps the column layout\n// stable so the table does not reflow when data arrives. The Skeleton itself is decorative + neutral\n// (it binds no brand/status — that invariant lives in the Skeleton component); this is just the cell\n// padding wrapping it.\nexport const tableSkeletonCellClass = \"px-(--space-3) py-(--space-3)\";\n",
21
21
  "path": "table/table.variants.ts",
22
22
  "target": "@ui/table/table.variants.ts",
23
23
  "type": "registry:ui"
@@ -11,7 +11,7 @@
11
11
  "type": "registry:ui"
12
12
  },
13
13
  {
14
- "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Label } from \"@/components/ui/label\";\nimport { textareaVariants, type TextareaVariantProps } from \"./textarea.variants\";\n\nexport interface TextareaProps\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"id\">,\n TextareaVariantProps {\n /** Bound visible label text (for/id). Required — the placeholder is never the name. */\n label: React.ReactNode;\n /** Help text below the field, linked via aria-describedby. */\n description?: React.ReactNode;\n /** Error message: reds the border, sets aria-invalid, joins aria-describedby. */\n error?: React.ReactNode;\n /** Field id; auto-generated from React.useId when omitted. */\n id?: string;\n /** auto-grow lower bound (rows). */\n minRows?: number;\n /** auto-grow upper bound (rows) — beyond it the field scrolls. */\n maxRows?: number;\n}\n\n/** Merge non-null describedby ids into a single attribute value (or undefined). */\nfunction describedBy(...ids: (string | false | undefined)[]): string | undefined {\n const present = ids.filter(Boolean) as string[];\n return present.length ? present.join(\" \") : undefined;\n}\n\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n function Textarea(\n {\n className,\n resize = \"vertical\",\n label,\n description,\n error,\n id,\n minRows = 3,\n maxRows = 8,\n rows,\n maxLength,\n value,\n defaultValue,\n onChange,\n disabled,\n readOnly,\n required,\n ...props\n },\n forwardedRef,\n ) {\n const autoId = React.useId();\n const fieldId = id ?? autoId;\n const descId = `${fieldId}-desc`;\n const errId = `${fieldId}-err`;\n const counterId = `${fieldId}-counter`;\n\n const innerRef = React.useRef<HTMLTextAreaElement>(null);\n React.useImperativeHandle(\n forwardedRef,\n () => innerRef.current as HTMLTextAreaElement,\n );\n\n // counter state: read controlled value when present, else track length locally\n const isControlled = value !== undefined;\n const [count, setCount] = React.useState(\n String(value ?? defaultValue ?? \"\").length,\n );\n const length = isControlled ? String(value ?? \"\").length : count;\n\n const isAutoGrow = resize === \"auto-grow\";\n\n // auto-grow: measure scrollHeight, then clamp the height to a ceiling computed\n // from maxRows × line-height. Beyond the ceiling the field scrolls (overflowY\n // auto) instead of growing unbounded; below it the field hugs its content\n // (overflowY hidden). The ceiling is written to el.style.maxHeight — there is no\n // class for it, so nothing references an undefined custom property.\n const resizeToContent = React.useCallback(() => {\n const el = innerRef.current;\n if (!el || !isAutoGrow) return;\n const style = window.getComputedStyle(el);\n // jsdom and \"normal\" line-height both yield NaN here; px() coerces any\n // non-finite value to 0 (or the fallback) so the ceiling stays a real number.\n const px = (v: string, fallback = 0) => {\n const n = parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n };\n // line-height may compute to \"normal\"; fall back to ~1.5× font size, else 20px.\n const lineHeight = px(style.lineHeight) || px(style.fontSize) * 1.5 || 20;\n const borders = px(style.borderTopWidth) + px(style.borderBottomWidth);\n const padding = px(style.paddingTop) + px(style.paddingBottom);\n const ceiling = maxRows * lineHeight + padding + borders;\n // measure the natural content height from a collapsed baseline\n el.style.height = \"auto\";\n const next = Math.min(el.scrollHeight, ceiling);\n el.style.height = `${next}px`;\n el.style.maxHeight = `${ceiling}px`;\n // scroll only once content exceeds the ceiling; hug content otherwise\n el.style.overflowY = el.scrollHeight > ceiling ? \"auto\" : \"hidden\";\n }, [isAutoGrow, maxRows]);\n\n React.useLayoutEffect(() => {\n if (isAutoGrow) resizeToContent();\n }, [isAutoGrow, resizeToContent, value]);\n\n const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n if (!isControlled) setCount(e.target.value.length);\n if (isAutoGrow) resizeToContent();\n onChange?.(e);\n };\n\n const hasCounter = maxLength !== undefined;\n const hasError = error != null && error !== false;\n\n return (\n <div className=\"flex flex-col gap-(--space-3)\">\n {/* Compose the Label primitive (define-once): it owns the canonical\n label role/color (text-label / text-text-primary) and the disabled\n dim. Passing `disabled` lets a disabled Textarea's name dim to\n --color-text-disabled via Label's own cva variant, and `required`\n surfaces the required mark there (the field still carries the real\n required / aria-required state below). textarea.md §5 lists\n text-secondary for the label, but label.md §3–5 is authoritative for\n the Label primitive: resting label text is text-text-primary. */}\n <Label htmlFor={fieldId} disabled={disabled} required={required}>\n {label}\n </Label>\n <textarea\n ref={innerRef}\n id={fieldId}\n // native <textarea> already carries an implicit aria-multiline; make the\n // multi-line contract explicit per the frozen ARIA contract (spec §7).\n aria-multiline=\"true\"\n rows={isAutoGrow ? minRows : rows}\n maxLength={maxLength}\n value={value}\n defaultValue={defaultValue}\n onChange={handleChange}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n aria-required={required || undefined}\n aria-invalid={hasError || undefined}\n aria-describedby={describedBy(\n // coerce each guard to a boolean so the expression narrows to\n // string | false (React.ReactNode can be null/0/0n, which would widen\n // the union past the describedBy signature otherwise)\n !!description && descId,\n hasError && errId,\n hasCounter && counterId,\n )}\n style={isAutoGrow ? { overflowY: \"hidden\" } : undefined}\n className={cn(textareaVariants({ resize }), className)}\n {...props}\n />\n <div className=\"flex items-start justify-between gap-(--space-3)\">\n <div className=\"text-caption text-text-secondary\">\n {description ? (\n // spec §5: label AND description text are text-secondary; text-muted is\n // reserved for the character counter at rest (see the counter below).\n <span id={descId} className=\"text-caption text-text-secondary\">\n {description}\n </span>\n ) : null}\n {hasError ? (\n <span id={errId} className=\"text-caption text-status-critical-fg\">\n {error}\n </span>\n ) : null}\n </div>\n {hasCounter ? (\n <span\n id={counterId}\n data-testid=\"textarea-counter\"\n aria-live=\"polite\"\n className=\"text-caption text-text-muted tabular-nums\"\n >\n {length}/{maxLength}\n </span>\n ) : null}\n </div>\n </div>\n );\n },\n);\n",
14
+ "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Label } from \"@/components/ui/label\";\nimport { textareaVariants, type TextareaVariantProps } from \"./textarea.variants\";\n\nexport interface TextareaProps\n extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, \"id\">,\n TextareaVariantProps {\n /** Bound visible label text (for/id). Required — the placeholder is never the name. */\n label: React.ReactNode;\n /** Help text below the field, linked via aria-describedby. */\n description?: React.ReactNode;\n /** Error message: reds the border, sets aria-invalid, joins aria-describedby. */\n error?: React.ReactNode;\n /** Field id; auto-generated from React.useId when omitted. */\n id?: string;\n /** auto-grow lower bound (rows). */\n minRows?: number;\n /** auto-grow upper bound (rows) — beyond it the field scrolls. */\n maxRows?: number;\n}\n\n/** Merge non-null describedby ids into a single attribute value (or undefined). */\nfunction describedBy(...ids: (string | false | undefined)[]): string | undefined {\n const present = ids.filter(Boolean) as string[];\n return present.length ? present.join(\" \") : undefined;\n}\n\nexport const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(\n function Textarea(\n {\n className,\n resize = \"vertical\",\n label,\n description,\n error,\n id,\n minRows = 3,\n maxRows = 8,\n rows,\n maxLength,\n value,\n defaultValue,\n onChange,\n disabled,\n readOnly,\n required,\n ...props\n },\n forwardedRef,\n ) {\n const autoId = React.useId();\n const fieldId = id ?? autoId;\n const descId = `${fieldId}-desc`;\n const errId = `${fieldId}-err`;\n const counterId = `${fieldId}-counter`;\n\n const innerRef = React.useRef<HTMLTextAreaElement>(null);\n React.useImperativeHandle(\n forwardedRef,\n () => innerRef.current as HTMLTextAreaElement,\n );\n\n // counter state: read controlled value when present, else track length locally\n const isControlled = value !== undefined;\n const [count, setCount] = React.useState(\n String(value ?? defaultValue ?? \"\").length,\n );\n const length = isControlled ? String(value ?? \"\").length : count;\n\n const isAutoGrow = resize === \"auto-grow\";\n\n // auto-grow: measure scrollHeight, then clamp the height to a ceiling computed\n // from maxRows × line-height. Beyond the ceiling the field scrolls (overflowY\n // auto) instead of growing unbounded; below it the field hugs its content\n // (overflowY hidden). The ceiling is written to el.style.maxHeight — there is no\n // class for it, so nothing references an undefined custom property.\n const resizeToContent = React.useCallback(() => {\n const el = innerRef.current;\n if (!el || !isAutoGrow) return;\n const style = window.getComputedStyle(el);\n // jsdom and \"normal\" line-height both yield NaN here; px() coerces any\n // non-finite value to 0 (or the fallback) so the ceiling stays a real number.\n const px = (v: string, fallback = 0) => {\n const n = parseFloat(v);\n return Number.isFinite(n) ? n : fallback;\n };\n // line-height may compute to \"normal\"; fall back to ~1.5× font size, else 20px.\n const lineHeight = px(style.lineHeight) || px(style.fontSize) * 1.5 || 20;\n const borders = px(style.borderTopWidth) + px(style.borderBottomWidth);\n const padding = px(style.paddingTop) + px(style.paddingBottom);\n const ceiling = maxRows * lineHeight + padding + borders;\n // measure the natural content height from a collapsed baseline\n el.style.height = \"auto\";\n const next = Math.min(el.scrollHeight, ceiling);\n el.style.height = `${next}px`;\n el.style.maxHeight = `${ceiling}px`;\n // scroll only once content exceeds the ceiling; hug content otherwise\n el.style.overflowY = el.scrollHeight > ceiling ? \"auto\" : \"hidden\";\n }, [isAutoGrow, maxRows]);\n\n React.useLayoutEffect(() => {\n if (isAutoGrow) resizeToContent();\n }, [isAutoGrow, resizeToContent, value]);\n\n const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {\n if (!isControlled) setCount(e.target.value.length);\n if (isAutoGrow) resizeToContent();\n onChange?.(e);\n };\n\n const hasCounter = maxLength !== undefined;\n const hasError = error != null && error !== false;\n\n return (\n <div className=\"flex flex-col gap-(--space-3)\">\n {/* Compose the Label primitive (define-once): it owns the canonical\n label role/color (text-label / text-text-primary) and the disabled\n dim. Passing `disabled` lets a disabled Textarea's name dim to\n --color-text-disabled via Label's own cva variant, and `required`\n surfaces the required mark there (the field still carries the real\n required / aria-required state below). textarea.md §5 lists\n text-secondary for the label, but label.md §3–5 is authoritative for\n the Label primitive: resting label text is text-text-primary. */}\n <Label htmlFor={fieldId} disabled={disabled} required={required}>\n {label}\n </Label>\n <textarea\n ref={innerRef}\n id={fieldId}\n // native <textarea> already carries an implicit aria-multiline; make the\n // multi-line contract explicit per the frozen ARIA contract (spec §7).\n aria-multiline=\"true\"\n rows={isAutoGrow ? minRows : rows}\n maxLength={maxLength}\n value={value}\n defaultValue={defaultValue}\n onChange={handleChange}\n disabled={disabled}\n readOnly={readOnly}\n required={required}\n aria-required={required || undefined}\n aria-invalid={hasError || undefined}\n aria-describedby={describedBy(\n // coerce each guard to a boolean so the expression narrows to\n // string | false (React.ReactNode can be null/0/0n, which would widen\n // the union past the describedBy signature otherwise)\n !!description && descId,\n hasError && errId,\n hasCounter && counterId,\n )}\n style={isAutoGrow ? { overflowY: \"hidden\" } : undefined}\n className={cn(textareaVariants({ resize }), className)}\n {...props}\n />\n <div className=\"flex items-start justify-between gap-(--space-3)\">\n <div className=\"text-caption text-text-secondary\">\n {description ? (\n // spec §5: label AND description text are text-secondary; text-muted is\n // reserved for the character counter at rest (see the counter below).\n <span id={descId} className=\"text-caption text-text-secondary\">\n {description}\n </span>\n ) : null}\n {hasError ? (\n <span id={errId} className=\"text-caption text-status-critical-on-surface\">\n {error}\n </span>\n ) : null}\n </div>\n {hasCounter ? (\n <span\n id={counterId}\n data-testid=\"textarea-counter\"\n aria-live=\"polite\"\n className=\"text-caption text-text-muted tabular-nums\"\n >\n {length}/{maxLength}\n </span>\n ) : null}\n </div>\n </div>\n );\n },\n);\n",
15
15
  "path": "textarea/textarea.tsx",
16
16
  "target": "@ui/textarea/textarea.tsx",
17
17
  "type": "registry:ui"