@verdify/ui 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +12 -0
  2. package/dist/components/accordion/accordion.variants.d.ts.map +1 -1
  3. package/dist/components/accordion/accordion.variants.js +2 -1
  4. package/dist/components/accordion/accordion.variants.js.map +1 -1
  5. package/dist/components/agent-badge/agent-badge.variants.d.ts.map +1 -1
  6. package/dist/components/agent-badge/agent-badge.variants.js.map +1 -1
  7. package/dist/components/alert/alert.variants.d.ts.map +1 -1
  8. package/dist/components/alert/alert.variants.js +3 -2
  9. package/dist/components/alert/alert.variants.js.map +1 -1
  10. package/dist/components/breadcrumb/breadcrumb.variants.d.ts.map +1 -1
  11. package/dist/components/breadcrumb/breadcrumb.variants.js +2 -1
  12. package/dist/components/breadcrumb/breadcrumb.variants.js.map +1 -1
  13. package/dist/components/button/button.variants.d.ts.map +1 -1
  14. package/dist/components/button/button.variants.js +2 -1
  15. package/dist/components/button/button.variants.js.map +1 -1
  16. package/dist/components/card/card.variants.d.ts.map +1 -1
  17. package/dist/components/card/card.variants.js +2 -1
  18. package/dist/components/card/card.variants.js.map +1 -1
  19. package/dist/components/checkbox/checkbox.js +1 -1
  20. package/dist/components/checkbox/checkbox.js.map +1 -1
  21. package/dist/components/checkbox/checkbox.variants.d.ts.map +1 -1
  22. package/dist/components/checkbox/checkbox.variants.js +2 -1
  23. package/dist/components/checkbox/checkbox.variants.js.map +1 -1
  24. package/dist/components/command-palette/command-palette.variants.d.ts +1 -1
  25. package/dist/components/command-palette/command-palette.variants.d.ts.map +1 -1
  26. package/dist/components/command-palette/command-palette.variants.js +5 -3
  27. package/dist/components/command-palette/command-palette.variants.js.map +1 -1
  28. package/dist/components/consent-toggle/consent-toggle.variants.d.ts +1 -1
  29. package/dist/components/consent-toggle/consent-toggle.variants.d.ts.map +1 -1
  30. package/dist/components/consent-toggle/consent-toggle.variants.js +1 -1
  31. package/dist/components/consent-toggle/consent-toggle.variants.js.map +1 -1
  32. package/dist/components/credential-card/credential-card.variants.d.ts +3 -3
  33. package/dist/components/credential-card/credential-card.variants.d.ts.map +1 -1
  34. package/dist/components/credential-card/credential-card.variants.js +3 -3
  35. package/dist/components/credential-card/credential-card.variants.js.map +1 -1
  36. package/dist/components/data-grid/data-grid.variants.d.ts +1 -1
  37. package/dist/components/data-grid/data-grid.variants.d.ts.map +1 -1
  38. package/dist/components/data-grid/data-grid.variants.js +11 -10
  39. package/dist/components/data-grid/data-grid.variants.js.map +1 -1
  40. package/dist/components/dialog/dialog.variants.d.ts.map +1 -1
  41. package/dist/components/dialog/dialog.variants.js +3 -2
  42. package/dist/components/dialog/dialog.variants.js.map +1 -1
  43. package/dist/components/identity-chip/identity-chip.variants.d.ts.map +1 -1
  44. package/dist/components/identity-chip/identity-chip.variants.js +3 -2
  45. package/dist/components/identity-chip/identity-chip.variants.js.map +1 -1
  46. package/dist/components/input/input.variants.d.ts.map +1 -1
  47. package/dist/components/input/input.variants.js +3 -2
  48. package/dist/components/input/input.variants.js.map +1 -1
  49. package/dist/components/label/label.variants.js +1 -1
  50. package/dist/components/label/label.variants.js.map +1 -1
  51. package/dist/components/menu/menu.d.ts.map +1 -1
  52. package/dist/components/menu/menu.js +1 -1
  53. package/dist/components/menu/menu.js.map +1 -1
  54. package/dist/components/menu/menu.variants.d.ts +1 -1
  55. package/dist/components/menu/menu.variants.d.ts.map +1 -1
  56. package/dist/components/menu/menu.variants.js +3 -2
  57. package/dist/components/menu/menu.variants.js.map +1 -1
  58. package/dist/components/pagination/pagination.variants.d.ts.map +1 -1
  59. package/dist/components/pagination/pagination.variants.js +2 -1
  60. package/dist/components/pagination/pagination.variants.js.map +1 -1
  61. package/dist/components/popover/popover.variants.d.ts.map +1 -1
  62. package/dist/components/popover/popover.variants.js +4 -3
  63. package/dist/components/popover/popover.variants.js.map +1 -1
  64. package/dist/components/progress/progress.variants.d.ts +1 -1
  65. package/dist/components/progress/progress.variants.d.ts.map +1 -1
  66. package/dist/components/progress/progress.variants.js +1 -1
  67. package/dist/components/progress/progress.variants.js.map +1 -1
  68. package/dist/components/radio/radio.d.ts.map +1 -1
  69. package/dist/components/radio/radio.js +2 -1
  70. package/dist/components/radio/radio.js.map +1 -1
  71. package/dist/components/select/select.variants.d.ts +3 -3
  72. package/dist/components/select/select.variants.d.ts.map +1 -1
  73. package/dist/components/select/select.variants.js +5 -4
  74. package/dist/components/select/select.variants.js.map +1 -1
  75. package/dist/components/sheet/sheet.variants.d.ts.map +1 -1
  76. package/dist/components/sheet/sheet.variants.js +3 -2
  77. package/dist/components/sheet/sheet.variants.js.map +1 -1
  78. package/dist/components/sidebar/sidebar.variants.d.ts +1 -1
  79. package/dist/components/sidebar/sidebar.variants.d.ts.map +1 -1
  80. package/dist/components/sidebar/sidebar.variants.js +4 -3
  81. package/dist/components/sidebar/sidebar.variants.js.map +1 -1
  82. package/dist/components/switch/switch.variants.d.ts.map +1 -1
  83. package/dist/components/switch/switch.variants.js +2 -1
  84. package/dist/components/switch/switch.variants.js.map +1 -1
  85. package/dist/components/table/table.d.ts.map +1 -1
  86. package/dist/components/table/table.js +1 -1
  87. package/dist/components/table/table.js.map +1 -1
  88. package/dist/components/table/table.variants.d.ts.map +1 -1
  89. package/dist/components/table/table.variants.js +9 -7
  90. package/dist/components/table/table.variants.js.map +1 -1
  91. package/dist/components/tabs/tabs.variants.d.ts.map +1 -1
  92. package/dist/components/tabs/tabs.variants.js +3 -2
  93. package/dist/components/tabs/tabs.variants.js.map +1 -1
  94. package/dist/components/textarea/textarea.js +2 -2
  95. package/dist/components/textarea/textarea.js.map +1 -1
  96. package/dist/components/textarea/textarea.variants.d.ts.map +1 -1
  97. package/dist/components/textarea/textarea.variants.js +2 -1
  98. package/dist/components/textarea/textarea.variants.js.map +1 -1
  99. package/dist/components/toast/toast.variants.d.ts.map +1 -1
  100. package/dist/components/toast/toast.variants.js +3 -2
  101. package/dist/components/toast/toast.variants.js.map +1 -1
  102. package/dist/components/trust-score/trust-score.variants.d.ts +1 -1
  103. package/dist/components/trust-score/trust-score.variants.d.ts.map +1 -1
  104. package/dist/components/trust-score/trust-score.variants.js +1 -1
  105. package/dist/components/trust-score/trust-score.variants.js.map +1 -1
  106. package/dist/index.d.ts +1 -0
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +3 -1
  109. package/dist/index.js.map +1 -1
  110. package/dist/lib/focus-ring.d.ts +2 -0
  111. package/dist/lib/focus-ring.d.ts.map +1 -0
  112. package/dist/lib/focus-ring.js +5 -0
  113. package/dist/lib/focus-ring.js.map +1 -0
  114. package/package.json +18 -19
  115. package/registry/accordion.json +3 -2
  116. package/registry/agent-badge.json +1 -1
  117. package/registry/alert.json +3 -2
  118. package/registry/breadcrumb.json +3 -2
  119. package/registry/button.json +3 -2
  120. package/registry/card.json +3 -2
  121. package/registry/checkbox.json +4 -3
  122. package/registry/command-palette.json +3 -2
  123. package/registry/consent-toggle.json +1 -1
  124. package/registry/credential-card.json +1 -1
  125. package/registry/data-grid.json +2 -1
  126. package/registry/dialog.json +3 -2
  127. package/registry/focus-ring.json +16 -0
  128. package/registry/identity-chip.json +2 -1
  129. package/registry/init.json +1 -1
  130. package/registry/input.json +3 -2
  131. package/registry/label.json +1 -1
  132. package/registry/menu.json +4 -3
  133. package/registry/pagination.json +3 -2
  134. package/registry/popover.json +3 -2
  135. package/registry/progress.json +1 -1
  136. package/registry/radio.json +3 -2
  137. package/registry/select.json +3 -2
  138. package/registry/sheet.json +3 -2
  139. package/registry/sidebar.json +3 -2
  140. package/registry/switch.json +3 -2
  141. package/registry/table.json +3 -2
  142. package/registry/tabs.json +3 -2
  143. package/registry/textarea.json +3 -2
  144. package/registry/toast.json +3 -2
  145. package/registry/trust-score.json +1 -1
  146. package/registry.json +4 -0
