@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.
- package/LICENSE +12 -0
- package/dist/components/accordion/accordion.variants.d.ts.map +1 -1
- package/dist/components/accordion/accordion.variants.js +2 -1
- package/dist/components/accordion/accordion.variants.js.map +1 -1
- package/dist/components/agent-badge/agent-badge.variants.d.ts.map +1 -1
- package/dist/components/agent-badge/agent-badge.variants.js.map +1 -1
- package/dist/components/alert/alert.variants.d.ts.map +1 -1
- package/dist/components/alert/alert.variants.js +3 -2
- package/dist/components/alert/alert.variants.js.map +1 -1
- package/dist/components/breadcrumb/breadcrumb.variants.d.ts.map +1 -1
- package/dist/components/breadcrumb/breadcrumb.variants.js +2 -1
- package/dist/components/breadcrumb/breadcrumb.variants.js.map +1 -1
- package/dist/components/button/button.variants.d.ts.map +1 -1
- package/dist/components/button/button.variants.js +2 -1
- package/dist/components/button/button.variants.js.map +1 -1
- package/dist/components/card/card.variants.d.ts.map +1 -1
- package/dist/components/card/card.variants.js +2 -1
- package/dist/components/card/card.variants.js.map +1 -1
- package/dist/components/checkbox/checkbox.js +1 -1
- package/dist/components/checkbox/checkbox.js.map +1 -1
- package/dist/components/checkbox/checkbox.variants.d.ts.map +1 -1
- package/dist/components/checkbox/checkbox.variants.js +2 -1
- package/dist/components/checkbox/checkbox.variants.js.map +1 -1
- package/dist/components/command-palette/command-palette.variants.d.ts +1 -1
- package/dist/components/command-palette/command-palette.variants.d.ts.map +1 -1
- package/dist/components/command-palette/command-palette.variants.js +5 -3
- package/dist/components/command-palette/command-palette.variants.js.map +1 -1
- package/dist/components/consent-toggle/consent-toggle.variants.d.ts +1 -1
- package/dist/components/consent-toggle/consent-toggle.variants.d.ts.map +1 -1
- package/dist/components/consent-toggle/consent-toggle.variants.js +1 -1
- package/dist/components/consent-toggle/consent-toggle.variants.js.map +1 -1
- package/dist/components/credential-card/credential-card.variants.d.ts +3 -3
- package/dist/components/credential-card/credential-card.variants.d.ts.map +1 -1
- package/dist/components/credential-card/credential-card.variants.js +3 -3
- package/dist/components/credential-card/credential-card.variants.js.map +1 -1
- package/dist/components/data-grid/data-grid.variants.d.ts +1 -1
- package/dist/components/data-grid/data-grid.variants.d.ts.map +1 -1
- package/dist/components/data-grid/data-grid.variants.js +11 -10
- package/dist/components/data-grid/data-grid.variants.js.map +1 -1
- package/dist/components/dialog/dialog.variants.d.ts.map +1 -1
- package/dist/components/dialog/dialog.variants.js +3 -2
- package/dist/components/dialog/dialog.variants.js.map +1 -1
- package/dist/components/identity-chip/identity-chip.variants.d.ts.map +1 -1
- package/dist/components/identity-chip/identity-chip.variants.js +3 -2
- package/dist/components/identity-chip/identity-chip.variants.js.map +1 -1
- package/dist/components/input/input.variants.d.ts.map +1 -1
- package/dist/components/input/input.variants.js +3 -2
- package/dist/components/input/input.variants.js.map +1 -1
- package/dist/components/label/label.variants.js +1 -1
- package/dist/components/label/label.variants.js.map +1 -1
- package/dist/components/menu/menu.d.ts.map +1 -1
- package/dist/components/menu/menu.js +1 -1
- package/dist/components/menu/menu.js.map +1 -1
- package/dist/components/menu/menu.variants.d.ts +1 -1
- package/dist/components/menu/menu.variants.d.ts.map +1 -1
- package/dist/components/menu/menu.variants.js +3 -2
- package/dist/components/menu/menu.variants.js.map +1 -1
- package/dist/components/pagination/pagination.variants.d.ts.map +1 -1
- package/dist/components/pagination/pagination.variants.js +2 -1
- package/dist/components/pagination/pagination.variants.js.map +1 -1
- package/dist/components/popover/popover.variants.d.ts.map +1 -1
- package/dist/components/popover/popover.variants.js +4 -3
- package/dist/components/popover/popover.variants.js.map +1 -1
- package/dist/components/progress/progress.variants.d.ts +1 -1
- package/dist/components/progress/progress.variants.d.ts.map +1 -1
- package/dist/components/progress/progress.variants.js +1 -1
- package/dist/components/progress/progress.variants.js.map +1 -1
- package/dist/components/radio/radio.d.ts.map +1 -1
- package/dist/components/radio/radio.js +2 -1
- package/dist/components/radio/radio.js.map +1 -1
- package/dist/components/select/select.variants.d.ts +3 -3
- package/dist/components/select/select.variants.d.ts.map +1 -1
- package/dist/components/select/select.variants.js +5 -4
- package/dist/components/select/select.variants.js.map +1 -1
- package/dist/components/sheet/sheet.variants.d.ts.map +1 -1
- package/dist/components/sheet/sheet.variants.js +3 -2
- package/dist/components/sheet/sheet.variants.js.map +1 -1
- package/dist/components/sidebar/sidebar.variants.d.ts +1 -1
- package/dist/components/sidebar/sidebar.variants.d.ts.map +1 -1
- package/dist/components/sidebar/sidebar.variants.js +4 -3
- package/dist/components/sidebar/sidebar.variants.js.map +1 -1
- package/dist/components/switch/switch.variants.d.ts.map +1 -1
- package/dist/components/switch/switch.variants.js +2 -1
- package/dist/components/switch/switch.variants.js.map +1 -1
- package/dist/components/table/table.d.ts.map +1 -1
- package/dist/components/table/table.js +1 -1
- package/dist/components/table/table.js.map +1 -1
- package/dist/components/table/table.variants.d.ts.map +1 -1
- package/dist/components/table/table.variants.js +9 -7
- package/dist/components/table/table.variants.js.map +1 -1
- package/dist/components/tabs/tabs.variants.d.ts.map +1 -1
- package/dist/components/tabs/tabs.variants.js +3 -2
- package/dist/components/tabs/tabs.variants.js.map +1 -1
- package/dist/components/textarea/textarea.js +2 -2
- package/dist/components/textarea/textarea.js.map +1 -1
- package/dist/components/textarea/textarea.variants.d.ts.map +1 -1
- package/dist/components/textarea/textarea.variants.js +2 -1
- package/dist/components/textarea/textarea.variants.js.map +1 -1
- package/dist/components/toast/toast.variants.d.ts.map +1 -1
- package/dist/components/toast/toast.variants.js +3 -2
- package/dist/components/toast/toast.variants.js.map +1 -1
- package/dist/components/trust-score/trust-score.variants.d.ts +1 -1
- package/dist/components/trust-score/trust-score.variants.d.ts.map +1 -1
- package/dist/components/trust-score/trust-score.variants.js +1 -1
- package/dist/components/trust-score/trust-score.variants.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/focus-ring.d.ts +2 -0
- package/dist/lib/focus-ring.d.ts.map +1 -0
- package/dist/lib/focus-ring.js +5 -0
- package/dist/lib/focus-ring.js.map +1 -0
- package/package.json +18 -19
- package/registry/accordion.json +3 -2
- package/registry/agent-badge.json +1 -1
- package/registry/alert.json +3 -2
- package/registry/breadcrumb.json +3 -2
- package/registry/button.json +3 -2
- package/registry/card.json +3 -2
- package/registry/checkbox.json +4 -3
- package/registry/command-palette.json +3 -2
- package/registry/consent-toggle.json +1 -1
- package/registry/credential-card.json +1 -1
- package/registry/data-grid.json +2 -1
- package/registry/dialog.json +3 -2
- package/registry/focus-ring.json +16 -0
- package/registry/identity-chip.json +2 -1
- package/registry/init.json +1 -1
- package/registry/input.json +3 -2
- package/registry/label.json +1 -1
- package/registry/menu.json +4 -3
- package/registry/pagination.json +3 -2
- package/registry/popover.json +3 -2
- package/registry/progress.json +1 -1
- package/registry/radio.json +3 -2
- package/registry/select.json +3 -2
- package/registry/sheet.json +3 -2
- package/registry/sidebar.json +3 -2
- package/registry/switch.json +3 -2
- package/registry/table.json +3 -2
- package/registry/tabs.json +3 -2
- package/registry/textarea.json +3 -2
- package/registry/toast.json +3 -2
- package/registry/trust-score.json +1 -1
- package/registry.json +4 -0
package/registry/dialog.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"type": "registry:ui"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Dialog is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents, neutrals carry the surface. The PANEL and the SCRIM are neutral; Sovereign Violet\n// appears only on a footer PRIMARY action — through Button, not here — and Verified Green never\n// appears on the dialog as decoration. A destructive confirm uses the destructive ACTION\n// treatment on its confirm button (Button), never a red panel. So NOTHING in this file binds an\n// --color-action-primary-* or --color-status-* fill (brand != state, G-U2). This is the ONLY\n// token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer behind the panel that separates the dialog from the page and\n// absorbs outside clicks (spec §2 scrim, §5 --color-scrim-*). It is a neutral dim on the modal\n// z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing, instant\n// under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre gate).\n// Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not arbitrary\n// values). On a light surface the dark scrim token applies (spec §5: scrim-dark on light).\nexport const dialogScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container holding the dialog content; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius, the lg elevation shadow above the scrim, centered on the modal z-layer.\n// It never exceeds the viewport and scrolls its BODY when content overflows (spec §3) — the\n// panel caps its own height to the viewport less the gutter; the DialogBody owns the scroll. The\n// open/close transition is the BASE duration + verdify easing, instant under reduced motion, and\n// rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). Panel padding/gaps come from --space-*.\n//\n// size = the panel's MAX WIDTH only (spec §3 sm/md/lg, md default), bound to the --container-*\n// scale; the panel is full-width up to that cap and centered. There is no fixed height — the\n// height emerges from the content up to the viewport cap.\nexport const dialogPanelVariants = cva(\n [\n // centered on the modal layer; full available width up to the size cap, with side gutters\n \"fixed left-1/2 top-1/2 z-(--z-index-modal) -translate-x-1/2 -translate-y-1/2\",\n \"flex w-[calc(100%-var(--space-8))] flex-col gap-(--space-4)\",\n // never taller than the viewport less the gutter; the body scrolls within (spec §3)\n \"max-h-[calc(100dvh-var(--space-8))]\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base open/close transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n \"data-[state=open]:scale-100 data-[state=closed]:scale-95\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n
|
|
15
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// Dialog is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents, neutrals carry the surface. The PANEL and the SCRIM are neutral; Sovereign Violet\n// appears only on a footer PRIMARY action — through Button, not here — and Verified Green never\n// appears on the dialog as decoration. A destructive confirm uses the destructive ACTION\n// treatment on its confirm button (Button), never a red panel. So NOTHING in this file binds an\n// --color-action-primary-* or --color-status-* fill (brand != state, G-U2). This is the ONLY\n// token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer behind the panel that separates the dialog from the page and\n// absorbs outside clicks (spec §2 scrim, §5 --color-scrim-*). It is a neutral dim on the modal\n// z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing, instant\n// under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre gate).\n// Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not arbitrary\n// values). On a light surface the dark scrim token applies (spec §5: scrim-dark on light).\nexport const dialogScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container holding the dialog content; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius, the lg elevation shadow above the scrim, centered on the modal z-layer.\n// It never exceeds the viewport and scrolls its BODY when content overflows (spec §3) — the\n// panel caps its own height to the viewport less the gutter; the DialogBody owns the scroll. The\n// open/close transition is the BASE duration + verdify easing, instant under reduced motion, and\n// rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). Panel padding/gaps come from --space-*.\n//\n// size = the panel's MAX WIDTH only (spec §3 sm/md/lg, md default), bound to the --container-*\n// scale; the panel is full-width up to that cap and centered. There is no fixed height — the\n// height emerges from the content up to the viewport cap.\nexport const dialogPanelVariants = cva(\n [\n // centered on the modal layer; full available width up to the size cap, with side gutters\n \"fixed left-1/2 top-1/2 z-(--z-index-modal) -translate-x-1/2 -translate-y-1/2\",\n \"flex w-[calc(100%-var(--space-8))] flex-col gap-(--space-4)\",\n // never taller than the viewport less the gutter; the body scrolls within (spec §3)\n \"max-h-[calc(100dvh-var(--space-8))]\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base open/close transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n \"data-[state=open]:scale-100 data-[state=closed]:scale-95\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n focusRing,\n ],\n {\n variants: {\n // size = max width only (spec §3). md is the default. Bound to the --container-* scale.\n size: {\n sm: \"max-w-(--container-sm)\",\n md: \"max-w-(--container-md)\",\n lg: \"max-w-(--container-lg)\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The header: the top region holding the title and the optional close button on the inline-end.\n// Logical-property layout (G-U6); a neutral hairline divider under it continues the surface.\nexport const dialogHeaderClass =\n \"flex items-start justify-between gap-(--space-4) border-b border-border-default pb-(--space-4)\";\n\n// The title: names the dialog in one short statement; it is the accessible name (Radix wires\n// aria-labelledby). The h2 type role in primary text (spec §5 --text-h2 / --color-text-primary).\nexport const dialogTitleClass = \"text-h2 text-text-primary\";\n\n// The description: optional supporting text under the title, associated with the panel for\n// screen readers (Radix wires aria-describedby). Body type role in secondary text (spec §5).\nexport const dialogDescriptionClass = \"text-body text-text-secondary\";\n\n// The body: the scrollable content region between header and footer. The panel caps its height\n// to the viewport; the body takes the remaining space and scrolls when content overflows (spec\n// §3). Body text is the body type role in secondary text (spec §5 --text-body / text-secondary).\nexport const dialogBodyClass =\n \"min-h-0 flex-1 overflow-y-auto text-body text-text-secondary\";\n\n// The footer: the action region holding the primary and any secondary/cancel action, aligned to\n// the inline-end with a neutral hairline divider above it. The actions themselves are Buttons —\n// the dialog spec does not restate their --color-action-* bindings (spec §5 note). Logical-\n// property layout (G-U6): actions flow inline-end with a gap.\nexport const dialogFooterClass =\n \"flex items-center justify-end gap-(--space-2) border-t border-border-default pt-(--space-4)\";\n\n// The close button: the dismiss control in the header. A NEUTRAL ghost surface — the glyph in\n// --color-action-ghost-fg at rest, the restrained ghost hover fill (spec §5 ghost-fg /\n// ghost-bg-hover), the md radius, the persistent focus ring, the target-size floor (44px touch /\n// 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor, never fixed\n// below it. fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const dialogCloseVariants = cva([\n \"inline-flex items-center justify-center rounded-(--radius-md)\",\n // neutral ghost surface: glyph color at rest + restrained hover fill (no bg/border at rest)\n \"text-action-ghost-fg hover:bg-action-ghost-bg-hover\",\n // fast functional hover transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer; the close button is square at the floor (DEC-B)\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile)\",\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; never removed\n \"outline-none\",\n focusRing,\n]);\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the\n// button's ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const dialogCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n\nexport type DialogPanelVariantProps = VariantProps<typeof dialogPanelVariants>;\n",
|
|
16
16
|
"path": "dialog/dialog.variants.ts",
|
|
17
17
|
"target": "@ui/dialog/dialog.variants.ts",
|
|
18
18
|
"type": "registry:ui"
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
],
|
|
27
27
|
"name": "dialog",
|
|
28
28
|
"registryDependencies": [
|
|
29
|
-
"@verdify/cn"
|
|
29
|
+
"@verdify/cn",
|
|
30
|
+
"@verdify/focus-ring"
|
|
30
31
|
],
|
|
31
32
|
"title": "dialog",
|
|
32
33
|
"type": "registry:ui"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [],
|
|
4
|
+
"files": [
|
|
5
|
+
{
|
|
6
|
+
"content": "// Two-tone focus ring (WCAG 1.4.11). Single source of truth — every interactive\n// component composes this constant instead of inlining the ring utilities.\n//\n// The indicator is a three-part composite, innermost-first:\n// 1. `focus-visible:ring-offset-2` — a 2px gap between the target and the ring,\n// so the ring renders on the page surface rather than on a filled control.\n// 2. `focus-visible:ring-2 ring-border-focus` — the 2px signal-blue identity\n// layer (`--color-border-focus` = `--focus-ring-color`, #4D9DFF). This layer\n// is ~2.55:1 on light surfaces alone — below the WCAG 1.4.11 ≥3:1 floor — so\n// it CANNOT carry the contrast obligation by itself.\n// 3. `focus-visible:outline-(--focus-ring-casing)` — the contrast (casing)\n// layer, bound to the per-band `--focus-ring-casing` CSS var (= the band's\n// AAA `text.primary`: dark ink on light bands, light ink on dark bands).\n// Sat at `outline-offset-4` (4px = the blue ring's 2px offset + 2px width) it\n// hugs the OUTER edge of the blue ring, so the composite always has a ≥3:1\n// edge against every surface and against any\n// filled control the ring sits over. Tailwind v4 `outline-(<custom-property>)`\n// maps to `outline-color: var(<custom-property>)`.\n//\n// `outline-none` suppresses the browser default so the two-tone composite is the\n// only indicator; it is never removed (removing it with no replacement is a defect).\nexport const focusRing =\n \"outline-none \" +\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 \" +\n \"focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-4 \" +\n \"focus-visible:outline-(--focus-ring-casing)\";\n",
|
|
7
|
+
"path": "lib/focus-ring.ts",
|
|
8
|
+
"target": "@lib/focus-ring.ts",
|
|
9
|
+
"type": "registry:lib"
|
|
10
|
+
}
|
|
11
|
+
],
|
|
12
|
+
"name": "focus-ring",
|
|
13
|
+
"registryDependencies": [],
|
|
14
|
+
"title": "focusRing (Verdify two-tone focus ring)",
|
|
15
|
+
"type": "registry:lib"
|
|
16
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type": "registry:ui"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// An IdentityChip is a compact inline reference to a single identity — a person or an AI agent\n// (spec §1). It COMPOSES the committed primitives — the Avatar for the picture, the optional\n// AgentBadge for the actor kind, the optional VerifiedBadge for a surfaced verification, and a\n// Skeleton in the chip's shape while the identity is still resolving — rather than reinventing\n// any of them. This file binds ONLY the chip body's neutral surface + text-role + layout classes\n// and (for the interactive variants) the ghost hover/press fill, the focus ring, the target-size\n// floor, and the fast functional transition; every composed primitive owns its own tokens.\n//\n// brand != state (spec §3/§5/§8). A chip carries NO status of its own — it reports who, never a\n// result — so the chip body consumes NOTHING from the status tier: no --color-status-* token is\n// bound here, and the verified-status green lives entirely inside the composed VerifiedBadge,\n// never painted on the chip. The brand violet (Sovereign Violet, the action accent) is never a\n// chip FILL either: the brand is not a status and a chip is not an action, so the body paints\n// from neutral surface / text / border roles. The interactive variants are real controls, so\n// they legitimately bind the ACTION-GHOST hover/press fill and the focus ring — the restrained\n// neutral control treatment, never a brand-colored or status-colored chip.\n//\n// The motion is the functional fast transition on verdify easing, collapsing to the instant\n// endpoint under reduced motion — never the 350ms VerifiedBadge-only theatre duration: a chip\n// is not a verification (G-U3).\n\n// The chip container (spec §2/§3/§4/§5). A self-contained rounded unit that holds the avatar,\n// the name, and the reserved badge positions in a single inline row at the --space-1 gap with\n// --space-2 inline padding, on the NEUTRAL raised surface with the muted hairline that separates\n// it from a same-colored surface. `relative` so a loading-state Skeleton placed inside can sit\n// `absolute inset-0` (the committed Avatar/Skeleton pattern). `min-w-0` so the name can truncate\n// rather than overflow when the chip is width-constrained (spec §2 — the name is never dropped).\n//\n// The `variant` axis is about HOW the chip is used (spec §3), never status and never a brand\n// fill — so NONE of the variants recolors the body. `static` (default) is a passive frame: no\n// focus, no hit target, no hover (spec §3/§4/§6). `interactive` and `removable` are real controls\n// and add the ghost hover/press fill, the focus ring, the target-size floor, and the fast\n// functional transition — the difference is the keyboard model and which element takes focus\n// (the body for `interactive`, the trailing remove-control for `removable`), carried by the tsx,\n// not by recoloring here.\nexport const identityChipVariants = cva(\n [\n // shape / layout: a single inline row, never shrinking in a flex line, names truncatable\n \"inline-flex min-w-0 shrink-0 items-center gap-(--space-1) px-(--space-2)\",\n // full radius + the neutral raised surface + the muted separating hairline (spec §5)\n \"rounded-(--radius-full) bg-surface-raised border border-surface-border-muted\",\n // relative so a loading Skeleton can be positioned absolute inset-0 inside (Avatar pattern)\n \"relative\",\n // global-first: never wrap the row; logical alignment so it mirrors under dir=rtl (G-U6)\n \"whitespace-nowrap text-start\",\n ],\n {\n variants: {\n // STRUCTURAL axis = spec §3 (how the chip is USED, never status, never a brand fill).\n variant: {\n // static (default): a read-only inline reference — non-interactive, no focus, no hit\n // target, no hover. The common case (spec §3).\n static: \"\",\n // interactive: the chip body is itself a control (an account-switcher trigger, a chip\n // that opens the profile). It takes the focus ring, the target-size floor, the restrained\n // ghost hover/press fill, and the fast functional transition (spec §3/§4). DEC-C: a\n // disabled control dims the label/name via the disabled TOKEN, never a blanket opacity.\n interactive: [\n \"cursor-pointer\",\n // fast functional motion on verdify easing; instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer (spec §5/§7, WCAG 2.5.8)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // restrained ghost hover/press fill — quiet neutral, never a status or brand color (spec §4)\n \"hover:bg-action-ghost-bg-hover active:bg-action-ghost-bg-hover\",\n // focus ring — always visible, never removed (spec §4/§7)\n
|
|
14
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// An IdentityChip is a compact inline reference to a single identity — a person or an AI agent\n// (spec §1). It COMPOSES the committed primitives — the Avatar for the picture, the optional\n// AgentBadge for the actor kind, the optional VerifiedBadge for a surfaced verification, and a\n// Skeleton in the chip's shape while the identity is still resolving — rather than reinventing\n// any of them. This file binds ONLY the chip body's neutral surface + text-role + layout classes\n// and (for the interactive variants) the ghost hover/press fill, the focus ring, the target-size\n// floor, and the fast functional transition; every composed primitive owns its own tokens.\n//\n// brand != state (spec §3/§5/§8). A chip carries NO status of its own — it reports who, never a\n// result — so the chip body consumes NOTHING from the status tier: no --color-status-* token is\n// bound here, and the verified-status green lives entirely inside the composed VerifiedBadge,\n// never painted on the chip. The brand violet (Sovereign Violet, the action accent) is never a\n// chip FILL either: the brand is not a status and a chip is not an action, so the body paints\n// from neutral surface / text / border roles. The interactive variants are real controls, so\n// they legitimately bind the ACTION-GHOST hover/press fill and the focus ring — the restrained\n// neutral control treatment, never a brand-colored or status-colored chip.\n//\n// The motion is the functional fast transition on verdify easing, collapsing to the instant\n// endpoint under reduced motion — never the 350ms VerifiedBadge-only theatre duration: a chip\n// is not a verification (G-U3).\n\n// The chip container (spec §2/§3/§4/§5). A self-contained rounded unit that holds the avatar,\n// the name, and the reserved badge positions in a single inline row at the --space-1 gap with\n// --space-2 inline padding, on the NEUTRAL raised surface with the muted hairline that separates\n// it from a same-colored surface. `relative` so a loading-state Skeleton placed inside can sit\n// `absolute inset-0` (the committed Avatar/Skeleton pattern). `min-w-0` so the name can truncate\n// rather than overflow when the chip is width-constrained (spec §2 — the name is never dropped).\n//\n// The `variant` axis is about HOW the chip is used (spec §3), never status and never a brand\n// fill — so NONE of the variants recolors the body. `static` (default) is a passive frame: no\n// focus, no hit target, no hover (spec §3/§4/§6). `interactive` and `removable` are real controls\n// and add the ghost hover/press fill, the focus ring, the target-size floor, and the fast\n// functional transition — the difference is the keyboard model and which element takes focus\n// (the body for `interactive`, the trailing remove-control for `removable`), carried by the tsx,\n// not by recoloring here.\nexport const identityChipVariants = cva(\n [\n // shape / layout: a single inline row, never shrinking in a flex line, names truncatable\n \"inline-flex min-w-0 shrink-0 items-center gap-(--space-1) px-(--space-2)\",\n // full radius + the neutral raised surface + the muted separating hairline (spec §5)\n \"rounded-(--radius-full) bg-surface-raised border border-surface-border-muted\",\n // relative so a loading Skeleton can be positioned absolute inset-0 inside (Avatar pattern)\n \"relative\",\n // global-first: never wrap the row; logical alignment so it mirrors under dir=rtl (G-U6)\n \"whitespace-nowrap text-start\",\n ],\n {\n variants: {\n // STRUCTURAL axis = spec §3 (how the chip is USED, never status, never a brand fill).\n variant: {\n // static (default): a read-only inline reference — non-interactive, no focus, no hit\n // target, no hover. The common case (spec §3).\n static: \"\",\n // interactive: the chip body is itself a control (an account-switcher trigger, a chip\n // that opens the profile). It takes the focus ring, the target-size floor, the restrained\n // ghost hover/press fill, and the fast functional transition (spec §3/§4). DEC-C: a\n // disabled control dims the label/name via the disabled TOKEN, never a blanket opacity.\n interactive: [\n \"cursor-pointer\",\n // fast functional motion on verdify easing; instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer (spec §5/§7, WCAG 2.5.8)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // restrained ghost hover/press fill — quiet neutral, never a status or brand color (spec §4)\n \"hover:bg-action-ghost-bg-hover active:bg-action-ghost-bg-hover\",\n // focus ring — always visible, never removed (spec §4/§7)\n focusRing,\n // disabled — DEC-C: dim the label via the disabled TOKEN, inert, never opacity-60 (spec §4)\n \"aria-disabled:pointer-events-none aria-disabled:text-text-disabled\",\n ],\n // removable: a token in an editable field. The chip BODY stays presentational (no focus\n // ring / target floor of its own) — the trailing remove-control is the focusable part and\n // owns the target size + focus ring (set on removeControlClass). The body keeps the static\n // surface (spec §3/§4).\n removable: \"\",\n },\n },\n defaultVariants: { variant: \"static\" },\n },\n);\n\n// The display name (spec §2/§5): the identity's name in the LABEL type role + the PRIMARY text\n// color. It carries the identity in the accessibility tree as text, so it is never the only part\n// dropped at small sizes — it truncates with an ellipsis rather than disappearing (spec §2/§7).\nexport const identityChipNameClass = \"min-w-0 truncate text-label text-text-primary\";\n\n// The optional supporting line (spec §2/§5): one handle / role / profile-context line that\n// disambiguates two identities sharing a name, in the CAPTION type role + the SECONDARY text\n// color. Supporting detail only — never a credential value and never a status color.\nexport const identityChipSecondaryClass = \"min-w-0 truncate text-caption text-text-secondary\";\n\n// The text block (spec §2): stacks the name above the optional secondary line; takes the\n// remaining inline space between the avatar and the reserved badge positions and lets the name\n// truncate (min-w-0) rather than overflow.\nexport const identityChipTextClass = \"flex min-w-0 flex-col\";\n\n// The trailing remove-control of the `removable` variant (spec §2/§4/§5/§6). It is the ONE\n// focusable part of a removable chip and owns its own activation, so it carries the target-size\n// floor, the focus ring, and the ghost hover/press fill — the control treatment lives HERE, not\n// on the chip body. The glyph takes the action-ghost foreground at the sm icon role; under reduced\n// motion the transition collapses to the instant endpoint. DEC-C: a disabled remove dims via the\n// disabled TOKEN, never a blanket opacity.\nexport const identityChipRemoveControlClass =\n \"inline-flex shrink-0 items-center justify-center rounded-(--radius-full) \" +\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile) \" +\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop) \" +\n \"text-action-ghost-fg cursor-pointer \" +\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"hover:bg-action-ghost-bg-hover active:bg-action-ghost-bg-hover \" +\n focusRing + \" \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The remove-control glyph (spec §5): one small decorative mark at the sm icon role, inheriting\n// the control's action-ghost foreground via currentColor.\nexport const identityChipRemoveGlyphClass = \"h-(--size-icon-sm) w-(--size-icon-sm)\";\n\nexport type IdentityChipVariantProps = VariantProps<typeof identityChipVariants>;\n",
|
|
15
15
|
"path": "identity-chip/identity-chip.variants.ts",
|
|
16
16
|
"target": "@ui/identity-chip/identity-chip.variants.ts",
|
|
17
17
|
"type": "registry:ui"
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"name": "identity-chip",
|
|
27
27
|
"registryDependencies": [
|
|
28
28
|
"@verdify/cn",
|
|
29
|
+
"@verdify/focus-ring",
|
|
29
30
|
"@verdify/agent-badge",
|
|
30
31
|
"@verdify/avatar",
|
|
31
32
|
"@verdify/skeleton",
|
package/registry/init.json
CHANGED
package/registry/input.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"type": "registry:ui"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// The text field. Token binding lives ONLY here. Native <input>, no Radix.\n// The closed state set for a text field is default·hover·focus·disabled·read-only·error\n// (input.md §4) — loading and pressed do NOT apply and are dropped.\nexport const inputVariants = cva(\n [\n // shape + resting field: control-* carries the field, neutrals not brand\n \"block w-full rounded-md border bg-control-bg text-control-fg\",\n \"border-control-border placeholder:text-control-placeholder\",\n // DEC-A — the value SIZE is text-base (16px) so iOS never zooms on focus; the\n // brand BODY line-height + letter-spacing ride along via the role-suffix vars.\n // text-body itself (0.9375rem / 15px) is NEVER bound on a form field: under the\n // role-aware cn it would collapse against text-base, and 15px would reintroduce\n // the iOS focus-zoom that the 16px reset exists to prevent.\n \"text-base leading-(--text-body--line-height) tracking-(--text-body--letter-spacing)\",\n // hover shows a text caret; the border does NOT change color (restraint)\n \"cursor-text\",\n // focus: visible 2px signal-blue ring at 2px offset + focused border, never\n // removed (2.4.7); border+ring meet 3:1 non-text contrast (1.4.11)\n \"outline-none\",\n
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// The text field. Token binding lives ONLY here. Native <input>, no Radix.\n// The closed state set for a text field is default·hover·focus·disabled·read-only·error\n// (input.md §4) — loading and pressed do NOT apply and are dropped.\nexport const inputVariants = cva(\n [\n // shape + resting field: control-* carries the field, neutrals not brand\n \"block w-full rounded-md border bg-control-bg text-control-fg\",\n \"border-control-border placeholder:text-control-placeholder\",\n // DEC-A — the value SIZE is text-base (16px) so iOS never zooms on focus; the\n // brand BODY line-height + letter-spacing ride along via the role-suffix vars.\n // text-body itself (0.9375rem / 15px) is NEVER bound on a form field: under the\n // role-aware cn it would collapse against text-base, and 15px would reintroduce\n // the iOS focus-zoom that the 16px reset exists to prevent.\n \"text-base leading-(--text-body--line-height) tracking-(--text-body--letter-spacing)\",\n // hover shows a text caret; the border does NOT change color (restraint)\n \"cursor-text\",\n // focus: visible 2px signal-blue ring at 2px offset + focused border, never\n // removed (2.4.7); border+ring meet 3:1 non-text contrast (1.4.11)\n \"outline-none\",\n focusRing,\n \"focus-visible:border-border-focus\",\n // colors transition functionally (no theatre); border + ring only\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n // disabled: muted value, non-interactive (native disabled drives tab skip)\n \"disabled:cursor-not-allowed disabled:text-text-disabled\",\n // read-only: editable-looking, selectable, stays in the tab order\n \"read-only:cursor-default\",\n // ERROR is the only colored field state — it borrows the STATUS color, never\n // the brand (§3, §8). Driven by the native aria-invalid attribute.\n \"aria-invalid:border-status-critical-border\",\n \"aria-invalid:focus-visible:ring-status-critical-border\",\n // 44px mobile / 40px desktop target floor, logical block-size. DEC-B: tokens\n // expose only target-size FLOORS, no height scale — every size anchors this\n // floor and never sets a fixed height below it (a11y). Resting height emerges\n // from the size variant's vertical padding above this floor.\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n ],\n {\n variants: {\n // DEC-B — the 16px no-zoom reset is a hard floor on every form-field size, so\n // (unlike a non-field control) the type role is held constant and the sizes\n // differ ONLY by vertical padding (density) ABOVE the shared target floor:\n // --space-1 (0.25rem) <= --space-2 (0.5rem) gives a coherent sm <= md height\n // progression, both >= the floor.\n size: {\n md: \"py-(--space-2)\",\n sm: \"py-(--space-1)\",\n },\n // logical inline padding; widened on the slot side to reserve room\n leadingSlot: { true: \"ps-(--space-9)\", false: \"ps-(--space-3)\" },\n trailingSlot: { true: \"pe-(--space-9)\", false: \"pe-(--space-3)\" },\n },\n defaultVariants: { size: \"md\", leadingSlot: false, trailingSlot: false },\n },\n);\n\nexport type InputVariantProps = VariantProps<typeof inputVariants>;\n\n// The message below the field. The error help text borrows the field's STATUS\n// color (the only colored field state); neutral help text is muted secondary.\nexport const inputMessageVariants = cva(\"mt-(--space-1) text-caption\", {\n variants: {\n tone: { help: \"text-text-secondary\", error: \"text-status-critical-on-surface\" },\n },\n defaultVariants: { tone: \"help\" },\n});\n",
|
|
21
21
|
"path": "input/input.variants.ts",
|
|
22
22
|
"target": "@ui/input/input.variants.ts",
|
|
23
23
|
"type": "registry:ui"
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"name": "input",
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"@verdify/cn"
|
|
28
|
+
"@verdify/cn",
|
|
29
|
+
"@verdify/focus-ring"
|
|
29
30
|
],
|
|
30
31
|
"title": "input",
|
|
31
32
|
"type": "registry:ui"
|
package/registry/label.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"type": "registry:ui"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Resting label text: the label type role + primary text color, laid out inline\n// with its mark/hint at the --space-2 gap. No focus ring, no target-size floor —\n// a Label is not interactive. `disabled` reflects the control's state visually.\nexport const labelVariants = cva(\n [\n \"inline-flex items-center gap-(--space-2)\",\n \"text-label font-medium text-text-primary select-none\",\n ],\n {\n variants: {\n disabled: {\n // reflects the associated control's disabled state; stays in the DOM\n true: \"text-text-disabled\",\n false: \"\",\n },\n },\n defaultVariants: { disabled: false },\n },\n);\n\n// The required mark — meaning carried by shape + text, never color alone. The\n// critical color is permitted ONLY here (paired with the asterisk glyph and the\n// visually-hidden \"required\" word), never on resting label text.\nexport const requiredMarkVariants = cva([\n \"inline-flex items-center gap-(--space-1) text-status-critical-
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Resting label text: the label type role + primary text color, laid out inline\n// with its mark/hint at the --space-2 gap. No focus ring, no target-size floor —\n// a Label is not interactive. `disabled` reflects the control's state visually.\nexport const labelVariants = cva(\n [\n \"inline-flex items-center gap-(--space-2)\",\n \"text-label font-medium text-text-primary select-none\",\n ],\n {\n variants: {\n disabled: {\n // reflects the associated control's disabled state; stays in the DOM\n true: \"text-text-disabled\",\n false: \"\",\n },\n },\n defaultVariants: { disabled: false },\n },\n);\n\n// The required mark — meaning carried by shape + text, never color alone. The\n// critical color is permitted ONLY here (paired with the asterisk glyph and the\n// visually-hidden \"required\" word), never on resting label text.\nexport const requiredMarkVariants = cva([\n \"inline-flex items-center gap-(--space-1) text-status-critical-on-surface\",\n]);\n\n// The optional hint — a short secondary note in the caption role + secondary color.\nexport const optionalHintVariants = cva([\"text-caption text-text-secondary\"]);\n\nexport type LabelVariantProps = VariantProps<typeof labelVariants>;\n",
|
|
21
21
|
"path": "label/label.variants.ts",
|
|
22
22
|
"target": "@ui/label/label.variants.ts",
|
|
23
23
|
"type": "registry:ui"
|
package/registry/menu.json
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"type": "registry:ui"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
|
-
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n menuTriggerClass,\n menuPopupClass,\n menuItemVariants,\n menuItemIconClass,\n menuItemShortcutClass,\n menuSubChevronClass,\n menuLabelClass,\n menuSeparatorClass,\n type MenuItemVariantProps,\n} from \"./menu.variants\";\n\nexport interface MenuProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root> {}\n\n/**\n * Menu is a popup list of ACTIONS that a trigger opens — the row of commands behind a button, an\n * avatar, or a row's overflow control (spec §1). Reach for it when you want to fire an action (open,\n * rename, sign out, revoke a key), not pick a value: use Select to choose one option from a list, and\n * use the Sidebar for page-level navigation. Each item runs a command and then the menu closes.\n *\n * It is a NEUTRAL surface (spec §3): the popup, items, and separators are neutral, and the brand\n * violet never marks an item as \"the special one.\" The one colored item is the destructive item,\n * which takes the destructive ACTION treatment because the command it runs is irreversible — a risk\n * signal, not a status result; a verified result is never reported by a menu item (brand != state).\n *\n * Wraps the Radix DropdownMenu primitive (WAI-ARIA APG menu-button + menu pattern), which provides\n * the portal, roving tabindex, type-ahead, submenu, and Escape/arrow keyboard model — a stateful\n * primitive, so this file is `'use client'`.\n */\nexport function Menu(props: MenuProps) {\n return <DropdownMenuPrimitive.Root {...props} />;\n}\n\nexport interface MenuTriggerProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> {}\n\n/**\n * The control that opens the menu (spec §2 trigger): the one stop in the page tab order for this\n * control, carrying the focus ring. Radix sets `aria-haspopup=\"menu\"`, `aria-expanded`, and\n * `aria-controls` (pointing at the popup) for you. Pass `asChild` to wrap your own Button so the\n * trigger inherits its role, keyboard, and focus ring rather than nesting a second button; the bare\n * (non-`asChild`) form renders the default neutral-ghost trigger.\n */\nexport const MenuTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,\n MenuTriggerProps\n>(function MenuTrigger({ className, asChild, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Trigger\n ref={ref}\n asChild={asChild}\n className={asChild ? className : cn(menuTriggerClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {}\n\n/**\n * Renders the portal and the popup (spec §2 popup): the floating `role=\"menu\"` surface that opens on\n * activation, raised above the page and anchored to the trigger. On open, focus moves into the popup\n * (first item, or last on Up) and roving tabindex tracks the active item; on close — by Escape,\n * activation, or click-away — focus returns to the trigger (Radix, spec §6/§7). The menu is NOT a\n * modal dialog: focus is not trapped, and Tab leaves the menu rather than stepping through items.\n * A neutral raised surface; brand violet and Verified Green never appear here (spec §3/§5/§8).\n */\nexport const MenuContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n MenuContentProps\n>(function MenuContent({ className, sideOffset = 4, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends (spec §6: \"wrapping at the ends\") — Radix leaves\n // it OFF by default, so we default it ON to honor the frozen keyboard model. Disabled items\n // are skipped by the roving handler for free (Radix, spec §4/§6).\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\nexport interface MenuItemProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`; the item names itself by its label text. */\n icon?: React.ReactNode;\n /** A trailing shortcut hint (spec §2): text such as \"⌘K\", in the muted label role; never a focus stop. */\n shortcut?: React.ReactNode;\n}\n\n/**\n * One command row (spec §2 item, §4 states): a `role=\"menuitem\"` whose activation runs its command\n * and closes the menu, returning focus to the trigger (Radix). It holds an optional leading icon, a\n * label, and an optional trailing shortcut hint. Pointer hover and keyboard arrow movement share ONE\n * highlight (Radix `data-highlighted`), so the active item is the same for both (spec §4 Hover).\n *\n * `destructive` (spec §3 `item=destructive`) marks the ONE colored item — a command that is\n * irreversible (revoke a key, delete a profile). It takes the destructive ACTION treatment and must\n * name the consequence in its TEXT, never by color alone (spec §7/§8). A `disabled` item stays in the\n * menu and readable to assistive technology (`aria-disabled`), is skipped by arrow movement, and does\n * not fire on activation (spec §4 Disabled / §7).\n */\nexport const MenuItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n MenuItemProps\n>(function MenuItem({ className, destructive, icon, shortcut, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Item\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {shortcut ? <span className={menuItemShortcutClass}>{shortcut}</span> : null}\n </DropdownMenuPrimitive.Item>\n );\n});\n\nexport interface MenuGroupProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group> {\n /**\n * The non-interactive heading that partitions the popup (spec §2 group / group-label). It names the\n * group for assistive technology via `aria-labelledby` (Radix `Label`) and is NEVER a focus stop.\n */\n label?: React.ReactNode;\n}\n\n/**\n * A set of related items under a non-interactive `group-label` that partitions the popup (spec §2\n * group). The items read as a related set (`role=\"group\"` named by the label); the label is never a\n * menuitem and never a focus stop.\n */\nexport const MenuGroup = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Group>,\n MenuGroupProps\n>(function MenuGroup({ label, children, ...props }, ref) {\n // Radix Group + Label do NOT auto-wire aria-labelledby (the Label renders a bare div with no id),\n // so the group would read as an unnamed role=group. We hand-wire it the same way Separator\n // hand-rolls a named anatomy when the primitive can't carry it (skill: compose the role by hand\n // when Radix can't express the spec's anatomy): generate a stable id on the Label and point the\n // Group's aria-labelledby at it, so the items read as a related set named by the label (spec §2\n // group / §7 group named by its label).\n const labelId = React.useId();\n return (\n <DropdownMenuPrimitive.Group\n ref={ref}\n aria-labelledby={label ? labelId : undefined}\n {...props}\n >\n {label ? (\n <DropdownMenuPrimitive.Label id={labelId} className={menuLabelClass}>\n {label}\n </DropdownMenuPrimitive.Label>\n ) : null}\n {children}\n </DropdownMenuPrimitive.Group>\n );\n});\n\nexport interface MenuLabelProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {}\n\n/**\n * A standalone non-interactive section label (spec §2 group-label) for a label that is not wrapped in\n * a `MenuGroup`. Like the group label it is the muted label-role heading and is never a focus stop.\n */\nexport const MenuLabel = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n MenuLabelProps\n>(function MenuLabel({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Label ref={ref} className={cn(menuLabelClass, className)} {...props} />\n );\n});\n\nexport interface MenuSeparatorProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> {}\n\n/**\n * A thin neutral divider between groups (spec §2 separator): decorative (`role=\"separator\"`), never a\n * focus stop.\n */\nexport const MenuSeparator = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n MenuSeparatorProps\n>(function MenuSeparator({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Separator\n ref={ref}\n className={cn(menuSeparatorClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuSubProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Sub> {}\n\n/**\n * A submenu: an item that opens a nested popup of its own items (spec §2 submenu). Keep nesting\n * shallow — deep trees are hard to operate by keyboard (spec §2). Wraps `MenuSubTrigger` +\n * `MenuSubContent`.\n */\nexport function MenuSub(props: MenuSubProps) {\n return <DropdownMenuPrimitive.Sub {...props} />;\n}\n\nexport interface MenuSubTriggerProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`. */\n icon?: React.ReactNode;\n}\n\n/**\n * The item that opens a submenu (spec §2/§6): a `role=\"menuitem\"` with `aria-haspopup=\"menu\"` and its\n * own `aria-expanded` (Radix). Right opens the submenu and focuses its first item; Left closes it and\n * returns focus here. It carries the same row treatment as a `MenuItem`, plus a trailing chevron\n * pointing to the inline-end.\n */\nexport const MenuSubTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n MenuSubTriggerProps\n>(function MenuSubTrigger({ className, destructive, icon, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n <ChevronGlyph />\n </DropdownMenuPrimitive.SubTrigger>\n );\n});\n\nexport interface MenuSubContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> {}\n\n/**\n * The nested popup of a submenu (spec §2 submenu): the same neutral raised `role=\"menu\"` surface as\n * `MenuContent`, anchored to its `MenuSubTrigger`.\n */\nexport const MenuSubContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n MenuSubContentProps\n>(function MenuSubContent({ className, sideOffset = 2, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.SubContent\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends inside the submenu too (spec §6), matching the\n // parent popup; Radix leaves it OFF by default.\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\n// The submenu chevron — inline SVG (no icon dep), --size-icon-md, pointing to the inline-end to\n// signal the nested popup. Decorative (aria-hidden); aria-haspopup/aria-expanded carry the state, not\n// the glyph (spec §2/§7). Drawn with currentColor so it inherits the row's color.\nfunction ChevronGlyph() {\n return (\n <span data-testid=\"menu-sub-chevron\" aria-hidden=\"true\" className={menuSubChevronClass}>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" focusable=\"false\">\n <path d=\"M6 4l4 4-4 4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n );\n}\n",
|
|
15
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { DropdownMenu as DropdownMenuPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n menuTriggerClass,\n menuPopupClass,\n menuItemVariants,\n menuItemIconClass,\n menuItemShortcutClass,\n menuSubChevronClass,\n menuLabelClass,\n menuSeparatorClass,\n type MenuItemVariantProps,\n} from \"./menu.variants\";\n\nexport interface MenuProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root> {}\n\n/**\n * Menu is a popup list of ACTIONS that a trigger opens — the row of commands behind a button, an\n * avatar, or a row's overflow control (spec §1). Reach for it when you want to fire an action (open,\n * rename, sign out, revoke a key), not pick a value: use Select to choose one option from a list, and\n * use the Sidebar for page-level navigation. Each item runs a command and then the menu closes.\n *\n * It is a NEUTRAL surface (spec §3): the popup, items, and separators are neutral, and the brand\n * violet never marks an item as \"the special one.\" The one colored item is the destructive item,\n * which takes the destructive ACTION treatment because the command it runs is irreversible — a risk\n * signal, not a status result; a verified result is never reported by a menu item (brand != state).\n *\n * Wraps the Radix DropdownMenu primitive (WAI-ARIA APG menu-button + menu pattern), which provides\n * the portal, roving tabindex, type-ahead, submenu, and Escape/arrow keyboard model — a stateful\n * primitive, so this file is `'use client'`.\n */\nexport function Menu(props: MenuProps) {\n return <DropdownMenuPrimitive.Root {...props} />;\n}\n\nexport interface MenuTriggerProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger> {}\n\n/**\n * The control that opens the menu (spec §2 trigger): the one stop in the page tab order for this\n * control, carrying the focus ring. Radix sets `aria-haspopup=\"menu\"`, `aria-expanded`, and\n * `aria-controls` (pointing at the popup) for you. Pass `asChild` to wrap your own Button so the\n * trigger inherits its role, keyboard, and focus ring rather than nesting a second button; the bare\n * (non-`asChild`) form renders the default neutral-ghost trigger.\n */\nexport const MenuTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,\n MenuTriggerProps\n>(function MenuTrigger({ className, asChild, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Trigger\n ref={ref}\n asChild={asChild}\n className={asChild ? className : cn(menuTriggerClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {}\n\n/**\n * Renders the portal and the popup (spec §2 popup): the floating `role=\"menu\"` surface that opens on\n * activation, raised above the page and anchored to the trigger. On open, focus moves into the popup\n * (first item, or last on Up) and roving tabindex tracks the active item; on close — by Escape,\n * activation, or click-away — focus returns to the trigger (Radix, spec §6/§7). The menu is NOT a\n * modal dialog: focus is not trapped, and Tab leaves the menu rather than stepping through items.\n * A neutral raised surface; brand violet and Verified Green never appear here (spec §3/§5/§8).\n */\nexport const MenuContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Content>,\n MenuContentProps\n>(function MenuContent({ className, sideOffset = 4, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends (spec §6: \"wrapping at the ends\") — Radix leaves\n // it OFF by default, so we default it ON to honor the frozen keyboard model. Disabled items\n // are skipped by the roving handler for free (Radix, spec §4/§6).\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\nexport interface MenuItemProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`; the item names itself by its label text. */\n icon?: React.ReactNode;\n /** A trailing shortcut hint (spec §2): text such as \"⌘K\", in the muted label role; never a focus stop. */\n shortcut?: React.ReactNode;\n}\n\n/**\n * One command row (spec §2 item, §4 states): a `role=\"menuitem\"` whose activation runs its command\n * and closes the menu, returning focus to the trigger (Radix). It holds an optional leading icon, a\n * label, and an optional trailing shortcut hint. Pointer hover and keyboard arrow movement share ONE\n * highlight (Radix `data-highlighted`), so the active item is the same for both (spec §4 Hover).\n *\n * `destructive` (spec §3 `item=destructive`) marks the ONE colored item — a command that is\n * irreversible (revoke a key, delete a profile). It takes the destructive ACTION treatment and must\n * name the consequence in its TEXT, never by color alone (spec §7/§8). A `disabled` item stays in the\n * menu and readable to assistive technology (`aria-disabled`), is skipped by arrow movement, and does\n * not fire on activation (spec §4 Disabled / §7).\n */\nexport const MenuItem = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Item>,\n MenuItemProps\n>(function MenuItem({ className, destructive, icon, shortcut, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Item\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n {shortcut ? (\n <span aria-hidden=\"true\" className={menuItemShortcutClass}>\n {shortcut}\n </span>\n ) : null}\n </DropdownMenuPrimitive.Item>\n );\n});\n\nexport interface MenuGroupProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Group> {\n /**\n * The non-interactive heading that partitions the popup (spec §2 group / group-label). It names the\n * group for assistive technology via `aria-labelledby` (Radix `Label`) and is NEVER a focus stop.\n */\n label?: React.ReactNode;\n}\n\n/**\n * A set of related items under a non-interactive `group-label` that partitions the popup (spec §2\n * group). The items read as a related set (`role=\"group\"` named by the label); the label is never a\n * menuitem and never a focus stop.\n */\nexport const MenuGroup = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Group>,\n MenuGroupProps\n>(function MenuGroup({ label, children, ...props }, ref) {\n // Radix Group + Label do NOT auto-wire aria-labelledby (the Label renders a bare div with no id),\n // so the group would read as an unnamed role=group. We hand-wire it the same way Separator\n // hand-rolls a named anatomy when the primitive can't carry it (skill: compose the role by hand\n // when Radix can't express the spec's anatomy): generate a stable id on the Label and point the\n // Group's aria-labelledby at it, so the items read as a related set named by the label (spec §2\n // group / §7 group named by its label).\n const labelId = React.useId();\n return (\n <DropdownMenuPrimitive.Group\n ref={ref}\n aria-labelledby={label ? labelId : undefined}\n {...props}\n >\n {label ? (\n <DropdownMenuPrimitive.Label id={labelId} className={menuLabelClass}>\n {label}\n </DropdownMenuPrimitive.Label>\n ) : null}\n {children}\n </DropdownMenuPrimitive.Group>\n );\n});\n\nexport interface MenuLabelProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> {}\n\n/**\n * A standalone non-interactive section label (spec §2 group-label) for a label that is not wrapped in\n * a `MenuGroup`. Like the group label it is the muted label-role heading and is never a focus stop.\n */\nexport const MenuLabel = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Label>,\n MenuLabelProps\n>(function MenuLabel({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Label ref={ref} className={cn(menuLabelClass, className)} {...props} />\n );\n});\n\nexport interface MenuSeparatorProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> {}\n\n/**\n * A thin neutral divider between groups (spec §2 separator): decorative (`role=\"separator\"`), never a\n * focus stop.\n */\nexport const MenuSeparator = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.Separator>,\n MenuSeparatorProps\n>(function MenuSeparator({ className, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Separator\n ref={ref}\n className={cn(menuSeparatorClass, className)}\n {...props}\n />\n );\n});\n\nexport interface MenuSubProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Sub> {}\n\n/**\n * A submenu: an item that opens a nested popup of its own items (spec §2 submenu). Keep nesting\n * shallow — deep trees are hard to operate by keyboard (spec §2). Wraps `MenuSubTrigger` +\n * `MenuSubContent`.\n */\nexport function MenuSub(props: MenuSubProps) {\n return <DropdownMenuPrimitive.Sub {...props} />;\n}\n\nexport interface MenuSubTriggerProps\n extends Omit<\n React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>,\n \"color\"\n >,\n MenuItemVariantProps {\n /** The leading icon (spec §2): decorative, sized by `--size-icon-md`. */\n icon?: React.ReactNode;\n}\n\n/**\n * The item that opens a submenu (spec §2/§6): a `role=\"menuitem\"` with `aria-haspopup=\"menu\"` and its\n * own `aria-expanded` (Radix). Right opens the submenu and focuses its first item; Left closes it and\n * returns focus here. It carries the same row treatment as a `MenuItem`, plus a trailing chevron\n * pointing to the inline-end.\n */\nexport const MenuSubTrigger = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,\n MenuSubTriggerProps\n>(function MenuSubTrigger({ className, destructive, icon, children, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.SubTrigger\n ref={ref}\n className={cn(menuItemVariants({ destructive }), className)}\n {...props}\n >\n {icon ? (\n <span aria-hidden=\"true\" className={menuItemIconClass}>\n {icon}\n </span>\n ) : null}\n <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n <ChevronGlyph />\n </DropdownMenuPrimitive.SubTrigger>\n );\n});\n\nexport interface MenuSubContentProps\n extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> {}\n\n/**\n * The nested popup of a submenu (spec §2 submenu): the same neutral raised `role=\"menu\"` surface as\n * `MenuContent`, anchored to its `MenuSubTrigger`.\n */\nexport const MenuSubContent = React.forwardRef<\n React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,\n MenuSubContentProps\n>(function MenuSubContent({ className, sideOffset = 2, loop = true, ...props }, ref) {\n return (\n <DropdownMenuPrimitive.Portal>\n <DropdownMenuPrimitive.SubContent\n ref={ref}\n sideOffset={sideOffset}\n // `loop` wraps arrow movement at the ends inside the submenu too (spec §6), matching the\n // parent popup; Radix leaves it OFF by default.\n loop={loop}\n className={cn(menuPopupClass, className)}\n {...props}\n />\n </DropdownMenuPrimitive.Portal>\n );\n});\n\n// The submenu chevron — inline SVG (no icon dep), --size-icon-md, pointing to the inline-end to\n// signal the nested popup. Decorative (aria-hidden); aria-haspopup/aria-expanded carry the state, not\n// the glyph (spec §2/§7). Drawn with currentColor so it inherits the row's color.\nfunction ChevronGlyph() {\n return (\n <span data-testid=\"menu-sub-chevron\" aria-hidden=\"true\" className={menuSubChevronClass}>\n <svg viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" focusable=\"false\">\n <path d=\"M6 4l4 4-4 4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n </span>\n );\n}\n",
|
|
16
16
|
"path": "menu/menu.tsx",
|
|
17
17
|
"target": "@ui/menu/menu.tsx",
|
|
18
18
|
"type": "registry:ui"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Menu is a popup list of ACTIONS a trigger opens (spec §1). It is a NEUTRAL surface (spec §3): its\n// items, popup, and separators are neutral, and the brand violet NEVER marks an item as \"the\n// special one.\" The ONE colored item is the DESTRUCTIVE item, which takes the destructive ACTION\n// treatment because the command it runs is irreversible — a RISK signal, not a status result. A\n// verified meaning is never reported by a menu item (that is VerifiedBadge's job), so NOTHING here\n// binds a --color-status-* token and the brand action-primary tier never appears (brand != state,\n// G-U2). This is the ONLY token-binding site (skill §5 hard rule). All open/close motion is the\n// FAST token transition on the verdify easing, instant under reduced motion — never the 350ms\n// VerifiedBadge-only theatre duration (G-U3).\n\n// The trigger: the one stop in the page tab order for this control (spec §2 trigger). A NEUTRAL\n// ghost surface — the label/glyph in the ghost action fg at rest, the restrained ghost hover fill,\n// the md radius, the persistent 2px focus ring (never removed, spec §4 Focus), and the target-size\n// floor (44px touch / 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor.\n// A disabled trigger dims via the disabled TOKEN (DEC-C), never a blanket opacity. fast functional\n// hover motion + verdify easing, instant under reduced motion (G-U3). This styles the DEFAULT\n// (non-asChild) trigger; when a Button is passed via `asChild` it carries its own treatment.\nexport const menuTriggerClass =\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-3) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n \"
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// Menu is a popup list of ACTIONS a trigger opens (spec §1). It is a NEUTRAL surface (spec §3): its\n// items, popup, and separators are neutral, and the brand violet NEVER marks an item as \"the\n// special one.\" The ONE colored item is the DESTRUCTIVE item, which takes the destructive ACTION\n// treatment because the command it runs is irreversible — a RISK signal, not a status result. A\n// verified meaning is never reported by a menu item (that is VerifiedBadge's job), so NOTHING here\n// binds a --color-status-* token and the brand action-primary tier never appears (brand != state,\n// G-U2). This is the ONLY token-binding site (skill §5 hard rule). All open/close motion is the\n// FAST token transition on the verdify easing, instant under reduced motion — never the 350ms\n// VerifiedBadge-only theatre duration (G-U3).\n\n// The trigger: the one stop in the page tab order for this control (spec §2 trigger). A NEUTRAL\n// ghost surface — the label/glyph in the ghost action fg at rest, the restrained ghost hover fill,\n// the md radius, the persistent 2px focus ring (never removed, spec §4 Focus), and the target-size\n// floor (44px touch / 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor.\n// A disabled trigger dims via the disabled TOKEN (DEC-C), never a blanket opacity. fast functional\n// hover motion + verdify easing, instant under reduced motion (G-U3). This styles the DEFAULT\n// (non-asChild) trigger; when a Button is passed via `asChild` it carries its own treatment.\nexport const menuTriggerClass =\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-3) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n focusRing + \" \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The popup (spec §2 popup, §5): the floating surface that opens on activation, raised above the\n// page and anchored to the trigger. A NEUTRAL raised surface (--color-surface-raised) with the\n// outer surface border, the md corner radius, and the md elevation shadow above the page, on the\n// POPOVER z-layer (a menu is a non-modal popover layer, not the modal layer). It NEVER wears a brand\n// or status fill (spec §3/§8). The open/close fade is a PLAIN fast transition + verdify easing,\n// instant under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3). Enter/exit ride\n// Radix's data-state on the content (attribute-selector variants, not arbitrary values). Inset\n// padding from --space-*; a SubContent shares the same surface treatment.\nexport const menuPopupClass =\n \"z-(--z-index-popover) min-w-(--container-sm) overflow-hidden p-(--space-1) \" +\n \"bg-surface-raised border border-surface-border rounded-(--radius-md) shadow-(--shadow-md) \" +\n \"transition-opacity duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\";\n\n// One command row (spec §2 item, §4 states). A neutral row at rest; the active/hovered item shares\n// ONE highlight (pointer and keyboard both drive Radix's data-highlighted, spec §4 Hover) painted\n// with the ghost hover fill.\n//\n// RESTING (default): the LABEL in the PRIMARY text color (spec §5 --color-text-primary) at the BODY\n// type role (spec §5 --text-body), on the popup surface with NO fill (spec §4 Default).\n// HIGHLIGHTED (data-highlighted): the restrained ghost-action hover fill (spec §5\n// --color-action-ghost-bg-hover) — pointer hover AND keyboard arrow movement set the same\n// data-highlighted, so the two share one highlight (spec §4 Hover/Active). No motion beyond the\n// token transition.\n// DISABLED (data-disabled): dims via the disabled TOKEN (DEC-C), never a blanket opacity; Radix\n// keeps it readable to AT (aria-disabled) and skips it in arrow movement (spec §4 Disabled / §7).\n// FOCUS: the open popup tracks the active item by ROVING FOCUS and shows the active fill, not a\n// second ring (spec §4 Focus) — so an item does NOT paint its own focus-visible ring; the active\n// fill is the focus affordance inside the menu.\n// Motion: fast token transition + verdify easing, instant under reduced motion (NEVER the check\n// theatre, G-U3). Target-size floor on every row (44px touch / 40px pointer, spec §7 2.5.8), never a\n// fixed height below the floor.\n//\n// `destructive` is the spec §3 `item=destructive` axis — the ONE colored item: the destructive\n// ACTION treatment (label/icon in the destructive fg; the highlight fill is the destructive bg). It\n// is a RISK signal, not the brand and NEVER status-verified (G-U2); the risk is also named in the\n// item's text + icon, never carried by color alone (spec §7/§8, 1.4.1).\nexport const menuItemVariants = cva(\n [\n // shape + the icon-to-label gap + logical inline padding so it mirrors under RTL (G-U6)\n \"relative flex items-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n // type ROLE + the resting (neutral) label color, no fill, pointer cursor\n \"text-body text-text-primary no-underline cursor-pointer select-none\",\n // the shared pointer+keyboard highlight: the restrained ghost-action fill (spec §4 Hover/Active)\n \"data-[highlighted]:bg-action-ghost-bg-hover\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre, G-U3)\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every row (spec §7, 2.5.8), never a fixed\n // height below the floor\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // the open popup tracks the active item by ROVING focus + the active fill, not a second ring\n // (spec §4 Focus) — no per-item focus-visible ring inside the menu\n \"outline-none\",\n // disabled (non-operable) row — DEC-C: dim via the disabled TOKEN, never opacity. Radix drives\n // it via data-disabled and keeps the label readable to AT (aria-disabled), skipping arrow focus.\n \"data-[disabled]:pointer-events-none data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n // the spec §3 `item=destructive` axis — the ONE colored item: the destructive ACTION\n // treatment, a RISK signal (not the brand, NEVER status-verified — G-U2). At rest the label +\n // icon take the destructive fg; the shared highlight fill is the destructive bg.\n destructive: {\n true: [\n \"text-action-destructive-fg\",\n \"data-[highlighted]:bg-action-destructive-bg\",\n ],\n false: \"\",\n },\n },\n defaultVariants: { destructive: false },\n },\n);\n\nexport type MenuItemVariantProps = VariantProps<typeof menuItemVariants>;\n\n// The leading item icon (spec §2/§5): the md icon role, decorative (the item names itself by its\n// label text, not the glyph). It inherits the row's color via currentColor, so a destructive row's\n// icon is the destructive fg and a disabled row's icon dims with the disabled token. shrink-0 so it\n// never collapses.\nexport const menuItemIconClass =\n \"inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center\";\n\n// The trailing shortcut hint on an item (spec §2/§5): the keyboard hint pushed to the inline-end, in\n// the MUTED text color at the LABEL type role (spec §5 --color-text-muted / --text-label).\n// Decorative wayfinding, never a focus stop; logical inline-end placement (G-U6).\nexport const menuItemShortcutClass =\n \"ms-auto ps-(--space-4) text-label text-text-muted\";\n\n// The submenu chevron on a SubTrigger (spec §2 submenu, §5): the md icon role, decorative; it\n// points to the inline-end to signal the nested popup. Inherits the row color via currentColor.\nexport const menuSubChevronClass =\n \"ms-auto inline-flex h-(--size-icon-md) w-(--size-icon-md) shrink-0 items-center justify-center\";\n\n// The group label (spec §2 group-label, §5): the non-interactive heading that partitions the popup;\n// it is NEVER a focus stop. It is essential de-emphasized text (it names the group), so it uses the\n// SECONDARY text color (AA) at the LABEL type role (spec §5 --color-text-secondary / --text-label) —\n// not the decorative-only muted role (accessibility.md). Logical inline padding mirrors under RTL.\nexport const menuLabelClass =\n \"px-(--space-2) py-(--space-1) text-label text-text-secondary select-none\";\n\n// The separator (spec §2 separator, §5): a thin neutral divider between groups. It is decorative and\n// never takes focus. A neutral hairline in the default border color (spec §5 --color-border-default),\n// with a little vertical breathing room. Negated logical inline margins keep the rule flush to the\n// popup's inner padding edge (it spans the popup inset, mirrors under RTL — G-U6).\nexport const menuSeparatorClass =\n \"-mx-(--space-1) my-(--space-1) h-px bg-border-default\";\n",
|
|
22
22
|
"path": "menu/menu.variants.ts",
|
|
23
23
|
"target": "@ui/menu/menu.variants.ts",
|
|
24
24
|
"type": "registry:ui"
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
],
|
|
27
27
|
"name": "menu",
|
|
28
28
|
"registryDependencies": [
|
|
29
|
-
"@verdify/cn"
|
|
29
|
+
"@verdify/cn",
|
|
30
|
+
"@verdify/focus-ring"
|
|
30
31
|
],
|
|
31
32
|
"title": "menu",
|
|
32
33
|
"type": "registry:ui"
|
package/registry/pagination.json
CHANGED
|
@@ -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// Pagination is a row of standard links/buttons inside a labeled navigation landmark (spec §7):\n// there is no APG \"pagination\" widget. Brand and status colors are accents — NEUTRALS carry the\n// surface (spec §3). The one accent the pager paints is on the CURRENT page, and that accent is\n// the primary ACTION (brand) alias, never a status color: a current page reports where you are in\n// the set, not a verification result, so it binds nothing from the status tier (brand != state,\n// G-U2). The set paints from the text, action(primary + ghost), border, and surface aliases only\n// (spec §5).\n\n// The nav landmark wrapping the control set. A neutral canvas surface with logical-property block\n// padding so the row mirrors under dir=\"rtl\" (G-U6). The optional divider above the set (spec §5,\n// border-default) is a caller decision applied via className, not a default binding.\nexport const paginationNavClass = \"bg-surface-canvas py-(--space-2)\";\n\n// The control list. An inline row of items with the inter-control gap; wraps when narrow. The\n// list carries no text color — each control sets its own.\nexport const paginationListClass =\n \"flex flex-wrap items-center gap-(--space-4)\";\n\n// A single item (the <li>). Inline so the control sits in the row.\nexport const paginationItemClass = \"inline-flex items-center\";\n\n// A page / prev / next control (spec §4). At rest, an enabled non-current control is the label\n// type role in the SECONDARY text color with NO fill; on hover it takes the restrained ghost fill\n// (the only fill a non-current control paints) and the cursor is a pointer. The CURRENT page lifts\n// its label to the primary action FG on the primary action BG and does not navigate — this accent\n// is the BRAND action alias (where you are), never a status. The focus ring is part of the base on\n// every state, never removed. Motion is the fast token transition on the verdify easing, collapsing\n// to the instant endpoint under reduced motion — never the 350ms VerifiedBadge-only theatre\n// duration (a page turning is a plain wait, G-U3). A disabled prev/next dims via the disabled TOKEN\n// (DEC-C): native `disabled:` for a button-form control and `aria-disabled:` for a link-form one\n// (an <a> has no native disabled), never a blanket opacity.\nexport const paginationControlVariants = cva(\n [\n // type ROLE + shape + the icon-to-label gap; logical inline padding so it mirrors under RTL\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n \"text-label font-medium select-none\",\n // resting state — enabled, non-current: secondary label, no fill, pointer cursor\n \"cursor-pointer text-text-secondary\",\n // hover: the restrained ghost fill (the only fill a non-current control paints)\n \"hover:bg-action-ghost-bg-hover\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre)\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every control (spec §7, 2.5.8)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // focus ring — identical on every state, never removed (spec §4 / 2.4.7)\n \"outline-none\",\n
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\nimport { focusRing } from \"@/lib/focus-ring\";\n\n// Pagination is a row of standard links/buttons inside a labeled navigation landmark (spec §7):\n// there is no APG \"pagination\" widget. Brand and status colors are accents — NEUTRALS carry the\n// surface (spec §3). The one accent the pager paints is on the CURRENT page, and that accent is\n// the primary ACTION (brand) alias, never a status color: a current page reports where you are in\n// the set, not a verification result, so it binds nothing from the status tier (brand != state,\n// G-U2). The set paints from the text, action(primary + ghost), border, and surface aliases only\n// (spec §5).\n\n// The nav landmark wrapping the control set. A neutral canvas surface with logical-property block\n// padding so the row mirrors under dir=\"rtl\" (G-U6). The optional divider above the set (spec §5,\n// border-default) is a caller decision applied via className, not a default binding.\nexport const paginationNavClass = \"bg-surface-canvas py-(--space-2)\";\n\n// The control list. An inline row of items with the inter-control gap; wraps when narrow. The\n// list carries no text color — each control sets its own.\nexport const paginationListClass =\n \"flex flex-wrap items-center gap-(--space-4)\";\n\n// A single item (the <li>). Inline so the control sits in the row.\nexport const paginationItemClass = \"inline-flex items-center\";\n\n// A page / prev / next control (spec §4). At rest, an enabled non-current control is the label\n// type role in the SECONDARY text color with NO fill; on hover it takes the restrained ghost fill\n// (the only fill a non-current control paints) and the cursor is a pointer. The CURRENT page lifts\n// its label to the primary action FG on the primary action BG and does not navigate — this accent\n// is the BRAND action alias (where you are), never a status. The focus ring is part of the base on\n// every state, never removed. Motion is the fast token transition on the verdify easing, collapsing\n// to the instant endpoint under reduced motion — never the 350ms VerifiedBadge-only theatre\n// duration (a page turning is a plain wait, G-U3). A disabled prev/next dims via the disabled TOKEN\n// (DEC-C): native `disabled:` for a button-form control and `aria-disabled:` for a link-form one\n// (an <a> has no native disabled), never a blanket opacity.\nexport const paginationControlVariants = cva(\n [\n // type ROLE + shape + the icon-to-label gap; logical inline padding so it mirrors under RTL\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-2)\",\n \"text-label font-medium select-none\",\n // resting state — enabled, non-current: secondary label, no fill, pointer cursor\n \"cursor-pointer text-text-secondary\",\n // hover: the restrained ghost fill (the only fill a non-current control paints)\n \"hover:bg-action-ghost-bg-hover\",\n // motion: fast + verdify easing, instant under reduced motion (NEVER the check theatre)\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor — 44px touch / 40px pointer, on every control (spec §7, 2.5.8)\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // focus ring — identical on every state, never removed (spec §4 / 2.4.7)\n \"outline-none\",\n focusRing,\n // disabled prev/next — DEC-C: dim via the disabled TOKEN for BOTH the native-button form\n // (`disabled`) and the link form (`aria-disabled`), never a blanket opacity. The component\n // also strips href + tabindex on a disabled link so it cannot navigate or be tabbed to.\n \"disabled:pointer-events-none disabled:text-text-disabled\",\n \"aria-disabled:pointer-events-none aria-disabled:text-text-disabled\",\n ],\n {\n variants: {\n // STATE axis (spec §4): the current page is the only control carrying the brand action fill.\n current: {\n true: [\n // the current page: brand action accent (where you are in the set), non-navigating.\n // This is the action(primary) alias, NEVER status-verified (brand != state, G-U2).\n \"bg-action-primary-bg text-action-primary-fg\",\n // the current page is not a control: no hover fill, no pointer\n \"cursor-default hover:bg-action-primary-bg\",\n ],\n false: \"\",\n },\n // SIZE axis (spec §3, DEC-B): both sizes hold the shared target-size floor above; sm differs\n // only by density (tighter vertical padding) below the type role, never a height below the floor.\n size: {\n md: \"py-(--space-2)\",\n sm: \"py-(--space-1)\",\n },\n },\n defaultVariants: { current: false, size: \"md\" },\n },\n);\n\nexport type PaginationControlVariantProps = VariantProps<typeof paginationControlVariants>;\n\n// The prev/next direction icon (spec §5): the sm icon role, decorative (the control names its\n// direction via aria-label, not the glyph). Inherits the control's current text color.\nexport const paginationIconClass =\n \"inline-flex h-(--size-icon-sm) w-(--size-icon-sm) shrink-0 items-center justify-center\";\n\n// The \"Page m of n\" readout for the prev-next variant (spec §3): plain text in the secondary\n// color at the label type role. Not a control — a non-interactive status of position in the set.\nexport const paginationStatusClass =\n \"inline-flex items-center px-(--space-2) text-label text-text-secondary select-none\";\n\n// The ellipsis gap (spec §2/§7): a DECORATIVE, non-interactive stand-in for a run of hidden page\n// controls, in the muted text color at the sm icon role. Removed from the a11y tree + tab order by\n// the component (aria-hidden); it is never a stop.\nexport const paginationEllipsisClass =\n \"inline-flex h-(--size-icon-sm) w-(--size-icon-sm) shrink-0 items-center justify-center text-text-muted\";\n",
|
|
22
22
|
"path": "pagination/pagination.variants.ts",
|
|
23
23
|
"target": "@ui/pagination/pagination.variants.ts",
|
|
24
24
|
"type": "registry:ui"
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
],
|
|
27
27
|
"name": "pagination",
|
|
28
28
|
"registryDependencies": [
|
|
29
|
-
"@verdify/cn"
|
|
29
|
+
"@verdify/cn",
|
|
30
|
+
"@verdify/focus-ring"
|
|
30
31
|
],
|
|
31
32
|
"title": "pagination",
|
|
32
33
|
"type": "registry:ui"
|
package/registry/popover.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"type": "registry:ui"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
"content": "// Popover is a small NON-MODAL surface a trigger opens next to itself to hold secondary content —\n// a short form, a definition, a few related controls (spec §1). It is a NEUTRAL surface (spec §3):\n// the panel, the arrow, and the border are neutral, and the brand violet NEVER tints the panel to\n// look \"premium.\" A status color NEVER paints the panel — a verified result is reported by a\n// VerifiedBadge placed INSIDE the body, not by coloring the popover. So NOTHING in this file binds a\n// --color-status-* token or the brand action-primary tier; color enters only through the components\n// the caller places inside it (brand != state, G-U2). This is the ONLY token-binding site (skill §5\n// hard rule). All open/close motion is the FAST token transition on the verdify easing, instant\n// under reduced motion — never the 350ms VerifiedBadge-only theatre duration (G-U3).\n\n// The trigger: the one stop in the page tab order for this control (spec §2 trigger, §4 Focus). A\n// NEUTRAL ghost surface — the label/glyph in the ghost action fg at rest (spec §5\n// --color-action-ghost-fg), the restrained ghost hover fill (spec §5 --color-action-ghost-bg-hover),\n// the md radius, the persistent 2px focus ring (never removed, spec §4 Focus), and the target-size\n// floor (44px touch / 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor.\n// A disabled trigger dims via the disabled TOKEN (DEC-C), never a blanket opacity. fast functional\n// hover motion + verdify easing, instant under reduced motion (G-U3). This styles the DEFAULT\n// (non-asChild) trigger; when a Button is passed via `asChild` it carries its own treatment.\nexport const popoverTriggerClass =\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-3) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n \"
|
|
20
|
+
"content": "import { focusRing } from \"@/lib/focus-ring\";\n// Popover is a small NON-MODAL surface a trigger opens next to itself to hold secondary content —\n// a short form, a definition, a few related controls (spec §1). It is a NEUTRAL surface (spec §3):\n// the panel, the arrow, and the border are neutral, and the brand violet NEVER tints the panel to\n// look \"premium.\" A status color NEVER paints the panel — a verified result is reported by a\n// VerifiedBadge placed INSIDE the body, not by coloring the popover. So NOTHING in this file binds a\n// --color-status-* token or the brand action-primary tier; color enters only through the components\n// the caller places inside it (brand != state, G-U2). This is the ONLY token-binding site (skill §5\n// hard rule). All open/close motion is the FAST token transition on the verdify easing, instant\n// under reduced motion — never the 350ms VerifiedBadge-only theatre duration (G-U3).\n\n// The trigger: the one stop in the page tab order for this control (spec §2 trigger, §4 Focus). A\n// NEUTRAL ghost surface — the label/glyph in the ghost action fg at rest (spec §5\n// --color-action-ghost-fg), the restrained ghost hover fill (spec §5 --color-action-ghost-bg-hover),\n// the md radius, the persistent 2px focus ring (never removed, spec §4 Focus), and the target-size\n// floor (44px touch / 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor.\n// A disabled trigger dims via the disabled TOKEN (DEC-C), never a blanket opacity. fast functional\n// hover motion + verdify easing, instant under reduced motion (G-U3). This styles the DEFAULT\n// (non-asChild) trigger; when a Button is passed via `asChild` it carries its own treatment.\nexport const popoverTriggerClass =\n \"inline-flex items-center justify-center gap-(--space-2) rounded-(--radius-md) px-(--space-3) \" +\n \"text-label text-action-ghost-fg cursor-pointer select-none \" +\n \"hover:bg-action-ghost-bg-hover \" +\n \"transition-[color,background-color] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop) \" +\n focusRing + \" \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The panel (spec §2 panel, §5): the floating surface that opens on activation, raised above the\n// page and anchored to the trigger; it repositions to stay in the viewport (Radix). A NEUTRAL raised\n// surface (spec §5 --color-surface-raised) with the outer surface border (spec §5\n// --color-surface-border), the md corner radius (spec §5 --radius-md), and the md elevation shadow\n// above the page (spec §5 --shadow-md), on the POPOVER z-layer (a popover is a non-modal popover\n// layer, NOT the modal layer — there is no scrim, the page behind stays live, spec §1/§7). It NEVER\n// wears a brand or status fill (spec §3/§8). Inset padding + content gaps from --space-*; a column so\n// the header, body, and footer stack. The open/close fade is a PLAIN fast transition + verdify\n// easing, instant under reduced motion — never the 350ms VerifiedBadge-only theatre (spec §4, G-U3).\n// Enter/exit ride Radix's data-state on the content (attribute-selector variants, not arbitrary\n// values). The panel is focusable (Radix sets tabIndex=-1) so focus can land on it when the body has\n// no focusable control; its ring is never removed (spec §7 focus management).\nexport const popoverPanelClass =\n \"z-(--z-index-popover) flex flex-col gap-(--space-3) \" +\n \"max-w-(--container-sm) p-(--space-4) \" +\n \"bg-surface-raised border border-surface-border rounded-(--radius-md) shadow-(--shadow-md) \" +\n \"transition-opacity duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant) \" +\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 \" +\n focusRing;\n\n// The header (spec §2 header): the top region holding a short title and an optional close control on\n// the inline-end. Logical-property layout (G-U6) so it mirrors under RTL.\nexport const popoverHeaderClass =\n \"flex items-start justify-between gap-(--space-3)\";\n\n// The title (spec §2 header title, §5): names the panel as a statement, sentence case. The h3 type\n// role in the PRIMARY text color (spec §5 --text-h3 / --color-text-primary). When present, it is the\n// panel's accessible name, wired via aria-labelledby (the panel takes role=\"dialog\", spec §7).\nexport const popoverTitleClass = \"text-h3 text-text-primary\";\n\n// The body (spec §2 body, §5): the content region — text, a short form, or a few controls. The body\n// type role; supporting text in the SECONDARY text color (spec §5 --text-body / --color-text-secondary).\n// Content that needs to scroll usually belongs in a Sheet (spec §2), so the body does not own a scroll.\nexport const popoverBodyClass = \"text-body text-text-secondary\";\n\n// The arrow (spec §2 arrow): a small DECORATIVE pointer joining the panel to its trigger, carrying no\n// meaning of its own (Radix renders it inside an aria-hidden wrapper). It is filled with the SAME\n// neutral raised surface as the panel so it reads as part of the surface, never a brand or status\n// fill (spec §3/§5). `fill-*` is the SVG fill utility for the Radix arrow polygon. (Radix's bare\n// arrow does not carry the panel's outer BORDER edge — a conformant, non-load-bearing deviation from\n// the §5 \"arrow edge\" border, flagged for amendment rather than hand-rolling a bordered polygon, the\n// same deviation the Tooltip arrow pins.)\nexport const popoverArrowClass = \"fill-surface-raised\";\n\n// The close control (spec §2 close, §5): the in-panel dismiss button. A NEUTRAL ghost surface — the\n// glyph in --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 it.\n// fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const popoverCloseClass =\n \"inline-flex items-center justify-center rounded-(--radius-md) \" +\n \"text-action-ghost-fg 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) min-w-(--size-target-mobile) \" +\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop) \" +\n focusRing;\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the close\n// button's ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const popoverCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n",
|
|
21
21
|
"path": "popover/popover.variants.ts",
|
|
22
22
|
"target": "@ui/popover/popover.variants.ts",
|
|
23
23
|
"type": "registry:ui"
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"name": "popover",
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"@verdify/cn"
|
|
28
|
+
"@verdify/cn",
|
|
29
|
+
"@verdify/focus-ring"
|
|
29
30
|
],
|
|
30
31
|
"title": "popover",
|
|
31
32
|
"type": "registry:ui"
|
package/registry/progress.json
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"type": "registry:ui"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Progress reports how far a task has advanced — a status OUTPUT, not a control\n// (spec §1/§4/§6). It takes no focus, binds no keys, and renders no focus ring or\n// target-size floor: the value reaches assistive technology through role=\"progressbar\"\n// + aria-value* and a polite live region, never motion (spec §7).\n//\n// Brand != state (spec §3/§5/§8): the fill is a plain control affordance, NOT a\n// verification result. Neutrals carry the track and the fill takes the primary ACTION\n// accent (the visible action color on the surface) — NEVER --color-status-verified-*,\n// because verified green is the in-product verified status and is never a generic\n// progress affordance. There is no \"verified\" Progress; surfacing a verified outcome is\n// the job of VerifiedBadge, whose deliberate check animation is never borrowed here.\n\n// The track (spec §2/§5): the full-length rail that holds the fill, in the neutral\n// control surface with the control border, clipped so the fill's rounded end and the\n// traveling indeterminate indicator never bleed past the rail. Rounded on the full radius.\n// In the error state the rail edge takes the critical status border, driven by\n// aria-invalid on the bar so the failure is identified BY THE FIELD, in text (spec §4/§7).\nexport const progressTrackClass =\n \"relative block h-(--space-2) w-full overflow-hidden \" +\n \"rounded-(--radius-full) bg-control-bg border border-control-border \" +\n \"aria-invalid:border-status-critical-border\";\n\n// The fill / indeterminate indicator keyframe (spec §3/§4): a TRAVELLING indicator. A\n// narrow indicator (`w-2/5`, set in the variant below) slides along the track's INLINE\n// axis from before the inline-start edge to past the inline-end edge, then loops. Travel\n// is driven on the LOGICAL `inset-inline-start` property (not a physical `translateX`), so\n// the indicator mirrors automatically under `dir=\"rtl\"` (G-U6 global-first). This keyframe\n// is pure geometry — it binds NO design token (the duration and easing tokens are bound on\n// the variant className, the single §5 binding site), so it is emitted from `progress.tsx`\n// as a component-scoped <style>, not from this binding file.\nexport const PROGRESS_INDETERMINATE_KEYFRAME = \"progress-indeterminate-travel\";\n\n// The fill (spec §2/§4/§5): the portion of the track that is done, in the primary action\n// accent, rounded on the full radius.\n//\n// DETERMINATE: its inline length is the value (set as an inline width in tsx — a data\n// percentage, not a design token). The length CHANGES on the FAST duration with verdify\n// easing, collapsing to the instant endpoint under reduced motion (spec §4/§5).\n//\n// INDETERMINATE: no measured length exists, so a moving indicator TRAVELS the track on a\n// continuous AMBIENT loop with verdify easing — restrained activity, never the 350ms\n// verified-check theatre duration (spec §3/§4/§8). The indicator is positioned `absolute`\n// on the track and its `inset-inline-start` is animated by the `progress-indeterminate-travel`\n// keyframe (defined in progress.tsx). The loop length is driven onto the ambient token via\n// the arbitrary `animation-duration` PROPERTY, the easing onto the verdify token, and the\n// loop is set to repeat — all keyword arbitrary properties or paren-shorthand tokens, not\n// raw values, so the token-binding gate does not flag them. Under prefers-reduced-motion the\n// travel is suppressed (`motion-reduce:animate-none`) and the busy state is carried by the\n// live region + name, not motion alone (spec §4/§7).\nexport const progressFillVariants = cva(\"block h-full rounded-(--radius-full) bg-action-primary-bg\", {\n variants: {\n // STRUCTURAL axis = spec §3: the determinate fill is a measured length; the\n // indeterminate indicator is a looping travel. Neither recolors the fill (brand !=\n // state) — they differ only by motion and how the length is set.\n indeterminate: {\n // a measured length set inline; the length transition runs on the fast duration\n false:\n \"transition-[inline-size] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // a narrow indicator that TRAVELS the inline axis on a continuous ambient loop;\n // static under reduced motion. Absolutely positioned so it slides past both edges\n // of the clipped track (overflow-hidden on the track hides the off-track portion).\n true:\n \"absolute w-2/5 [animation-name:progress-indeterminate-travel] \" +\n \"[animation-iteration-count:infinite] \" +\n \"[animation-duration:var(--motion-duration-ambient)] \" +\n \"ease-(--motion-easing-verdify) motion-reduce:animate-none\",\n },\n },\n defaultVariants: { indeterminate: false },\n});\n\n// The bar root (spec §2): a layout column stacking the label/value-text row, the track,\n// and the optional description/error message at the --space-2 gap. Non-interactive: no\n// focus ring, no tab stop, no target-size floor (spec §4/§6/§7).\nexport const progressRootClass = \"flex w-full flex-col gap-(--space-2)\";\n\n// The label + value-text header row (spec §2): the label sits inline-start, the optional\n// value-text inline-end, spread across the bar's width.\nexport const progressHeaderClass = \"flex items-baseline justify-between gap-(--space-2)\";\n\n// The label (spec §2/§5): the text naming the task, in the primary text color at the\n// caption type role.\nexport const progressLabelClass = \"text-caption text-text-primary\";\n\n// The optional value-text readout (spec §2/§5): a plain progress readout (\"40%\",\n// \"Step 2 of 4\") in the secondary text color at the caption role. Determinate only.\nexport const progressValueTextClass = \"text-caption text-text-secondary\";\n\n// The optional one-line description (spec §2/§5): a statement clarifying what is running,\n// in the secondary text color at the caption role.\nexport const progressDescriptionClass = \"text-caption text-text-secondary\";\n\n// The error message (spec §4/§5/§8): states what failed and the next step, naming the\n// failure without blaming the reader. The critical status FOREGROUND marks the text at the\n// caption role; the field edge (track) takes the critical border via aria-invalid. Error\n// is shown in TEXT, never by color alone.\nexport const progressErrorClass = \"text-caption text-status-critical-
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Progress reports how far a task has advanced — a status OUTPUT, not a control\n// (spec §1/§4/§6). It takes no focus, binds no keys, and renders no focus ring or\n// target-size floor: the value reaches assistive technology through role=\"progressbar\"\n// + aria-value* and a polite live region, never motion (spec §7).\n//\n// Brand != state (spec §3/§5/§8): the fill is a plain control affordance, NOT a\n// verification result. Neutrals carry the track and the fill takes the primary ACTION\n// accent (the visible action color on the surface) — NEVER --color-status-verified-*,\n// because verified green is the in-product verified status and is never a generic\n// progress affordance. There is no \"verified\" Progress; surfacing a verified outcome is\n// the job of VerifiedBadge, whose deliberate check animation is never borrowed here.\n\n// The track (spec §2/§5): the full-length rail that holds the fill, in the neutral\n// control surface with the control border, clipped so the fill's rounded end and the\n// traveling indeterminate indicator never bleed past the rail. Rounded on the full radius.\n// In the error state the rail edge takes the critical status border, driven by\n// aria-invalid on the bar so the failure is identified BY THE FIELD, in text (spec §4/§7).\nexport const progressTrackClass =\n \"relative block h-(--space-2) w-full overflow-hidden \" +\n \"rounded-(--radius-full) bg-control-bg border border-control-border \" +\n \"aria-invalid:border-status-critical-border\";\n\n// The fill / indeterminate indicator keyframe (spec §3/§4): a TRAVELLING indicator. A\n// narrow indicator (`w-2/5`, set in the variant below) slides along the track's INLINE\n// axis from before the inline-start edge to past the inline-end edge, then loops. Travel\n// is driven on the LOGICAL `inset-inline-start` property (not a physical `translateX`), so\n// the indicator mirrors automatically under `dir=\"rtl\"` (G-U6 global-first). This keyframe\n// is pure geometry — it binds NO design token (the duration and easing tokens are bound on\n// the variant className, the single §5 binding site), so it is emitted from `progress.tsx`\n// as a component-scoped <style>, not from this binding file.\nexport const PROGRESS_INDETERMINATE_KEYFRAME = \"progress-indeterminate-travel\";\n\n// The fill (spec §2/§4/§5): the portion of the track that is done, in the primary action\n// accent, rounded on the full radius.\n//\n// DETERMINATE: its inline length is the value (set as an inline width in tsx — a data\n// percentage, not a design token). The length CHANGES on the FAST duration with verdify\n// easing, collapsing to the instant endpoint under reduced motion (spec §4/§5).\n//\n// INDETERMINATE: no measured length exists, so a moving indicator TRAVELS the track on a\n// continuous AMBIENT loop with verdify easing — restrained activity, never the 350ms\n// verified-check theatre duration (spec §3/§4/§8). The indicator is positioned `absolute`\n// on the track and its `inset-inline-start` is animated by the `progress-indeterminate-travel`\n// keyframe (defined in progress.tsx). The loop length is driven onto the ambient token via\n// the arbitrary `animation-duration` PROPERTY, the easing onto the verdify token, and the\n// loop is set to repeat — all keyword arbitrary properties or paren-shorthand tokens, not\n// raw values, so the token-binding gate does not flag them. Under prefers-reduced-motion the\n// travel is suppressed (`motion-reduce:animate-none`) and the busy state is carried by the\n// live region + name, not motion alone (spec §4/§7).\nexport const progressFillVariants = cva(\"block h-full rounded-(--radius-full) bg-action-primary-bg\", {\n variants: {\n // STRUCTURAL axis = spec §3: the determinate fill is a measured length; the\n // indeterminate indicator is a looping travel. Neither recolors the fill (brand !=\n // state) — they differ only by motion and how the length is set.\n indeterminate: {\n // a measured length set inline; the length transition runs on the fast duration\n false:\n \"transition-[inline-size] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // a narrow indicator that TRAVELS the inline axis on a continuous ambient loop;\n // static under reduced motion. Absolutely positioned so it slides past both edges\n // of the clipped track (overflow-hidden on the track hides the off-track portion).\n true:\n \"absolute w-2/5 [animation-name:progress-indeterminate-travel] \" +\n \"[animation-iteration-count:infinite] \" +\n \"[animation-duration:var(--motion-duration-ambient)] \" +\n \"ease-(--motion-easing-verdify) motion-reduce:animate-none\",\n },\n },\n defaultVariants: { indeterminate: false },\n});\n\n// The bar root (spec §2): a layout column stacking the label/value-text row, the track,\n// and the optional description/error message at the --space-2 gap. Non-interactive: no\n// focus ring, no tab stop, no target-size floor (spec §4/§6/§7).\nexport const progressRootClass = \"flex w-full flex-col gap-(--space-2)\";\n\n// The label + value-text header row (spec §2): the label sits inline-start, the optional\n// value-text inline-end, spread across the bar's width.\nexport const progressHeaderClass = \"flex items-baseline justify-between gap-(--space-2)\";\n\n// The label (spec §2/§5): the text naming the task, in the primary text color at the\n// caption type role.\nexport const progressLabelClass = \"text-caption text-text-primary\";\n\n// The optional value-text readout (spec §2/§5): a plain progress readout (\"40%\",\n// \"Step 2 of 4\") in the secondary text color at the caption role. Determinate only.\nexport const progressValueTextClass = \"text-caption text-text-secondary\";\n\n// The optional one-line description (spec §2/§5): a statement clarifying what is running,\n// in the secondary text color at the caption role.\nexport const progressDescriptionClass = \"text-caption text-text-secondary\";\n\n// The error message (spec §4/§5/§8): states what failed and the next step, naming the\n// failure without blaming the reader. The critical status FOREGROUND marks the text at the\n// caption role; the field edge (track) takes the critical border via aria-invalid. Error\n// is shown in TEXT, never by color alone.\nexport const progressErrorClass = \"text-caption text-status-critical-on-surface\";\n\nexport type ProgressFillVariantProps = VariantProps<typeof progressFillVariants>;\n",
|
|
21
21
|
"path": "progress/progress.variants.ts",
|
|
22
22
|
"target": "@ui/progress/progress.variants.ts",
|
|
23
23
|
"type": "registry:ui"
|
package/registry/radio.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"type": "registry:ui"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport {\n radioControlVariants,\n radioDotVariants,\n radioTargetVariants,\n radioLabelVariants,\n radioDescriptionVariants,\n radioCardVariants,\n radioGroupVariants,\n type RadioGroupVariantProps,\n} from \"./radio.variants\";\n\ninterface RadioGroupContextValue {\n name: string;\n value: string | undefined;\n variant: NonNullable<RadioGroupVariantProps[\"variant\"]>;\n select: (value: string) => void;\n register: (value: string, el: HTMLInputElement | null, disabled: boolean) => void;\n onArrow: (current: string, dir: 1 | -1) => void;\n rovingValue: string | undefined; // which option owns tabindex=0\n}\n\nconst RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);\n\nfunction useRadioGroup(): RadioGroupContextValue {\n const ctx = React.useContext(RadioGroupContext);\n if (!ctx) throw new Error(\"<Radio> must be used inside <RadioGroup>\");\n return ctx;\n}\n\nexport interface RadioGroupProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\">,\n RadioGroupVariantProps {\n /** Shared `name` for the native radios in this group. */\n name: string;\n /** The group label — the question the options answer. */\n label: React.ReactNode;\n /** Uncontrolled initial selection. */\n defaultValue?: string;\n /** Controlled selection. */\n value?: string;\n /** Fires with the newly selected value. */\n onValueChange?: (value: string) => void;\n}\n\nexport const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(\n function RadioGroup(\n { className, name, label, defaultValue, value: controlled, onValueChange,\n variant = \"default\", children, ...props },\n ref,\n ) {\n const labelId = React.useId();\n const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n const value = controlled ?? uncontrolled;\n // insertion-ordered registry of options: value → { el, disabled }\n const items = React.useRef<Map<string, { el: HTMLInputElement | null; disabled: boolean }>>(\n new Map(),\n );\n\n const select = React.useCallback(\n (next: string) => {\n if (controlled === undefined) setUncontrolled(next);\n onValueChange?.(next);\n },\n [controlled, onValueChange],\n );\n\n const register = React.useCallback(\n (v: string, el: HTMLInputElement | null, disabled: boolean) => {\n if (el) items.current.set(v, { el, disabled });\n else items.current.delete(v);\n },\n [],\n );\n\n // Arrow navigation: move to next/prev ENABLED option, wrapping, then select + focus.\n // Focus is a post-commit concern, so it legitimately reads the ref Map for the\n // live DOM node; only the render-time roving fallback (below) avoids that Map.\n const onArrow = React.useCallback(\n (current: string, dir: 1 | -1) => {\n const entries = [...items.current.entries()];\n const enabled = entries.filter(([, m]) => !m.disabled);\n if (enabled.length === 0) return;\n const idx = enabled.findIndex(([v]) => v === current);\n const nextIdx = (idx + dir + enabled.length) % enabled.length;\n const [nextValue, nextMeta] = enabled[nextIdx];\n select(nextValue);\n nextMeta.el?.focus();\n },\n [select],\n );\n\n // Roving tabindex owner: the selected option, else the first ENABLED option.\n // Computed from React.Children DURING RENDER, not from the post-commit `items`\n // ref Map — children register via useEffect AFTER the group commits, which would\n // not trigger a recompute, leaving no tab stop when nothing is selected. Reading\n // children at render time recomputes on every render and keeps the defaultValue\n // path working (a real `value` always wins).\n const firstEnabledValue = React.useMemo(() => {\n let first: string | undefined;\n React.Children.forEach(children, (child) => {\n if (first !== undefined) return;\n if (!React.isValidElement<RadioProps>(child)) return;\n if (child.props.disabled) return;\n first = child.props.value;\n });\n return first;\n }, [children]);\n const rovingValue = value ?? firstEnabledValue;\n\n const ctx = React.useMemo<RadioGroupContextValue>(\n () => ({ name, value, variant: variant ?? \"default\", select, register, onArrow, rovingValue }),\n [name, value, variant, select, register, onArrow, rovingValue],\n );\n\n return (\n <RadioGroupContext.Provider value={ctx}>\n <div\n ref={ref}\n role=\"radiogroup\"\n aria-labelledby={labelId}\n className={cn(radioGroupVariants({ variant }), className)}\n {...props}\n >\n <span id={labelId} className=\"text-label text-text-primary\">\n {label}\n </span>\n {children}\n </div>\n </RadioGroupContext.Provider>\n );\n },\n);\n\nexport interface RadioProps\n extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"type\" | \"name\" | \"value\"> {\n /** This option's value within the group. */\n value: string;\n /** Secondary text under the option label (With-description variant). */\n description?: React.ReactNode;\n}\n\nexport const Radio = React.forwardRef<HTMLInputElement, RadioProps>(\n function Radio({ className, value, description, disabled = false, children, ...props }, ref) {\n const ctx = useRadioGroup();\n const inputRef = React.useRef<HTMLInputElement | null>(null);\n const descId = React.useId();\n const checked = ctx.value === value;\n const tabbable = ctx.rovingValue === value;\n\n // Register this option with the group so roving + arrow nav can see it.\n React.useEffect(() => {\n ctx.register(value, inputRef.current, disabled);\n return () => ctx.register(value, null, disabled);\n }, [ctx, value, disabled]);\n\n const setRefs = (el: HTMLInputElement | null) => {\n inputRef.current = el;\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;\n };\n\n const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (disabled) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n ctx.onArrow(value, 1);\n } else if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") {\n e.preventDefault();\n ctx.onArrow(value, -1);\n } else if (e.key === \" \") {\n e.preventDefault();\n if (!checked) ctx.select(value);\n }\n };\n\n // The naming <label> wraps ONLY the input, the control, and the option-label\n // text — never the description. A native <label> contributes its text content to\n // the input's accessible name, so a nested description would pollute the name\n // (spec §7: the name comes from the associated label). The description sits\n // OUTSIDE the label and is linked via aria-describedby (mirrors Checkbox/Select).\n const row = (\n <label className=\"flex items-start gap-2\">\n {/* native radio is the peer; visually hidden but in the a11y tree */}\n <input\n ref={setRefs}\n type=\"radio\"\n name={ctx.name}\n value={value}\n checked={checked}\n disabled={disabled}\n aria-checked={checked}\n aria-disabled={disabled || undefined}\n aria-describedby={description ? descId : undefined}\n tabIndex={tabbable ? 0 : -1}\n onChange={() => ctx.select(value)}\n onKeyDown={onKeyDown}\n className={cn(\n \"peer sr-only\",\n
|
|
14
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { focusRing } from \"@/lib/focus-ring\";\nimport {\n radioControlVariants,\n radioDotVariants,\n radioTargetVariants,\n radioLabelVariants,\n radioDescriptionVariants,\n radioCardVariants,\n radioGroupVariants,\n type RadioGroupVariantProps,\n} from \"./radio.variants\";\n\ninterface RadioGroupContextValue {\n name: string;\n value: string | undefined;\n variant: NonNullable<RadioGroupVariantProps[\"variant\"]>;\n select: (value: string) => void;\n register: (value: string, el: HTMLInputElement | null, disabled: boolean) => void;\n onArrow: (current: string, dir: 1 | -1) => void;\n rovingValue: string | undefined; // which option owns tabindex=0\n}\n\nconst RadioGroupContext = React.createContext<RadioGroupContextValue | null>(null);\n\nfunction useRadioGroup(): RadioGroupContextValue {\n const ctx = React.useContext(RadioGroupContext);\n if (!ctx) throw new Error(\"<Radio> must be used inside <RadioGroup>\");\n return ctx;\n}\n\nexport interface RadioGroupProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\">,\n RadioGroupVariantProps {\n /** Shared `name` for the native radios in this group. */\n name: string;\n /** The group label — the question the options answer. */\n label: React.ReactNode;\n /** Uncontrolled initial selection. */\n defaultValue?: string;\n /** Controlled selection. */\n value?: string;\n /** Fires with the newly selected value. */\n onValueChange?: (value: string) => void;\n}\n\nexport const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(\n function RadioGroup(\n { className, name, label, defaultValue, value: controlled, onValueChange,\n variant = \"default\", children, ...props },\n ref,\n ) {\n const labelId = React.useId();\n const [uncontrolled, setUncontrolled] = React.useState(defaultValue);\n const value = controlled ?? uncontrolled;\n // insertion-ordered registry of options: value → { el, disabled }\n const items = React.useRef<Map<string, { el: HTMLInputElement | null; disabled: boolean }>>(\n new Map(),\n );\n\n const select = React.useCallback(\n (next: string) => {\n if (controlled === undefined) setUncontrolled(next);\n onValueChange?.(next);\n },\n [controlled, onValueChange],\n );\n\n const register = React.useCallback(\n (v: string, el: HTMLInputElement | null, disabled: boolean) => {\n if (el) items.current.set(v, { el, disabled });\n else items.current.delete(v);\n },\n [],\n );\n\n // Arrow navigation: move to next/prev ENABLED option, wrapping, then select + focus.\n // Focus is a post-commit concern, so it legitimately reads the ref Map for the\n // live DOM node; only the render-time roving fallback (below) avoids that Map.\n const onArrow = React.useCallback(\n (current: string, dir: 1 | -1) => {\n const entries = [...items.current.entries()];\n const enabled = entries.filter(([, m]) => !m.disabled);\n if (enabled.length === 0) return;\n const idx = enabled.findIndex(([v]) => v === current);\n const nextIdx = (idx + dir + enabled.length) % enabled.length;\n const [nextValue, nextMeta] = enabled[nextIdx];\n select(nextValue);\n nextMeta.el?.focus();\n },\n [select],\n );\n\n // Roving tabindex owner: the selected option, else the first ENABLED option.\n // Computed from React.Children DURING RENDER, not from the post-commit `items`\n // ref Map — children register via useEffect AFTER the group commits, which would\n // not trigger a recompute, leaving no tab stop when nothing is selected. Reading\n // children at render time recomputes on every render and keeps the defaultValue\n // path working (a real `value` always wins).\n const firstEnabledValue = React.useMemo(() => {\n let first: string | undefined;\n React.Children.forEach(children, (child) => {\n if (first !== undefined) return;\n if (!React.isValidElement<RadioProps>(child)) return;\n if (child.props.disabled) return;\n first = child.props.value;\n });\n return first;\n }, [children]);\n const rovingValue = value ?? firstEnabledValue;\n\n const ctx = React.useMemo<RadioGroupContextValue>(\n () => ({ name, value, variant: variant ?? \"default\", select, register, onArrow, rovingValue }),\n [name, value, variant, select, register, onArrow, rovingValue],\n );\n\n return (\n <RadioGroupContext.Provider value={ctx}>\n <div\n ref={ref}\n role=\"radiogroup\"\n aria-labelledby={labelId}\n className={cn(radioGroupVariants({ variant }), className)}\n {...props}\n >\n <span id={labelId} className=\"text-label text-text-primary\">\n {label}\n </span>\n {children}\n </div>\n </RadioGroupContext.Provider>\n );\n },\n);\n\nexport interface RadioProps\n extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"type\" | \"name\" | \"value\"> {\n /** This option's value within the group. */\n value: string;\n /** Secondary text under the option label (With-description variant). */\n description?: React.ReactNode;\n}\n\nexport const Radio = React.forwardRef<HTMLInputElement, RadioProps>(\n function Radio({ className, value, description, disabled = false, children, ...props }, ref) {\n const ctx = useRadioGroup();\n const inputRef = React.useRef<HTMLInputElement | null>(null);\n const descId = React.useId();\n const checked = ctx.value === value;\n const tabbable = ctx.rovingValue === value;\n\n // Register this option with the group so roving + arrow nav can see it.\n React.useEffect(() => {\n ctx.register(value, inputRef.current, disabled);\n return () => ctx.register(value, null, disabled);\n }, [ctx, value, disabled]);\n\n const setRefs = (el: HTMLInputElement | null) => {\n inputRef.current = el;\n if (typeof ref === \"function\") ref(el);\n else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;\n };\n\n const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {\n if (disabled) return;\n if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") {\n e.preventDefault();\n ctx.onArrow(value, 1);\n } else if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") {\n e.preventDefault();\n ctx.onArrow(value, -1);\n } else if (e.key === \" \") {\n e.preventDefault();\n if (!checked) ctx.select(value);\n }\n };\n\n // The naming <label> wraps ONLY the input, the control, and the option-label\n // text — never the description. A native <label> contributes its text content to\n // the input's accessible name, so a nested description would pollute the name\n // (spec §7: the name comes from the associated label). The description sits\n // OUTSIDE the label and is linked via aria-describedby (mirrors Checkbox/Select).\n const row = (\n <label className=\"flex items-start gap-2\">\n {/* native radio is the peer; visually hidden but in the a11y tree */}\n <input\n ref={setRefs}\n type=\"radio\"\n name={ctx.name}\n value={value}\n checked={checked}\n disabled={disabled}\n aria-checked={checked}\n aria-disabled={disabled || undefined}\n aria-describedby={description ? descId : undefined}\n tabIndex={tabbable ? 0 : -1}\n onChange={() => ctx.select(value)}\n onKeyDown={onKeyDown}\n className={cn(\n \"peer sr-only\",\n focusRing,\n )}\n {...props}\n />\n <span\n aria-hidden=\"true\"\n data-testid={`radio-control-${value}`}\n className={cn(radioControlVariants())}\n >\n {/* the dot is a child of the control, not a sibling of the peer input,\n so its visibility is driven by the explicit `selected` variant. */}\n <span\n data-testid={`radio-dot-${value}`}\n className={cn(radioDotVariants({ selected: checked, disabled }))}\n />\n </span>\n {/* disabled colour comes from the explicit cva variant (the label-text span\n is not a sibling of the peer input, so peer-disabled cannot reach it). */}\n <span className={cn(radioLabelVariants({ disabled }))}>{children}</span>\n </label>\n );\n\n // The option container the test queries: it carries the target-size floor once\n // and stacks the naming row above an optional description.\n const option = (\n <div\n data-testid={`radio-target-${value}`}\n className={cn(radioTargetVariants(), \"flex-col\", className)}\n >\n {row}\n {description ? (\n <span id={descId} className={cn(radioDescriptionVariants())}>\n {description}\n </span>\n ) : null}\n </div>\n );\n\n return ctx.variant === \"card\" ? (\n <span data-testid={`radio-card-${value}`} className={cn(radioCardVariants())}>\n {option}\n </span>\n ) : (\n option\n );\n },\n);\n",
|
|
15
15
|
"path": "radio/radio.tsx",
|
|
16
16
|
"target": "@ui/radio/radio.tsx",
|
|
17
17
|
"type": "registry:ui"
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
],
|
|
26
26
|
"name": "radio",
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"@verdify/cn"
|
|
28
|
+
"@verdify/cn",
|
|
29
|
+
"@verdify/focus-ring"
|
|
29
30
|
],
|
|
30
31
|
"title": "radio",
|
|
31
32
|
"type": "registry:ui"
|