@verdify/ui 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -17
- package/package.json +42 -34
- package/registry/accordion.json +33 -0
- package/registry/agent-badge.json +32 -0
- package/registry/alert.json +32 -0
- package/registry/avatar.json +34 -0
- package/registry/badge.json +32 -0
- package/registry/breadcrumb.json +33 -0
- package/registry/button.json +33 -0
- package/registry/card.json +33 -0
- package/registry/checkbox.json +32 -0
- package/registry/cn.json +19 -0
- package/registry/command-palette.json +33 -0
- package/registry/consent-toggle.json +34 -0
- package/registry/credential-card.json +35 -0
- package/registry/data-grid.json +33 -0
- package/registry/dialog.json +33 -0
- package/registry/identity-chip.json +36 -0
- package/registry/init.json +17 -0
- package/registry/input.json +32 -0
- package/registry/label.json +32 -0
- package/registry/menu.json +33 -0
- package/registry/pagination.json +33 -0
- package/registry/popover.json +32 -0
- package/registry/progress.json +32 -0
- package/registry/radio.json +32 -0
- package/registry/select.json +33 -0
- package/registry/separator.json +33 -0
- package/registry/sheet.json +33 -0
- package/registry/sidebar.json +33 -0
- package/registry/skeleton.json +32 -0
- package/registry/spinner.json +32 -0
- package/registry/switch.json +32 -0
- package/registry/table.json +33 -0
- package/registry/tabs.json +33 -0
- package/registry/textarea.json +33 -0
- package/registry/toast.json +33 -0
- package/registry/tooltip.json +32 -0
- package/registry/trust-score.json +33 -0
- package/registry/verified-badge.json +32 -0
- package/registry.json +159 -0
- package/LICENSE +0 -12
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "export {\n Pagination,\n PaginationList,\n PaginationItem,\n PaginationLink,\n PaginationPrevious,\n PaginationNext,\n PaginationStatus,\n PaginationEllipsis,\n type PaginationProps,\n type PaginationListProps,\n type PaginationItemProps,\n type PaginationLinkProps,\n type PaginationPreviousProps,\n type PaginationNextProps,\n type PaginationStatusProps,\n type PaginationEllipsisProps,\n} from \"./pagination\";\nexport {\n paginationNavClass,\n paginationListClass,\n paginationItemClass,\n paginationControlVariants,\n paginationIconClass,\n paginationStatusClass,\n paginationEllipsisClass,\n type PaginationControlVariantProps,\n} from \"./pagination.variants\";\n",
|
|
10
|
+
"path": "pagination/index.ts",
|
|
11
|
+
"target": "@ui/pagination/index.ts",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "import * as React from \"react\";\nimport { Slot } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n paginationNavClass,\n paginationListClass,\n paginationItemClass,\n paginationControlVariants,\n paginationIconClass,\n paginationStatusClass,\n paginationEllipsisClass,\n type PaginationControlVariantProps,\n} from \"./pagination.variants\";\n\nexport interface PaginationProps extends React.ComponentPropsWithoutRef<\"nav\"> {\n /**\n * The landmark's accessible name (spec §7). Defaults to `\"Pagination\"` so the control set is\n * distinguishable from other `navigation` landmarks on the page. Override it (or point\n * `aria-labelledby` at a visible label) when the app names its pagers differently.\n */\n \"aria-label\"?: string;\n /**\n * The next page is resolving (spec §4 Loading). Marks the region `aria-busy=\"true\"` so a screen\n * reader announces the wait; the control set stays operable so you can change your mind. A wait\n * is a plain wait — the deliberate motion duration is reserved for the verified check, never for\n * a page turning. The destination list shows its own loading affordance, not this control.\n */\n loading?: boolean;\n}\n\n/**\n * Pagination splits a long list into numbered pages and lets you move between them one page at a\n * time or jump to a specific page (spec §1). It is the `navigation` landmark wrapping a set of\n * standard links or buttons — there is no APG \"pagination\" widget. Reach for it when the choice\n * changes which records you see; reach for Tabs when the choice switches between peer panels.\n *\n * The set is neutral wayfinding (spec §3): the only accent is the CURRENT page, which takes the\n * primary ACTION (brand) alias — where you are in the set — never a status color. A current page\n * does not report a verified result, so coloring it with the verified green would break\n * brand != state; a verified meaning belongs to VerifiedBadge.\n *\n * It is render-only (no hook / no stateful Radix primitive), so it needs no `'use client'`.\n */\nexport const Pagination = React.forwardRef<HTMLElement, PaginationProps>(function Pagination(\n { className, \"aria-label\": ariaLabel = \"Pagination\", loading = false, ...props },\n ref,\n) {\n return (\n // the navigation landmark, named so a screen reader can find and skip the control set; while a\n // page resolves the region is aria-busy (spec §7) but stays operable (spec §4)\n <nav\n ref={ref}\n aria-label={ariaLabel}\n aria-busy={loading || undefined}\n className={cn(paginationNavClass, className)}\n {...props}\n />\n );\n});\n\nexport type PaginationListProps = React.ComponentPropsWithoutRef<\"ul\">;\n\n/**\n * The control list (spec §2): a row of items, each holding one control. Unordered — the controls\n * are peers (prev, pages, next), not a ranked sequence — so it is a `<ul>`.\n */\nexport const PaginationList = React.forwardRef<HTMLUListElement, PaginationListProps>(\n function PaginationList({ className, ...props }, ref) {\n return <ul ref={ref} className={cn(paginationListClass, className)} {...props} />;\n },\n);\n\nexport type PaginationItemProps = React.ComponentPropsWithoutRef<\"li\">;\n\n/**\n * One item in the control set (spec §2): a `<li>` holding a `PaginationLink`, `PaginationPrevious`,\n * `PaginationNext`, `PaginationEllipsis`, or `PaginationStatus`.\n */\nexport const PaginationItem = React.forwardRef<HTMLLIElement, PaginationItemProps>(\n function PaginationItem({ className, ...props }, ref) {\n return <li ref={ref} className={cn(paginationItemClass, className)} {...props} />;\n },\n);\n\nexport interface PaginationLinkProps\n extends Omit<React.ComponentPropsWithoutRef<\"a\">, \"color\">,\n Pick<PaginationControlVariantProps, \"size\"> {\n /**\n * The page this control goes to (spec §7). When set, the control is a native link\n * (`role=\"link\"`) that navigates by URL; omit it for a native `button` that changes the list in\n * place. Pick one model per pager and keep it consistent (spec §7).\n */\n href?: string;\n /**\n * This is the page you are on (spec §4 Current). The control lifts to the primary action accent,\n * is marked `aria-current=\"page\"`, does NOT navigate, and is rendered non-interactive so Tab does\n * not stop on a control that does nothing. Exactly one control in a set is current.\n */\n isCurrent?: boolean;\n /**\n * Project the page styling onto a caller-supplied anchor (a framework router `<Link>` rendered as\n * an `<a>`) via Radix Slot, instead of the default element (spec §2/§7). Slot runs\n * `React.Children.only` — pass exactly one anchor child whose `aria-label` names its destination.\n */\n asChild?: boolean;\n}\n\n/**\n * One page control (spec §2/§4). A native `<a>` when `href` is set (navigates by URL) or a native\n * `<button>` when it is not (changes the list in place) — so it exposes the link/button role and\n * is operable without extra wiring; it gets the visible focus ring, the restrained ghost hover\n * fill, and the target-size floor. The current page is non-navigating plain text marked\n * `aria-current=\"page\"`.\n *\n * A page control's accessible name states the page it goes to, not just its number (spec §7); pass\n * an explicit `aria-label` to override the default `\"Go to page {children}\"`.\n */\nexport const PaginationLink = React.forwardRef<HTMLAnchorElement | HTMLButtonElement, PaginationLinkProps>(\n function PaginationLink(\n { className, children, href, isCurrent = false, asChild = false, size, \"aria-label\": ariaLabel, ...props },\n ref,\n ) {\n const classes = cn(paginationControlVariants({ current: isCurrent, size }), className);\n // the current page is NON-INTERACTIVE: plain text marked aria-current, not a link/button, so a\n // keyboard user never tabs to a control that does nothing and a screen reader is never told the\n // page they are on is somewhere to go (spec §4/§7).\n if (isCurrent) {\n return (\n <span\n ref={ref as React.Ref<HTMLSpanElement>}\n aria-current=\"page\"\n className={classes}\n {...(props as React.ComponentPropsWithoutRef<\"span\">)}\n >\n {children}\n </span>\n );\n }\n // the accessible name states the destination page, not just its number (spec §7)\n const label = ariaLabel ?? (typeof children === \"string\" || typeof children === \"number\"\n ? `Go to page ${children}`\n : undefined);\n if (asChild) {\n return (\n <Slot.Root ref={ref} aria-label={label} className={classes} {...props}>\n {children as React.ReactElement}\n </Slot.Root>\n );\n }\n // link when it navigates by URL (spec §7); button when it changes the list in place\n if (href !== undefined) {\n return (\n <a ref={ref as React.Ref<HTMLAnchorElement>} href={href} aria-label={label} className={classes} {...props}>\n {children}\n </a>\n );\n }\n return (\n <button\n ref={ref as React.Ref<HTMLButtonElement>}\n type=\"button\"\n aria-label={label}\n className={classes}\n {...(props as React.ComponentPropsWithoutRef<\"button\">)}\n >\n {children}\n </button>\n );\n },\n);\n\ninterface PaginationDirectionProps\n extends Omit<React.ComponentPropsWithoutRef<\"a\">, \"color\">,\n Pick<PaginationControlVariantProps, \"size\"> {\n href?: string;\n /**\n * The control is non-operable on the edge page — Prev on the first page, Next on the last (spec\n * §4 Disabled). A button-form control (no `href`) uses the native `disabled`; a link-form control\n * (`href` set) uses `aria-disabled`, drops its `href`, and leaves the tab order, while its\n * direction stays readable to assistive technology so the control's meaning stays clear.\n */\n disabled?: boolean;\n asChild?: boolean;\n}\n\n/** Shared body for the two direction controls — Prev and Next differ only by label + glyph. */\nfunction makeDirection(\n defaultLabel: string,\n glyphSide: \"prev\" | \"next\",\n Glyph: () => React.ReactElement,\n) {\n return React.forwardRef<HTMLAnchorElement | HTMLButtonElement, PaginationDirectionProps>(\n function PaginationDirection(\n { className, children, href, disabled = false, asChild = false, size, \"aria-label\": ariaLabel, ...props },\n ref,\n ) {\n // the direction name is the accessible name — the icon is decorative, never a visible label\n const label = ariaLabel ?? defaultLabel;\n const classes = cn(paginationControlVariants({ size }), className);\n const content = (\n <>\n {glyphSide === \"prev\" ? <Glyph /> : null}\n {children}\n {glyphSide === \"next\" ? <Glyph /> : null}\n </>\n );\n if (asChild) {\n return (\n <Slot.Root ref={ref} aria-label={label} className={classes} {...props}>\n {children as React.ReactElement}\n </Slot.Root>\n );\n }\n // link-form (href set): aria-disabled drives the disabled state because an <a> has no native\n // disabled; the component strips href + tabindex so it cannot navigate or be tabbed to (spec §4)\n if (href !== undefined) {\n return (\n <a\n ref={ref as React.Ref<HTMLAnchorElement>}\n href={disabled ? undefined : href}\n aria-label={label}\n aria-disabled={disabled || undefined}\n tabIndex={disabled ? -1 : undefined}\n className={classes}\n {...props}\n >\n {content}\n </a>\n );\n }\n // button-form: native disabled is non-operable and skipped by Tab for free (spec §4)\n return (\n <button\n ref={ref as React.Ref<HTMLButtonElement>}\n type=\"button\"\n aria-label={label}\n disabled={disabled}\n className={classes}\n {...(props as React.ComponentPropsWithoutRef<\"button\">)}\n >\n {content}\n </button>\n );\n },\n );\n}\n\n/** The default prev glyph — a chevron pointing against the reading direction. Decorative. */\nfunction PrevGlyph() {\n return (\n <span aria-hidden=\"true\" data-testid=\"pagination-prev-icon\" className={paginationIconClass}>\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <path d=\"M10 4L6 8l4 4\" />\n </svg>\n </span>\n );\n}\n\n/** The default next glyph — a chevron pointing in the reading direction. Decorative. */\nfunction NextGlyph() {\n return (\n <span aria-hidden=\"true\" data-testid=\"pagination-next-icon\" className={paginationIconClass}>\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <path d=\"M6 4l4 4-4 4\" />\n </svg>\n </span>\n );\n}\n\nexport type PaginationPreviousProps = PaginationDirectionProps;\n\n/**\n * The control that moves to the previous page (spec §2). Holds a decorative direction glyph and\n * names its direction (`\"Previous page\"` by default); non-operable on the first page (spec §4).\n */\nexport const PaginationPrevious = makeDirection(\"Previous page\", \"prev\", PrevGlyph);\n\nexport type PaginationNextProps = PaginationDirectionProps;\n\n/**\n * The control that moves to the next page (spec §2). Holds a decorative direction glyph and names\n * its direction (`\"Next page\"` by default); non-operable on the last page (spec §4).\n */\nexport const PaginationNext = makeDirection(\"Next page\", \"next\", NextGlyph);\n\nexport interface PaginationStatusProps extends React.ComponentPropsWithoutRef<\"span\"> {\n /** The current page number. */\n current: number;\n /** The total page count. */\n total: number;\n}\n\n/**\n * The \"Page m of n\" readout for the `prev-next` variant (spec §3): a non-interactive position\n * indicator between Prev and Next, used in dense rails or compact surfaces where a full number run\n * does not fit. Plain text in the secondary color at the label type role.\n */\nexport const PaginationStatus = React.forwardRef<HTMLSpanElement, PaginationStatusProps>(\n function PaginationStatus({ className, current, total, children, ...props }, ref) {\n return (\n <span ref={ref} className={cn(paginationStatusClass, className)} {...props}>\n {children ?? `Page ${current} of ${total}`}\n </span>\n );\n },\n);\n\nexport type PaginationEllipsisProps = React.ComponentPropsWithoutRef<\"span\">;\n\n/**\n * A non-interactive gap standing in for a run of hidden page controls when the range is long (spec\n * §2/§7). It is presentational: `aria-hidden`, out of the tab order, never a stop. The page\n * controls it hides are still reachable through the visible page links around it.\n */\nexport const PaginationEllipsis = React.forwardRef<HTMLSpanElement, PaginationEllipsisProps>(\n function PaginationEllipsis({ className, children, ...props }, ref) {\n return (\n <span\n ref={ref}\n aria-hidden=\"true\"\n data-testid=\"pagination-ellipsis\"\n className={cn(paginationEllipsisClass, className)}\n {...props}\n >\n {children ?? <EllipsisGlyph />}\n </span>\n );\n },\n);\n\n/** The default overflow glyph — three dots. Decorative. */\nfunction EllipsisGlyph() {\n return (\n <svg\n width=\"16\"\n height=\"16\"\n viewBox=\"0 0 16 16\"\n fill=\"currentColor\"\n aria-hidden=\"true\"\n focusable=\"false\"\n >\n <circle cx=\"3\" cy=\"8\" r=\"1.25\" />\n <circle cx=\"8\" cy=\"8\" r=\"1.25\" />\n <circle cx=\"13\" cy=\"8\" r=\"1.25\" />\n </svg>\n );\n}\n",
|
|
16
|
+
"path": "pagination/pagination.tsx",
|
|
17
|
+
"target": "@ui/pagination/pagination.tsx",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
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 \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\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
|
+
"path": "pagination/pagination.variants.ts",
|
|
23
|
+
"target": "@ui/pagination/pagination.variants.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "pagination",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "pagination",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"radix-ui@^1.1.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "export {\n Popover,\n PopoverTrigger,\n PopoverContent,\n PopoverHeader,\n PopoverTitle,\n PopoverBody,\n PopoverClose,\n PopoverArrow,\n type PopoverProps,\n type PopoverTriggerProps,\n type PopoverContentProps,\n type PopoverHeaderProps,\n type PopoverTitleProps,\n type PopoverBodyProps,\n type PopoverCloseProps,\n type PopoverArrowProps,\n} from \"./popover\";\nexport {\n popoverTriggerClass,\n popoverPanelClass,\n popoverHeaderClass,\n popoverTitleClass,\n popoverBodyClass,\n popoverArrowClass,\n popoverCloseClass,\n popoverCloseGlyphClass,\n} from \"./popover.variants\";\n",
|
|
9
|
+
"path": "popover/index.ts",
|
|
10
|
+
"target": "@ui/popover/index.ts",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Popover as PopoverPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n popoverTriggerClass,\n popoverPanelClass,\n popoverHeaderClass,\n popoverTitleClass,\n popoverBodyClass,\n popoverArrowClass,\n popoverCloseClass,\n popoverCloseGlyphClass,\n} from \"./popover.variants\";\n\n// Radix Popover has no Title primitive (unlike Dialog), so it never auto-wires aria-labelledby and\n// never sets role=\"dialog\" on the panel. The spec §7 makes the panel a GENERIC container by default\n// and only role=\"dialog\" + aria-labelledby WHEN the panel carries a title (a short modal-like task\n// with a name). We hand-compose that: PopoverTitle registers its generated id into this context, and\n// PopoverContent reads the registered id to decide whether to set the dialog role + label. This is\n// the compound \"compose a role by hand when Radix can't express the spec's anatomy\" pattern (skill F)\n// and the named Radix-vs-spec deviation step — accept Radix's non-modal popover and add the labelling\n// the spec names on top of it, rather than abandoning the primitive.\ntype PopoverContextValue = {\n titleId: string | undefined;\n setTitleId: (id: string | undefined) => void;\n};\nconst PopoverContext = React.createContext<PopoverContextValue>({\n titleId: undefined,\n setTitleId: () => {},\n});\n\nexport interface PopoverProps\n extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Root> {}\n\n/**\n * Popover is a small, NON-MODAL surface that a trigger opens next to itself to hold secondary\n * content — a short form, a definition, a set of related controls, a detail card — without leaving\n * the page (spec §1). Reach for it when the content is too rich for a Tooltip's plain label yet too\n * small to interrupt the page with a Dialog or a Sheet. The page behind a popover stays LIVE: it is\n * not dimmed, focus is not trapped, and you can keep working around it.\n *\n * It is a NEUTRAL surface (spec §3): the panel, arrow, and border are neutral, and the brand violet\n * never tints the panel to look \"premium.\" Color enters only through the components placed inside it\n * — a primary Button, a VerifiedBadge — never on the panel or arrow themselves (brand != state).\n *\n * Wraps the Radix Popover primitive (WAI-ARIA disclosure pattern, NON-MODAL: `modal` defaults to\n * `false`, so the page is not made inert and Tab leaves the panel rather than cycling inside it). A\n * stateful primitive, so this file is `'use client'`.\n */\nexport function Popover({ children, ...rootProps }: PopoverProps) {\n const [titleId, setTitleId] = React.useState<string | undefined>(undefined);\n const value = React.useMemo(() => ({ titleId, setTitleId }), [titleId]);\n return (\n <PopoverContext.Provider value={value}>\n {/* `modal` is intentionally NOT set: Radix defaults it to false, which is exactly the spec's\n non-modal contract — no scrim, no focus-trap, no inert siblings, Tab leaves the panel. */}\n <PopoverPrimitive.Root {...rootProps}>{children}</PopoverPrimitive.Root>\n </PopoverContext.Provider>\n );\n}\n\nexport interface PopoverTriggerProps\n extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger> {}\n\n/**\n * The control that opens the popover (spec §2 trigger): the one stop in the page tab order for this\n * control, carrying the 2px focus ring. Radix sets `aria-expanded` (the single source of truth for\n * open vs. closed, spec §7) and `aria-controls` (pointing at the panel) for you. Pass `asChild` to\n * wrap your own Button so the trigger inherits its role, keyboard, and focus ring rather than nesting\n * a second button; the bare (non-`asChild`) form renders the default neutral-ghost trigger.\n */\nexport const PopoverTrigger = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Trigger>,\n PopoverTriggerProps\n>(function PopoverTrigger({ className, asChild, ...props }, ref) {\n return (\n <PopoverPrimitive.Trigger\n ref={ref}\n asChild={asChild}\n className={asChild ? className : cn(popoverTriggerClass, className)}\n {...props}\n />\n );\n});\n\nexport interface PopoverContentProps\n extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {\n /** Render the optional decorative arrow joining the panel to its trigger (spec §2 arrow). Default `false`. */\n arrow?: boolean;\n}\n\n/**\n * Renders the portal and the panel (spec §2 panel): the floating NEUTRAL raised surface that opens\n * on activation, raised above the page on the POPOVER layer and anchored to the trigger, repositioning\n * to stay in the viewport (Radix). It is NON-MODAL — NO scrim, NO `aria-modal`, NO focus-trap — the\n * page behind stays live and Tab leaves the panel rather than cycling inside it, so the popover is\n * never a keyboard trap (spec §7, 2.1.2). On open, Radix moves focus INTO the panel (to the first\n * focusable control, or to the panel container when it has none); on close — by Escape, the close\n * control, the trigger toggle, or a click away — focus returns to the trigger (spec §6/§7).\n *\n * When the panel carries a `PopoverTitle`, the panel takes `role=\"dialog\"` and is named by the title\n * via `aria-labelledby` (spec §7: the only dialog-role case — a short modal-like task WITH a title);\n * an untitled panel stays a generic container with no role. Even titled it never sets `aria-modal`,\n * because the popover is non-modal. The open/close is the FAST motion duration and a plain fade,\n * instant under `prefers-reduced-motion` — never the deliberate \"check\" duration (spec §4, G-U3).\n */\nexport const PopoverContent = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Content>,\n PopoverContentProps\n>(function PopoverContent(\n { className, children, sideOffset = 6, arrow = false, ...props },\n ref,\n) {\n const { titleId } = React.useContext(PopoverContext);\n return (\n <PopoverPrimitive.Portal>\n <PopoverPrimitive.Content\n ref={ref}\n sideOffset={sideOffset}\n // role=\"dialog\" + aria-labelledby ONLY when the panel carries a title (spec §7). Radix\n // Popover does NOT emit either, so we set them by hand from the registered title id; an\n // untitled panel is a plain generic container. aria-modal is NEVER set — the popover is\n // non-modal (no focus-trap, no inert page), and a literal aria-modal would lie to AT.\n role={titleId ? \"dialog\" : undefined}\n aria-labelledby={titleId}\n className={cn(popoverPanelClass, className)}\n {...props}\n >\n {children}\n {arrow ? (\n <PopoverPrimitive.Arrow\n data-testid=\"popover-arrow\"\n // The arrow is purely DECORATIVE (spec §2/§7): Radix renders it as a bare <svg> with no\n // role and no aria-hidden, so we set aria-hidden explicitly to keep it out of the AT tree\n // per the frozen contract — the arrow carries no meaning the panel does not already.\n aria-hidden=\"true\"\n className={popoverArrowClass}\n />\n ) : null}\n </PopoverPrimitive.Content>\n </PopoverPrimitive.Portal>\n );\n});\n\nexport interface PopoverHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/** The top region: a short title and the optional close control on the inline-end (spec §2 header). */\nexport const PopoverHeader = React.forwardRef<HTMLDivElement, PopoverHeaderProps>(\n function PopoverHeader({ className, ...props }, ref) {\n return <div ref={ref} className={cn(popoverHeaderClass, className)} {...props} />;\n },\n);\n\nexport interface PopoverTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {}\n\n/**\n * Names the panel in one short statement, sentence case (spec §2 header title). When present it IS\n * the panel's accessible name: it generates a stable id and registers it with the Popover so the\n * panel takes `role=\"dialog\"` + `aria-labelledby` (spec §7). Rendered as an `<h2>` by default at the\n * panel's title type role; pass `as`/override via props if a different heading level fits the page\n * outline.\n */\nexport const PopoverTitle = React.forwardRef<HTMLHeadingElement, PopoverTitleProps>(\n function PopoverTitle({ className, id, ...props }, ref) {\n const ctx = React.useContext(PopoverContext);\n const generatedId = React.useId();\n const titleId = id ?? generatedId;\n // register this title's id so PopoverContent can name the panel + take the dialog role; clear it\n // on unmount so a titled panel that loses its title reverts to a generic container.\n React.useEffect(() => {\n ctx.setTitleId(titleId);\n return () => ctx.setTitleId(undefined);\n }, [ctx, titleId]);\n return (\n <h2 ref={ref} id={titleId} className={cn(popoverTitleClass, className)} {...props} />\n );\n },\n);\n\nexport interface PopoverBodyProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/**\n * The content region (spec §2 body): text, a short form, or a few controls. Keep it small — content\n * that needs to scroll usually belongs in a Sheet (spec §2). Body text is the body type role in the\n * secondary text color (spec §5).\n */\nexport const PopoverBody = React.forwardRef<HTMLDivElement, PopoverBodyProps>(\n function PopoverBody({ className, ...props }, ref) {\n return <div ref={ref} className={cn(popoverBodyClass, className)} {...props} />;\n },\n);\n\n// A neutral X glyph, --size-icon-md, drawn with currentColor so it inherits the close button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nfunction CloseGlyph() {\n return (\n <svg\n data-testid=\"popover-close-glyph\"\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n className={popoverCloseGlyphClass}\n >\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nexport interface PopoverCloseProps\n extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Close> {}\n\n/**\n * The in-panel dismiss control (spec §2 close): closing returns focus to the trigger (Radix). A\n * popover with no in-panel close is still dismissible by Escape and by clicking away (spec §2/§6).\n * Two forms, both proven by the tests:\n * - default (no children): the styled neutral-ghost icon-button for the header — pass an\n * `aria-label` (e.g. \"Close\"), since the glyph is decorative and the placeholder is never a name.\n * - `asChild`: wrap your own control so it also dismisses without nesting a second button.\n */\nexport const PopoverClose = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Close>,\n PopoverCloseProps\n>(function PopoverClose({ className, children, asChild, ...props }, ref) {\n if (asChild) {\n return (\n <PopoverPrimitive.Close ref={ref} asChild className={className} {...props}>\n {children}\n </PopoverPrimitive.Close>\n );\n }\n return (\n <PopoverPrimitive.Close\n ref={ref}\n className={cn(popoverCloseClass, className)}\n {...props}\n >\n {children ?? <CloseGlyph />}\n </PopoverPrimitive.Close>\n );\n});\n\n/**\n * The optional decorative arrow joining the panel to its trigger (spec §2 arrow). It carries the\n * panel's neutral surface fill, never a brand or status color, and is hidden from assistive\n * technology (Radix renders it inside an aria-hidden wrapper). Usually rendered for you by\n * `PopoverContent`'s `arrow` prop; exported for direct composition.\n */\nexport const PopoverArrow = React.forwardRef<\n React.ElementRef<typeof PopoverPrimitive.Arrow>,\n React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Arrow>\n>(function PopoverArrow({ className, ...props }, ref) {\n return (\n <PopoverPrimitive.Arrow\n ref={ref}\n data-testid=\"popover-arrow\"\n // decorative — kept out of the AT tree per spec §2/§7 (Radix's bare arrow sets no aria-hidden)\n aria-hidden=\"true\"\n className={cn(popoverArrowClass, className)}\n {...props}\n />\n );\n});\n\nexport type PopoverArrowProps = React.ComponentPropsWithoutRef<\n typeof PopoverPrimitive.Arrow\n>;\n",
|
|
15
|
+
"path": "popover/popover.tsx",
|
|
16
|
+
"target": "@ui/popover/popover.tsx",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
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 \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2 \" +\n \"disabled:pointer-events-none disabled:text-text-disabled\";\n\n// The 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 \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\";\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 \"outline-none focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\";\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
|
+
"path": "popover/popover.variants.ts",
|
|
22
|
+
"target": "@ui/popover/popover.variants.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "popover",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn"
|
|
29
|
+
],
|
|
30
|
+
"title": "popover",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "export { Progress, type ProgressProps } from \"./progress\";\nexport {\n progressFillVariants,\n progressRootClass,\n progressHeaderClass,\n progressTrackClass,\n progressLabelClass,\n progressValueTextClass,\n progressDescriptionClass,\n progressErrorClass,\n type ProgressFillVariantProps,\n} from \"./progress.variants\";\n",
|
|
9
|
+
"path": "progress/index.ts",
|
|
10
|
+
"target": "@ui/progress/index.ts",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"content": "\"use client\";\nimport * as React from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport {\n progressFillVariants,\n progressRootClass,\n progressHeaderClass,\n progressTrackClass,\n progressLabelClass,\n progressValueTextClass,\n progressDescriptionClass,\n progressErrorClass,\n PROGRESS_INDETERMINATE_KEYFRAME,\n} from \"./progress.variants\";\n\n// The indeterminate indicator's TRAVEL keyframe (spec §3/§4): a narrow indicator slides\n// along the track's INLINE axis from before the inline-start edge (-40%, its own width) to\n// past the inline-end edge (100%), then loops — a moving indicator that travels the track,\n// not an in-place fade. Travel is on the LOGICAL `inset-inline-start`, so it mirrors under\n// dir=\"rtl\" with no extra rule (G-U6). This is pure geometry and binds NO design token (the\n// ambient duration + verdify easing are bound on the fill className, the §5 binding site),\n// so it lives here as a component-scoped <style> rather than in the auto-generated tokens\n// preset or in the .variants.ts binding file. The track's `overflow-hidden` clips the\n// indicator while it sits past either edge.\nconst PROGRESS_INDETERMINATE_CSS = `@keyframes ${PROGRESS_INDETERMINATE_KEYFRAME}{from{inset-inline-start:-40%}to{inset-inline-start:100%}}`;\n\nexport interface ProgressProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"aria-label\"> {\n /**\n * The text naming the task, bound to the bar (spec §2/§7). When present it is rendered\n * as visible text AND becomes the accessible name via `aria-labelledby`. Progress is\n * never unlabeled: provide EITHER a visible `label` or an `aria-label`.\n */\n label?: React.ReactNode;\n /**\n * The accessible name when no visible {@link label} fits (spec §7). State the task\n * (\"Uploading your file.\"). Used only when there is no visible label — the bar is never\n * nameless to assistive technology.\n */\n \"aria-label\"?: string;\n /**\n * The current progress value (spec §4/§7). Sets `aria-valuenow` (the raw value) and the\n * determinate fill length (clamped to {@link min}..{@link max}). Omit it — or set\n * {@link indeterminate} — when the size of the work is genuinely unknown; do not fake a\n * number.\n */\n value?: number;\n /** Lower bound of the range (spec §7). Default `0`. */\n min?: number;\n /** Upper bound of the range (spec §7). Default `100`. */\n max?: number;\n /**\n * Whether the work is running but its size is unknown (spec §3/§4). A moving indicator\n * travels the track and NO value is claimed: `aria-valuenow` and the visible\n * `valueText` are both suppressed. Switch back to a determinate `value` the moment a\n * real measure is known.\n */\n indeterminate?: boolean;\n /**\n * An optional plain progress readout next to the bar (spec §2/§7) — \"40%\" or\n * \"Step 2 of 4\". Determinate only: it is rendered AND exposed as `aria-valuetext` so a\n * bare number is never misleading. Ignored when {@link indeterminate}, where no honest\n * number exists.\n */\n valueText?: React.ReactNode;\n /**\n * One line clarifying what is running (spec §2), written as a statement ending with a\n * period. Wired to the bar via `aria-describedby`.\n */\n description?: React.ReactNode;\n /**\n * The task failed before completing (spec §4/§7). Renders the message beside the bar in\n * the critical status color, sets `aria-invalid=\"true\"` and the critical track edge,\n * escalates the live region to assertive, and points `aria-describedby` at the error.\n * State what failed and the next step, without blaming the reader.\n */\n error?: React.ReactNode;\n}\n\n/**\n * Progress reports how far a task has advanced so the reader knows the system is working\n * and roughly how long is left (spec §1). Use the `value` (determinate) form when the work\n * is measurable — a known percentage or a step count — and the `indeterminate` form only\n * when the size of the work is genuinely unknown; an honest number beats a guessed one.\n *\n * It is a status OUTPUT, not a control: it takes no focus, binds no keys, and is never a\n * tab stop (spec §4/§6). The bar carries `role=\"progressbar\"` with `aria-valuenow`/`min`/\n * `max` (and `aria-valuetext` for a plain readout); value changes are announced through a\n * polite live region so a screen-reader user hears progress without focus moving (spec\n * §7). A blocking error escalates the live region to assertive and sets `aria-invalid`.\n *\n * The fill is a plain control affordance: neutrals carry the track and the fill takes the\n * primary ACTION accent, never the verified status color, and never animates on the\n * verified check's only theatre duration — a finished bar means the task ran, not that\n * anything is verified (brand != state, spec §3/§5/§8).\n */\nexport const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(\n function Progress(\n {\n className,\n label,\n \"aria-label\": ariaLabel,\n value,\n min = 0,\n max = 100,\n indeterminate = false,\n valueText,\n description,\n error,\n ...props\n },\n ref,\n ) {\n const labelId = React.useId();\n const valueTextId = React.useId();\n const descriptionId = React.useId();\n const errorId = React.useId();\n\n const hasVisibleLabel = label != null && label !== false;\n const invalid = error != null && error !== false;\n // value-text is a determinate readout only — an indeterminate bar claims no number\n const hasValueText = !indeterminate && valueText != null && valueText !== false;\n const hasDescription = description != null && description !== false;\n\n // the determinate fill length is the value as a percentage of the range, clamped to\n // 0..100% so an out-of-range value never overflows the track (spec §4). aria-valuenow\n // still reports the raw value the caller passed.\n const clampedPct =\n !indeterminate && value != null && max > min\n ? Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))\n : 0;\n\n // description + error are both read with the bar; space-join their ids, error last so\n // the failure is heard with the field (spec §2/§7). Collapse to undefined when neither.\n const describedBy =\n [hasDescription ? descriptionId : null, invalid ? errorId : null]\n .filter(Boolean)\n .join(\" \") || undefined;\n\n return (\n <div\n ref={ref}\n className={cn(progressRootClass, className)}\n {...props}\n >\n {(hasVisibleLabel || hasValueText) && (\n <div className={progressHeaderClass}>\n {hasVisibleLabel ? (\n <span id={labelId} className={progressLabelClass}>\n {label}\n </span>\n ) : null}\n {hasValueText ? (\n <span id={valueTextId} className={progressValueTextClass}>\n {valueText}\n </span>\n ) : null}\n </div>\n )}\n\n {/* role=\"progressbar\" lives on the track (spec §7). It is a polite live region so\n value changes are announced without focus moving; a blocking error escalates to\n assertive. Non-interactive: no tabIndex, no focus ring, no target-size floor\n (spec §4/§6). Named by the visible label (aria-labelledby) or, when none, an\n aria-label — never both, never nameless. */}\n <div\n data-testid=\"progress-track\"\n role=\"progressbar\"\n aria-live={invalid ? \"assertive\" : \"polite\"}\n aria-labelledby={hasVisibleLabel ? labelId : undefined}\n aria-label={hasVisibleLabel ? undefined : ariaLabel}\n aria-describedby={describedBy}\n aria-invalid={invalid || undefined}\n // indeterminate omits aria-valuenow so assistive tech announces an unknown\n // amount rather than a false number (spec §4/§7)\n aria-valuenow={indeterminate ? undefined : value}\n aria-valuemin={indeterminate ? undefined : min}\n aria-valuemax={indeterminate ? undefined : max}\n // a plain readout when a bare number would mislead (\"Step 2 of 4\")\n aria-valuetext={hasValueText && typeof valueText === \"string\" ? valueText : undefined}\n className={progressTrackClass}\n >\n {/* the fill / moving indicator. Determinate: the inline length is the value (a\n data percentage, not a design token), set as an inline width. Indeterminate: a\n narrow indicator (w-2/5) whose inset-inline-start is driven by the travel\n keyframe below, so it MOVES across the track; no length is set inline. */}\n {indeterminate ? (\n <style>{PROGRESS_INDETERMINATE_CSS}</style>\n ) : null}\n <span\n data-testid=\"progress-fill\"\n aria-hidden=\"true\"\n className={progressFillVariants({ indeterminate })}\n style={indeterminate ? undefined : { inlineSize: `${clampedPct}%` }}\n />\n </div>\n\n {hasDescription ? (\n <span id={descriptionId} className={progressDescriptionClass}>\n {description}\n </span>\n ) : null}\n\n {invalid ? (\n <span id={errorId} className={progressErrorClass}>\n {error}\n </span>\n ) : null}\n </div>\n );\n },\n);\n",
|
|
15
|
+
"path": "progress/progress.tsx",
|
|
16
|
+
"target": "@ui/progress/progress.tsx",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Progress reports how far a task has advanced — a status OUTPUT, not a control\n// (spec §1/§4/§6). It takes no focus, binds no keys, and renders no focus ring or\n// target-size floor: the value reaches assistive technology through role=\"progressbar\"\n// + aria-value* and a polite live region, never motion (spec §7).\n//\n// Brand != state (spec §3/§5/§8): the fill is a plain control affordance, NOT a\n// verification result. Neutrals carry the track and the fill takes the primary ACTION\n// accent (the visible action color on the surface) — NEVER --color-status-verified-*,\n// because verified green is the in-product verified status and is never a generic\n// progress affordance. There is no \"verified\" Progress; surfacing a verified outcome is\n// the job of VerifiedBadge, whose deliberate check animation is never borrowed here.\n\n// The track (spec §2/§5): the full-length rail that holds the fill, in the neutral\n// control surface with the control border, clipped so the fill's rounded end and the\n// traveling indeterminate indicator never bleed past the rail. Rounded on the full radius.\n// In the error state the rail edge takes the critical status border, driven by\n// aria-invalid on the bar so the failure is identified BY THE FIELD, in text (spec §4/§7).\nexport const progressTrackClass =\n \"relative block h-(--space-2) w-full overflow-hidden \" +\n \"rounded-(--radius-full) bg-control-bg border border-control-border \" +\n \"aria-invalid:border-status-critical-border\";\n\n// The fill / indeterminate indicator keyframe (spec §3/§4): a TRAVELLING indicator. A\n// narrow indicator (`w-2/5`, set in the variant below) slides along the track's INLINE\n// axis from before the inline-start edge to past the inline-end edge, then loops. Travel\n// is driven on the LOGICAL `inset-inline-start` property (not a physical `translateX`), so\n// the indicator mirrors automatically under `dir=\"rtl\"` (G-U6 global-first). This keyframe\n// is pure geometry — it binds NO design token (the duration and easing tokens are bound on\n// the variant className, the single §5 binding site), so it is emitted from `progress.tsx`\n// as a component-scoped <style>, not from this binding file.\nexport const PROGRESS_INDETERMINATE_KEYFRAME = \"progress-indeterminate-travel\";\n\n// The fill (spec §2/§4/§5): the portion of the track that is done, in the primary action\n// accent, rounded on the full radius.\n//\n// DETERMINATE: its inline length is the value (set as an inline width in tsx — a data\n// percentage, not a design token). The length CHANGES on the FAST duration with verdify\n// easing, collapsing to the instant endpoint under reduced motion (spec §4/§5).\n//\n// INDETERMINATE: no measured length exists, so a moving indicator TRAVELS the track on a\n// continuous AMBIENT loop with verdify easing — restrained activity, never the 350ms\n// verified-check theatre duration (spec §3/§4/§8). The indicator is positioned `absolute`\n// on the track and its `inset-inline-start` is animated by the `progress-indeterminate-travel`\n// keyframe (defined in progress.tsx). The loop length is driven onto the ambient token via\n// the arbitrary `animation-duration` PROPERTY, the easing onto the verdify token, and the\n// loop is set to repeat — all keyword arbitrary properties or paren-shorthand tokens, not\n// raw values, so the token-binding gate does not flag them. Under prefers-reduced-motion the\n// travel is suppressed (`motion-reduce:animate-none`) and the busy state is carried by the\n// live region + name, not motion alone (spec §4/§7).\nexport const progressFillVariants = cva(\"block h-full rounded-(--radius-full) bg-action-primary-bg\", {\n variants: {\n // STRUCTURAL axis = spec §3: the determinate fill is a measured length; the\n // indeterminate indicator is a looping travel. Neither recolors the fill (brand !=\n // state) — they differ only by motion and how the length is set.\n indeterminate: {\n // a measured length set inline; the length transition runs on the fast duration\n false:\n \"transition-[inline-size] duration-(--motion-duration-fast) ease-(--motion-easing-verdify) \" +\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // a narrow indicator that TRAVELS the inline axis on a continuous ambient loop;\n // static under reduced motion. Absolutely positioned so it slides past both edges\n // of the clipped track (overflow-hidden on the track hides the off-track portion).\n true:\n \"absolute w-2/5 [animation-name:progress-indeterminate-travel] \" +\n \"[animation-iteration-count:infinite] \" +\n \"[animation-duration:var(--motion-duration-ambient)] \" +\n \"ease-(--motion-easing-verdify) motion-reduce:animate-none\",\n },\n },\n defaultVariants: { indeterminate: false },\n});\n\n// The bar root (spec §2): a layout column stacking the label/value-text row, the track,\n// and the optional description/error message at the --space-2 gap. Non-interactive: no\n// focus ring, no tab stop, no target-size floor (spec §4/§6/§7).\nexport const progressRootClass = \"flex w-full flex-col gap-(--space-2)\";\n\n// The label + value-text header row (spec §2): the label sits inline-start, the optional\n// value-text inline-end, spread across the bar's width.\nexport const progressHeaderClass = \"flex items-baseline justify-between gap-(--space-2)\";\n\n// The label (spec §2/§5): the text naming the task, in the primary text color at the\n// caption type role.\nexport const progressLabelClass = \"text-caption text-text-primary\";\n\n// The optional value-text readout (spec §2/§5): a plain progress readout (\"40%\",\n// \"Step 2 of 4\") in the secondary text color at the caption role. Determinate only.\nexport const progressValueTextClass = \"text-caption text-text-secondary\";\n\n// The optional one-line description (spec §2/§5): a statement clarifying what is running,\n// in the secondary text color at the caption role.\nexport const progressDescriptionClass = \"text-caption text-text-secondary\";\n\n// The error message (spec §4/§5/§8): states what failed and the next step, naming the\n// failure without blaming the reader. The critical status FOREGROUND marks the text at the\n// caption role; the field edge (track) takes the critical border via aria-invalid. Error\n// is shown in TEXT, never by color alone.\nexport const progressErrorClass = \"text-caption text-status-critical-fg\";\n\nexport type ProgressFillVariantProps = VariantProps<typeof progressFillVariants>;\n",
|
|
21
|
+
"path": "progress/progress.variants.ts",
|
|
22
|
+
"target": "@ui/progress/progress.variants.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "progress",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn"
|
|
29
|
+
],
|
|
30
|
+
"title": "progress",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0"
|
|
5
|
+
],
|
|
6
|
+
"files": [
|
|
7
|
+
{
|
|
8
|
+
"content": "export { RadioGroup, Radio, type RadioGroupProps, type RadioProps } from \"./radio\";\nexport {\n radioControlVariants,\n radioDotVariants,\n radioTargetVariants,\n radioLabelVariants,\n radioDescriptionVariants,\n radioCardVariants,\n radioGroupVariants,\n type RadioGroupVariantProps,\n} from \"./radio.variants\";\n",
|
|
9
|
+
"path": "radio/index.ts",
|
|
10
|
+
"target": "@ui/radio/index.ts",
|
|
11
|
+
"type": "registry:ui"
|
|
12
|
+
},
|
|
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 \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\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
|
+
"path": "radio/radio.tsx",
|
|
16
|
+
"target": "@ui/radio/radio.tsx",
|
|
17
|
+
"type": "registry:ui"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// The native <input> is visually hidden but kept in the a11y tree; the styled\n// sibling `control` paints the ring and reacts via Tailwind peer-* state. The\n// control IS a direct sibling of the peer input, so peer-checked/peer-disabled\n// resolve here. (The dot is a CHILD of the control, so it cannot use peer-* —\n// it is driven by the `selected` cva variant instead; see radioDotVariants.)\nexport const radioControlVariants = cva([\n \"relative inline-flex shrink-0 items-center justify-center\",\n \"h-(--size-icon-sm) w-(--size-icon-sm) rounded-(--radius-full)\",\n \"bg-control-bg border border-control-border\",\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n // selected: outer ring switches to the foreground colour\n \"peer-checked:border-control-fg\",\n // disabled: control border dims with the label\n \"peer-disabled:border-text-disabled\",\n]);\n\n// The filled inner dot — scales from 0 → 1 on select (functional motion, not\n// theatre). The dot is nested inside the control span, NOT a sibling of the peer\n// input, so peer-checked would never match; visibility is driven by the explicit\n// `selected` variant, which the option passes from the group's checked state.\n//\n// DEC-C / spec §5: a disabled control (the dot is the control's filled indicator)\n// renders in --color-text-disabled — the SAME token the label dims to. The dot is\n// not a sibling of the peer input, so the colour is driven by an explicit\n// `disabled` cva variant (mirroring radioLabelVariants); enabled keeps control-fg.\nexport const radioDotVariants = cva(\n [\n \"h-1.5 w-1.5 rounded-(--radius-full)\",\n \"transition-transform duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:transition-none\",\n ],\n {\n variants: {\n selected: { true: \"scale-100\", false: \"scale-0\" },\n disabled: { true: \"bg-text-disabled\", false: \"bg-control-fg\" },\n },\n defaultVariants: { selected: false, disabled: false },\n },\n);\n\n// The hit-target wrapper around control + label — meets the target-size floor.\nexport const radioTargetVariants = cva([\n \"flex items-start gap-2\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n]);\n\n// The option-label / description type roles. The label-text span is nested in a\n// wrapper (not a sibling of the peer input), so peer-disabled cannot reach it;\n// the disabled colour is driven by the explicit `disabled` variant the option\n// passes from its own prop (mirrors checkboxLabelVariants).\nexport const radioLabelVariants = cva([\"text-label text-text-primary select-none\"], {\n variants: {\n disabled: { true: \"text-text-disabled\", false: \"\" },\n },\n defaultVariants: { disabled: false },\n});\nexport const radioDescriptionVariants = cva([\"text-caption text-text-secondary\"]);\n\n// Card variant — bordered selectable surface; strengthens border on hover.\nexport const radioCardVariants = cva([\n \"rounded-md border border-border-default p-3\",\n \"hover:border-border-strong\",\n]);\n\n// The stacked-options group spacing.\nexport const radioGroupVariants = cva([\"flex flex-col gap-3\"], {\n variants: {\n variant: {\n default: \"\",\n \"with-description\": \"\",\n card: \"\",\n },\n },\n defaultVariants: { variant: \"default\" },\n});\n\nexport type RadioGroupVariantProps = VariantProps<typeof radioGroupVariants>;\n",
|
|
21
|
+
"path": "radio/radio.variants.ts",
|
|
22
|
+
"target": "@ui/radio/radio.variants.ts",
|
|
23
|
+
"type": "registry:ui"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"name": "radio",
|
|
27
|
+
"registryDependencies": [
|
|
28
|
+
"@verdify/cn"
|
|
29
|
+
],
|
|
30
|
+
"title": "radio",
|
|
31
|
+
"type": "registry:ui"
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "export { Select, type SelectProps, type SelectOption } from \"./select\";\nexport {\n triggerVariants,\n type TriggerVariantProps,\n} from \"./select.variants\";\n",
|
|
10
|
+
"path": "select/index.ts",
|
|
11
|
+
"target": "@ui/select/index.ts",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Select as SelectPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n triggerVariants,\n placeholderClass,\n listboxClass,\n optionVariants,\n checkClass,\n groupLabelClass,\n separatorClass,\n errorTextClass,\n descriptionClass,\n labelClass,\n type TriggerVariantProps,\n} from \"./select.variants\";\n\nexport interface SelectOption {\n value: string;\n label: string;\n /** Optional group heading; consecutive options sharing a group are grouped. */\n group?: string;\n disabled?: boolean;\n}\n\nexport interface SelectProps extends TriggerVariantProps {\n /** Visible, associated label — required; never replaced by the placeholder. */\n label: string;\n options: SelectOption[];\n placeholder?: string;\n /** Non-error helper text, wired via aria-describedby. */\n description?: string;\n /** Error text; sets aria-invalid + the strong border and wires aria-describedby. */\n error?: string;\n disabled?: boolean;\n /** Options are resolving: sets aria-busy without blocking focus. */\n loading?: boolean;\n value?: string;\n defaultValue?: string;\n onValueChange?: (value: string) => void;\n className?: string;\n}\n\n// Chevron + check as inline SVG (no extra icon dep); --size-icon-md sizing.\nfunction ChevronIcon() {\n return (\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n className=\"h-(--size-icon-md) w-(--size-icon-md)\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n >\n <path d=\"M4 6l4 4 4-4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nfunction CheckMark() {\n return (\n <svg\n data-testid=\"select-check\"\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n className={cn(\"h-(--size-icon-md) w-(--size-icon-md)\", checkClass)}\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n >\n <path d=\"M3.5 8.5l3 3 6-6.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\n/** Group consecutive options by their `group` key, preserving order. */\nfunction groupOptions(options: SelectOption[]): { group?: string; items: SelectOption[] }[] {\n const out: { group?: string; items: SelectOption[] }[] = [];\n for (const opt of options) {\n const last = out[out.length - 1];\n if (last && last.group === opt.group) last.items.push(opt);\n else out.push({ group: opt.group, items: [opt] });\n }\n return out;\n}\n\nexport function Select({\n label,\n options,\n placeholder,\n description,\n error,\n disabled = false,\n loading = false,\n value,\n defaultValue,\n onValueChange,\n size,\n width,\n className,\n}: SelectProps) {\n const reactId = React.useId();\n const labelId = `${reactId}-label`;\n const descId = `${reactId}-desc`;\n const errId = `${reactId}-err`;\n // aria-describedby: helper first, then error (both when present).\n const describedBy =\n [description ? descId : null, error ? errId : null].filter(Boolean).join(\" \") || undefined;\n const groups = groupOptions(options);\n\n return (\n <div className={cn(\"inline-flex flex-col\", width === \"full\" && \"w-full\", className)}>\n <label id={labelId} className={labelClass}>\n {label}\n </label>\n <SelectPrimitive.Root\n value={value}\n defaultValue={defaultValue}\n onValueChange={onValueChange}\n disabled={disabled}\n >\n <SelectPrimitive.Trigger\n // accessible name comes from the visible label, never the placeholder\n aria-labelledby={labelId}\n // Radix's trigger emits role=combobox + aria-controls + aria-expanded but\n // NOT aria-haspopup; set it explicitly so the §7 listbox contract holds.\n aria-haspopup=\"listbox\"\n // FOCUS-MODEL DEVIATION from select.md §7 (recipe-level, accepted):\n // §7 mandates DOM focus stay on the trigger with the active option conveyed\n // ONLY via aria-activedescendant. radix-ui's Select (react-select 2.2.6)\n // implements the *focus-moving* variant of the same APG select-only\n // listbox pattern instead — it moves real DOM focus into the option items\n // (focusSelectedItem/focusFirst) and emits NO aria-activedescendant at all.\n // Both are documented APG variants; the keyboard model (incl. type-ahead),\n // visible focus, name/role/value, and target-size floor all hold, and axe\n // is clean closed and open. The literal §7 focus mechanism is therefore the\n // one part of the contract this primitive does not implement; honoring it\n // verbatim would require hand-rolling the listbox and abandoning Radix.\n // select.md §7 wording to be amended to permit the focus-moving variant.\n aria-invalid={error ? true : undefined}\n aria-describedby={describedBy}\n aria-busy={loading || undefined}\n className={cn(triggerVariants({ size, width }))}\n >\n <SelectPrimitive.Value placeholder={placeholder} className={placeholderClass} />\n <SelectPrimitive.Icon>\n <ChevronIcon />\n </SelectPrimitive.Icon>\n </SelectPrimitive.Trigger>\n\n <SelectPrimitive.Portal>\n <SelectPrimitive.Content\n role=\"listbox\"\n position=\"popper\"\n sideOffset={4}\n className={listboxClass}\n >\n <SelectPrimitive.Viewport>\n {groups.map((g, gi) => {\n const groupLabelId = `${reactId}-group-${gi}`;\n const body = g.items.map((opt) => (\n <SelectPrimitive.Item\n key={opt.value}\n value={opt.value}\n disabled={opt.disabled}\n className={cn(optionVariants({ size }))}\n >\n <SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>\n <SelectPrimitive.ItemIndicator>\n <CheckMark />\n </SelectPrimitive.ItemIndicator>\n </SelectPrimitive.Item>\n ));\n if (!g.group) return <React.Fragment key={gi}>{body}</React.Fragment>;\n return (\n <React.Fragment key={gi}>\n {gi > 0 ? <SelectPrimitive.Separator className={separatorClass} /> : null}\n <SelectPrimitive.Group aria-labelledby={groupLabelId}>\n <SelectPrimitive.Label id={groupLabelId} className={groupLabelClass}>\n {g.group}\n </SelectPrimitive.Label>\n {body}\n </SelectPrimitive.Group>\n </React.Fragment>\n );\n })}\n </SelectPrimitive.Viewport>\n </SelectPrimitive.Content>\n </SelectPrimitive.Portal>\n </SelectPrimitive.Root>\n\n {description ? (\n <span id={descId} className={descriptionClass}>\n {description}\n </span>\n ) : null}\n {error ? (\n <span id={errId} className={errorTextClass}>\n {error}\n </span>\n ) : null}\n </div>\n );\n}\n",
|
|
16
|
+
"path": "select/select.tsx",
|
|
17
|
+
"target": "@ui/select/select.tsx",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Trigger: control-* tier surface, secondary hover fill, focus ring that persists\n// while open, strong border in error, target-size floor, base+verdify motion.\nexport const triggerVariants = cva(\n [\n \"inline-flex items-center justify-between gap-2 rounded-(--radius-md) px-3\",\n \"bg-control-bg text-control-fg border border-control-border\",\n \"hover:bg-action-secondary-bg-hover cursor-pointer select-none\",\n // DEC-A — the trigger is a form field, so its value SIZE is text-base (16px): the\n // iOS no-zoom reset is a hard floor that holds on EVERY size. The brand type ROLE\n // (line-height + letter-spacing) rides along via the role-suffix vars set per size\n // below. text-body itself (a 15px font-size) is NEVER bound on the trigger — under\n // the role-aware cn it collapses against text-base, and 15px reintroduces the iOS\n // focus zoom the reset exists to prevent. So the font-size stays text-base across\n // sizes; only the leading/tracking role and the padding density shift.\n \"text-base\",\n // open/close transition — base duration + verdify easing, never deliberate theatre\n \"transition-colors duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n // target-size floor: 44px touch / 40px pointer\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // visible 2px signal-blue ring at 2px offset; persists while the listbox is open\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n \"data-[state=open]:ring-2 data-[state=open]:ring-border-focus data-[state=open]:ring-offset-2\",\n // error: strong border treatment, driven by aria-invalid\n \"aria-invalid:border-border-strong\",\n // disabled: out of tab order (Radix sets data-disabled), reduced emphasis\n \"data-[disabled]:pointer-events-none data-[disabled]:cursor-default\",\n \"disabled:text-text-disabled data-[disabled]:text-text-disabled\",\n ],\n {\n variants: {\n // DEC-B: @verdify/tokens exposes only target-size FLOORS (44px / 40px), no\n // height scale. Every size anchors the shared floor (min-h-, in the base) and\n // NEVER sets a fixed height below it (a11y). Each size is TYPE-ROLE + vertical\n // padding (density) ABOVE the floor; the resting height EMERGES from the padding\n // and grows monotonically sm <= md <= lg. Because the trigger is a form field\n // (DEC-A), the value font-SIZE is pinned to text-base in the base — so here the\n // type role shifts only through the brand line-height + letter-spacing role\n // suffix (NOT the font-size), tightening at sm (caption metrics) and loosening at\n // lg (body-lg metrics). The padding ladder is --space-1 (.25rem) <= --space-2\n // (.5rem) <= --space-3 (.75rem), all >= the floor. (An earlier build set fixed\n // h-(--size-target-*), which made lg SHORTER than sm/md on desktop — the inverse\n // of the requirement — and is removed.)\n size: {\n sm: \"leading-(--text-caption--line-height) tracking-(--text-caption--letter-spacing) py-(--space-1)\",\n md: \"leading-(--text-body--line-height) tracking-(--text-body--letter-spacing) py-(--space-2)\",\n lg: \"leading-(--text-body-lg--line-height) tracking-(--text-body-lg--letter-spacing) py-(--space-3)\",\n },\n width: {\n auto: \"w-auto\",\n full: \"w-full\",\n },\n },\n defaultVariants: { size: \"md\", width: \"auto\" },\n },\n);\n\n// Placeholder text: de-emphasised, never the accessible name.\nexport const placeholderClass = \"text-control-placeholder\";\n\n// Listbox: raised surface, outer border, md elevation; opens with base motion only.\nexport const listboxClass = [\n \"z-50 overflow-hidden rounded-(--radius-md) p-1\",\n \"bg-surface-raised border border-surface-border shadow-(--shadow-md)\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:transition-none\",\n].join(\" \");\n\n// Option row: primary label, secondary hover/active fill, target-row floor.\n// DEC-B: the option row is parameterized by the SAME size the trigger is, so the\n// listbox density tracks the trigger. It anchors the shared target-row floor\n// (min-h-, in the base) and NEVER sets a fixed height below it; each size is\n// TYPE-ROLE + vertical padding (density) ABOVE the floor, the row height emerging\n// from the padding. Unlike the trigger, an option row is NOT a focused text field —\n// the iOS no-zoom reset does not apply — so here the type role shifts through the\n// actual font-SIZE role: caption (sm) < body (md) < body-lg (lg), paired with the\n// identical --space-1 <= --space-2 <= --space-3 padding ladder as the trigger. Both\n// type role and density therefore climb monotonically sm <= md <= lg, all >= floor.\nexport const optionVariants = cva(\n [\n \"relative flex items-center gap-2 rounded-(--radius-md) pe-8 ps-3\",\n \"text-text-primary outline-none cursor-pointer select-none\",\n \"min-h-(--size-target-mobile) sm:min-h-(--size-target-desktop)\",\n // active (highlighted) option uses the secondary hover fill\n \"data-[highlighted]:bg-action-secondary-bg-hover data-[highlighted]:outline-none\",\n // disabled option: reduced emphasis, not operable\n \"data-[disabled]:text-text-disabled data-[disabled]:pointer-events-none\",\n ],\n {\n variants: {\n size: {\n sm: \"text-caption py-(--space-1)\",\n md: \"text-body py-(--space-2)\",\n lg: \"text-body-lg py-(--space-3)\",\n },\n },\n defaultVariants: { size: \"md\" },\n },\n);\n\n// The selected-option check — a NEUTRAL mark (text-text-primary), never a status color.\nexport const checkClass = \"absolute end-2 inline-flex text-text-primary\";\n\n// Group heading: non-selectable, muted.\nexport const groupLabelClass = \"px-3 py-1 text-label text-text-muted select-none\";\n\n// Listbox/option dividers.\nexport const separatorClass = \"my-1 h-px bg-border-default\";\n\n// Error-slot text — status critical foreground.\nexport const errorTextClass = \"mt-1 text-label text-status-critical-fg\";\n// Non-error helper text — muted.\nexport const descriptionClass = \"mt-1 text-label text-text-muted\";\n// The visible, associated label.\nexport const labelClass = \"text-label text-text-primary\";\n\nexport type TriggerVariantProps = VariantProps<typeof triggerVariants>;\n",
|
|
22
|
+
"path": "select/select.variants.ts",
|
|
23
|
+
"target": "@ui/select/select.variants.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "select",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "select",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "export { Separator, type SeparatorProps } from \"./separator\";\nexport {\n separatorVariants,\n labeledSeparatorVariants,\n ruleSegmentVariants,\n separatorLabelClass,\n type SeparatorVariantProps,\n} from \"./separator.variants\";\n",
|
|
10
|
+
"path": "separator/index.ts",
|
|
11
|
+
"target": "@ui/separator/index.ts",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "import * as React from \"react\";\nimport { Separator as SeparatorPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n separatorVariants,\n labeledSeparatorVariants,\n ruleSegmentVariants,\n separatorLabelClass,\n type SeparatorVariantProps,\n} from \"./separator.variants\";\n\nexport interface SeparatorProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"role\">,\n SeparatorVariantProps {\n /**\n * `horizontal` (default) splits content stacked top to bottom; `vertical`\n * splits content laid out inline and spans the block size of its row.\n */\n orientation?: \"horizontal\" | \"vertical\";\n /**\n * Decorative: the split is already obvious from layout and the line only\n * reinforces it. Removed from the accessibility tree (role=\"none\"). Ignored\n * when a `label` is present — a labeled Separator is always semantic (spec §3).\n */\n decorative?: boolean;\n /**\n * A short centered word or phrase (for example \"or\") breaking the rule. When\n * set, the rule renders as two segments with the label between them and the\n * Separator is always semantic, exposing the label as its accessible name.\n */\n label?: string;\n}\n\nexport const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(\n function Separator(\n { className, orientation = \"horizontal\", decorative = false, label, ...props },\n ref,\n ) {\n // A labeled Separator is always semantic (never decorative): a role=\"separator\"\n // container with two aria-hidden rule segments flanking the centered label, which\n // is the element's accessible name. Radix Separator.Root renders a single element,\n // so the two-segment-plus-label anatomy (spec §2) is composed here.\n if (label != null && label !== \"\") {\n return (\n <div\n ref={ref}\n role=\"separator\"\n aria-label={label}\n aria-orientation={orientation === \"vertical\" ? \"vertical\" : undefined}\n data-orientation={orientation}\n className={cn(labeledSeparatorVariants({ orientation }), className)}\n {...props}\n >\n <div\n data-testid=\"separator-rule\"\n aria-hidden=\"true\"\n className={cn(ruleSegmentVariants({ orientation }))}\n />\n <span className={separatorLabelClass}>{label}</span>\n <div\n data-testid=\"separator-rule\"\n aria-hidden=\"true\"\n className={cn(ruleSegmentVariants({ orientation }))}\n />\n </div>\n );\n }\n\n // The bare rule. Radix Separator.Root gives the role mapping for free:\n // role=\"separator\" when semantic (with aria-orientation only on the vertical one,\n // since horizontal is the implicit default), or role=\"none\" when decorative —\n // removing it from the accessibility tree. It is render-only (no hook / no state),\n // so this component needs no 'use client' directive.\n return (\n <SeparatorPrimitive.Root\n ref={ref}\n orientation={orientation}\n decorative={decorative}\n className={cn(separatorVariants({ orientation }), className)}\n {...props}\n />\n );\n },\n);\n",
|
|
16
|
+
"path": "separator/separator.tsx",
|
|
17
|
+
"target": "@ui/separator/separator.tsx",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// The rule stroke: a 1px line painted from the border-intent alias (NOT a neutral\n// primitive), so the divider re-points with the theme. A Separator has no\n// interactive state (spec §4) — no focus ring, no target-size floor, no state axis;\n// the ONLY axis is the structural orientation (spec §3). Block spacing (horizontal)\n// or inline spacing (vertical) is reserved around the rule from --space-4.\n//\n// Sizing uses the standard 1px utilities (h-px / w-px) — a stroke, not an arbitrary\n// raw value — and the full cross-axis extent (w-full / self-stretch).\nexport const separatorVariants = cva(\n [\n // a flat, neutral structural line — the stroke comes from the border-default alias\n \"shrink-0 bg-border-default\",\n ],\n {\n variants: {\n orientation: {\n // splits content stacked top-to-bottom: a full-width 1px rule with block spacing\n horizontal: \"h-px w-full my-(--space-4)\",\n // splits content laid out inline: a 1px rule spanning the row's block size,\n // with inline spacing\n vertical: \"w-px self-stretch mx-(--space-4)\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n },\n);\n\n// The labeled container: a semantic separator whose rule renders as two segments with\n// the centered label between them. The container reserves the same block/inline\n// spacing as the bare rule; the gap between each segment and the label is --space-2.\nexport const labeledSeparatorVariants = cva([\"flex items-center\"], {\n variants: {\n orientation: {\n horizontal: \"w-full gap-(--space-2) my-(--space-4)\",\n // a vertical labeled separator stacks its two segments around the label\n vertical: \"flex-col self-stretch gap-(--space-2) mx-(--space-4)\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n});\n\n// One flanking rule segment inside a labeled separator: a decorative 1px line that\n// grows to fill the space on its side of the label. Same border-default stroke.\nexport const ruleSegmentVariants = cva([\"shrink-0 grow bg-border-default\"], {\n variants: {\n orientation: {\n horizontal: \"h-px\",\n vertical: \"w-px\",\n },\n },\n defaultVariants: { orientation: \"horizontal\" },\n});\n\n// The centered label text: the caption type role + secondary text color. Never the\n// brand violet, never a status hue — a divider carries no verification meaning.\nexport const separatorLabelClass = \"shrink-0 text-caption text-text-secondary\";\n\nexport type SeparatorVariantProps = VariantProps<typeof separatorVariants>;\n",
|
|
22
|
+
"path": "separator/separator.variants.ts",
|
|
23
|
+
"target": "@ui/separator/separator.variants.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "separator",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "separator",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"dependencies": [
|
|
4
|
+
"class-variance-authority@^0.7.0",
|
|
5
|
+
"radix-ui@^1.1.0"
|
|
6
|
+
],
|
|
7
|
+
"files": [
|
|
8
|
+
{
|
|
9
|
+
"content": "export {\n Sheet,\n SheetTrigger,\n SheetContent,\n SheetHeader,\n SheetTitle,\n SheetDescription,\n SheetBody,\n SheetFooter,\n SheetClose,\n type SheetProps,\n type SheetTriggerProps,\n type SheetContentProps,\n type SheetHeaderProps,\n type SheetTitleProps,\n type SheetDescriptionProps,\n type SheetBodyProps,\n type SheetFooterProps,\n type SheetCloseProps,\n} from \"./sheet\";\nexport {\n sheetPanelVariants,\n sheetScrimVariants,\n sheetCloseVariants,\n type SheetPanelVariantProps,\n} from \"./sheet.variants\";\n",
|
|
10
|
+
"path": "sheet/index.ts",
|
|
11
|
+
"target": "@ui/sheet/index.ts",
|
|
12
|
+
"type": "registry:ui"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Dialog as DialogPrimitive } from \"radix-ui\";\nimport { cn } from \"@/lib/cn\";\nimport {\n sheetScrimVariants,\n sheetPanelVariants,\n sheetHeaderClass,\n sheetTitleClass,\n sheetDescriptionClass,\n sheetBodyClass,\n sheetFooterClass,\n sheetCloseVariants,\n sheetCloseGlyphClass,\n type SheetPanelVariantProps,\n} from \"./sheet.variants\";\n\ntype SheetSide = NonNullable<SheetPanelVariantProps[\"side\"]>;\ntype SheetSize = NonNullable<SheetPanelVariantProps[\"size\"]>;\n\n// The presentation axes (spec §3) are set ONCE on the root and travel to the content via context,\n// so callers don't repeat `side`/`size` on `SheetContent` (mirrors the Dialog/Tabs/Accordion\n// precedent). `side` decides the docking edge + slide axis; `size` sets the panel's cross-axis\n// extent. Unlike Dialog, NEITHER axis affects the scrim-dismiss policy — a sheet is NEVER closable\n// by the scrim alone (spec §2 / §8); only Escape or the close control dismisses it.\ntype SheetContextValue = {\n side: SheetSide;\n size: SheetSize;\n};\nconst SheetContext = React.createContext<SheetContextValue>({\n side: \"inline-end\",\n size: \"md\",\n});\n\nexport interface SheetProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Root> {\n /**\n * Docking edge + slide axis (spec §3): `inline-end` (default — slides from the inline-end edge,\n * the right in LTR, for most secondary tasks), `inline-start` (slides from the inline-start edge,\n * for navigation or start-of-reading-order context), or `block-end` (slides up from the block-end\n * edge as a bottom sheet, for narrow and touch viewports). Logical, so it mirrors under dir=rtl.\n */\n side?: SheetSide;\n /**\n * Cross-axis extent (spec §3): `sm` / `md` (default) / `lg` — a width for an inline side, a\n * height for a block side. `lg` never grows to the full viewport; a task that fills the screen\n * should be a route, not a sheet.\n */\n size?: SheetSize;\n}\n\n/**\n * Sheet is a modal panel that slides in from a viewport edge to hold a focused, secondary task —\n * edit a profile, review a credential, configure a filter — without leaving the page underneath\n * (spec §1). Use it when the task is too large for a popover and you want the page to stay in\n * context behind a scrim; use Dialog for a small centered confirmation and a full route for a\n * primary task. It is a NEUTRAL overlay surface: the panel and scrim are neutral, brand violet\n * appears only on a footer primary action through Button, and Verified Green never appears here as\n * decoration (spec §3 / §5 / §8, brand != state). Wraps the Radix Dialog primitive (WAI-ARIA APG\n * modal-dialog pattern) — a stateful primitive, so this file is `'use client'`.\n */\nexport function Sheet({ side = \"inline-end\", size = \"md\", children, ...rootProps }: SheetProps) {\n return (\n <SheetContext.Provider value={{ side, size }}>\n <DialogPrimitive.Root {...rootProps}>{children}</DialogPrimitive.Root>\n </SheetContext.Provider>\n );\n}\n\nexport interface SheetTriggerProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger> {}\n\n/**\n * The control that opens the sheet (spec §7: focus returns here on close). Pass `asChild` to wrap\n * your own Button so the trigger inherits its role, keyboard, and focus ring rather than nesting a\n * second button.\n */\nexport const SheetTrigger = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Trigger>,\n SheetTriggerProps\n>(function SheetTrigger(props, ref) {\n return <DialogPrimitive.Trigger ref={ref} {...props} />;\n});\n\nexport interface SheetContentProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}\n\n/**\n * Renders the portal, the scrim, and the edge-anchored panel (spec §2 scrim + panel). The panel is\n * `role=\"dialog\"` with `aria-modal=\"true\"` (Radix), takes the focus trap, is named by its\n * `SheetTitle` via `aria-labelledby` and described by its `SheetDescription` via `aria-describedby`\n * (Radix wires both). On open, focus moves into the panel — to the first meaningful control, or the\n * panel itself when none exists — and returns to the trigger on close (spec §7). Content behind the\n * open sheet is inert. A sheet is NEVER dismissed by a scrim (outside) click (spec §2 / §8 Don't):\n * Escape and the close control are the only dismissals, so the scrim is a visible inert edge, not a\n * dismiss affordance.\n */\nexport const SheetContent = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Content>,\n SheetContentProps\n>(function SheetContent({ className, children, ...props }, ref) {\n const { side, size } = React.useContext(SheetContext);\n\n // Block scrim-click dismissal (and the focus-out auto-dismiss that would follow a click landing\n // outside) on EVERY sheet — unlike a standard Dialog, the scrim is never a dismiss control (spec\n // §2 / §8). preventDefault on the outside-pointer/interaction keeps the panel open; Escape and\n // the in-panel close control remain the dismissals.\n const guardOutside = (event: Event) => event.preventDefault();\n\n return (\n <DialogPrimitive.Portal>\n <DialogPrimitive.Overlay\n data-testid=\"sheet-scrim\"\n className={sheetScrimVariants()}\n />\n <DialogPrimitive.Content\n ref={ref}\n // The spec §7 ARIA contract names aria-modal=\"true\" explicitly. This Radix version makes the\n // rest of the page inert (pointer-events:none on body + aria-hidden on siblings) but does\n // not emit aria-modal, so we set it to honor the frozen contract literally; the panel IS\n // modal (the focus trap + inert siblings back the claim).\n aria-modal=\"true\"\n className={cn(sheetPanelVariants({ side, size }), className)}\n onPointerDownOutside={guardOutside}\n onInteractOutside={guardOutside}\n {...props}\n >\n {children}\n </DialogPrimitive.Content>\n </DialogPrimitive.Portal>\n );\n});\n\nexport interface SheetHeaderProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/** The top region: the title and the close button on the inline-end (spec §2 header). */\nexport const SheetHeader = React.forwardRef<HTMLDivElement, SheetHeaderProps>(\n function SheetHeader({ className, ...props }, ref) {\n return <div ref={ref} className={cn(sheetHeaderClass, className)} {...props} />;\n },\n);\n\nexport interface SheetTitleProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> {}\n\n/**\n * Names the task in one short statement, sentence case, no exclamation mark — it IS the sheet's\n * accessible name, wired to the panel via `aria-labelledby` by Radix (spec §2 title, §7). Rendered\n * as an `<h2>` by default at the sheet's title type role.\n */\nexport const SheetTitle = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Title>,\n SheetTitleProps\n>(function SheetTitle({ className, ...props }, ref) {\n return <DialogPrimitive.Title ref={ref} className={cn(sheetTitleClass, className)} {...props} />;\n});\n\nexport interface SheetDescriptionProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> {}\n\n/**\n * Optional supporting text directly under the title, associated with the panel for screen readers\n * via `aria-describedby` (Radix) (spec §2, §7).\n */\nexport const SheetDescription = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Description>,\n SheetDescriptionProps\n>(function SheetDescription({ className, ...props }, ref) {\n return (\n <DialogPrimitive.Description\n ref={ref}\n className={cn(sheetDescriptionClass, className)}\n {...props}\n />\n );\n});\n\nexport interface SheetBodyProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/**\n * The scrollable content region between header and footer (spec §2 body, §4 scrolled). The panel is\n * a fixed-height flex column; the body takes the remaining space and is the ONLY part that scrolls\n * when content overflows — the header and footer stay pinned.\n */\nexport const SheetBody = React.forwardRef<HTMLDivElement, SheetBodyProps>(\n function SheetBody({ className, ...props }, ref) {\n return <div ref={ref} className={cn(sheetBodyClass, className)} {...props} />;\n },\n);\n\nexport interface SheetFooterProps extends React.HTMLAttributes<HTMLDivElement> {}\n\n/**\n * The optional action region (spec §2 footer). The primary action sits at the inline-end with a\n * Cancel beside it; the actions are Buttons — the footer consumes the `--color-action-*` aliases\n * THROUGH Button, which the sheet spec does not restate (spec §5 note). A Cancel never replaces the\n * close control, which stays present in the header.\n */\nexport const SheetFooter = React.forwardRef<HTMLDivElement, SheetFooterProps>(\n function SheetFooter({ className, ...props }, ref) {\n return <div ref={ref} className={cn(sheetFooterClass, className)} {...props} />;\n },\n);\n\n// A neutral X glyph, --size-icon-md, drawn with currentColor so it inherits the close button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nfunction CloseGlyph() {\n return (\n <svg\n data-testid=\"sheet-close-glyph\"\n aria-hidden=\"true\"\n viewBox=\"0 0 16 16\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"1.5\"\n className={sheetCloseGlyphClass}\n >\n <path d=\"M4 4l8 8M12 4l-8 8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n </svg>\n );\n}\n\nexport interface SheetCloseProps\n extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close> {}\n\n/**\n * The dismiss control (spec §2 close): always present and reachable — a sheet is never closable by\n * the scrim alone (spec §8). Closing returns focus to the trigger (Radix). Two forms, both proven\n * by the tests:\n * - default (no children): the styled neutral-ghost icon-button for the header — pass an\n * `aria-label` (e.g. \"Close\"), since the glyph is decorative and the placeholder is never a name.\n * - `asChild`: wrap a footer Button (e.g. \"Cancel\") so the cancel action also dismisses without\n * nesting a second button. A Cancel never replaces the header close control.\n */\nexport const SheetClose = React.forwardRef<\n React.ElementRef<typeof DialogPrimitive.Close>,\n SheetCloseProps\n>(function SheetClose({ className, children, asChild, ...props }, ref) {\n if (asChild) {\n return (\n <DialogPrimitive.Close ref={ref} asChild className={className} {...props}>\n {children}\n </DialogPrimitive.Close>\n );\n }\n return (\n <DialogPrimitive.Close\n ref={ref}\n className={cn(sheetCloseVariants(), className)}\n {...props}\n >\n {children ?? <CloseGlyph />}\n </DialogPrimitive.Close>\n );\n});\n",
|
|
16
|
+
"path": "sheet/sheet.tsx",
|
|
17
|
+
"target": "@ui/sheet/sheet.tsx",
|
|
18
|
+
"type": "registry:ui"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"content": "import { cva, type VariantProps } from \"class-variance-authority\";\n\n// Sheet is a NEUTRAL overlay surface (spec §1/§3/§5/§8): brand violet and Verified Green are\n// accents that enter only through the components placed INSIDE the panel (a primary Button, a\n// VerifiedBadge), never on the PANEL or the SCRIM themselves. Restraint over volume — neutrals\n// carry the surface. So NOTHING in this file binds an --color-action-primary-* or --color-status-*\n// fill (brand != state, G-U2). This is the ONLY token-binding site (skill §5 hard rule).\n\n// The scrim: the dimming layer between the page and the panel; it signals the page behind is inert\n// and gives the focus trap a visible edge (spec §2 scrim, §5 --color-scrim-*). A neutral dim on the\n// modal z-layer, decorative (no role). The fade is a PLAIN base transition + verdify easing,\n// instant under reduced motion — never the 350ms VerifiedBadge-only theatre (G-U3 motion-theatre\n// gate). Enter/exit ride Radix's data-state on the overlay (attribute-selector variants, not\n// arbitrary values). On a light surface the dark scrim token applies (spec §5: scrim-dark).\nexport const sheetScrimVariants = cva([\n \"fixed inset-0 z-(--z-index-modal) bg-scrim-dark\",\n \"transition-opacity duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n]);\n\n// The panel: the raised container anchored to ONE viewport edge; it takes role=dialog + the focus\n// trap (Radix). A NEUTRAL raised surface (--color-surface-raised) with the outer surface border,\n// the lg corner radius on its LEADING corners, the lg elevation shadow above the scrim, fixed on\n// the modal z-layer. It never fills the viewport (spec §3: lg never grows to full screen — a sheet\n// that fills the screen should be a route) and scrolls its BODY when content overflows (spec §4\n// scrolled) — the panel is a flex column; the SheetBody owns the scroll. Panel padding/gaps come\n// from --space-*.\n//\n// side (spec §3) = the docking edge AND the slide axis:\n// - inline-end (default) / inline-start: pinned to the full BLOCK extent (inset-y-0), sized by\n// WIDTH, sliding along the inline axis (translate-x). LTR right / left; mirrors under dir=rtl\n// because start/end are logical (G-U6).\n// - block-end: pinned to the full INLINE extent (inset-x-0), sized by HEIGHT, sliding up from the\n// bottom (translate-y). Reads as a bottom sheet on narrow/touch viewports.\n//\n// size (spec §3) = the CROSS-AXIS extent only — a WIDTH for an inline side, a HEIGHT for a block\n// side. md is the default. Resolved per-side by the compoundVariants below (an inline side maps\n// size -> w-(--container-*); a block side maps size -> h-(--container-*)). Bound to the shared\n// --container-* scale, the same scale Dialog caps its width with.\n//\n// The slide+fade open/close is the BASE duration + verdify easing, instant under reduced motion,\n// and rides Radix's data-state (attribute-selector enter/exit, not arbitrary values). NEVER the\n// deliberate verified-check theatre (G-U3). The closed translate is a per-side keyword utility\n// (translate-x-full / -translate-x-full / translate-y-full), set in the side variant.\nexport const sheetPanelVariants = cva(\n [\n \"fixed z-(--z-index-modal)\",\n // a flex column: header + footer stay pinned, the body scrolls within (spec §4 scrolled)\n \"flex flex-col gap-(--space-4)\",\n // neutral raised surface + outer border + lg radius + lg elevation; panel inset padding\n \"bg-surface-raised border border-surface-border rounded-(--radius-lg) shadow-(--shadow-lg)\",\n \"p-(--space-6)\",\n // base slide+fade open/close + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-[opacity,transform] duration-(--motion-duration-base) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // enter/exit ride Radix data-state — the fade is shared across sides (the per-side slide lives\n // in the side variant); attribute-selector variants, not arbitrary values\n \"data-[state=open]:opacity-100 data-[state=closed]:opacity-0\",\n // the panel takes focus when there is no obvious first control; its ring is never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n ],\n {\n variants: {\n // side = the docking edge + slide axis (spec §3). Logical inset properties (G-U6): inset-y-0\n // / inset-x-0 pin the full extent along the docked edge; start-0 / end-0 / bottom-0 dock it.\n // The closed-state translate slides the panel fully off its own edge; open returns it to 0.\n side: {\n \"inline-end\": [\n \"inset-y-0 end-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full\",\n ],\n \"inline-start\": [\n \"inset-y-0 start-0 h-full\",\n \"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full\",\n ],\n \"block-end\": [\n \"inset-x-0 bottom-0 w-full\",\n \"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full\",\n ],\n },\n // size = cross-axis extent. The concrete axis (width vs height) is resolved per-side by the\n // compoundVariants below — this key only carries the default for type inference.\n size: {\n sm: \"\",\n md: \"\",\n lg: \"\",\n },\n },\n compoundVariants: [\n // inline sides: size -> WIDTH (the cross-axis of a side that pins the block extent)\n { side: [\"inline-end\", \"inline-start\"], size: \"sm\", class: \"w-(--container-sm)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"md\", class: \"w-(--container-md)\" },\n { side: [\"inline-end\", \"inline-start\"], size: \"lg\", class: \"w-(--container-lg)\" },\n // block side: size -> HEIGHT (the cross-axis of a side that pins the inline extent)\n { side: \"block-end\", size: \"sm\", class: \"h-(--container-sm)\" },\n { side: \"block-end\", size: \"md\", class: \"h-(--container-md)\" },\n { side: \"block-end\", size: \"lg\", class: \"h-(--container-lg)\" },\n ],\n defaultVariants: { side: \"inline-end\", size: \"md\" },\n },\n);\n\n// The header: the top region holding the title and the close button on the inline-end (spec §2\n// header). Logical-property layout (G-U6); a MUTED surface-border hairline divider under it (spec\n// §5 --color-surface-border-muted) — the panel's inner dividers are muted, distinct from its outer\n// surface-border edge.\nexport const sheetHeaderClass =\n \"flex items-start justify-between gap-(--space-4) border-b border-surface-border-muted pb-(--space-4)\";\n\n// The title: names the task as a statement, sentence case (spec §2). It IS the panel's accessible\n// name (Radix wires aria-labelledby). The h2 type role in primary text (spec §5 --text-h2 /\n// --color-text-primary).\nexport const sheetTitleClass = \"text-h2 text-text-primary\";\n\n// The description: optional supporting text under the title, associated with the panel for screen\n// readers (Radix wires aria-describedby). Body type role in secondary text (spec §5).\nexport const sheetDescriptionClass = \"text-body text-text-secondary\";\n\n// The body: the scrollable content region between header and footer (spec §2 body, §4 scrolled).\n// The panel is a fixed-height flex column; the body takes the remaining space and is the ONLY part\n// that scrolls — the header and footer stay pinned. Body text is the body type role in secondary\n// text (spec §5 --text-body / text-secondary).\nexport const sheetBodyClass =\n \"min-h-0 flex-1 overflow-y-auto text-body text-text-secondary\";\n\n// The footer: the optional action region (spec §2 footer). The primary action sits at the\n// inline-end with a Cancel beside it; the actions are Buttons — the sheet spec does not restate\n// their --color-action-* bindings (spec §5 note). A MUTED surface-border hairline divider above it\n// (spec §5). Logical-property layout (G-U6): actions flow inline-end with a gap.\nexport const sheetFooterClass =\n \"flex items-center justify-end gap-(--space-2) border-t border-surface-border-muted pt-(--space-4)\";\n\n// The close button: the dismiss control in the header, always present and reachable — a sheet is\n// never closable by the scrim alone (spec §2 close, §8). A NEUTRAL ghost surface — the glyph in\n// --color-action-ghost-fg at rest, the restrained ghost hover fill (spec §5 ghost-fg /\n// ghost-bg-hover), the md radius, the persistent focus ring, the target-size floor (44px touch /\n// 40px pointer, spec §7 2.5.8 / DEC-B) with the height EMERGING from the floor, never fixed below\n// it. fast functional hover motion + verdify easing, instant under reduced motion (G-U3).\nexport const sheetCloseVariants = cva([\n \"inline-flex items-center justify-center rounded-(--radius-md)\",\n // neutral ghost surface: glyph color at rest + restrained hover fill (no bg/border at rest)\n \"text-action-ghost-fg hover:bg-action-ghost-bg-hover\",\n // fast functional hover transition + verdify easing, instant under reduced motion (NEVER deliberate)\n \"transition-colors duration-(--motion-duration-fast) ease-(--motion-easing-verdify)\",\n \"motion-reduce:duration-(--motion-duration-instant)\",\n // target-size floor: 44px touch / 40px pointer; the close button is square at the floor (DEC-B)\n \"min-h-(--size-target-mobile) min-w-(--size-target-mobile)\",\n \"sm:min-h-(--size-target-desktop) sm:min-w-(--size-target-desktop)\",\n // visible 2px focus ring at 2px offset; never removed\n \"outline-none\",\n \"focus-visible:ring-2 focus-visible:ring-border-focus focus-visible:ring-offset-2\",\n]);\n\n// The close glyph: a neutral X, --size-icon-md, drawn with currentColor so it inherits the button's\n// ghost-fg. Decorative (aria-hidden) — the button carries the accessible name (spec §7).\nexport const sheetCloseGlyphClass = \"h-(--size-icon-md) w-(--size-icon-md)\";\n\nexport type SheetPanelVariantProps = VariantProps<typeof sheetPanelVariants>;\n",
|
|
22
|
+
"path": "sheet/sheet.variants.ts",
|
|
23
|
+
"target": "@ui/sheet/sheet.variants.ts",
|
|
24
|
+
"type": "registry:ui"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"name": "sheet",
|
|
28
|
+
"registryDependencies": [
|
|
29
|
+
"@verdify/cn"
|
|
30
|
+
],
|
|
31
|
+
"title": "sheet",
|
|
32
|
+
"type": "registry:ui"
|
|
33
|
+
}
|