@@ -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\";\nimport { focusRing } from \"@/lib/focus-ring\";\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 focusRing,\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, essential de-emphasized text — uses secondary (AA), not the\n// decorative-only muted role (accessibility.md).\nexport const groupLabelClass = \"px-3 py-1 text-label text-text-secondary 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"
@@ -26,7 +26,8 @@
26
26
  ],
27
27
  "name": "select",
28
28
  "registryDependencies": [
29
- "@verdify/cn"
29
+ "@verdify/cn",
30
+ "@verdify/focus-ring"
30
31
  ],
31
32
  "title": "select",
32
33
  "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// Sheet is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents that enter only through the components placed INSIDE the panel (a primary Button, a\n// VerifiedBadge), never on the PANEL or the SCRIM themselves. Restraint over volume — neutrals\n// carry the surface. So NOTHING in this file binds an --color-action-primary-* or --color-status-*\n// fill (brand != state, G-U2). This is the ONLY token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer between the page and the panel; it signals the page behind is inert\n// and gives the focus trap a visible edge (spec §2 scrim, §5 --color-scrim-*). A neutral dim on the\n// modal z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing,\n// instant under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre\n// gate). Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not\n// arbitrary values). On a light surface the dark scrim token applies (spec §5: scrim-dark).\nexport const sheetScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container anchored to ONE viewport edge; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius on its LEADING corners, the lg elevation shadow above the scrim, fixed on\n// the modal z-layer. It never fills the viewport (spec §3: lg never grows to full screen — a sheet\n// that fills the screen should be a route) and scrolls its BODY when content overflows (spec §4\n// scrolled) — the panel is a flex column; the SheetBody owns the scroll. Panel padding/gaps come\n// from --space-*.\n//\n// side (spec §3) = the docking edge AND the slide axis:\n// - inline-end (default) / inline-start: pinned to the full BLOCK extent (inset-y-0), sized by\n// WIDTH, sliding along the inline axis (translate-x). LTR right / left; mirrors under dir=rtl\n// because start/end are logical (G-U6).\n// - block-end: pinned to the full INLINE extent (inset-x-0), sized by HEIGHT, sliding up from the\n// bottom (translate-y). Reads as a bottom sheet on narrow/touch viewports.\n//\n// size (spec §3) = the CROSS-AXIS extent only — a WIDTH for an inline side, a HEIGHT for a block\n// side. md is the default. Resolved per-side by the compoundVariants below (an inline side maps\n// size -> w-(--container-*); a block side maps size -> h-(--container-*)). Bound to the shared\n// --container-* scale, the same scale Dialog caps its width with.\n//\n// The slide+fade open/close is the BASE duration + verdify easing, instant under reduced motion,\n// and rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). The closed translate is a per-side keyword utility\n// (translate-x-full / -translate-x-full / translate-y-full), set in the side variant.\nexport const sheetPanelVariants = cva(\n [\n \"fixed z-(--z-index-modal)\",\n // a flex column: header + footer stay pinned, the body scrolls within (spec §4 scrolled)\n \"flex flex-col gap-(--space-4)\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base slide+fade open/close + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — the fade is shared across sides (the per-side slide lives\n // in the side variant); attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n ],\n {\n variants: {\n // side = the docking edge + slide axis (spec §3). Logical inset properties (G-U6): inset-y-0\n // / inset-x-0 pin the full extent along the docked edge; start-0 / end-0 / bottom-0 dock it.\n // The closed-state translate slides the panel fully off its own edge; open returns it to 0.\n side: {\n \"inline-end\": [\n \"inset-y-0 end-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full\",\n ],\n \"inline-start\": [\n \"inset-y-0 start-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full\",\n ],\n \"block-end\": [\n \"inset-x-0 bottom-0 w-full\",\n \"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full\",\n ],\n },\n // size = cross-axis extent. The concrete axis (width vs height) is resolved per-side by the\n // compoundVariants below — this key only carries the default for type inference.\n size: {\n sm: \"\",\n md: \"\",\n lg: \"\",\n },\n },\n compoundVariants: [\n // inline sides: size -> WIDTH (the cross-axis of a side that pins the block extent)\n { side: [\"inline-end\", \"inline-start\"], size: \"sm\", class: \"w-(--container-sm)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"md\", class: \"w-(--container-md)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"lg\", class: \"w-(--container-lg)\" },\n // block side: size -> HEIGHT (the cross-axis of a side that pins the inline extent)\n { side: \"block-end\", size: \"sm\", class: \"h-(--container-sm)\" },\n { side: \"block-end\", size: \"md\", class: \"h-(--container-md)\" },\n { side: \"block-end\", size: \"lg\", class: \"h-(--container-lg)\" },\n ],\n defaultVariants: { side: \"inline-end\", size: \"md\" },\n },\n);\n\n// The header: the top region holding the title and the close button on the inline-end (spec §2\n// header). Logical-property layout (G-U6); a MUTED surface-border hairline divider under it (spec\n// §5 --color-surface-border-muted) — the panel's inner dividers are muted, distinct from its outer\n// surface-border edge.\nexport const sheetHeaderClass =\n \"flex items-start justify-between gap-(--space-4) border-b border-surface-border-muted pb-(--space-4)\";\n\n// The title: names the task as a statement, sentence case (spec §2). It IS the panel's accessible\n// name (Radix wires aria-labelledby). The h2 type role in primary text (spec §5 --text-h2 /\n// --color-text-primary).\nexport const sheetTitleClass = \"text-h2 text-text-primary\";\n\n// The description: optional supporting text under the title, associated with the panel for screen\n// readers (Radix wires aria-describedby). Body type role in secondary text (spec §5).\nexport const sheetDescriptionClass = \"text-body text-text-secondary\";\n\n// The body: the scrollable content region between header and footer (spec §2 body, §4 scrolled).\n// The panel is a fixed-height flex column; the body takes the remaining space and is the ONLY part\n// that scrolls — the header and footer stay pinned. Body text is the body type role in secondary\n// text (spec §5 --text-body / text-secondary).\nexport const sheetBodyClass =\n \"min-h-0 flex-1 overflow-y-auto text-body text-text-secondary\";\n\n// The footer: the optional action region (spec §2 footer). The primary action sits at the\n// inline-end with a Cancel beside it; the actions are Buttons — the sheet spec does not restate\n// their --color-action-* bindings (spec §5 note). A MUTED surface-border hairline divider above it\n// (spec §5). Logical-property layout (G-U6): actions flow inline-end with a gap.\nexport const sheetFooterClass =\n \"flex items-center justify-end gap-(--space-2) border-t border-surface-border-muted pt-(--space-4)\";\n\n// The close button: the dismiss control in the header, always present and reachable — a sheet is\n// never closable by the scrim alone (spec §2 close, §8). A NEUTRAL ghost surface — the glyph in\n// --color-action-ghost-fg at rest, the restrained ghost hover fill (spec §5 ghost-fg /\n// ghost-bg-hover), the md radius, the persistent focus ring, the target-size floor (44px touch /\n// 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor, never fixed below\n// it. fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const sheetCloseVariants = cva([\n \"inline-flex items-center justify-center rounded-(--radius-md)\",\n // neutral ghost surface: glyph color at rest + restrained hover fill (no bg/border at rest)\n \"text-action-ghost-fg hover:bg-action-ghost-bg-hover\",\n // fast functional hover transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer; the close button is square at the floor (DEC-B)\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile)\",\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n]);\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const sheetCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n\nexport type SheetPanelVariantProps = VariantProps<typeof sheetPanelVariants>;\n",
21
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// Sheet is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents that enter only through the components placed INSIDE the panel (a primary Button, a\n// VerifiedBadge), never on the PANEL or the SCRIM themselves. Restraint over volume — neutrals\n// carry the surface. So NOTHING in this file binds an --color-action-primary-* or --color-status-*\n// fill (brand != state, G-U2). This is the ONLY token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer between the page and the panel; it signals the page behind is inert\n// and gives the focus trap a visible edge (spec §2 scrim, §5 --color-scrim-*). A neutral dim on the\n// modal z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing,\n// instant under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre\n// gate). Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not\n// arbitrary values). On a light surface the dark scrim token applies (spec §5: scrim-dark).\nexport const sheetScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container anchored to ONE viewport edge; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius on its LEADING corners, the lg elevation shadow above the scrim, fixed on\n// the modal z-layer. It never fills the viewport (spec §3: lg never grows to full screen — a sheet\n// that fills the screen should be a route) and scrolls its BODY when content overflows (spec §4\n// scrolled) — the panel is a flex column; the SheetBody owns the scroll. Panel padding/gaps come\n// from --space-*.\n//\n// side (spec §3) = the docking edge AND the slide axis:\n// - inline-end (default) / inline-start: pinned to the full BLOCK extent (inset-y-0), sized by\n// WIDTH, sliding along the inline axis (translate-x). LTR right / left; mirrors under dir=rtl\n// because start/end are logical (G-U6).\n// - block-end: pinned to the full INLINE extent (inset-x-0), sized by HEIGHT, sliding up from the\n// bottom (translate-y). Reads as a bottom sheet on narrow/touch viewports.\n//\n// size (spec §3) = the CROSS-AXIS extent only — a WIDTH for an inline side, a HEIGHT for a block\n// side. md is the default. Resolved per-side by the compoundVariants below (an inline side maps\n// size -> w-(--container-*); a block side maps size -> h-(--container-*)). Bound to the shared\n// --container-* scale, the same scale Dialog caps its width with.\n//\n// The slide+fade open/close is the BASE duration + verdify easing, instant under reduced motion,\n// and rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). The closed translate is a per-side keyword utility\n// (translate-x-full / -translate-x-full / translate-y-full), set in the side variant.\nexport const sheetPanelVariants = cva(\n [\n \"fixed z-(--z-index-modal)\",\n // a flex column: header + footer stay pinned, the body scrolls within (spec §4 scrolled)\n \"flex flex-col gap-(--space-4)\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base slide+fade open/close + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — the fade is shared across sides (the per-side slide lives\n // in the side variant); attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n focusRing,\n ],\n {\n variants: {\n // side = the docking edge + slide axis (spec §3). Logical inset properties (G-U6): inset-y-0\n // / inset-x-0 pin the full extent along the docked edge; start-0 / end-0 / bottom-0 dock it.\n // The closed-state translate slides the panel fully off its own edge; open returns it to 0.\n side: {\n \"inline-end\": [\n \"inset-y-0 end-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full\",\n ],\n \"inline-start\": [\n \"inset-y-0 start-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full\",\n ],\n \"block-end\": [\n \"inset-x-0 bottom-0 w-full\",\n \"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full\",\n ],\n },\n // size = cross-axis extent. The concrete axis (width vs height) is resolved per-side by the\n // compoundVariants below — this key only carries the default for type inference.\n size: {\n sm: \"\",\n md: \"\",\n lg: \"\",\n },\n },\n compoundVariants: [\n // inline sides: size -> WIDTH (the cross-axis of a side that pins the block extent)\n { side: [\"inline-end\", \"inline-start\"], size: \"sm\", class: \"w-(--container-sm)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"md\", class: \"w-(--container-md)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"lg\", class: \"w-(--container-lg)\" },\n // block side: size -> HEIGHT (the cross-axis of a side that pins the inline extent)\n { side: \"block-end\", size: \"sm\", class: \"h-(--container-sm)\" },\n { side: \"block-end\", size: \"md\", class: \"h-(--container-md)\" },\n { side: \"block-end\", size: \"lg\", class: \"h-(--container-lg)\" },\n ],\n defaultVariants: { side: \"inline-end\", size: \"md\" },\n },\n);\n\n// The header: the top region holding the title and the close button on the inline-end (spec §2\n// header). Logical-property layout (G-U6); a MUTED surface-border hairline divider under it (spec\n// §5 --color-surface-border-muted) — the panel's inner dividers are muted, distinct from its outer\n// surface-border edge.\nexport const sheetHeaderClass =\n \"flex items-start justify-between gap-(--space-4) border-b border-surface-border-muted pb-(--space-4)\";\n\n// The title: names the task as a statement, sentence case (spec §2). It IS the panel's accessible\n// name (Radix wires aria-labelledby). The h2 type role in primary text (spec §5 --text-h2 /\n// --color-text-primary).\nexport const sheetTitleClass = \"text-h2 text-text-primary\";\n\n// The description: optional supporting text under the title, associated with the panel for screen\n// readers (Radix wires aria-describedby). Body type role in secondary text (spec §5).\nexport const sheetDescriptionClass = \"text-body text-text-secondary\";\n\n// The body: the scrollable content region between header and footer (spec §2 body, §4 scrolled).\n// The panel is a fixed-height flex column; the body takes the remaining space and is the ONLY part\n// that scrolls — the header and footer stay pinned. Body text is the body type role in secondary\n// text (spec §5 --text-body / text-secondary).\nexport const sheetBodyClass =\n \"min-h-0 flex-1 overflow-y-auto text-body text-text-secondary\";\n\n// The footer: the optional action region (spec §2 footer). The primary action sits at the\n// inline-end with a Cancel beside it; the actions are Buttons — the sheet spec does not restate\n// their --color-action-* bindings (spec §5 note). A MUTED surface-border hairline divider above it\n// (spec §5). Logical-property layout (G-U6): actions flow inline-end with a gap.\nexport const sheetFooterClass =\n \"flex items-center justify-end gap-(--space-2) border-t border-surface-border-muted pt-(--space-4)\";\n\n// The close button: the dismiss control in the header, always present and reachable — a sheet is\n// never closable by the scrim alone (spec §2 close, §8). A NEUTRAL ghost surface — the glyph in\n// --color-action-ghost-fg at rest, the restrained ghost hover fill (spec §5 ghost-fg /\n// ghost-bg-hover), the md radius, the persistent focus ring, the target-size floor (44px touch /\n// 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor, never fixed below\n// it. fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const sheetCloseVariants = cva([\n \"inline-flex items-center justify-center rounded-(--radius-md)\",\n // neutral ghost surface: glyph color at rest + restrained hover fill (no bg/border at rest)\n \"text-action-ghost-fg hover:bg-action-ghost-bg-hover\",\n // fast functional hover transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer; the close button is square at the floor (DEC-B)\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile)\",\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; never removed\n \"outline-none\",\n focusRing,\n]);\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const sheetCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n\nexport type SheetPanelVariantProps = VariantProps<typeof sheetPanelVariants>;\n",
22
22
  "path": "sheet/sheet.variants.ts",
23
23
  "target": "@ui/sheet/sheet.variants.ts",
24
24
  "type": "registry:ui"
@@ -26,7 +26,8 @@
26
26
  ],
27
27
  "name": "sheet",
28
28
  "registryDependencies": [
29
- "@verdify/cn"
29
+ "@verdify/cn",
30
+ "@verdify/focus-ring"
30
31
  ],
31
32
  "title": "sheet",
32
33
  "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// A Sidebar is the primary navigation rail down one edge of an app shell (spec §1). It is a\n// NEUTRAL surface (spec §3): the rail does not wear the brand. The only accent is on the CURRENT\n// item, which takes the primary ACTION (brand) alias — where you are — never a status color: a\n// current item reports your location, not a verification result, so it binds nothing from the\n// status tier (brand != state, G-U2). A verified meaning belongs to VerifiedBadge. The rail\n// paints from the surface, text, action(primary + ghost), and border aliases only (spec §5).\n\n// The nav landmark wrapping the rail. The raised neutral surface against the page canvas, an edge\n// against the content area (a logical inline-end border when docked inline-start, inline-start when\n// docked inline-end — G-U6, so it mirrors under dir=\"rtl\"), the sm elevation where it floats above\n// content, and the expand/collapse WIDTH transition. Motion is the BASE token transition on the\n// verdify easing, collapsing to the instant endpoint under reduced motion — never the 350ms\n// VerifiedBadge-only theatre duration (toggling the rail is a plain transition, not theatre — spec\n// §4 Collapsed, G-U3). The rail is NEVER tinted with the brand or a status fill (spec §3/§8).\nexport const sidebarRailVariants = cva(\n [\n // surface + elevation + column layout with the rail insets\n \"flex flex-col gap-(--space-2) bg-surface-raised shadow-sm\",\n \"p-(--space-2)\",\n // the expand/collapse width transition — base duration + verdify easing, instant under\n // reduced motion (NEVER the deliberate verified-check theatre, G-U3)\n \"transition-[width] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n ],\n {\n variants: {\n // SIDE axis (spec §3): the rail docks at a logical edge and carries its border on the edge\n // that faces the content area — inline-end when docked inline-start, inline-start when docked\n // inline-end. Logical properties only (border-e / border-s), so it mirrors under dir=\"rtl\".\n side: {\n \"inline-start\": \"border-e border-surface-border\",\n \"inline-end\": \"border-s border-surface-border\",\n },\n },\n defaultVariants: { side: \"inline-start\" },\n },\n);\n\nexport type SidebarRailVariantProps = VariantProps<typeof sidebarRailVariants>;\n\n// The header slot (spec §2): the top slot for a product mark or workspace switcher. It is NOT a\n// navigation item and is skipped by item arrow movement. A separator from the list below it via a\n// muted hairline (spec §5, surface-border-muted). Plain structural slot — no variant axis.\nexport const sidebarHeaderClass =\n \"flex items-center gap-(--space-2) px-(--space-2) py-(--space-2) border-b border-surface-border-muted\";\n\n// A labeled cluster of items (spec §2 group). Restraint over volume keeps the rail scannable, so a\n// group is plain structure; its dividers use the muted hairline (spec §5). No variant axis.\nexport const sidebarGroupClass = \"flex flex-col gap-(--space-1) py-(--space-2)\";\n\n// The group heading (spec §2/§5): the cluster label in the MUTED text color at the CAPTION type\n// role. Decorative-weight wayfinding, not a navigation item.\nexport const sidebarGroupLabelClass =\n \"px-(--space-2) py-(--space-1) text-caption text-text-muted select-none\";\n\n// The item list (the <ul>): a flush column of items. The list carries no text color — each item\n// sets its own. The roving arrow-key handler is wired by the component on this element.\nexport const sidebarListClass = \"flex flex-col gap-(--space-1) m-0 p-0 list-none\";\n\n// One navigation item (spec §2 item, §4 states). A native <a href> link.\n//\n// RESTING (default): the LABEL in the SECONDARY text color and the icon in the GHOST action fg, on\n// the rail surface with NO fill (spec §4 Default).\n// HOVER: a restrained ghost-action hover fill; the cursor is a pointer (spec §4 Hover). The only\n// fill a resting item ever paints.\n// CURRENT (aria-current=page): the leading INDICATOR BAR is painted in the primary ACTION (brand)\n// alias — where you are — and the label lifts to the PRIMARY text color. This spec offers two\n// indicator treatments (spec §5: an indicator \"bar OR fill\"); this component uses the restrained\n// BAR (a logical inline-start border accent) so the rail never wears a brand SURFACE (spec §3/§8).\n// The current state is never carried by color alone: the indicator bar shape AND aria-current also\n// encode it, so it survives a contrast or color-blind reading (spec §4 Current / 1.4.1, brand !=\n// state — NEVER status-verified).\n// FOCUS: the visible 2px focus ring, part of the base on every state, never removed; it persists in\n// both the expanded and collapsed rail (spec §4 Focus / 2.4.7).\n// DISABLED (aria-disabled): dims via the disabled TOKEN (DEC-C), never a blanket opacity; the\n// component also strips href + tabindex and skips it in arrow movement, while the label stays\n// readable to AT (spec §4 Disabled / §7).\n// Motion is the fast token transition on the verdify easing, instant under reduced motion — never\n// the 350ms VerifiedBadge-only theatre duration (G-U3).\nexport const sidebarItemVariants = cva(\n [\n // shape + the icon-to-label gap + logical inline padding so it mirrors under RTL; the leading\n // indicator bar is a left/start accent rendered via a logical inline-start border that is\n // transparent at rest and painted in the action alias when current\n \"relative flex items-center gap-(--space-3) rounded-(--radius-md) px-(--space-3)\",\n \"border-s-2 border-transparent\",\n // type ROLE + resting label color, no fill, pointer cursor\n \"text-label text-text-secondary no-underline cursor-pointer select-none\",\n // hover: the restrained ghost-action fill (the only fill a resting item paints)\n \"hover:bg-action-ghost-bg-hover\",\n // CURRENT: the leading indicator BAR is painted in the brand action alias and the label lifts to\n // the primary text color. This is the action(primary) alias (where you are), NEVER status-verified\n // (brand != state, G-U2). The indicator bar SHAPE + aria-current carry the state alongside the\n // color, so it survives a color-blind read; the rail itself stays a neutral surface (no brand fill).\n \"aria-[current=page]:border-s-action-primary-bg aria-[current=page]:text-text-primary\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre, G-U3)\n \"transition-[color,background-color,border-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every item (spec §7, 2.5.8), never a fixed\n // height below the floor\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // focus ring — identical on every state, never removed; persists expanded and collapsed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n // disabled (non-operable) item — DEC-C: dim via the disabled TOKEN, never opacity. aria-disabled\n // drives it because an <a> has no native disabled; the component also strips href + tabindex.\n \"aria-disabled:pointer-events-none aria-disabled:text-text-disabled\",\n ],\n { variants: {}, defaultVariants: {} },\n);\n\nexport type SidebarItemVariantProps = VariantProps<typeof sidebarItemVariants>;\n\n// The leading item icon (spec §5): the md icon role, decorative (the item names itself by its label\n// text, not the glyph). At rest it is the GHOST action fg; it inherits the disabled token when the\n// item is disabled (aria-disabled on the parent link). When the item is current it lifts with the\n// label to the primary text color. shrink-0 so it never collapses when labels are hidden.\nexport const sidebarItemIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center text-action-ghost-fg\";\n\n// The item label text (spec §5): the part hidden in the collapsed rail. When collapsed the label is\n// visually hidden but stays in the accessibility tree (sr-only), so the link keeps its accessible\n// name without depending on a tooltip being open (spec §4 Collapsed / §7).\nexport const sidebarItemLabelClass = \"min-w-0 flex-1 truncate\";\n\n// A trailing count or status on an item (spec §2): text or an aria-label, never color alone (spec\n// §7). Plain secondary caption text, hidden with the label when collapsed.\nexport const sidebarItemTrailingClass =\n \"ms-auto inline-flex items-center text-caption text-text-secondary\";\n\n// The footer slot (spec §2): a bottom slot for an account or settings entry; its items follow the\n// same item rules. Pushed to the bottom; separated from the list above by the muted hairline.\nexport const sidebarFooterClass =\n \"mt-auto flex flex-col gap-(--space-1) pt-(--space-2) border-t border-surface-border-muted\";\n\n// The collapse-toggle (spec §2/§4/§6/§7): a native <button>, NOT an item, reachable in the rail's\n// tab order. It carries the ghost-action glyph and the same neutral hover fill, focus ring, and\n// target-size floor as an item. Its glyph is decorative (aria-hidden); aria-expanded + aria-label\n// carry the action. Same fast motion, never the deliberate theatre.\nexport const sidebarCollapseToggleClass = cva(\n [\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n \"text-action-ghost-fg cursor-pointer select-none\",\n \"hover:bg-action-ghost-bg-hover\",\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"disabled:pointer-events-none disabled:text-text-disabled\",\n ],\n { variants: {}, defaultVariants: {} },\n);\n\nexport type SidebarCollapseToggleVariantProps = VariantProps<typeof sidebarCollapseToggleClass>;\n\n// The collapse-toggle glyph (spec §5): the md icon role, decorative. It rotates to mirror the rail\n// width (collapsed vs expanded) as a reinforcement; aria-expanded carries the state, not the glyph.\nexport const sidebarCollapseIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center transition-transform duration-(--motion-duration-base) ease-(--motion-easing-verdify) motion-reduce:duration-(--motion-duration-instant)\";\n",
21
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// A Sidebar is the primary navigation rail down one edge of an app shell (spec §1). It is a\n// NEUTRAL surface (spec §3): the rail does not wear the brand. The only accent is on the CURRENT\n// item, which takes the primary ACTION (brand) alias — where you are — never a status color: a\n// current item reports your location, not a verification result, so it binds nothing from the\n// status tier (brand != state, G-U2). A verified meaning belongs to VerifiedBadge. The rail\n// paints from the surface, text, action(primary + ghost), and border aliases only (spec §5).\n\n// The nav landmark wrapping the rail. The raised neutral surface against the page canvas, an edge\n// against the content area (a logical inline-end border when docked inline-start, inline-start when\n// docked inline-end — G-U6, so it mirrors under dir=\"rtl\"), the sm elevation where it floats above\n// content, and the expand/collapse WIDTH transition. Motion is the BASE token transition on the\n// verdify easing, collapsing to the instant endpoint under reduced motion — never the 350ms\n// VerifiedBadge-only theatre duration (toggling the rail is a plain transition, not theatre — spec\n// §4 Collapsed, G-U3). The rail is NEVER tinted with the brand or a status fill (spec §3/§8).\nexport const sidebarRailVariants = cva(\n [\n // surface + elevation + column layout with the rail insets\n \"flex flex-col gap-(--space-2) bg-surface-raised shadow-sm\",\n \"p-(--space-2)\",\n // the expand/collapse width transition — base duration + verdify easing, instant under\n // reduced motion (NEVER the deliberate verified-check theatre, G-U3)\n \"transition-[width] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n ],\n {\n variants: {\n // SIDE axis (spec §3): the rail docks at a logical edge and carries its border on the edge\n // that faces the content area — inline-end when docked inline-start, inline-start when docked\n // inline-end. Logical properties only (border-e / border-s), so it mirrors under dir=\"rtl\".\n side: {\n \"inline-start\": \"border-e border-surface-border\",\n \"inline-end\": \"border-s border-surface-border\",\n },\n },\n defaultVariants: { side: \"inline-start\" },\n },\n);\n\nexport type SidebarRailVariantProps = VariantProps<typeof sidebarRailVariants>;\n\n// The header slot (spec §2): the top slot for a product mark or workspace switcher. It is NOT a\n// navigation item and is skipped by item arrow movement. A separator from the list below it via a\n// muted hairline (spec §5, surface-border-muted). Plain structural slot — no variant axis.\nexport const sidebarHeaderClass =\n \"flex items-center gap-(--space-2) px-(--space-2) py-(--space-2) border-b border-surface-border-muted\";\n\n// A labeled cluster of items (spec §2 group). Restraint over volume keeps the rail scannable, so a\n// group is plain structure; its dividers use the muted hairline (spec §5). No variant axis.\nexport const sidebarGroupClass = \"flex flex-col gap-(--space-1) py-(--space-2)\";\n\n// The group heading (spec §2/§5): the cluster label in the SECONDARY text color at the CAPTION type\n// role. It is essential de-emphasized text — it names the cluster of items — so it uses\n// --color-text-secondary (AA), not the decorative-only muted role (accessibility.md).\nexport const sidebarGroupLabelClass =\n \"px-(--space-2) py-(--space-1) text-caption text-text-secondary select-none\";\n\n// The item list (the <ul>): a flush column of items. The list carries no text color — each item\n// sets its own. The roving arrow-key handler is wired by the component on this element.\nexport const sidebarListClass = \"flex flex-col gap-(--space-1) m-0 p-0 list-none\";\n\n// One navigation item (spec §2 item, §4 states). A native <a href> link.\n//\n// RESTING (default): the LABEL in the SECONDARY text color and the icon in the GHOST action fg, on\n// the rail surface with NO fill (spec §4 Default).\n// HOVER: a restrained ghost-action hover fill; the cursor is a pointer (spec §4 Hover). The only\n// fill a resting item ever paints.\n// CURRENT (aria-current=page): the leading INDICATOR BAR is painted in the primary ACTION (brand)\n// alias — where you are — and the label lifts to the PRIMARY text color. This spec offers two\n// indicator treatments (spec §5: an indicator \"bar OR fill\"); this component uses the restrained\n// BAR (a logical inline-start border accent) so the rail never wears a brand SURFACE (spec §3/§8).\n// The current state is never carried by color alone: the indicator bar shape AND aria-current also\n// encode it, so it survives a contrast or color-blind reading (spec §4 Current / 1.4.1, brand !=\n// state — NEVER status-verified).\n// FOCUS: the visible 2px focus ring, part of the base on every state, never removed; it persists in\n// both the expanded and collapsed rail (spec §4 Focus / 2.4.7).\n// DISABLED (aria-disabled): dims via the disabled TOKEN (DEC-C), never a blanket opacity; the\n// component also strips href + tabindex and skips it in arrow movement, while the label stays\n// readable to AT (spec §4 Disabled / §7).\n// Motion is the fast token transition on the verdify easing, instant under reduced motion — never\n// the 350ms VerifiedBadge-only theatre duration (G-U3).\nexport const sidebarItemVariants = cva(\n [\n // shape + the icon-to-label gap + logical inline padding so it mirrors under RTL; the leading\n // indicator bar is a left/start accent rendered via a logical inline-start border that is\n // transparent at rest and painted in the action alias when current\n \"relative flex items-center gap-(--space-3) rounded-(--radius-md) px-(--space-3)\",\n \"border-s-2 border-transparent\",\n // type ROLE + resting label color, no fill, pointer cursor\n \"text-label text-text-secondary no-underline cursor-pointer select-none\",\n // hover: the restrained ghost-action fill (the only fill a resting item paints)\n \"hover:bg-action-ghost-bg-hover\",\n // CURRENT: the leading indicator BAR is painted in the brand action alias and the label lifts to\n // the primary text color. This is the action(primary) alias (where you are), NEVER status-verified\n // (brand != state, G-U2). The indicator bar SHAPE + aria-current carry the state alongside the\n // color, so it survives a color-blind read; the rail itself stays a neutral surface (no brand fill).\n \"aria-[current=page]:border-s-action-primary-bg aria-[current=page]:text-text-primary\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre, G-U3)\n \"transition-[color,background-color,border-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every item (spec §7, 2.5.8), never a fixed\n // height below the floor\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // focus ring — identical on every state, never removed; persists expanded and collapsed\n \"outline-none\",\n focusRing,\n // disabled (non-operable) item — DEC-C: dim via the disabled TOKEN, never opacity. aria-disabled\n // drives it because an <a> has no native disabled; the component also strips href + tabindex.\n \"aria-disabled:pointer-events-none aria-disabled:text-text-disabled\",\n ],\n { variants: {}, defaultVariants: {} },\n);\n\nexport type SidebarItemVariantProps = VariantProps<typeof sidebarItemVariants>;\n\n// The leading item icon (spec §5): the md icon role, decorative (the item names itself by its label\n// text, not the glyph). At rest it is the GHOST action fg; it inherits the disabled token when the\n// item is disabled (aria-disabled on the parent link). When the item is current it lifts with the\n// label to the primary text color. shrink-0 so it never collapses when labels are hidden.\nexport const sidebarItemIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center text-action-ghost-fg\";\n\n// The item label text (spec §5): the part hidden in the collapsed rail. When collapsed the label is\n// visually hidden but stays in the accessibility tree (sr-only), so the link keeps its accessible\n// name without depending on a tooltip being open (spec §4 Collapsed / §7).\nexport const sidebarItemLabelClass = \"min-w-0 flex-1 truncate\";\n\n// A trailing count or status on an item (spec §2): text or an aria-label, never color alone (spec\n// §7). Plain secondary caption text, hidden with the label when collapsed.\nexport const sidebarItemTrailingClass =\n \"ms-auto inline-flex items-center text-caption text-text-secondary\";\n\n// The footer slot (spec §2): a bottom slot for an account or settings entry; its items follow the\n// same item rules. Pushed to the bottom; separated from the list above by the muted hairline.\nexport const sidebarFooterClass =\n \"mt-auto flex flex-col gap-(--space-1) pt-(--space-2) border-t border-surface-border-muted\";\n\n// The collapse-toggle (spec §2/§4/§6/§7): a native <button>, NOT an item, reachable in the rail's\n// tab order. It carries the ghost-action glyph and the same neutral hover fill, focus ring, and\n// target-size floor as an item. Its glyph is decorative (aria-hidden); aria-expanded + aria-label\n// carry the action. Same fast motion, never the deliberate theatre.\nexport const sidebarCollapseToggleClass = cva(\n [\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n \"text-action-ghost-fg cursor-pointer select-none\",\n \"hover:bg-action-ghost-bg-hover\",\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n \"outline-none\",\n focusRing,\n \"disabled:pointer-events-none disabled:text-text-disabled\",\n ],\n { variants: {}, defaultVariants: {} },\n);\n\nexport type SidebarCollapseToggleVariantProps = VariantProps<typeof sidebarCollapseToggleClass>;\n\n// The collapse-toggle glyph (spec §5): the md icon role, decorative. It rotates to mirror the rail\n// width (collapsed vs expanded) as a reinforcement; aria-expanded carries the state, not the glyph.\nexport const sidebarCollapseIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center transition-transform duration-(--motion-duration-base) ease-(--motion-easing-verdify) motion-reduce:duration-(--motion-duration-instant)\";\n",
22
22
  "path": "sidebar/sidebar.variants.ts",
23
23
  "target": "@ui/sidebar/sidebar.variants.ts",
24
24
  "type": "registry:ui"
@@ -26,7 +26,8 @@
26
26
  ],
27
27
  "name": "sidebar",
28
28
  "registryDependencies": [
29
- "@verdify/cn"
29
+ "@verdify/cn",
30
+ "@verdify/focus-ring"
30
31
  ],
31
32
  "title": "sidebar",
32
33
  "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// The track: a native <button role=\"switch\">. Off binds control-*; the\n// aria-checked: state-variant binds the on (action-primary) track — never a\n// status-* utility (brand ≠ state). The visible 2px focus ring is never removed.\n// The target-size floor lives on the hit-area wrapper (switchHitAreaVariants), NOT\n// here: a min-height floor on the SAME node as the h-5/h-6 track would force both\n// sizes to the 44px floor (used height = max(min-height, height)), erasing the\n// size variant. The track keeps only its own h-5/h-6 visible height.\nexport const switchTrackVariants = cva(\n [\n \"relative inline-flex shrink-0 items-center rounded-full border\",\n // off (resting) track + resting border\n \"bg-control-bg border-control-border\",\n // on track (state-variant) + on hover / pressed\n \"aria-checked:bg-action-primary-bg aria-checked:border-action-primary-bg\",\n \"aria-checked:hover:bg-action-primary-bg-hover aria-checked:active:bg-action-primary-bg-active\",\n // fast, functional track-tint — no theatre\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n // visible 2px signal-blue ring at 2px offset, on every state, never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n // disabled: no pointer; the checked value still reads to AT. DEC-C / spec §4:\n // reduced emphasis comes from dimming the thumb (the indicator) to the disabled\n // TOKEN — see switchThumbVariants — NOT a blanket opacity-60 dim of the track.\n \"disabled:pointer-events-none\",\n \"aria-busy:pointer-events-none\",\n ],\n {\n variants: {\n // visible track height/width only — the hit-area floor is a separate wrapper,\n // so sm renders a genuinely shorter visible track than md\n size: {\n md: \"h-6 w-11 px-0.5\",\n sm: \"h-5 w-9 px-0.5\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The thumb: control-fg fill, slides start→end. Position (not color) carries on/off\n// state, via the aria-checked: translate variant on the parent button.\n//\n// DEC-C / spec §4: a disabled switch dims the thumb (its indicator) to\n// --color-text-disabled — the SAME token the label dims to — NOT a blanket\n// opacity-60 dim of the track. The thumb is a child of the button, not a sibling\n// of a peer input, so the colour is driven by an explicit `disabled` cva variant\n// the component passes (mirrors switchLabelVariants); enabled keeps control-fg.\nexport const switchThumbVariants = cva(\n [\n \"pointer-events-none block rounded-full shadow-sm\",\n \"translate-x-0 aria-checked:translate-x-full\",\n \"transition-transform duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n ],\n {\n variants: {\n size: {\n md: \"h-5 w-5\",\n sm: \"h-4 w-4\",\n },\n disabled: { true: \"bg-text-disabled\", false: \"bg-control-fg\" },\n },\n defaultVariants: { size: \"md\", disabled: false },\n },\n);\n\n// The hit-area wrapper around the track. This — NOT the visible track — carries the\n// target-size floor (44px touch / 40px pointer) so the touch target always meets the\n// minimum while the track keeps its smaller h-5/h-6 visible height. inline-flex +\n// items-center vertically centres the shorter track within the taller hit area.\nexport const switchHitAreaVariants = cva([\n \"inline-flex shrink-0 items-center justify-center\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n]);\n\n// The label naming the setting. The <label> element is nested in a column wrapper,\n// not a sibling of the button, so peer-disabled never reaches it; the disabled\n// colour is driven by the explicit `disabled` variant the component passes (mirrors\n// checkboxLabelVariants / radioLabelVariants).\nexport const switchLabelVariants = cva(\n [\"text-label text-text-primary select-none\"],\n {\n variants: {\n disabled: { true: \"text-text-disabled\", false: \"\" },\n },\n defaultVariants: { disabled: false },\n },\n);\n\nexport type SwitchVariantProps = VariantProps<typeof switchTrackVariants>;\n",
20
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// The track: a native <button role=\"switch\">. Off binds control-*; the\n// aria-checked: state-variant binds the on (action-primary) track — never a\n// status-* utility (brand ≠ state). The visible 2px focus ring is never removed.\n// The target-size floor lives on the hit-area wrapper (switchHitAreaVariants), NOT\n// here: a min-height floor on the SAME node as the h-5/h-6 track would force both\n// sizes to the 44px floor (used height = max(min-height, height)), erasing the\n// size variant. The track keeps only its own h-5/h-6 visible height.\nexport const switchTrackVariants = cva(\n [\n \"relative inline-flex shrink-0 items-center rounded-full border\",\n // off (resting) track + resting border\n \"bg-control-bg border-control-border\",\n // on track (state-variant) + on hover / pressed\n \"aria-checked:bg-action-primary-bg aria-checked:border-action-primary-bg\",\n \"aria-checked:hover:bg-action-primary-bg-hover aria-checked:active:bg-action-primary-bg-active\",\n // fast, functional track-tint — no theatre\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n // visible 2px signal-blue ring at 2px offset, on every state, never removed\n \"outline-none\",\n focusRing,\n // disabled: no pointer; the checked value still reads to AT. DEC-C / spec §4:\n // reduced emphasis comes from dimming the thumb (the indicator) to the disabled\n // TOKEN — see switchThumbVariants — NOT a blanket opacity-60 dim of the track.\n \"disabled:pointer-events-none\",\n \"aria-busy:pointer-events-none\",\n ],\n {\n variants: {\n // visible track height/width only — the hit-area floor is a separate wrapper,\n // so sm renders a genuinely shorter visible track than md\n size: {\n md: \"h-6 w-11 px-0.5\",\n sm: \"h-5 w-9 px-0.5\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The thumb: control-fg fill, slides start→end. Position (not color) carries on/off\n// state, via the aria-checked: translate variant on the parent button.\n//\n// DEC-C / spec §4: a disabled switch dims the thumb (its indicator) to\n// --color-text-disabled — the SAME token the label dims to — NOT a blanket\n// opacity-60 dim of the track. The thumb is a child of the button, not a sibling\n// of a peer input, so the colour is driven by an explicit `disabled` cva variant\n// the component passes (mirrors switchLabelVariants); enabled keeps control-fg.\nexport const switchThumbVariants = cva(\n [\n \"pointer-events-none block rounded-full shadow-sm\",\n \"translate-x-0 aria-checked:translate-x-full\",\n \"transition-transform duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n ],\n {\n variants: {\n size: {\n md: \"h-5 w-5\",\n sm: \"h-4 w-4\",\n },\n disabled: { true: \"bg-text-disabled\", false: \"bg-control-fg\" },\n },\n defaultVariants: { size: \"md\", disabled: false },\n },\n);\n\n// The hit-area wrapper around the track. This — NOT the visible track — carries the\n// target-size floor (44px touch / 40px pointer) so the touch target always meets the\n// minimum while the track keeps its smaller h-5/h-6 visible height. inline-flex +\n// items-center vertically centres the shorter track within the taller hit area.\nexport const switchHitAreaVariants = cva([\n \"inline-flex shrink-0 items-center justify-center\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n]);\n\n// The label naming the setting. The <label> element is nested in a column wrapper,\n// not a sibling of the button, so peer-disabled never reaches it; the disabled\n// colour is driven by the explicit `disabled` variant the component passes (mirrors\n// checkboxLabelVariants / radioLabelVariants).\nexport const switchLabelVariants = cva(\n [\"text-label text-text-primary select-none\"],\n {\n variants: {\n disabled: { true: \"text-text-disabled\", false: \"\" },\n },\n defaultVariants: { disabled: false },\n },\n);\n\nexport type SwitchVariantProps = VariantProps<typeof switchTrackVariants>;\n",
21
21
  "path": "switch/switch.variants.ts",
22
22
  "target": "@ui/switch/switch.variants.ts",
23
23
  "type": "registry:ui"
@@ -25,7 +25,8 @@
25
25
  ],
26
26
  "name": "switch",
27
27
  "registryDependencies": [
28
- "@verdify/cn"
28
+ "@verdify/cn",
29
+ "@verdify/focus-ring"
29
30
  ],
30
31
  "title": "switch",
31
32
  "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\";\nimport { focusRing } from \"@/lib/focus-ring\";\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. Essential (it conveys data), so\n // it uses secondary (AA), not the decorative-only muted role (accessibility.md).\n true: \"text-text-secondary\",\n false: \"\",\n },\n status: {\n none: \"\",\n // each status is the fg only, paired with the cell's text — the 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 focusRing;\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"
@@ -26,6 +26,7 @@
26
26
  "name": "table",
27
27
  "registryDependencies": [
28
28
  "@verdify/cn",
29
+ "@verdify/focus-ring",
29
30
  "@verdify/skeleton"
30
31
  ],
31
32
  "title": "table",
@@ -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// Tabs is a NEUTRAL layout container (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents, neutrals carry the surface. The SELECTED tab + its indicator take the BRAND primary\n// action accent (--color-action-primary-*), NEVER a status color — a selected tab reports which\n// panel is open, not a verified result. Surfacing a verified result is VerifiedBadge's job\n// (brand != state, G-U2). This file is the ONLY token-binding site (skill §5 hard rule).\n\n// The tablist: the row (or column) of tabs. A neutral baseline divider (border-border-default)\n// runs along the inline-end edge of the tabs; in horizontal it is the bottom border under the\n// row, in vertical the inline-end border of the column. The canvas surface backs it.\nexport const tabsListVariants = cva(\n [\n // logical-property layout (G-U6) — flex row by default, gapped; the canvas backs the row\n \"flex bg-surface-canvas\",\n // the neutral baseline the underline indicator sits on (spec §5 border-default)\n \"border-border-default\",\n ],\n {\n variants: {\n orientation: {\n // horizontal: tabs sit in a row above a 1px baseline; gap between tabs\n horizontal: \"flex-row items-end gap-(--space-1) border-b\",\n // vertical: tabs stack in a column beside a 1px inline-end rail (side rail)\n vertical: \"flex-col items-stretch gap-(--space-1) border-e\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n },\n);\n\n// The tab: one selectable label in the tablist. A NEUTRAL ghost surface at rest — the unselected\n// label in --color-text-secondary, the leading icon + label in the ghost fg, a restrained ghost\n// hover fill. SELECTED lifts the label to --color-text-primary and paints the indicator in the\n// BRAND action-primary (underline border, or pill fill). The persistent focus ring; the\n// target-size floor (padding density above it, DEC-B); fast functional indicator-slide motion\n// (NEVER the deliberate verified-check theatre, G-U3); DEC-C disabled via the disabled TOKEN.\n//\n// Radix Tabs.Trigger drives data-state=active|inactive, data-disabled, and data-orientation on\n// the tab button, so the selected/disabled bindings are attribute-selector variants (allowed —\n// not arbitrary values). aria-selected + the visible panel ALSO encode selection, so color is\n// never the sole signal (spec §4 Selected / use-of-color 1.4.1).\nexport const tabsTabVariants = cva(\n [\n // layout: label (+ optional leading icon / trailing count) on a single centered row\n \"group inline-flex items-center justify-center gap-(--space-2) px-(--space-4)\",\n // type role + weight; ghost fg is the resting label + leading-icon color (spec §5 ghost-fg);\n // unselected label color is text-secondary, lifting to text-primary when active\n \"text-label font-medium cursor-pointer select-none whitespace-nowrap\",\n \"text-text-secondary data-[state=active]:text-text-primary\",\n // restrained ghost hover fill — never a brand fill on an unselected tab (spec §4 Hover)\n \"hover:bg-action-ghost-bg-hover\",\n // the indicator slide is a PLAIN, fast transition + verdify easing, instant under reduced\n // motion. Never the 350ms VerifiedBadge-only theatre duration (G-U3 motion-theatre gate).\n \"transition-[color,background-color,border-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer (§7 2.5.5/Material), padding density above it;\n // the height EMERGES from the floor + py, never a fixed height below the a11y floor (DEC-B)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; persists whether the tab is selected or not, and is\n // DISTINCT from selection — arrows move focus across tabs while selection stays put (spec §4)\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n // disabled — DEC-C: reduced emphasis via the disabled TOKEN (Radix sets data-disabled +\n // removes it from the roving sequence), never a blanket opacity on the control\n \"data-[disabled]:pointer-events-none data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n variant: {\n // underline (default): a transparent bottom border at rest that paints to the BRAND\n // action-primary when selected (spec §5: action-primary indicator). The border box is\n // always reserved so the row does not reflow on selection.\n underline: [\n \"border-b-2 border-b-transparent -mb-px\",\n \"data-[state=active]:border-action-primary-bg\",\n ],\n // pill: the selected tab is a filled brand chip — action-primary fill, its fg label, md\n // radius (spec §5: action-primary-bg fill, action-primary-fg label, radius-md)\n pill: [\n \"rounded-(--radius-md)\",\n \"data-[state=active]:bg-action-primary-bg data-[state=active]:text-action-primary-fg\",\n ],\n },\n size: {\n // DEC-B: both sizes hold the shared target-size floor; they differ by vertical padding\n // density ABOVE it (md roomier, sm denser for side rails), never a fixed height below it.\n md: \"py-(--space-2)\",\n sm: \"py-(--space-1)\",\n },\n },\n defaultVariants: { variant: \"underline\", size: \"md\" },\n },\n);\n\n// The panel: the content region tied to the selected tab. The canvas surface, the primary body\n// text at the body type role, panel insets from --space-4. In horizontal layout a divider above\n// it (border-t) continues the neutral hairline; in vertical it insets beside the rail. Radix\n// gives it role=tabpanel + aria-labelledby back to its tab, and tabindex=0 when it holds no\n// focusable content so Tab always reaches it (spec §7 focus management).\nexport const tabsPanelVariants = cva(\n [\n \"bg-surface-canvas text-text-primary text-body\",\n \"px-(--space-4) py-(--space-4)\",\n // visible focus ring when the panel itself takes focus (it is tabindex=0); never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n ],\n {\n variants: {\n orientation: {\n // horizontal: a neutral hairline above the panel continues the tablist baseline\n horizontal: \"border-t border-border-default\",\n // vertical: the panel sits beside the column rail; no top divider\n vertical: \"\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n },\n);\n\nexport type TabsListVariantProps = VariantProps<typeof tabsListVariants>;\nexport type TabsTabVariantProps = VariantProps<typeof tabsTabVariants>;\nexport type TabsPanelVariantProps = VariantProps<typeof tabsPanelVariants>;\n",
21
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// Tabs is a NEUTRAL layout container (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents, neutrals carry the surface. The SELECTED tab + its indicator take the BRAND primary\n// action accent (--color-action-primary-*), NEVER a status color — a selected tab reports which\n// panel is open, not a verified result. Surfacing a verified result is VerifiedBadge's job\n// (brand != state, G-U2). This file is the ONLY token-binding site (skill §5 hard rule).\n\n// The tablist: the row (or column) of tabs. A neutral baseline divider (border-border-default)\n// runs along the inline-end edge of the tabs; in horizontal it is the bottom border under the\n// row, in vertical the inline-end border of the column. The canvas surface backs it.\nexport const tabsListVariants = cva(\n [\n // logical-property layout (G-U6) — flex row by default, gapped; the canvas backs the row\n \"flex bg-surface-canvas\",\n // the neutral baseline the underline indicator sits on (spec §5 border-default)\n \"border-border-default\",\n ],\n {\n variants: {\n orientation: {\n // horizontal: tabs sit in a row above a 1px baseline; gap between tabs\n horizontal: \"flex-row items-end gap-(--space-1) border-b\",\n // vertical: tabs stack in a column beside a 1px inline-end rail (side rail)\n vertical: \"flex-col items-stretch gap-(--space-1) border-e\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n },\n);\n\n// The tab: one selectable label in the tablist. A NEUTRAL ghost surface at rest — the unselected\n// label in --color-text-secondary, the leading icon + label in the ghost fg, a restrained ghost\n// hover fill. SELECTED lifts the label to --color-text-primary and paints the indicator in the\n// BRAND action-primary (underline border, or pill fill). The persistent focus ring; the\n// target-size floor (padding density above it, DEC-B); fast functional indicator-slide motion\n// (NEVER the deliberate verified-check theatre, G-U3); DEC-C disabled via the disabled TOKEN.\n//\n// Radix Tabs.Trigger drives data-state=active|inactive, data-disabled, and data-orientation on\n// the tab button, so the selected/disabled bindings are attribute-selector variants (allowed —\n// not arbitrary values). aria-selected + the visible panel ALSO encode selection, so color is\n// never the sole signal (spec §4 Selected / use-of-color 1.4.1).\nexport const tabsTabVariants = cva(\n [\n // layout: label (+ optional leading icon / trailing count) on a single centered row\n \"group inline-flex items-center justify-center gap-(--space-2) px-(--space-4)\",\n // type role + weight; ghost fg is the resting label + leading-icon color (spec §5 ghost-fg);\n // unselected label color is text-secondary, lifting to text-primary when active\n \"text-label font-medium cursor-pointer select-none whitespace-nowrap\",\n \"text-text-secondary data-[state=active]:text-text-primary\",\n // restrained ghost hover fill — never a brand fill on an unselected tab (spec §4 Hover)\n \"hover:bg-action-ghost-bg-hover\",\n // the indicator slide is a PLAIN, fast transition + verdify easing, instant under reduced\n // motion. Never the 350ms VerifiedBadge-only theatre duration (G-U3 motion-theatre gate).\n \"transition-[color,background-color,border-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer (§7 2.5.5/Material), padding density above it;\n // the height EMERGES from the floor + py, never a fixed height below the a11y floor (DEC-B)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; persists whether the tab is selected or not, and is\n // DISTINCT from selection — arrows move focus across tabs while selection stays put (spec §4)\n \"outline-none\",\n focusRing,\n // disabled — DEC-C: reduced emphasis via the disabled TOKEN (Radix sets data-disabled +\n // removes it from the roving sequence), never a blanket opacity on the control\n \"data-[disabled]:pointer-events-none data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n variant: {\n // underline (default): a transparent bottom border at rest that paints to the BRAND\n // action-primary when selected (spec §5: action-primary indicator). The border box is\n // always reserved so the row does not reflow on selection.\n underline: [\n \"border-b-2 border-b-transparent -mb-px\",\n \"data-[state=active]:border-action-primary-bg\",\n ],\n // pill: the selected tab is a filled brand chip — action-primary fill, its fg label, md\n // radius (spec §5: action-primary-bg fill, action-primary-fg label, radius-md)\n pill: [\n \"rounded-(--radius-md)\",\n \"data-[state=active]:bg-action-primary-bg data-[state=active]:text-action-primary-fg\",\n ],\n },\n size: {\n // DEC-B: both sizes hold the shared target-size floor; they differ by vertical padding\n // density ABOVE it (md roomier, sm denser for side rails), never a fixed height below it.\n md: \"py-(--space-2)\",\n sm: \"py-(--space-1)\",\n },\n },\n defaultVariants: { variant: \"underline\", size: \"md\" },\n },\n);\n\n// The panel: the content region tied to the selected tab. The canvas surface, the primary body\n// text at the body type role, panel insets from --space-4. In horizontal layout a divider above\n// it (border-t) continues the neutral hairline; in vertical it insets beside the rail. Radix\n// gives it role=tabpanel + aria-labelledby back to its tab, and tabindex=0 when it holds no\n// focusable content so Tab always reaches it (spec §7 focus management).\nexport const tabsPanelVariants = cva(\n [\n \"bg-surface-canvas text-text-primary text-body\",\n \"px-(--space-4) py-(--space-4)\",\n // visible focus ring when the panel itself takes focus (it is tabindex=0); never removed\n \"outline-none\",\n focusRing,\n ],\n {\n variants: {\n orientation: {\n // horizontal: a neutral hairline above the panel continues the tablist baseline\n horizontal: \"border-t border-border-default\",\n // vertical: the panel sits beside the column rail; no top divider\n vertical: \"\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n },\n);\n\nexport type TabsListVariantProps = VariantProps<typeof tabsListVariants>;\nexport type TabsTabVariantProps = VariantProps<typeof tabsTabVariants>;\nexport type TabsPanelVariantProps = VariantProps<typeof tabsPanelVariants>;\n",
22
22
  "path": "tabs/tabs.variants.ts",
23
23
  "target": "@ui/tabs/tabs.variants.ts",
24
24
  "type": "registry:ui"
@@ -26,7 +26,8 @@
26
26
  ],
27
27
  "name": "tabs",
28
28
  "registryDependencies": [
29
- "@verdify/cn"
29
+ "@verdify/cn",
30
+ "@verdify/focus-ring"
30
31
  ],
31
32
  "title": "tabs",
32
33
  "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 { 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-secondary 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"
18
18
  },
19
19
  {
20
- "content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// The multi-line text field. Token binding lives ONLY here. Native <textarea>, no Radix.\n// The closed state set is default·hover·focus·disabled·read-only·error (textarea.md §4) —\n// loading and pressed do NOT apply and are dropped. Neutrals carry the field; the only\n// accent is the critical status in the error state — never brand violet for a state,\n// never a status color as a flourish (§3, §8).\nexport const textareaVariants = cva(\n [\n // shape + radius + internal padding; control intent tier carries the field\n \"block w-full rounded-md px-(--space-2) py-(--space-2)\",\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 // resting fill / border / text / placeholder — control intent tier\n \"bg-control-bg border border-control-border text-control-fg\",\n \"placeholder:text-control-placeholder\",\n // hover darkens the border only; fill is unchanged (restraint)\n \"hover:border-border-strong\",\n // focus: visible 2px signal-blue ring at 2px offset + focused border, never\n // removed (2.4.7); fast functional transition, no theatre\n \"outline-none transition-[color,border-color,box-shadow] duration-(--motion-duration-fast)\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"focus-visible:border-border-focus\",\n \"motion-reduce:transition-none\",\n // ERROR is the only colored field state — it borrows the STATUS color, never the\n // brand (§3, §8). Driven by the native aria-invalid attribute.\n \"aria-invalid:border-status-critical-border\",\n // disabled: reduced-emphasis text + placeholder, not editable\n \"disabled:cursor-not-allowed disabled:text-text-disabled\",\n \"disabled:placeholder:text-text-disabled\",\n // 44px mobile / 40px desktop minimum block-size floor, logical (a11y target size)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n ],\n {\n variants: {\n resize: {\n // reader drags the height; content may exceed the start height\n vertical: \"resize-y\",\n // fixed height; reader scrolls within it\n none: \"resize-none\",\n // height is driven programmatically between minRows/maxRows, then scrolls;\n // the manual grip is disabled. The ceiling is NOT a class — it is computed\n // from maxRows × line-height in the layout effect and written to\n // el.style.maxHeight (a bare CSS-var class here would reference an undefined\n // custom property and the token-binding gate would flag a non-token var).\n \"auto-grow\": \"resize-none\",\n },\n },\n defaultVariants: { resize: \"vertical\" },\n },\n);\n\nexport type TextareaVariantProps = VariantProps<typeof textareaVariants>;\n",
20
+ "content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// The multi-line text field. Token binding lives ONLY here. Native <textarea>, no Radix.\n// The closed state set is default·hover·focus·disabled·read-only·error (textarea.md §4) —\n// loading and pressed do NOT apply and are dropped. Neutrals carry the field; the only\n// accent is the critical status in the error state — never brand violet for a state,\n// never a status color as a flourish (§3, §8).\nexport const textareaVariants = cva(\n [\n // shape + radius + internal padding; control intent tier carries the field\n \"block w-full rounded-md px-(--space-2) py-(--space-2)\",\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 // resting fill / border / text / placeholder — control intent tier\n \"bg-control-bg border border-control-border text-control-fg\",\n \"placeholder:text-control-placeholder\",\n // hover darkens the border only; fill is unchanged (restraint)\n \"hover:border-border-strong\",\n // focus: visible 2px signal-blue ring at 2px offset + focused border, never\n // removed (2.4.7); fast functional transition, no theatre\n \"outline-none transition-[color,border-color,box-shadow] duration-(--motion-duration-fast)\",\n focusRing,\n \"focus-visible:border-border-focus\",\n \"motion-reduce:transition-none\",\n // ERROR is the only colored field state — it borrows the STATUS color, never the\n // brand (§3, §8). Driven by the native aria-invalid attribute.\n \"aria-invalid:border-status-critical-border\",\n // disabled: reduced-emphasis text + placeholder, not editable\n \"disabled:cursor-not-allowed disabled:text-text-disabled\",\n \"disabled:placeholder:text-text-disabled\",\n // 44px mobile / 40px desktop minimum block-size floor, logical (a11y target size)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n ],\n {\n variants: {\n resize: {\n // reader drags the height; content may exceed the start height\n vertical: \"resize-y\",\n // fixed height; reader scrolls within it\n none: \"resize-none\",\n // height is driven programmatically between minRows/maxRows, then scrolls;\n // the manual grip is disabled. The ceiling is NOT a class — it is computed\n // from maxRows × line-height in the layout effect and written to\n // el.style.maxHeight (a bare CSS-var class here would reference an undefined\n // custom property and the token-binding gate would flag a non-token var).\n \"auto-grow\": \"resize-none\",\n },\n },\n defaultVariants: { resize: \"vertical\" },\n },\n);\n\nexport type TextareaVariantProps = VariantProps<typeof textareaVariants>;\n",
21
21
  "path": "textarea/textarea.variants.ts",
22
22
  "target": "@ui/textarea/textarea.variants.ts",
23
23
  "type": "registry:ui"
@@ -26,6 +26,7 @@
26
26
  "name": "textarea",
27
27
  "registryDependencies": [
28
28
  "@verdify/cn",
29
+ "@verdify/focus-ring",
29
30
  "@verdify/label"
30
31
  ],
31
32
  "title": "textarea",