diff --git a/examples/shadcn-ui.dspack.json b/examples/shadcn-ui.dspack.json index 838263e..9d2327d 100644 --- a/examples/shadcn-ui.dspack.json +++ b/examples/shadcn-ui.dspack.json @@ -3,7 +3,7 @@ "dspack": "0.4", "name": "shadcn/ui", "description": "A collection of reusable components built with Radix UI and Tailwind CSS. Components are copied into your project, not installed as a dependency.", - "version": "3.0.0", + "version": "3.1.0", "metadata": { "source": "https://ui.shadcn.com", "license": "MIT" @@ -380,10 +380,10 @@ }, "alert-dialog": { "name": "Alert Dialog", - "description": "A modal dialog that interrupts the user with important content and expects a response. Built on Radix UI AlertDialog. Renders with a required action and a cancel option. The user cannot dismiss it by clicking the overlay or pressing Escape — they must choose an explicit action.", + "description": "A modal dialog that interrupts the user with important content and expects a response. Built on Radix UI AlertDialog. Renders with a required action and a cancel option. The user cannot dismiss it by clicking the overlay or pressing Escape \u2014 they must choose an explicit action.", "status": "stable", "whenToUse": "Use for confirming destructive or irreversible actions: deleting records, discarding unsaved changes, revoking access. Use when the consequence of proceeding is significant enough that the user should explicitly acknowledge it.", - "whenNotToUse": "Do not use for informational messages or non-destructive confirmations. Use Dialog for those. Do not use when the user should be able to dismiss without choosing — that is what Dialog provides.", + "whenNotToUse": "Do not use for informational messages or non-destructive confirmations. Use Dialog for those. Do not use when the user should be able to dismiss without choosing \u2014 that is what Dialog provides.", "props": { "open": { "type": "boolean", @@ -547,7 +547,7 @@ "description": "A modal window that appears over the page content. Built on Radix UI Dialog. Can be dismissed by clicking the overlay, pressing Escape, or activating a close button.", "status": "stable", "whenToUse": "Use for forms, settings panels, detail views, or any content that benefits from focused attention without leaving the current page. Appropriate when the user should be able to dismiss the content without taking an action.", - "whenNotToUse": "Do not use for confirming destructive actions. Use AlertDialog instead — it prevents accidental dismissal. Do not use for simple, brief messages; use a toast or inline alert.", + "whenNotToUse": "Do not use for confirming destructive actions. Use AlertDialog instead \u2014 it prevents accidental dismissal. Do not use for simple, brief messages; use a toast or inline alert.", "props": { "open": { "type": "boolean", @@ -684,7 +684,7 @@ "description": "A container for grouping related content and actions. Provides visual separation through a bordered surface.", "status": "stable", "whenToUse": "Use to group related information into a distinct visual unit. Appropriate for dashboard widgets, summary panels, list items with detail, or any content that benefits from visual containment.", - "whenNotToUse": "Do not nest Cards inside other Cards. If content hierarchy is needed, use headings and spacing within a single Card. Do not use as a clickable element — wrap content in an interactive element instead.", + "whenNotToUse": "Do not nest Cards inside other Cards. If content hierarchy is needed, use headings and spacing within a single Card. Do not use as a clickable element \u2014 wrap content in an interactive element instead.", "props": { "className": { "type": "string", @@ -879,7 +879,7 @@ "description": "A menu that appears on activation of a trigger element. Built on Radix UI DropdownMenu. Supports items, checkboxes, radio groups, sub-menus, separators, and keyboard navigation.", "status": "stable", "whenToUse": "Use when a set of actions or options needs to be accessible from a single trigger without taking up permanent screen space. Appropriate for overflow menus, context-sensitive actions, and settings with multiple choices.", - "whenNotToUse": "Do not use for navigation — use a navigation menu or links. Do not use when the options should always be visible; use RadioGroup or a set of Buttons instead.", + "whenNotToUse": "Do not use for navigation \u2014 use a navigation menu or links. Do not use when the options should always be visible; use RadioGroup or a set of Buttons instead.", "props": { "open": { "type": "boolean", @@ -1044,10 +1044,10 @@ }, "table": { "name": "Table", - "description": "A set of primitives for presenting tabular data with a meaningful row-and-column relationship. These are thin, presentational wrappers over the native table elements — Table renders a inside a horizontally scrollable container, and the sub-components render thead, tbody, tfoot, tr, th, td, and caption. There is no built-in sorting, filtering, pagination, or selection; those are composed on top.", + "description": "A set of primitives for presenting tabular data with a meaningful row-and-column relationship. These are thin, presentational wrappers over the native table elements \u2014 Table renders a
inside a horizontally scrollable container, and the sub-components render thead, tbody, tfoot, tr, th, td, and caption. There is no built-in sorting, filtering, pagination, or selection; those are composed on top.", "status": "experimental", "whenToUse": "Use to display data where each row is a record and each column is a comparable attribute: members and their roles, invoices, audit logs, permission lists. Reach for Table whenever the relationship between rows and columns carries meaning that assistive technology should be able to announce.", - "whenNotToUse": "Do not use Table to lay out or align non-tabular content — use CSS grid or flexbox for layout. Do not use a table to present a single record's key/value pairs; use a description list (dl) or a Card instead. For large interactive datasets, still use these primitives for markup but drive state with a headless table library rather than building a bespoke component.", + "whenNotToUse": "Do not use Table to lay out or align non-tabular content \u2014 use CSS grid or flexbox for layout. Do not use a table to present a single record's key/value pairs; use a description list (dl) or a Card instead. For large interactive datasets, still use these primitives for markup but drive state with a headless table library rather than building a bespoke component.", "accessibility": { "role": "table", "requiredAttributes": [ @@ -1073,7 +1073,7 @@ } ], "labelRequirement": "required-accessible-name", - "notes": "Preserve the native semantic structure: real thead, tbody, tfoot, th, and td elements — never reconstruct a table from styled divs, which strips the row/column semantics screen readers depend on. Do NOT use Table for visual layout, alignment, or positioning; that is the job of CSS grid or flexbox. Provide an accessible name through TableCaption (preferred) or aria-label/aria-labelledby. A sortable column header must be a real Button inside the th, with the th carrying aria-sort, so the control is keyboard-operable and announced as a button." + "notes": "Preserve the native semantic structure: real thead, tbody, tfoot, th, and td elements \u2014 never reconstruct a table from styled divs, which strips the row/column semantics screen readers depend on. Do NOT use Table for visual layout, alignment, or positioning; that is the job of CSS grid or flexbox. Provide an accessible name through TableCaption (preferred) or aria-label/aria-labelledby. A sortable column header must be a real Button inside the th, with the th carrying aria-sort, so the control is keyboard-operable and announced as a button." }, "composition": { "subComponents": [ @@ -1125,7 +1125,7 @@ "acceptsChildren": "text" } ], - "notes": "TableRow must appear inside TableHeader, TableBody, or TableFooter — not directly under Table. Header cells use TableHead (th); data cells use TableCell (td). When present, TableCaption is the first child of Table. These primitives carry no data behavior; compose sorting, filtering, pagination, and selection on top of them." + "notes": "TableRow must appear inside TableHeader, TableBody, or TableFooter \u2014 not directly under Table. Header cells use TableHead (th); data cells use TableCell (td). When present, TableCaption is the first child of Table. These primitives carry no data behavior; compose sorting, filtering, pagination, and selection on top of them." }, "constraints": [ { @@ -1155,7 +1155,7 @@ }, { "context": "Any data table", - "rule": "Give the table an accessible name describing its contents — a TableCaption (preferred), or aria-label/aria-labelledby", + "rule": "Give the table an accessible name describing its contents \u2014 a TableCaption (preferred), or aria-label/aria-labelledby", "severity": "must" } ], @@ -1183,7 +1183,7 @@ "description": "A non-modal floating panel anchored to a trigger, rendered in a portal. Built on Radix UI Popover. The content element carries role=\"dialog\" and the trigger carries aria-haspopup=\"dialog\", aria-expanded, and aria-controls. Unlike Dialog, it is non-modal by default: focus is not trapped, the page behind stays scrollable and interactive, and the rest of the page is not hidden from assistive technology.", "status": "stable", "whenToUse": "Use for rich, interactive content that belongs to a specific control and should appear next to it: a date picker, a column-visibility menu, a filter builder, a small edit form, a combobox surface. Reach for Popover when the content is transient, contains focusable elements, and the user should be able to keep working with the page around it.", - "whenNotToUse": "Do not use for plain descriptive text on hover — use Tooltip, which is announced via aria-describedby and costs no interaction. Do not use for content that must be dealt with before the user continues; use Dialog, which is modal and traps focus. Do not use for a list of commands or actions; use DropdownMenu, which implements the menu pattern with roving focus and typeahead. Do not use for large or edge-anchored panels such as navigation or detail drawers; use Sheet.", + "whenNotToUse": "Do not use for plain descriptive text on hover \u2014 use Tooltip, which is announced via aria-describedby and costs no interaction. Do not use for content that must be dealt with before the user continues; use Dialog, which is modal and traps focus. Do not use for a list of commands or actions; use DropdownMenu, which implements the menu pattern with roving focus and typeahead. Do not use for large or edge-anchored panels such as navigation or detail drawers; use Sheet.", "props": { "open": { "type": "boolean", @@ -1229,7 +1229,7 @@ } ], "default": "bottom", - "description": "On PopoverContent. Preferred edge of the anchor to open against. This is a preference, not a guarantee — with avoidCollisions left on, the popover flips to the opposite side when the preferred side would overflow the viewport." + "description": "On PopoverContent. Preferred edge of the anchor to open against. This is a preference, not a guarantee \u2014 with avoidCollisions left on, the popover flips to the opposite side when the preferred side would overflow the viewport." }, "align": { "type": "enum", @@ -1325,7 +1325,7 @@ ], "focusManagement": "On open, focus moves into the content. On close, focus returns to PopoverTrigger. Focus is trapped only when modal is true; in the default non-modal mode Tab walks out of the panel into the rest of the page, which is why the panel must not hold a task that has to be finished before the user moves on.", "labelRequirement": "required-accessible-name", - "notes": "PopoverHeader, PopoverTitle, and PopoverDescription are plain presentational elements in shadcn's implementation — a div, a div, and a p. They are not Radix parts and carry no ARIA wiring, unlike DialogTitle and DialogDescription. Rendering a PopoverTitle therefore does not give the panel an accessible name; the aria-labelledby link must be authored by hand. Because the default mode is non-modal, screen reader users can navigate away from an open popover with no warning; do not rely on the panel holding their attention." + "notes": "PopoverHeader, PopoverTitle, and PopoverDescription are plain presentational elements in shadcn's implementation \u2014 a div, a div, and a p. They are not Radix parts and carry no ARIA wiring, unlike DialogTitle and DialogDescription. Rendering a PopoverTitle therefore does not give the panel an accessible name; the aria-labelledby link must be authored by hand. Because the default mode is non-modal, screen reader users can navigate away from an open popover with no warning; do not rely on the panel holding their attention." }, "composition": { "subComponents": [ @@ -1351,7 +1351,7 @@ { "id": "popover-anchor", "name": "PopoverAnchor", - "description": "An optional alternative positioning reference. When present, PopoverContent positions against the anchor instead of the trigger. Use it when the visual origin of the panel differs from the control that opens it — anchoring a combobox list to the whole input while a chevron button triggers it, for example. Must be a descendant of the same Popover root.", + "description": "An optional alternative positioning reference. When present, PopoverContent positions against the anchor instead of the trigger. Use it when the visual origin of the panel differs from the control that opens it \u2014 anchoring a combobox list to the whole input while a chevron button triggers it, for example. Must be a descendant of the same Popover root.", "slot": "anchor", "acceptsChildren": "any" }, @@ -1365,13 +1365,13 @@ { "id": "popover-title", "name": "PopoverTitle", - "description": "The panel's visible title. A presentational element, not a Radix part — give it an id and reference it from PopoverContent's aria-labelledby to make it the accessible name.", + "description": "The panel's visible title. A presentational element, not a Radix part \u2014 give it an id and reference it from PopoverContent's aria-labelledby to make it the accessible name.", "acceptsChildren": "text" }, { "id": "popover-description", "name": "PopoverDescription", - "description": "Supporting copy beneath the title. A presentational p element — reference its id from PopoverContent's aria-describedby to have it announced.", + "description": "Supporting copy beneath the title. A presentational p element \u2014 reference its id from PopoverContent's aria-describedby to have it announced.", "acceptsChildren": "text" } ], @@ -1379,7 +1379,7 @@ "popover-trigger", "popover-content" ], - "notes": "Positioning props live on PopoverContent, never on the Popover root; open state props live on the root, never on the content. PopoverContent portals itself, so no explicit Portal sub-component is exported or needed. PopoverAnchor, when used, replaces the trigger as the positioning reference but not as the control — the trigger still opens the panel. PopoverHeader, PopoverTitle, and PopoverDescription were added to the shadcn registry after the original three-part API; older installs may only export Popover, PopoverTrigger, PopoverContent, and PopoverAnchor." + "notes": "Positioning props live on PopoverContent, never on the Popover root; open state props live on the root, never on the content. PopoverContent portals itself, so no explicit Portal sub-component is exported or needed. PopoverAnchor, when used, replaces the trigger as the positioning reference but not as the control \u2014 the trigger still opens the panel. PopoverHeader, PopoverTitle, and PopoverDescription were added to the shadcn registry after the original three-part API; older installs may only export Popover, PopoverTrigger, PopoverContent, and PopoverAnchor." }, "constraints": [ { @@ -1389,7 +1389,7 @@ }, { "context": "Any PopoverContent", - "rule": "Give the panel an accessible name by wiring aria-labelledby to the PopoverTitle's id, or by setting aria-label — Radix Popover does not wire this automatically", + "rule": "Give the panel an accessible name by wiring aria-labelledby to the PopoverTitle's id, or by setting aria-label \u2014 Radix Popover does not wire this automatically", "severity": "must" }, { @@ -1399,7 +1399,7 @@ }, { "context": "Content the user must act on before continuing, or that would leave the app in an inconsistent state if abandoned", - "rule": "Use Dialog instead of Popover, or set modal on Popover — the default non-modal popover lets focus and pointer input leave freely", + "rule": "Use Dialog instead of Popover, or set modal on Popover \u2014 the default non-modal popover lets focus and pointer input leave freely", "severity": "must-not" }, { @@ -1451,14 +1451,14 @@ "categories": [ "overlay" ], - "notes": "Overlap to disambiguate later: Popover, Dialog, Sheet, DropdownMenu, and Tooltip all render portalled content above the page, and nothing in the type system stops an author from using any of them for the same job. The distinctions that matter are modality (Popover is non-modal by default, Dialog and Sheet are modal), anchoring (Popover and Tooltip position against a trigger; Dialog and Sheet position against the viewport), and interactivity (Tooltip content is unreachable, Popover content is fully interactive). A governance rule should later select on those axes rather than on component identity — for example, flagging interactive controls inside Tooltip content, and flagging a Popover carrying a form with a submit action that ought to be a Dialog. Token note: this entry references popover and popover-foreground, which are real shadcn theme variables (--popover, --popover-foreground) but are not yet present in this pack's token registry; they should be registered rather than substituted with background and foreground, which would misdescribe the component." + "notes": "Overlap to disambiguate later: Popover, Dialog, Sheet, DropdownMenu, and Tooltip all render portalled content above the page, and nothing in the type system stops an author from using any of them for the same job. The distinctions that matter are modality (Popover is non-modal by default, Dialog and Sheet are modal), anchoring (Popover and Tooltip position against a trigger; Dialog and Sheet position against the viewport), and interactivity (Tooltip content is unreachable, Popover content is fully interactive). A governance rule should later select on those axes rather than on component identity \u2014 for example, flagging interactive controls inside Tooltip content, and flagging a Popover carrying a form with a submit action that ought to be a Dialog. Token note: this entry references popover and popover-foreground, which are real shadcn theme variables (--popover, --popover-foreground) but are not yet present in this pack's token registry; they should be registered rather than substituted with background and foreground, which would misdescribe the component." }, "tooltip": { "name": "Tooltip", "description": "A small text label that appears on hover or keyboard focus to describe the control it is attached to. Built on Radix UI Tooltip. The content carries role=\"tooltip\" and is linked to the trigger with aria-describedby while open. It is supplementary by definition: it does not receive focus, it closes on click and on pointer-down, and it never opens from touch input.", "status": "stable", - "whenToUse": "Use to name or clarify a control whose purpose is not fully carried by its visible presentation — an icon-only button, a truncated cell, an abbreviated column header, a disabled control whose reason for being disabled needs explaining. Keep the content to a short phrase.", - "whenNotToUse": "Do not use for anything the user actually needs. Tooltip content is unreachable on touch devices and disappears on click, so information that is required to complete a task must live in the page, in a Popover, or in helper text. Do not put links, buttons, form fields, or any focusable element inside a tooltip — they cannot be reached. Do not use a tooltip as a control's only accessible name if the control is not the trigger itself.", + "whenToUse": "Use to name or clarify a control whose purpose is not fully carried by its visible presentation \u2014 an icon-only button, a truncated cell, an abbreviated column header, a disabled control whose reason for being disabled needs explaining. Keep the content to a short phrase.", + "whenNotToUse": "Do not use for anything the user actually needs. Tooltip content is unreachable on touch devices and disappears on click, so information that is required to complete a task must live in the page, in a Popover, or in helper text. Do not put links, buttons, form fields, or any focusable element inside a tooltip \u2014 they cannot be reached. Do not use a tooltip as a control's only accessible name if the control is not the trigger itself.", "props": { "delayDuration": { "type": "number", @@ -1481,7 +1481,7 @@ "open": { "type": "boolean", "propRole": "state", - "description": "On Tooltip (root). Controlled open state. Use sparingly — forcing a tooltip open bypasses the hover and focus semantics screen reader and touch users depend on." + "description": "On Tooltip (root). Controlled open state. Use sparingly \u2014 forcing a tooltip open bypasses the hover and focus semantics screen reader and touch users depend on." }, "defaultOpen": { "type": "boolean", @@ -1559,7 +1559,7 @@ "aria-label": { "type": "string", "propRole": "content", - "description": "On TooltipContent. Replaces the visible content in the accessibility tree with this string, rendered into a visually hidden element that carries role=\"tooltip\". Use only when the visible content does not read well when announced — for example a formatted value where the spoken form should differ. Setting it removes role=\"tooltip\" from the visible element." + "description": "On TooltipContent. Replaces the visible content in the accessibility tree with this string, rendered into a visually hidden element that carries role=\"tooltip\". Use only when the visible content does not read well when announced \u2014 for example a formatted value where the spoken form should differ. Setting it removes role=\"tooltip\" from the visible element." }, "asChild": { "type": "boolean", @@ -1574,7 +1574,7 @@ { "attribute": "aria-describedby", "condition": "wired automatically by Radix while the tooltip is open", - "description": "Radix sets aria-describedby on the trigger, pointing at the content id. This makes the tooltip a description, not a name — an icon-only button still needs its own accessible name from aria-label or visually hidden text." + "description": "Radix sets aria-describedby on the trigger, pointing at the content id. This makes the tooltip a description, not a name \u2014 an icon-only button still needs its own accessible name from aria-label or visually hidden text." }, { "attribute": "aria-label", @@ -1600,16 +1600,16 @@ "description": "Closes the tooltip, because activating the trigger dismisses it." } ], - "focusManagement": "The tooltip never takes focus — focus stays on the trigger the whole time it is open, and the content is not in the tab order. This is exactly why interactive content inside a tooltip is unreachable. The trigger must itself be focusable: a natively disabled button fires no focus or pointer events, so wrap it in a focusable element or mark it aria-disabled with the control still focusable if the tooltip needs to explain why it is unavailable.", + "focusManagement": "The tooltip never takes focus \u2014 focus stays on the trigger the whole time it is open, and the content is not in the tab order. This is exactly why interactive content inside a tooltip is unreachable. The trigger must itself be focusable: a natively disabled button fires no focus or pointer events, so wrap it in a focusable element or mark it aria-disabled with the control still focusable if the tooltip needs to explain why it is unavailable.", "labelRequirement": "required-accessible-name", - "notes": "Radix explicitly ignores pointer events whose pointerType is \"touch\", so a tooltip never opens from a tap or a touch hover — on phones and tablets the content simply does not exist. Pointer-down and click also close it. Treat every tooltip as decoration over information already available elsewhere. Because the content is linked with aria-describedby, it is announced after the control's name and role; a control whose name comes only from its tooltip will be announced as unnamed to some users and as nothing at all on touch." + "notes": "Radix explicitly ignores pointer events whose pointerType is \"touch\", so a tooltip never opens from a tap or a touch hover \u2014 on phones and tablets the content simply does not exist. Pointer-down and click also close it. Treat every tooltip as decoration over information already available elsewhere. Because the content is linked with aria-describedby, it is announced after the control's name and role; a control whose name comes only from its tooltip will be announced as unnamed to some users and as nothing at all on touch." }, "composition": { "subComponents": [ { "id": "tooltip-provider", "name": "TooltipProvider", - "description": "Supplies shared timing context — delayDuration, skipDelayDuration, disableHoverableContent — to every Tooltip beneath it. Mount it once near the application root. It is not optional: a Tooltip rendered outside a provider throws, because Radix creates the provider context with no default value.", + "description": "Supplies shared timing context \u2014 delayDuration, skipDelayDuration, disableHoverableContent \u2014 to every Tooltip beneath it. Mount it once near the application root. It is not optional: a Tooltip rendered outside a provider throws, because Radix creates the provider context with no default value.", "required": true, "slot": "provider", "acceptsChildren": "any" @@ -1628,7 +1628,7 @@ { "id": "tooltip-content", "name": "TooltipContent", - "description": "The floating label, portalled to the document body and carrying role=\"tooltip\". shadcn's implementation renders a built-in arrow inside it, so no separate Arrow sub-component is exported. Accepts short text only — it is not reachable by keyboard or touch.", + "description": "The floating label, portalled to the document body and carrying role=\"tooltip\". shadcn's implementation renders a built-in arrow inside it, so no separate Arrow sub-component is exported. Accepts short text only \u2014 it is not reachable by keyboard or touch.", "required": true, "slot": "content", "acceptsChildren": "text" @@ -1649,17 +1649,17 @@ }, { "context": "Any application rendering a Tooltip", - "rule": "Mount a single TooltipProvider at the application root — Tooltip throws when rendered outside one", + "rule": "Mount a single TooltipProvider at the application root \u2014 Tooltip throws when rendered outside one", "severity": "must" }, { "context": "Links, buttons, form fields, or any focusable element inside tooltip content", - "rule": "Move the interactive content into a Popover — tooltip content never receives focus and closes on click, so those controls are unreachable", + "rule": "Move the interactive content into a Popover \u2014 tooltip content never receives focus and closes on click, so those controls are unreachable", "severity": "must-not" }, { "context": "Information a user needs in order to complete a task", - "rule": "Do not put it only in a Tooltip — it is entirely absent on touch devices; put it in the page, in helper text, or in a Popover", + "rule": "Do not put it only in a Tooltip \u2014 it is entirely absent on touch devices; put it in the page, in helper text, or in a Popover", "severity": "must-not" }, { @@ -1669,7 +1669,7 @@ }, { "context": "A tooltip on a disabled control", - "rule": "Keep the trigger focusable — use aria-disabled with the control still in the tab order, or wrap it, because a natively disabled button emits no pointer or focus events and the tooltip will never open", + "rule": "Keep the trigger focusable \u2014 use aria-disabled with the control still in the tab order, or wrap it, because a natively disabled button emits no pointer or focus events and the tooltip will never open", "severity": "must" }, { @@ -1704,14 +1704,14 @@ "categories": [ "overlay" ], - "notes": "Overlap to disambiguate later: Tooltip and Popover are both trigger-anchored portalled overlays and are frequently interchanged by mistake. The dividing line is reachability, not size — tooltip content cannot be focused, is dismissed by click, and is never shown to touch users, so anything load-bearing or interactive belongs in a Popover. A governance rule should later assert that TooltipContent's subtree contains no interactive-category component and no focusable element, and should flag a Tooltip whose content is the only place a piece of information appears. Token note: shadcn's TooltipContent inverts the surface, using bg-foreground with text-background rather than a dedicated tooltip token pair, so the tokens listed here are deliberately the base semantic colors and not a popover-style pair." + "notes": "Overlap to disambiguate later: Tooltip and Popover are both trigger-anchored portalled overlays and are frequently interchanged by mistake. The dividing line is reachability, not size \u2014 tooltip content cannot be focused, is dismissed by click, and is never shown to touch users, so anything load-bearing or interactive belongs in a Popover. A governance rule should later assert that TooltipContent's subtree contains no interactive-category component and no focusable element, and should flag a Tooltip whose content is the only place a piece of information appears. Token note: shadcn's TooltipContent inverts the surface, using bg-foreground with text-background rather than a dedicated tooltip token pair, so the tokens listed here are deliberately the base semantic colors and not a popover-style pair." }, "sheet": { "name": "Sheet", - "description": "A modal panel that slides in from an edge of the viewport. Built on the same Radix UI Dialog primitive as Dialog — same modality, same focus trap, same dismissal behavior — differing only in that its content is pinned to a viewport edge rather than centered, and sized to that edge. Renders an overlay and, by default, a close button in the corner of the panel.", + "description": "A modal panel that slides in from an edge of the viewport. Built on the same Radix UI Dialog primitive as Dialog \u2014 same modality, same focus trap, same dismissal behavior \u2014 differing only in that its content is pinned to a viewport edge rather than centered, and sized to that edge. Renders an overlay and, by default, a close button in the corner of the panel.", "status": "stable", "whenToUse": "Use for a focused task or detail view that needs more room than a Popover and benefits from staying visually connected to the edge it came from: mobile navigation, a filters panel, a record's detail or edit view, a settings drawer. The left and right sides suit navigation and long forms; the bottom side suits mobile action panels.", - "whenNotToUse": "Do not use for confirming a destructive action — use AlertDialog, which cannot be dismissed by accident. Do not use for a short message or a small form that fits in a centered window; Dialog is the more conventional choice and demands less travel from the user's eye. Do not use for content anchored to a specific control, such as a picker or a small menu; use Popover or DropdownMenu. Do not use for brief status feedback; use a toast.", + "whenNotToUse": "Do not use for confirming a destructive action \u2014 use AlertDialog, which cannot be dismissed by accident. Do not use for a short message or a small form that fits in a centered window; Dialog is the more conventional choice and demands less travel from the user's eye. Do not use for content anchored to a specific control, such as a picker or a small menu; use Popover or DropdownMenu. Do not use for brief status feedback; use a toast.", "props": { "open": { "type": "boolean", @@ -1733,7 +1733,7 @@ "type": "boolean", "propRole": "flag", "default": true, - "description": "On Sheet (root). When true (the default) focus is trapped in the panel, the rest of the page is hidden from assistive technology, and outside pointer events are blocked. Setting it false breaks the expectation the overlay sets — the page still looks dimmed and unavailable while remaining interactive." + "description": "On Sheet (root). When true (the default) focus is trapped in the panel, the rest of the page is hidden from assistive technology, and outside pointer events are blocked. Setting it false breaks the expectation the overlay sets \u2014 the page still looks dimmed and unavailable while remaining interactive." }, "side": { "type": "enum", @@ -1749,7 +1749,7 @@ }, { "value": "top", - "description": "Slide down from the top edge, height sized to content. Use sparingly — it collides with system and browser chrome and with sticky headers." + "description": "Slide down from the top edge, height sized to content. Use sparingly \u2014 it collides with system and browser chrome and with sticky headers." }, { "value": "bottom", @@ -1763,12 +1763,12 @@ "type": "boolean", "propRole": "flag", "default": true, - "description": "On SheetContent. Renders the built-in close button in the panel's top-right corner, an icon button with visually hidden \"Close\" text. Set false only when supplying an explicit SheetClose elsewhere in the panel — never to remove the visible dismissal affordance entirely." + "description": "On SheetContent. Renders the built-in close button in the panel's top-right corner, an icon button with visually hidden \"Close\" text. Set false only when supplying an explicit SheetClose elsewhere in the panel \u2014 never to remove the visible dismissal affordance entirely." }, "onEscapeKeyDown": { "type": "function", "propRole": "handler", - "description": "On SheetContent. Fires when Escape is pressed. Call preventDefault to keep the sheet open — do this only to confirm discarding unsaved work, never to make the panel undismissable." + "description": "On SheetContent. Fires when Escape is pressed. Call preventDefault to keep the sheet open \u2014 do this only to confirm discarding unsaved work, never to make the panel undismissable." }, "onInteractOutside": { "type": "function", @@ -1818,9 +1818,9 @@ "description": "Closes the sheet and returns focus to SheetTrigger." } ], - "focusManagement": "On open, focus moves into the panel — to the first focusable element, which is usually the built-in close button. On close, focus returns to SheetTrigger. Focus is trapped inside the panel and the rest of the page is marked aria-hidden while open, so a screen reader user cannot wander out of it. When the sheet is opened programmatically rather than by a trigger, set focus deliberately on close, because there is no trigger to return to.", + "focusManagement": "On open, focus moves into the panel \u2014 to the first focusable element, which is usually the built-in close button. On close, focus returns to SheetTrigger. Focus is trapped inside the panel and the rest of the page is marked aria-hidden while open, so a screen reader user cannot wander out of it. When the sheet is opened programmatically rather than by a trigger, set focus deliberately on close, because there is no trigger to return to.", "labelRequirement": "required-visible", - "notes": "Because a Sheet is a Radix Dialog, all of Dialog's accessibility obligations apply unchanged. The panel is a scroll container in its own right; long content must scroll inside the panel rather than the page, and a footer with the primary action should stay reachable without scrolling past it. On small viewports a left or right sheet occupies three quarters of the width, so it is effectively full screen — check that the close affordance is inside the safe area." + "notes": "Because a Sheet is a Radix Dialog, all of Dialog's accessibility obligations apply unchanged. The panel is a scroll container in its own right; long content must scroll inside the panel rather than the page, and a footer with the primary action should stay reachable without scrolling past it. On small viewports a left or right sheet occupies three quarters of the width, so it is effectively full screen \u2014 check that the close affordance is inside the safe area." }, "composition": { "subComponents": [ @@ -1852,7 +1852,7 @@ { "id": "sheet-title", "name": "SheetTitle", - "description": "The panel's title. This is Radix's Dialog.Title, so rendering it gives the sheet its accessible name via aria-labelledby. Required for an accessible sheet — hide it visually rather than omitting it.", + "description": "The panel's title. This is Radix's Dialog.Title, so rendering it gives the sheet its accessible name via aria-labelledby. Required for an accessible sheet \u2014 hide it visually rather than omitting it.", "required": true, "acceptsChildren": "text" }, @@ -1967,14 +1967,14 @@ "categories": [ "overlay" ], - "notes": "Overlap to disambiguate later: Sheet is the Radix Dialog primitive with edge-anchored positioning — the sub-component sets are one-to-one (Trigger, Content, Header, Title, Description, Footer, Close) and the accessibility contract is identical, so nothing structural distinguishes a Sheet from a Dialog. The choice between them is presentational and contextual: viewport size, how much room the task needs, and whether the panel should read as attached to a page edge. AlertDialog is the one genuine behavioral distinction in this family, because it refuses dismissal by Escape or overlay. A governance rule should later encode: destructive confirmation must be AlertDialog, never Sheet or Dialog; a modal overlay carrying navigation or a long form should prefer Sheet; a short confirmation or small form should prefer Dialog; and an author should not nest a Sheet inside a Dialog or another Sheet, since stacked focus traps have no defined return path. Because Sheet, Dialog, and AlertDialog share prop names and sub-component shapes, that rule should key on intent and content, not on prop values." + "notes": "Overlap to disambiguate later: Sheet is the Radix Dialog primitive with edge-anchored positioning \u2014 the sub-component sets are one-to-one (Trigger, Content, Header, Title, Description, Footer, Close) and the accessibility contract is identical, so nothing structural distinguishes a Sheet from a Dialog. The choice between them is presentational and contextual: viewport size, how much room the task needs, and whether the panel should read as attached to a page edge. AlertDialog is the one genuine behavioral distinction in this family, because it refuses dismissal by Escape or overlay. A governance rule should later encode: destructive confirmation must be AlertDialog, never Sheet or Dialog; a modal overlay carrying navigation or a long form should prefer Sheet; a short confirmation or small form should prefer Dialog; and an author should not nest a Sheet inside a Dialog or another Sheet, since stacked focus traps have no defined return path. Because Sheet, Dialog, and AlertDialog share prop names and sub-component shapes, that rule should key on intent and content, not on prop values." }, "field": { "name": "Field", - "description": "The family of layout and labeling primitives shadcn ships for composing form fields: Field wraps one control with its label, helper text, and validation message; FieldSet and FieldLegend give a set of related controls real fieldset/legend semantics; FieldGroup stacks fields and establishes the container query that the responsive orientation reads. Field renders a div with role=\"group\" and data-orientation; the rest are thin wrappers over label, legend, fieldset, and p. The family holds no form state, generates no ids, and performs no validation — the label/control/error association is written by hand (htmlFor plus id, aria-invalid, data-invalid) or driven by a form library's controller.", + "description": "The family of layout and labeling primitives shadcn ships for composing form fields: Field wraps one control with its label, helper text, and validation message; FieldSet and FieldLegend give a set of related controls real fieldset/legend semantics; FieldGroup stacks fields and establishes the container query that the responsive orientation reads. Field renders a div with role=\"group\" and data-orientation; the rest are thin wrappers over label, legend, fieldset, and p. The family holds no form state, generates no ids, and performs no validation \u2014 the label/control/error association is written by hand (htmlFor plus id, aria-invalid, data-invalid) or driven by a form library's controller.", "status": "stable", - "whenToUse": "Use for every hand-composed form field: pair FieldLabel with an Input, Textarea, Select, Checkbox, Switch, or Slider and add FieldDescription and FieldError as needed. Use FieldSet with FieldLegend when several controls answer one question (a checkbox group, a radio group, an address block), FieldGroup to stack related fields with consistent spacing and to enable responsive orientation, and orientation=\"horizontal\" for controls that read better beside their label such as switches and checkboxes. This is also the layer shadcn's current form guides build on — drive it with react-hook-form's Controller, TanStack Form, or Formisch.", - "whenNotToUse": "Do not use Field as a general-purpose layout box: it renders role=\"group\", which assistive technology announces, so non-form content wrapped in it is misdescribed. Do not use FieldSet and FieldLegend around a single control — the legend announces a grouping that does not exist. Do not expect Field to wire anything: if you want ids, aria-describedby, and aria-invalid applied for you inside a react-hook-form codebase, that is the older Form family (FormItem/FormControl). Do not mix the two families within one field.", + "whenToUse": "Use for every hand-composed form field: pair FieldLabel with an Input, Textarea, Select, Checkbox, Switch, or Slider and add FieldDescription and FieldError as needed. Use FieldSet with FieldLegend when several controls answer one question (a checkbox group, a radio group, an address block), FieldGroup to stack related fields with consistent spacing and to enable responsive orientation, and orientation=\"horizontal\" for controls that read better beside their label such as switches and checkboxes. This is also the layer shadcn's current form guides build on \u2014 drive it with react-hook-form's Controller, TanStack Form, or Formisch.", + "whenNotToUse": "Do not use Field as a general-purpose layout box: it renders role=\"group\", which assistive technology announces, so non-form content wrapped in it is misdescribed. Do not use FieldSet and FieldLegend around a single control \u2014 the legend announces a grouping that does not exist. Do not expect Field to wire anything: if you want ids, aria-describedby, and aria-invalid applied for you inside a react-hook-form codebase, that is the older Form family (FormItem/FormControl). Do not mix the two families within one field.", "props": { "orientation": { "type": "enum", @@ -1999,7 +1999,7 @@ "data-invalid": { "type": "boolean", "propRole": "state", - "description": "Switches the whole field block into the error treatment, including the label and description. It is styling only and is never exposed to assistive technology, so it does not replace aria-invalid on the control itself — set both. With react-hook-form this is data-invalid={fieldState.invalid}." + "description": "Switches the whole field block into the error treatment, including the label and description. It is styling only and is never exposed to assistive technology, so it does not replace aria-invalid on the control itself \u2014 set both. With react-hook-form this is data-invalid={fieldState.invalid}." } }, "accessibility": { @@ -2030,22 +2030,22 @@ "keyboardInteractions": [ { "key": "Tab", - "description": "Moves to the next focusable control. Field adds no keyboard behavior of its own — the wrappers are not focus stops, so tab order is exactly the order of the controls you place inside them." + "description": "Moves to the next focusable control. Field adds no keyboard behavior of its own \u2014 the wrappers are not focus stops, so tab order is exactly the order of the controls you place inside them." }, { "key": "Shift+Tab", "description": "Moves to the previous focusable control." } ], - "focusManagement": "Field never moves focus. FieldError renders with role=\"alert\", so a message that appears after a failed submit is announced without stealing focus. Moving focus to the first invalid control after a failed submit is the form library's job — react-hook-form does it by default.", - "notes": "Clicking a FieldLabel focuses or toggles the control it names, because FieldLabel renders a real label element (Radix Label) — this only works when htmlFor matches the control's id, or when the label wraps the control. FieldSet plus FieldLegend is the only construct in the family that gives a set of controls a shared accessible name; a bare FieldGroup is a layout wrapper with no semantics. FieldTitle is styled like a label but is a plain div, so it names nothing on its own: use it only inside a choice card whose surrounding FieldLabel carries the htmlFor. Placeholder text is never a label." + "focusManagement": "Field never moves focus. FieldError renders with role=\"alert\", so a message that appears after a failed submit is announced without stealing focus. Moving focus to the first invalid control after a failed submit is the form library's job \u2014 react-hook-form does it by default.", + "notes": "Clicking a FieldLabel focuses or toggles the control it names, because FieldLabel renders a real label element (Radix Label) \u2014 this only works when htmlFor matches the control's id, or when the label wraps the control. FieldSet plus FieldLegend is the only construct in the family that gives a set of controls a shared accessible name; a bare FieldGroup is a layout wrapper with no semantics. FieldTitle is styled like a label but is a plain div, so it names nothing on its own: use it only inside a choice card whose surrounding FieldLabel carries the htmlFor. Placeholder text is never a label." }, "composition": { "subComponents": [ { "id": "field-set", "name": "FieldSet", - "description": "A semantic fieldset that groups controls answering one question — a checkbox group, a radio group, an address block. Pair it with a FieldLegend so the group has an accessible name, and put the individual Fields in a FieldGroup inside it.", + "description": "A semantic fieldset that groups controls answering one question \u2014 a checkbox group, a radio group, an address block. Pair it with a FieldLegend so the group has an accessible name, and put the individual Fields in a FieldGroup inside it.", "acceptsChildren": "components", "categories": [ "form" @@ -2073,7 +2073,7 @@ { "id": "field-content", "name": "FieldContent", - "description": "A flex column that keeps the label, title, description, and error together when the control sits beside them — the layout you need for horizontal and responsive fields. Omit it in a plain vertical field with no description.", + "description": "A flex column that keeps the label, title, description, and error together when the control sits beside them \u2014 the layout you need for horizontal and responsive fields. Omit it in a plain vertical field with no description.", "slot": "content", "acceptsChildren": "components", "categories": [ @@ -2093,7 +2093,7 @@ { "id": "field-title", "name": "FieldTitle", - "description": "Label-styled heading text for use inside FieldContent, typically as the headline of a choice card above its FieldDescription. It renders a div, not a label, so it provides no label association — the accessible name must still come from the surrounding FieldLabel or from the control.", + "description": "Label-styled heading text for use inside FieldContent, typically as the headline of a choice card above its FieldDescription. It renders a div, not a label, so it provides no label association \u2014 the accessible name must still come from the surrounding FieldLabel or from the control.", "slot": "title", "acceptsChildren": "any", "categories": [ @@ -2113,7 +2113,7 @@ { "id": "field-error", "name": "FieldError", - "description": "The validation message container, rendered as a div with role=\"alert\" so a message that appears after submit is announced. It takes either children or an errors array of objects with a message property — the shape react-hook-form's fieldState.error and Standard Schema issues (Zod, Valibot, ArkType) already have. Given several messages it de-duplicates them and renders a bulleted list; given nothing it renders nothing.", + "description": "The validation message container, rendered as a div with role=\"alert\" so a message that appears after submit is announced. It takes either children or an errors array of objects with a message property \u2014 the shape react-hook-form's fieldState.error and Standard Schema issues (Zod, Valibot, ArkType) already have. Given several messages it de-duplicates them and renders a bulleted list; given nothing it renders nothing.", "slot": "error", "acceptsChildren": "any", "categories": [ @@ -2123,14 +2123,14 @@ { "id": "field-separator", "name": "FieldSeparator", - "description": "A divider between sections of a FieldGroup, optionally with inline text such as \"Or continue with\". Use it sparingly — every separator is a section boundary a screen reader user has to interpret.", + "description": "A divider between sections of a FieldGroup, optionally with inline text such as \"Or continue with\". Use it sparingly \u2014 every separator is a section boundary a screen reader user has to interpret.", "acceptsChildren": "text", "categories": [ "form" ] } ], - "notes": "The nesting the docs prescribe is FieldSet > FieldLegend + FieldDescription + FieldGroup > Field, and inside a Field: FieldLabel, the control, FieldDescription, FieldError — in that reading order, with FieldError immediately after the control it describes. When the label sits beside the control, wrap the label, description, and error in FieldContent so they stay aligned. Any labelable control belongs inside a Field: Input, Textarea, Select, Checkbox, RadioGroupItem, Switch, Slider, InputGroup, or your own. No sub-component is marked required, deliberately: in the documented choice-card and radio patterns FieldLabel is an ancestor of Field rather than a descendant, so a structural must-contain rule would be false — the labeling requirement is carried as a constraint instead. FieldGroup is required in practice only for orientation=\"responsive\", which resolves against the container query FieldGroup establishes." + "notes": "The nesting the docs prescribe is FieldSet > FieldLegend + FieldDescription + FieldGroup > Field, and inside a Field: FieldLabel, the control, FieldDescription, FieldError \u2014 in that reading order, with FieldError immediately after the control it describes. When the label sits beside the control, wrap the label, description, and error in FieldContent so they stay aligned. Any labelable control belongs inside a Field: Input, Textarea, Select, Checkbox, RadioGroupItem, Switch, Slider, InputGroup, or your own. No sub-component is marked required, deliberately: in the documented choice-card and radio patterns FieldLabel is an ancestor of Field rather than a descendant, so a structural must-contain rule would be false \u2014 the labeling requirement is carried as a constraint instead. FieldGroup is required in practice only for orientation=\"responsive\", which resolves against the container query FieldGroup establishes." }, "constraints": [ { @@ -2140,7 +2140,7 @@ }, { "context": "The association between a FieldLabel and its control", - "rule": "Set htmlFor on the FieldLabel to the control's id — Field generates and wires nothing", + "rule": "Set htmlFor on the FieldLabel to the control's id \u2014 Field generates and wires nothing", "severity": "must" }, { @@ -2160,7 +2160,7 @@ }, { "context": "A single standalone control", - "rule": "Do not wrap it in FieldSet and FieldLegend — the legend announces a group that does not exist", + "rule": "Do not wrap it in FieldSet and FieldLegend \u2014 the legend announces a group that does not exist", "severity": "should-not" }, { @@ -2213,10 +2213,10 @@ }, "form": { "name": "Form", - "description": "The react-hook-form wrapper family. Form is a re-export of react-hook-form's FormProvider, FormField wraps its Controller and publishes the field name on context, and FormItem generates one React id per field from which every other id is derived: the control gets `${id}-form-item`, the description `${id}-form-item-description`, the message `${id}-form-item-message`. FormControl is a Radix Slot that clones the single control inside it and stamps on id, aria-describedby, and aria-invalid, so the label/description/error association is generated rather than hand-written. Form itself renders no DOM — you still write the native form element and pass form.handleSubmit(onSubmit) to its onSubmit.", + "description": "The react-hook-form wrapper family. Form is a re-export of react-hook-form's FormProvider, FormField wraps its Controller and publishes the field name on context, and FormItem generates one React id per field from which every other id is derived: the control gets `${id}-form-item`, the description `${id}-form-item-description`, the message `${id}-form-item-message`. FormControl is a Radix Slot that clones the single control inside it and stamps on id, aria-describedby, and aria-invalid, so the label/description/error association is generated rather than hand-written. Form itself renders no DOM \u2014 you still write the native form element and pass form.handleSubmit(onSubmit) to its onSubmit.", "status": "stable", "whenToUse": "Use in codebases already built on react-hook-form that want the field wiring generated: unique ids per field, htmlFor bound to the control, aria-describedby pointing at the description and, once the field errors, at the message, aria-invalid reflecting the field's error state, and the error text pulled straight out of formState without touching it.", - "whenNotToUse": "Do not use without react-hook-form: every part reads context from FormProvider and FormField, so outside them useFormField throws or produces ids like \"undefined-form-item\". Do not use it for new work in preference to Field — shadcn still ships form in the registry, but the component no longer has a documentation page and the current form guides compose Field with Controller instead, which also gives you horizontal and responsive layouts, fieldset grouping, and support for TanStack Form and Formisch. Do not reach for it when you need a field layout the family does not have: FormItem is a plain vertical stack.", + "whenNotToUse": "Do not use without react-hook-form: every part reads context from FormProvider and FormField, so outside them useFormField throws or produces ids like \"undefined-form-item\". Do not use it for new work in preference to Field \u2014 shadcn still ships form in the registry, but the component no longer has a documentation page and the current form guides compose Field with Controller instead, which also gives you horizontal and responsive layouts, fieldset grouping, and support for TanStack Form and Formisch. Do not reach for it when you need a field layout the family does not have: FormItem is a plain vertical stack.", "props": { "control": { "type": "object", @@ -2232,7 +2232,7 @@ "handleSubmit": { "type": "function", "propRole": "handler", - "description": "The submit wrapper from useForm(), carried on context along with the rest of the hook result. It belongs on the native form element as onSubmit={form.handleSubmit(onSubmit)} — Form renders no element, so it cannot receive an onSubmit of its own." + "description": "The submit wrapper from useForm(), carried on context along with the rest of the hook result. It belongs on the native form element as onSubmit={form.handleSubmit(onSubmit)} \u2014 Form renders no element, so it cannot receive an onSubmit of its own." } }, "accessibility": { @@ -2256,7 +2256,7 @@ { "attribute": "aria-invalid", "condition": "applied automatically by FormControl when the field has an error", - "description": "Reflects the field's error state from formState. Do not set aria-invalid by hand on a control inside FormControl — FormControl owns it and will overwrite or contradict yours depending on prop order." + "description": "Reflects the field's error state from formState. Do not set aria-invalid by hand on a control inside FormControl \u2014 FormControl owns it and will overwrite or contradict yours depending on prop order." }, { "attribute": "aria-labelledby", @@ -2267,7 +2267,7 @@ "keyboardInteractions": [ { "key": "Tab", - "description": "Moves to the next control. The Form parts add no keyboard behavior of their own — FormItem is a div and FormControl renders no element, so tab order is the order of the controls." + "description": "Moves to the next control. The Form parts add no keyboard behavior of their own \u2014 FormItem is a div and FormControl renders no element, so tab order is the order of the controls." }, { "key": "Shift+Tab", @@ -2278,15 +2278,15 @@ "description": "Submits the form from a single-line text control, which is native form behavior and requires a submit button in the form. It runs the resolver first, so an invalid form errors instead of submitting." } ], - "focusManagement": "On a failed submit react-hook-form moves focus to the first invalid field (shouldFocusError defaults to true), which works because the control keeps the ref that Controller passes through FormControl. Note that FormMessage returns null while the field is valid, so the element aria-describedby points at exists only once the field errors — and it is a plain p, not a live region, so the message is announced when the control is next read rather than at the moment it appears. If you need immediate announcement, use the Field family's FieldError, which renders with role=\"alert\".", - "notes": "The family declares no ARIA role of its own: Form and FormField render nothing, FormControl renders nothing, and FormItem is a plain div — form semantics come from the native form element you write. Every part depends on both contexts: FormField supplies the field name, FormItem supplies the generated id. A FormLabel or FormControl outside a FormItem still renders, but its ids collapse to \"undefined-form-item\", so every such field on the page collides on the same id. A control that is not wrapped in FormControl receives no id, no aria-describedby, and no aria-invalid — the wiring is the whole point of the wrapper, and skipping it leaves an unlabeled field that looks correct on screen. FormLabel must carry visible text; a placeholder is not a label." + "focusManagement": "On a failed submit react-hook-form moves focus to the first invalid field (shouldFocusError defaults to true), which works because the control keeps the ref that Controller passes through FormControl. Note that FormMessage returns null while the field is valid, so the element aria-describedby points at exists only once the field errors \u2014 and it is a plain p, not a live region, so the message is announced when the control is next read rather than at the moment it appears. If you need immediate announcement, use the Field family's FieldError, which renders with role=\"alert\".", + "notes": "The family declares no ARIA role of its own: Form and FormField render nothing, FormControl renders nothing, and FormItem is a plain div \u2014 form semantics come from the native form element you write. Every part depends on both contexts: FormField supplies the field name, FormItem supplies the generated id. A FormLabel or FormControl outside a FormItem still renders, but its ids collapse to \"undefined-form-item\", so every such field on the page collides on the same id. A control that is not wrapped in FormControl receives no id, no aria-describedby, and no aria-invalid \u2014 the wiring is the whole point of the wrapper, and skipping it leaves an unlabeled field that looks correct on screen. FormLabel must carry visible text; a placeholder is not a label." }, "composition": { "subComponents": [ { "id": "form-field", "name": "FormField", - "description": "Wraps react-hook-form's Controller and publishes the field name on context so the parts beneath it can resolve their state. It takes name, control, and a render prop — the field's UI is returned from render({ field, fieldState }), not passed as children — and renders no element itself.", + "description": "Wraps react-hook-form's Controller and publishes the field name on context so the parts beneath it can resolve their state. It takes name, control, and a render prop \u2014 the field's UI is returned from render({ field, fieldState }), not passed as children \u2014 and renders no element itself.", "required": true, "acceptsChildren": "none", "categories": [ @@ -2307,7 +2307,7 @@ { "id": "form-label", "name": "FormLabel", - "description": "The field's label, rendered as a Radix Label with htmlFor bound to the generated control id and data-error set when the field is invalid, which is what turns the label text destructive. It takes no error prop — it reads the field's error from context.", + "description": "The field's label, rendered as a Radix Label with htmlFor bound to the generated control id and data-error set when the field is invalid, which is what turns the label text destructive. It takes no error prop \u2014 it reads the field's error from context.", "required": true, "slot": "label", "acceptsChildren": "text", @@ -2318,7 +2318,7 @@ { "id": "form-control", "name": "FormControl", - "description": "A Radix Slot that must wrap exactly one control and renders no element of its own. It merges id, aria-describedby, and aria-invalid onto that child, which is what makes the association automatic. The child must forward props and ref — every shadcn control does; a plain function component that drops them silently loses the wiring.", + "description": "A Radix Slot that must wrap exactly one control and renders no element of its own. It merges id, aria-describedby, and aria-invalid onto that child, which is what makes the association automatic. The child must forward props and ref \u2014 every shadcn control does; a plain function component that drops them silently loses the wiring.", "required": true, "slot": "control", "acceptsChildren": "components", @@ -2329,7 +2329,7 @@ { "id": "form-description", "name": "FormDescription", - "description": "Helper text rendered as a p carrying the generated description id. FormControl references that id from aria-describedby unconditionally, so include a FormDescription in any FormItem where guidance helps — and know that omitting it leaves the reference pointing at nothing.", + "description": "Helper text rendered as a p carrying the generated description id. FormControl references that id from aria-describedby unconditionally, so include a FormDescription in any FormItem where guidance helps \u2014 and know that omitting it leaves the reference pointing at nothing.", "slot": "description", "acceptsChildren": "any", "categories": [ @@ -2339,7 +2339,7 @@ { "id": "form-message", "name": "FormMessage", - "description": "The validation message, rendered as a p carrying the generated message id. It prints the field's error message when there is one, falls back to its children when there is not, and returns null when there is neither — so the element exists only while the field is invalid. Author it in every FormItem: FormControl adds its id to aria-describedby the moment the field errors.", + "description": "The validation message, rendered as a p carrying the generated message id. It prints the field's error message when there is one, falls back to its children when there is not, and returns null when there is neither \u2014 so the element exists only while the field is invalid. Author it in every FormItem: FormControl adds its id to aria-describedby the moment the field errors.", "required": true, "slot": "message", "acceptsChildren": "text", @@ -2353,7 +2353,7 @@ "form-item", "form-control" ], - "notes": "The shape is fixed: around a native , then one FormField per field whose render returns a FormItem containing FormLabel, FormControl wrapping the control, an optional FormDescription, and FormMessage. Form is FormProvider and FormField is Controller, so neither emits markup — the native form element is yours to write, as are the submit Button and any layout between fields. useFormField is a hook, not a component: it is what every part calls to reach the field name, the generated ids, and the field's error. It throws outside a Form, because there is no react-hook-form context to read, and it misbehaves outside a FormField or a FormItem, where the field name and the generated id are undefined. Do not mix this family with the Field family inside one field; both want to own the association, and only one of them can." + "notes": "The shape is fixed: around a native , then one FormField per field whose render returns a FormItem containing FormLabel, FormControl wrapping the control, an optional FormDescription, and FormMessage. Form is FormProvider and FormField is Controller, so neither emits markup \u2014 the native form element is yours to write, as are the submit Button and any layout between fields. useFormField is a hook, not a component: it is what every part calls to reach the field name, the generated ids, and the field's error. It throws outside a Form, because there is no react-hook-form context to read, and it misbehaves outside a FormField or a FormItem, where the field name and the generated id are undefined. Do not mix this family with the Field family inside one field; both want to own the association, and only one of them can." }, "constraints": [ { @@ -2363,7 +2363,7 @@ }, { "context": "A new form, or a form built on TanStack Form, Formisch, or no form library", - "rule": "Compose the Field family with the library's own controller instead — shadcn's current form guides build on Field, and Form is react-hook-form-only", + "rule": "Compose the Field family with the library's own controller instead \u2014 shadcn's current form guides build on Field, and Form is react-hook-form-only", "severity": "should" }, { @@ -2373,7 +2373,7 @@ }, { "context": "Attributes on a control inside FormControl", - "rule": "Do not set your own id or aria-invalid — FormControl owns them and FormLabel's htmlFor targets the generated id", + "rule": "Do not set your own id or aria-invalid \u2014 FormControl owns them and FormLabel's htmlFor targets the generated id", "severity": "must-not" }, { @@ -2383,7 +2383,7 @@ }, { "context": "Submitting the form", - "rule": "Put onSubmit={form.handleSubmit(onSubmit)} on the native form element — Form is FormProvider and renders no DOM to receive it", + "rule": "Put onSubmit={form.handleSubmit(onSubmit)} on the native form element \u2014 Form is FormProvider and renders no DOM to receive it", "severity": "must" }, { @@ -2393,7 +2393,7 @@ }, { "context": "A single field", - "rule": "Do not combine Form parts with Field parts — both own the label and error association, and mixing them produces duplicate or dangling references", + "rule": "Do not combine Form parts with Field parts \u2014 both own the label and error association, and mixing them produces duplicate or dangling references", "severity": "must-not" }, { @@ -2425,10 +2425,10 @@ }, "separator": { "name": "Separator", - "description": "A one-pixel rule that divides content. Built on Radix UI Separator. It stretches to the full width of its container when horizontal and to its full height when vertical, and it is hidden from assistive technology by default — shadcn's wrapper sets decorative to true.", + "description": "A one-pixel rule that divides content. Built on Radix UI Separator. It stretches to the full width of its container when horizontal and to its full height when vertical, and it is hidden from assistive technology by default \u2014 shadcn's wrapper sets decorative to true.", "status": "stable", "whenToUse": "Use to mark a boundary between groups of related content: between sections of a sidebar or a settings page, between a card's header and its body, between inline metadata in a row. Reach for it when spacing alone leaves the grouping ambiguous.", - "whenNotToUse": "Do not use a Separator to create breathing room — that is what margin and padding are for. Do not use one to draw a line under a container that already has its own border. Inside a DropdownMenu, Select, or Command list, use that component's own separator part so the divider inherits the menu's spacing and semantics.", + "whenNotToUse": "Do not use a Separator to create breathing room \u2014 that is what margin and padding are for. Do not use one to draw a line under a container that already has its own border. Inside a DropdownMenu, Select, or Command list, use that component's own separator part so the divider inherits the menu's spacing and semantics.", "props": { "orientation": { "type": "enum", @@ -2440,7 +2440,7 @@ }, { "value": "vertical", - "description": "A full-height rule between items laid out along a row — toolbars, inline stat groups, button clusters. Needs a container that resolves a height (a flex row with items-stretch, or an explicit height), or it renders as nothing." + "description": "A full-height rule between items laid out along a row \u2014 toolbars, inline stat groups, button clusters. Needs a container that resolves a height (a flex row with items-stretch, or an explicit height), or it renders as nothing." } ], "default": "horizontal", @@ -2450,7 +2450,7 @@ "type": "boolean", "propRole": "flag", "default": true, - "description": "Whether the rule is purely visual. At the default of true the element renders role=\"none\" and assistive technology skips it entirely. Set it to false when the divider marks a real boundary — the element then renders role=\"separator\", and a vertical one also carries aria-orientation=\"vertical\"." + "description": "Whether the rule is purely visual. At the default of true the element renders role=\"none\" and assistive technology skips it entirely. Set it to false when the divider marks a real boundary \u2014 the element then renders role=\"separator\", and a vertical one also carries aria-orientation=\"vertical\"." } }, "accessibility": { @@ -2460,7 +2460,7 @@ { "attribute": "decorative", "condition": "when the divider communicates a grouping that is not already conveyed by a heading, a landmark, or list structure", - "description": "Pass decorative={false} so the element is exposed as role=\"separator\" instead of role=\"none\". shadcn defaults it to true, which removes the rule from the accessibility tree — correct for ornament, wrong for a real boundary." + "description": "Pass decorative={false} so the element is exposed as role=\"separator\" instead of role=\"none\". shadcn defaults it to true, which removes the rule from the accessibility tree \u2014 correct for ornament, wrong for a real boundary." }, { "attribute": "aria-orientation", @@ -2469,7 +2469,7 @@ } ], "focusManagement": "Separator is never focusable and never enters the tab order; it has no keyboard behaviour of its own.", - "notes": "role=\"separator\" without a tabindex is a static separator, not a splitter — never attach drag-to-resize behaviour to it. A divider the user can move is a different control with its own value semantics; use a Resizable handle for that." + "notes": "role=\"separator\" without a tabindex is a static separator, not a splitter \u2014 never attach drag-to-resize behaviour to it. A divider the user can move is a different control with its own value semantics; use a Resizable handle for that." }, "constraints": [ { @@ -2484,7 +2484,7 @@ }, { "context": "A vertical separator", - "rule": "Give it a container that resolves a height — a flex row with items-stretch, or an explicit height; a vertical separator in an auto-height parent collapses to zero pixels", + "rule": "Give it a container that resolves a height \u2014 a flex row with items-stretch, or an explicit height; a vertical separator in an auto-height parent collapses to zero pixels", "severity": "must" }, { @@ -2522,10 +2522,10 @@ }, "avatar": { "name": "Avatar", - "description": "A compact, rounded stand-in for a person or entity. Built on Radix UI Avatar: AvatarImage mounts only once the image has actually loaded, and AvatarFallback fills the same box while it is loading or whenever it is missing or broken — so the slot is never empty and the layout never shifts.", + "description": "A compact, rounded stand-in for a person or entity. Built on Radix UI Avatar: AvatarImage mounts only once the image has actually loaded, and AvatarFallback fills the same box while it is loading or whenever it is missing or broken \u2014 so the slot is never empty and the layout never shifts.", "status": "stable", "whenToUse": "Use to identify a person, organisation, or account at a glance: comment authors, assignees in a table row, members in a list, the signed-in user behind a menu trigger. Use AvatarGroup when several participants have to share one slot.", - "whenNotToUse": "Do not use Avatar for imagery that is not standing in for an identity — thumbnails, cover art, product photos belong in an AspectRatio or a plain image. Do not make the Avatar itself clickable; wrap it in a Button, a link, or a DropdownMenuTrigger so the control is focusable and named. Do not use an avatar alone to carry status.", + "whenNotToUse": "Do not use Avatar for imagery that is not standing in for an identity \u2014 thumbnails, cover art, product photos belong in an AspectRatio or a plain image. Do not make the Avatar itself clickable; wrap it in a Button, a link, or a DropdownMenuTrigger so the control is focusable and named. Do not use an avatar alone to carry status.", "props": { "size": { "type": "enum", @@ -2555,15 +2555,15 @@ { "attribute": "alt", "condition": "on every AvatarImage", - "description": "AvatarImage renders a native img. Name the person or entity when the avatar is the only thing identifying them; pass an empty string when their name is already visible beside it, so the identity is not announced twice. The attribute itself is never optional — a missing alt leaves the file name as the accessible name." + "description": "AvatarImage renders a native img. Name the person or entity when the avatar is the only thing identifying them; pass an empty string when their name is already visible beside it, so the identity is not announced twice. The attribute itself is never optional \u2014 a missing alt leaves the file name as the accessible name." }, { "attribute": "aria-label", - "condition": "when the Avatar is the visual for a control — inside a Button, a link, or a DropdownMenuTrigger", + "condition": "when the Avatar is the visual for a control \u2014 inside a Button, a link, or a DropdownMenuTrigger", "description": "Name the control by what it does (\"Open account menu\"), not by the picture. The wrapping control owns the accessible name; the Avatar inside it should be decorative." } ], - "focusManagement": "Avatar is not focusable and takes no part in the tab order. When it is the visual for a control, the wrapping element owns focus and must show the focus ring — the avatar's own rounding should not clip it.", + "focusManagement": "Avatar is not focusable and takes no part in the tab order. When it is the visual for a control, the wrapping element owns focus and must show the focus ring \u2014 the avatar's own rounding should not clip it.", "notes": "AvatarFallback is not a nicety: it is what most users on slow or blocked connections actually see, and what renders whenever the URL 404s. Give it initials or an identifying icon, never an empty box. Initials are a weak identity on their own, so keep the person's name in adjacent text or in the image's alt." }, "composition": { @@ -2593,7 +2593,7 @@ { "id": "avatar-badge", "name": "AvatarBadge", - "description": "A small indicator pinned to the bottom-right of the avatar for presence or status — online, away, verified. It sizes itself from the parent Avatar's size prop. Purely visual: it exposes no accessible name of its own, so the state it signals has to exist in text too.", + "description": "A small indicator pinned to the bottom-right of the avatar for presence or status \u2014 online, away, verified. It sizes itself from the parent Avatar's size prop. Purely visual: it exposes no accessible name of its own, so the state it signals has to exist in text too.", "required": false, "slot": "badge", "acceptsChildren": "any", @@ -2604,7 +2604,7 @@ { "id": "avatar-group", "name": "AvatarGroup", - "description": "A row of overlapping Avatars representing several people in a single slot. It inverts the usual nesting — it is a wrapper around Avatar instances rather than a child of one — and applies the overlap and ring treatment to each avatar it holds.", + "description": "A row of overlapping Avatars representing several people in a single slot. It inverts the usual nesting \u2014 it is a wrapper around Avatar instances rather than a child of one \u2014 and applies the overlap and ring treatment to each avatar it holds.", "required": false, "slot": "group", "acceptsChildren": "components", @@ -2624,7 +2624,7 @@ ] } ], - "notes": "An Avatar holds an AvatarImage and an AvatarFallback, optionally followed by an AvatarBadge. The fallback is the required part, not the image: an avatar with no image is ordinary, an avatar with nothing to show when the image fails is a bug. AvatarGroup sits outside this nesting — it is a parent that contains several Avatars, optionally closed by an AvatarGroupCount — so treat it as a sibling concern rather than a slot inside Avatar." + "notes": "An Avatar holds an AvatarImage and an AvatarFallback, optionally followed by an AvatarBadge. The fallback is the required part, not the image: an avatar with no image is ordinary, an avatar with nothing to show when the image fails is a bug. AvatarGroup sits outside this nesting \u2014 it is a parent that contains several Avatars, optionally closed by an AvatarGroupCount \u2014 so treat it as a sibling concern rather than a slot inside Avatar." }, "constraints": [ { @@ -2634,7 +2634,7 @@ }, { "context": "An AvatarImage", - "rule": "Always pass alt — the person's or entity's name when the avatar identifies them, an empty string when that name is already visible beside it", + "rule": "Always pass alt \u2014 the person's or entity's name when the avatar identifies them, an empty string when that name is already visible beside it", "severity": "must" }, { @@ -2688,10 +2688,10 @@ }, "accordion": { "name": "Accordion", - "description": "A stack of headings that each reveal a panel of content. Built on Radix UI Accordion. The root decides whether one section or several can be open at a time; each item pairs a trigger — a button inside a heading — with the region that trigger controls.", + "description": "A stack of headings that each reveal a panel of content. Built on Radix UI Accordion. The root decides whether one section or several can be open at a time; each item pairs a trigger \u2014 a button inside a heading \u2014 with the region that trigger controls.", "status": "stable", "whenToUse": "Use to compress a long page of self-contained sections into a list of headings someone can scan and open selectively: FAQs, grouped settings, order or invoice details, reference documentation. It works when the headings alone are enough to decide what to open, and when the sections are peers rather than steps.", - "whenNotToUse": "Do not hide content the reader needs in order to act — pricing, validation errors, required form fields, or anything they must compare across sections. For a single region that shows and hides, use Collapsible; there is no heading list to justify an accordion of one. For mutually exclusive views of the same subject, use Tabs. For content that merely overflows its box, use ScrollArea. For an ordered, multi-step task, use a stepper rather than an accordion.", + "whenNotToUse": "Do not hide content the reader needs in order to act \u2014 pricing, validation errors, required form fields, or anything they must compare across sections. For a single region that shows and hides, use Collapsible; there is no heading list to justify an accordion of one. For mutually exclusive views of the same subject, use Tabs. For content that merely overflows its box, use ScrollArea. For an ordered, multi-step task, use a stepper rather than an accordion.", "props": { "type": { "type": "enum", @@ -2707,7 +2707,7 @@ "description": "Sections open and close independently and any number can be open at once. Use when the reader may want to hold several panels open together, such as a settings page or a comparison." } ], - "description": "Whether the accordion is a one-at-a-time disclosure or a set of independent ones. Radix requires it — there is no default. It also decides the shape of value and defaultValue: a string when single, an array of strings when multiple." + "description": "Whether the accordion is a one-at-a-time disclosure or a set of independent ones. Radix requires it \u2014 there is no default. It also decides the shape of value and defaultValue: a string when single, an array of strings when multiple." }, "collapsible": { "type": "boolean", @@ -2742,7 +2742,7 @@ "values": [ { "value": "vertical", - "description": "Stacked headings — the ordinary arrangement. Arrow Up and Arrow Down move between triggers." + "description": "Stacked headings \u2014 the ordinary arrangement. Arrow Up and Arrow Down move between triggers." }, { "value": "horizontal", @@ -2781,7 +2781,7 @@ { "attribute": "aria-expanded", "condition": "supplied by the primitive on every trigger", - "description": "Radix sets it from the open state, alongside aria-controls on the trigger and aria-labelledby on the panel. Do not write these by hand — overriding them desynchronises what is announced from what is visible." + "description": "Radix sets it from the open state, alongside aria-controls on the trigger and aria-labelledby on the panel. Do not write these by hand \u2014 overriding them desynchronises what is announced from what is visible." } ], "keyboardInteractions": [ @@ -2819,14 +2819,14 @@ }, { "key": "Tab", - "description": "Moves focus to the next focusable element in the document — the first focusable child of an open panel when there is one, otherwise the next trigger." + "description": "Moves focus to the next focusable element in the document \u2014 the first focusable child of an open panel when there is one, otherwise the next trigger." }, { "key": "Shift + Tab", "description": "Moves focus to the previous focusable element." } ], - "focusManagement": "Focus stays on the trigger when a section expands or collapses; the panel is never auto-focused, so the reader chooses when to enter it with Tab. Only triggers participate in arrow-key navigation. A closed panel is unmounted, so nothing inside it can hold focus — which is also why in-page anchors and browser find-in-page cannot reach it.", + "focusManagement": "Focus stays on the trigger when a section expands or collapses; the panel is never auto-focused, so the reader chooses when to enter it with Tab. Only triggers participate in arrow-key navigation. A closed panel is unmounted, so nothing inside it can hold focus \u2014 which is also why in-page anchors and browser find-in-page cannot reach it.", "notes": "The accordion root carries no ARIA role of its own; the semantics live in its parts. Each AccordionTrigger is a button wrapped in Radix's Header, which renders an h3, and each AccordionContent is exposed as a region labelled by its trigger. Because that heading level is fixed at h3 in shadcn's generated component, place the accordion beneath an h2 so the document outline stays ordered. The trigger label is the only thing a screen-reader user has to decide on, so it must describe the panel, not gesture at it." }, "composition": { @@ -2834,7 +2834,7 @@ { "id": "accordion-item", "name": "AccordionItem", - "description": "One section: a trigger and the panel it controls. Its required value prop is the section's identity — unique within the accordion and stable across renders. Set disabled here to make a single section unavailable without disabling the whole accordion.", + "description": "One section: a trigger and the panel it controls. Its required value prop is the section's identity \u2014 unique within the accordion and stable across renders. Set disabled here to make a single section unavailable without disabling the whole accordion.", "required": true, "slot": "item", "acceptsChildren": "components", @@ -2865,12 +2865,12 @@ ] } ], - "notes": "An Accordion contains one or more AccordionItems and nothing else directly. Each item holds exactly one AccordionTrigger followed by one AccordionContent, in that order. The heading wrapper around the trigger is rendered for you — do not add your own heading inside AccordionTrigger, and do not nest a button, link, or form control in it, since the trigger is itself a button." + "notes": "An Accordion contains one or more AccordionItems and nothing else directly. Each item holds exactly one AccordionTrigger followed by one AccordionContent, in that order. The heading wrapper around the trigger is rendered for you \u2014 do not add your own heading inside AccordionTrigger, and do not nest a button, link, or form control in it, since the trigger is itself a button." }, "constraints": [ { "context": "Every Accordion", - "rule": "Set type explicitly — single when the panels are long and only one should be open, multiple when the sections are independent", + "rule": "Set type explicitly \u2014 single when the panels are long and only one should be open, multiple when the sections are independent", "severity": "must" }, { @@ -2880,7 +2880,7 @@ }, { "context": "An accordion with type=\"multiple\"", - "rule": "Do not pass collapsible — it has no effect, because every item can already be closed", + "rule": "Do not pass collapsible \u2014 it has no effect, because every item can already be closed", "severity": "must-not" }, { @@ -2952,7 +2952,7 @@ "description": "A bounded region that scrolls with a styled scrollbar that looks the same in every browser. Built on Radix UI ScrollArea: the content sits in a native overflow viewport, so scrolling itself stays native, while the visible scrollbar is a custom element that overlays the content instead of taking width from it.", "status": "stable", "whenToUse": "Use where a fixed-size box has to hold more content than fits and the platform scrollbar would be intrusive or inconsistent: a sidebar of navigation links, a long list inside a popover or dialog, a log or code panel, a horizontal row of cards. The bounded height or width is what makes it scroll.", - "whenNotToUse": "Do not use it for the page itself — the document's own scrolling carries scroll restoration, anchor navigation, browser chrome behaviour, and the scrollbar people expect. Do not use it to tuck away content you would rather not lay out: if the content matters, give it room. For very long or virtualised lists, point the virtualiser at the viewport element or use a native overflow container, since the custom scrollbar has to measure content it does not own.", + "whenNotToUse": "Do not use it for the page itself \u2014 the document's own scrolling carries scroll restoration, anchor navigation, browser chrome behaviour, and the scrollbar people expect. Do not use it to tuck away content you would rather not lay out: if the content matters, give it room. For very long or virtualised lists, point the virtualiser at the viewport element or use a native overflow container, since the custom scrollbar has to measure content it does not own.", "props": { "type": { "type": "enum", @@ -2960,7 +2960,7 @@ "values": [ { "value": "hover", - "description": "Scrollbars appear while the pointer is over the area and fade out after scrolling stops. The default and the quietest — but it tells a reader nothing until they hover." + "description": "Scrollbars appear while the pointer is over the area and fade out after scrolling stops. The default and the quietest \u2014 but it tells a reader nothing until they hover." }, { "value": "scroll", @@ -3007,7 +3007,7 @@ "requiredAttributes": [ { "attribute": "aria-label", - "condition": "when the scroll area holds a distinct body of content — a log, a list, a panel of links — and no visible heading names it", + "condition": "when the scroll area holds a distinct body of content \u2014 a log, a list, a panel of links \u2014 and no visible heading names it", "description": "Props you pass to ScrollArea land on its root element, so name the region there and pair the name with role=\"region\". Without a name, a keyboard user who lands in the scroller is told only that they are in a group." }, { @@ -3018,13 +3018,13 @@ { "attribute": "tabindex", "condition": "when the scrollable content contains no focusable elements of its own and you must support browsers that do not focus scroll containers", - "description": "Chrome 127+ and Firefox let the keyboard focus a scroll container with no focusable children — which is why shadcn styles a focus ring on the viewport. Where you cannot rely on that, the viewport needs an explicit tabIndex={0}, and setting it means editing the generated component: props passed to ScrollArea go to the root, not to the viewport that actually scrolls." + "description": "Chrome 127+ and Firefox let the keyboard focus a scroll container with no focusable children \u2014 which is why shadcn styles a focus ring on the viewport. Where you cannot rely on that, the viewport needs an explicit tabIndex={0}, and setting it means editing the generated component: props passed to ScrollArea go to the root, not to the viewport that actually scrolls." } ], "keyboardInteractions": [ { "key": "Arrow Up / Arrow Down", - "description": "Scrolls the viewport by a line when the scroll container has focus. Handled natively by the browser — Radix adds no key handling of its own." + "description": "Scrolls the viewport by a line when the scroll container has focus. Handled natively by the browser \u2014 Radix adds no key handling of its own." }, { "key": "Page Up / Page Down", @@ -3039,7 +3039,7 @@ "description": "Moves focus through focusable content inside the viewport; the browser scrolls each newly focused element into view." } ], - "focusManagement": "The scrollbar and its thumb are pointer-only affordances: they take no focus and answer no keys, so they must never be the only way to reach content. Keyboard scrolling comes from the browser acting on the focused element's scroll container — either something inside the viewport holds focus, or the viewport itself does.", + "focusManagement": "The scrollbar and its thumb are pointer-only affordances: they take no focus and answer no keys, so they must never be the only way to reach content. Keyboard scrolling comes from the browser acting on the focused element's scroll container \u2014 either something inside the viewport holds focus, or the viewport itself does.", "notes": "Radix deliberately leaves the viewport, scrollbar, thumb, and corner without ARIA roles: the scrolling is native and the bar is decoration over it. That also means an overflowing ScrollArea offers no built-in hint that content continues, so keep an edge fade, a count, or a heading nearby. Never suppress native scrolling in favour of the custom bar." }, "composition": { @@ -3058,7 +3058,7 @@ { "id": "scroll-area-thumb", "name": "ScrollAreaThumb", - "description": "The draggable indicator inside a scrollbar, sized from the ratio of visible to total content. Every ScrollBar renders one automatically; it is not exported from the generated component and is never authored by hand. Pointer-only — it takes no focus and answers no keys.", + "description": "The draggable indicator inside a scrollbar, sized from the ratio of visible to total content. Every ScrollBar renders one automatically; it is not exported from the generated component and is never authored by hand. Pointer-only \u2014 it takes no focus and answers no keys.", "required": false, "slot": "thumb", "acceptsChildren": "none", @@ -3067,12 +3067,12 @@ ] } ], - "notes": "ScrollArea puts whatever you give it inside Radix's Viewport — the element that actually scrolls — and renders a vertical ScrollBar and a Corner beside it. Neither the viewport nor the corner is exported, so the only part you write yourself is an extra ScrollBar with orientation=\"horizontal\". Size comes from the ScrollArea itself: with no bounded box there is no overflow and nothing scrolls." + "notes": "ScrollArea puts whatever you give it inside Radix's Viewport \u2014 the element that actually scrolls \u2014 and renders a vertical ScrollBar and a Corner beside it. Neither the viewport nor the corner is exported, so the only part you write yourself is an extra ScrollBar with orientation=\"horizontal\". Size comes from the ScrollArea itself: with no bounded box there is no overflow and nothing scrolls." }, "constraints": [ { "context": "Every ScrollArea", - "rule": "Constrain its box — a height for vertical scrolling, a width for horizontal; an area free to grow never overflows and never scrolls", + "rule": "Constrain its box \u2014 a height for vertical scrolling, a width for horizontal; an area free to grow never overflows and never scrolls", "severity": "must" }, { @@ -3137,18 +3137,18 @@ "description": "A box that holds a fixed width-to-height ratio at any width. Built on Radix UI AspectRatio, which reserves the space up front rather than waiting for the content, so an image, video, or map keeps its slot from the first paint and nothing below it jumps when the asset arrives.", "status": "stable", "whenToUse": "Use to reserve space for media whose intrinsic size you do not control or do not yet know: remote images, video and map embeds, iframes, and the skeletons that stand in for them. Use it across a grid or list so every tile crops to the same shape.", - "whenNotToUse": "Do not wrap text or anything that has to grow with its content — a fixed ratio clips or stretches it. Do not use it for media whose intrinsic dimensions are known and stable; set width and height on the element and let the browser reserve the space. For a plain fixed ratio in a modern browser, the CSS aspect-ratio utility (Tailwind's aspect-video, aspect-square) does the same job without a wrapper element.", + "whenNotToUse": "Do not wrap text or anything that has to grow with its content \u2014 a fixed ratio clips or stretches it. Do not use it for media whose intrinsic dimensions are known and stable; set width and height on the element and let the browser reserve the space. For a plain fixed ratio in a modern browser, the CSS aspect-ratio utility (Tailwind's aspect-video, aspect-square) does the same job without a wrapper element.", "props": { "ratio": { "type": "number", "propRole": "dimension", "default": 1, - "description": "Width divided by height. Write it as a division — 16 / 9 for video, 4 / 3 for classic photography, 1 for a square, 9 / 16 for portrait — so the intent survives review. Left unset the box is square, which is rarely what the design meant." + "description": "Width divided by height. Write it as a division \u2014 16 / 9 for video, 4 / 3 for classic photography, 1 for a square, 9 / 16 for portrait \u2014 so the intent survives review. Left unset the box is square, which is rarely what the design meant." } }, "accessibility": { "labelRequirement": "none", - "notes": "AspectRatio is a presentational box: it renders no ARIA role and contributes nothing to the accessibility tree. The content inside keeps its own obligations — an img still needs alt, an iframe still needs a title, a video still needs captions. Because content is usually fitted with object-cover, check that the crop never removes information the reader needs, such as text baked into an image; where it would, use object-contain and accept the letterboxing." + "notes": "AspectRatio is a presentational box: it renders no ARIA role and contributes nothing to the accessibility tree. The content inside keeps its own obligations \u2014 an img still needs alt, an iframe still needs a title, a video still needs captions. Because content is usually fitted with object-cover, check that the crop never removes information the reader needs, such as text baked into an image; where it would, use object-contain and accept the letterboxing." }, "constraints": [ { @@ -3158,7 +3158,7 @@ }, { "context": "Media placed inside an AspectRatio", - "rule": "Make it fill the box — full height and width with object-cover, or object-contain where cropping would lose meaning; otherwise the reserved space and the media disagree", + "rule": "Make it fill the box \u2014 full height and width with object-cover, or object-contain where cropping would lose meaning; otherwise the reserved space and the media disagree", "severity": "must" }, { @@ -3178,7 +3178,7 @@ }, { "context": "An iframe or video embed inside an AspectRatio", - "rule": "Keep the embed's own accessible name — a title on the iframe — since AspectRatio supplies none", + "rule": "Keep the embed's own accessible name \u2014 a title on the iframe \u2014 since AspectRatio supplies none", "severity": "must" }, { @@ -3998,7 +3998,7 @@ "description": "A caption that names a form control, built on Radix UI Label. Renders a native label element and suppresses text selection on double click so repeated clicks toggle the control rather than selecting its text. The label supplies the control's accessible name and extends its hit target.", "status": "stable", "whenToUse": "Use to name every form control on a surface: Input, Textarea, Checkbox, Switch, RadioGroupItem, and the Select trigger. Point htmlFor at the control's id so clicking or tapping the label focuses the control and, for checkboxes, switches, and radio items, activates it.", - "whenNotToUse": "Do not use it as a general text style for headings, hint text, or table column titles — it carries labelling semantics that assistive technology will try to associate with a control. Do not use one Label to name a group of controls such as a RadioGroup or a set of related checkboxes; a fieldset with a legend names a group. Do not put one on a Button — a button's own text is its accessible name.", + "whenNotToUse": "Do not use it as a general text style for headings, hint text, or table column titles \u2014 it carries labelling semantics that assistive technology will try to associate with a control. Do not use one Label to name a group of controls such as a RadioGroup or a set of related checkboxes; a fieldset with a legend names a group. Do not put one on a Button \u2014 a button's own text is its accessible name.", "props": { "htmlFor": { "type": "string", @@ -4027,7 +4027,7 @@ } ], "focusManagement": "Not focusable and not in the tab order. Activating the label moves focus to the associated control and, for Checkbox, Switch, and RadioGroupItem, toggles or selects it.", - "notes": "A label element exposes no ARIA role of its own; it contributes the accessible name of the control it references. It must contain non-empty text — an empty Label silently leaves the control unnamed. A placeholder, a title attribute, or adjacent prose that is not bound with htmlFor is not a substitute. Do not nest interactive content (buttons, links) inside a Label: clicking it would activate both the label's control and the nested control." + "notes": "A label element exposes no ARIA role of its own; it contributes the accessible name of the control it references. It must contain non-empty text \u2014 an empty Label silently leaves the control unnamed. A placeholder, a title attribute, or adjacent prose that is not bound with htmlFor is not a substitute. Do not nest interactive content (buttons, links) inside a Label: clicking it would activate both the label's control and the nested control." }, "constraints": [ { @@ -4037,7 +4037,7 @@ }, { "context": "A control whose purpose is already suggested by placeholder text", - "rule": "Still render a Label — placeholder text disappears on input and is not an accessible name", + "rule": "Still render a Label \u2014 placeholder text disappears on input and is not an accessible name", "severity": "must" }, { @@ -4057,7 +4057,7 @@ }, { "context": "Interactive content such as a Button or link inside label text", - "rule": "Move it outside the Label — activating the label would trigger both it and the labelled control", + "rule": "Move it outside the Label \u2014 activating the label would trigger both it and the labelled control", "severity": "must-not" } ], @@ -4084,7 +4084,7 @@ "description": "A multi-line text input rendered as a native textarea element. The shadcn wrapper applies field-sizing-content over a minimum height, so the control grows with what the user types instead of staying at a fixed row count.", "status": "stable", "whenToUse": "Use for free-form prose whose length is open-ended: descriptions, comments, support ticket bodies, release notes, feedback. Reach for it whenever a plausible answer would wrap onto a second line, or whenever the user should be able to press Enter for a new paragraph.", - "whenNotToUse": "Do not use for single-line values such as names, email addresses, amounts, or search queries, where Enter should submit rather than insert a newline; use Input. Do not use it as a code or rich-text editor — it has no formatting, syntax, or structured undo affordances. Do not use it to collect a value from a known set of options; use Select or RadioGroup.", + "whenNotToUse": "Do not use for single-line values such as names, email addresses, amounts, or search queries, where Enter should submit rather than insert a newline; use Input. Do not use it as a code or rich-text editor \u2014 it has no formatting, syntax, or structured undo affordances. Do not use it to collect a value from a known set of options; use Select or RadioGroup.", "props": { "value": { "type": "string", @@ -4104,7 +4104,7 @@ "placeholder": { "type": "string", "propRole": "content", - "description": "Hint text shown while the field is empty. Use it for an example of the expected content, never as the field's name — it disappears as soon as the user types." + "description": "Hint text shown while the field is empty. Use it for an example of the expected content, never as the field's name \u2014 it disappears as soon as the user types." }, "rows": { "type": "number", @@ -4114,7 +4114,7 @@ "maxLength": { "type": "number", "propRole": "dimension", - "description": "Maximum number of characters the browser will accept. Enforcement is silent — the field simply stops accepting input — so pair it with a visible count." + "description": "Maximum number of characters the browser will accept. Enforcement is silent \u2014 the field simply stops accepting input \u2014 so pair it with a visible count." }, "disabled": { "type": "boolean", @@ -4194,7 +4194,7 @@ }, { "context": "A textarea that needs a fixed or capped height", - "rule": "Constrain it with height classes — field-sizing-content supersedes the rows attribute", + "rule": "Constrain it with height classes \u2014 field-sizing-content supersedes the rows attribute", "severity": "should" }, { @@ -4228,7 +4228,7 @@ }, "checkbox": { "name": "Checkbox", - "description": "A tri-state checkbox built on Radix UI Checkbox. Renders a button with role checkbox plus a visually hidden native input so the value participates in form submission. The check mark is rendered internally by the component's indicator, so there is nothing to compose inside it — the option's text belongs in a sibling Label.", + "description": "A tri-state checkbox built on Radix UI Checkbox. Renders a button with role checkbox plus a visually hidden native input so the value participates in form submission. The check mark is rendered internally by the component's indicator, so there is nothing to compose inside it \u2014 the option's text belongs in a sibling Label.", "status": "stable", "whenToUse": "Use for independent boolean choices the user opts into: accepting terms, selecting rows, enabling several non-exclusive filters or permissions. Use a set of checkboxes whenever more than one option in a group may be chosen at once.", "whenNotToUse": "Do not use for mutually exclusive options; use RadioGroup. Do not use for a setting that takes effect the instant it is flipped with no Save step; that is Switch. Do not use a checkbox as a trigger for an action or for navigation; use Button.", @@ -4250,7 +4250,7 @@ "description": "Mixed state for a parent checkbox whose children are only partly selected. Radix exposes it as aria-checked=\"mixed\"; activating it resolves to checked, so it is never part of the user-facing click cycle." } ], - "description": "Controlled checked state. Pair it with onCheckedChange — a checked value without a handler produces a box the user cannot toggle." + "description": "Controlled checked state. Pair it with onCheckedChange \u2014 a checked value without a handler produces a box the user cannot toggle." }, "defaultChecked": { "type": "boolean | 'indeterminate'", @@ -4299,7 +4299,7 @@ }, { "key": "Tab", - "description": "Moves focus to the checkbox. Every checkbox is its own tab stop — unlike RadioGroup, where the whole group is one stop." + "description": "Moves focus to the checkbox. Every checkbox is its own tab stop \u2014 unlike RadioGroup, where the whole group is one stop." } ], "labelRequirement": "required-visible", @@ -4320,7 +4320,7 @@ } ], "focusManagement": "Focusable and in the tab order; focus stays on the control after toggling so the new state is announced in place. The hidden native input is never focused.", - "notes": "Radix sets aria-checked=\"mixed\" when checked is \"indeterminate\", and activating an indeterminate checkbox resolves it to checked — the mixed state is something the parent sets from its children's state, never a third position the user cycles through. Do not convey checked state by color alone; the check mark carries it." + "notes": "Radix sets aria-checked=\"mixed\" when checked is \"indeterminate\", and activating an indeterminate checkbox resolves it to checked \u2014 the mixed state is something the parent sets from its children's state, never a third position the user cycles through. Do not convey checked state by color alone; the check mark carries it." }, "constraints": [ { @@ -4387,7 +4387,7 @@ "description": "A control that turns a single setting on or off and applies the change immediately, built on Radix UI Switch. Renders a button with role switch and aria-checked plus a hidden native input for form submission, and slides a thumb between two positions to show state.", "status": "stable", "whenToUse": "Use for a setting whose effect is immediate and reversible: enabling notifications, turning on a beta feature, toggling dark mode in a preferences panel. The label names the setting; the switch's position carries on or off.", - "whenNotToUse": "Do not use inside a form that is only committed on Save — a switch implies the change has already happened; use Checkbox. Do not use it to accept terms or capture consent that is submitted with a form; that is a Checkbox. Do not use it to choose among options or for anything that is not a two-state setting; use RadioGroup or Select.", + "whenNotToUse": "Do not use inside a form that is only committed on Save \u2014 a switch implies the change has already happened; use Checkbox. Do not use it to accept terms or capture consent that is submitted with a form; that is a Checkbox. Do not use it to choose among options or for anything that is not a two-state setting; use RadioGroup or Select.", "props": { "checked": { "type": "boolean", @@ -4402,7 +4402,7 @@ "onCheckedChange": { "type": "function", "propRole": "handler", - "description": "Fires with the new boolean state when the user toggles the switch. This is where the setting is persisted — there is no Save step." + "description": "Fires with the new boolean state when the user toggles the switch. This is where the setting is persisted \u2014 there is no Save step." }, "size": { "type": "enum", @@ -4424,7 +4424,7 @@ "type": "boolean", "propRole": "flag", "default": false, - "description": "Whether the switch is non-interactive. Explain in nearby text why a setting is locked — a disabled switch on its own reads as an unexplained dead control." + "description": "Whether the switch is non-interactive. Explain in nearby text why a setting is locked \u2014 a disabled switch on its own reads as an unexplained dead control." }, "required": { "type": "boolean", @@ -4493,7 +4493,7 @@ }, { "context": "The wording of a switch's label", - "rule": "Name the setting rather than its current state — \"Email notifications\", never \"On\"", + "rule": "Name the setting rather than its current state \u2014 \"Email notifications\", never \"On\"", "severity": "must" }, { @@ -4503,7 +4503,7 @@ }, { "context": "A switch backed by a network request", - "rule": "Reflect the persisted result — revert the position and surface an error if the write fails, rather than leaving an optimistic state that lies about the setting", + "rule": "Reflect the persisted result \u2014 revert the position and surface an error if the write fails, rather than leaving an optimistic state that lies about the setting", "severity": "should" }, { @@ -4539,7 +4539,7 @@ "description": "A set of mutually exclusive options where exactly one may be selected, built on Radix UI RadioGroup. The group renders role radiogroup and manages a roving tabindex, so the whole group is a single tab stop; each RadioGroupItem is a button with role radio backed by a hidden native input for form submission.", "status": "stable", "whenToUse": "Use when the user must pick exactly one option from a small, stable set that is worth seeing all at once: a billing interval, a shipping speed, a visibility level. Showing every option side by side, with its trade-offs visible, is the reason to choose RadioGroup over Select.", - "whenNotToUse": "Do not use when more than one option may be selected; use Checkboxes. Do not use for long or runtime-generated lists — past roughly six options the group crowds the form and Select is easier to scan. Do not use for a binary on/off setting; use Switch or a single Checkbox.", + "whenNotToUse": "Do not use when more than one option may be selected; use Checkboxes. Do not use for long or runtime-generated lists \u2014 past roughly six options the group crowds the form and Select is easier to scan. Do not use for a binary on/off setting; use Switch or a single Checkbox.", "props": { "value": { "type": "string", @@ -4549,7 +4549,7 @@ "defaultValue": { "type": "string", "propRole": "state", - "description": "Initially selected value for an uncontrolled group. Omit it when there is no safe default — an unselected group forces a deliberate choice." + "description": "Initially selected value for an uncontrolled group. Omit it when there is no safe default \u2014 an unselected group forces a deliberate choice." }, "onValueChange": { "type": "function", @@ -4615,7 +4615,7 @@ { "id": "radio-group-item", "name": "RadioGroupItem", - "description": "A single option. Needs a value that is unique within the group and an id so a sibling Label can name it. The selected indicator is rendered internally, so the item takes no children — the option's text goes in a Label next to it, not inside it.", + "description": "A single option. Needs a value that is unique within the group and an id so a sibling Label can name it. The selected indicator is rendered internally, so the item takes no children \u2014 the option's text goes in a Label next to it, not inside it.", "required": true, "slot": "item", "acceptsChildren": "none", @@ -4627,7 +4627,7 @@ "requiredChildren": [ "radio-group-item" ], - "notes": "RadioGroupItem must be a descendant of RadioGroup, which supplies the selection context and the roving tabindex; an item outside a group is inert. Each item pairs with a Label whose htmlFor matches the item's id. Wrap the group in a fieldset with a legend so the whole set has an accessible name, and keep the items' order stable — arrow-key navigation follows DOM order." + "notes": "RadioGroupItem must be a descendant of RadioGroup, which supplies the selection context and the roving tabindex; an item outside a group is inert. Each item pairs with a Label whose htmlFor matches the item's id. Wrap the group in a fieldset with a legend so the whole set has an accessible name, and keep the items' order stable \u2014 arrow-key navigation follows DOM order." }, "accessibility": { "role": "radiogroup", @@ -4638,7 +4638,7 @@ }, { "key": "ArrowDown / ArrowRight", - "description": "Moves focus to the next item and checks it — selection follows focus." + "description": "Moves focus to the next item and checks it \u2014 selection follows focus." }, { "key": "ArrowUp / ArrowLeft", @@ -4676,7 +4676,7 @@ "description": "Set it on the RadioGroup so the state is announced for the set. shadcn's item styles also key off aria-invalid, so mirror it onto the RadioGroupItems when the options themselves should show the error." } ], - "focusManagement": "Roving tabindex: exactly one item is tabbable at a time, and arrow keys move focus within the group, checking as they land. Because selection follows focus, a keyboard user cannot browse the options without selecting them — keep the set short and keep onValueChange free of side effects.", + "focusManagement": "Roving tabindex: exactly one item is tabbable at a time, and arrow keys move focus within the group, checking as they land. Because selection follows focus, a keyboard user cannot browse the options without selecting them \u2014 keep the set short and keep onValueChange free of side effects.", "notes": "The group name and the option names are separate obligations: a fieldset legend (or aria-labelledby) names the set, and a Label per item names each option. Do not convey the selected option by color alone; the indicator dot carries it." }, "constraints": [ @@ -4717,7 +4717,7 @@ }, { "context": "Reacting to a change of selection", - "rule": "Do not navigate, submit, or trigger destructive side effects on change — selection follows focus, so arrow keys would fire them for every option passed over", + "rule": "Do not navigate, submit, or trigger destructive side effects on change \u2014 selection follows focus, so arrow keys would fire them for every option passed over", "severity": "must-not" } ], @@ -4744,10 +4744,10 @@ }, "select": { "name": "Select", - "description": "A select-only combobox built on Radix UI Select: a trigger button that opens a portalled listbox of options. The trigger renders role combobox, the content renders role listbox, and each item renders role option, while a hidden native select carries the value into form submission. It is not a native select element — the list is fully stylable but always renders in a layer above the page, and it accepts no typed input.", + "description": "A select-only combobox built on Radix UI Select: a trigger button that opens a portalled listbox of options. The trigger renders role combobox, the content renders role listbox, and each item renders role option, while a hidden native select carries the value into form submission. It is not a native select element \u2014 the list is fully stylable but always renders in a layer above the page, and it accepts no typed input.", "status": "stable", "whenToUse": "Use to pick one value from a known list that is too long to show inline: a country, a timezone, a project, a status. It earns its click from roughly six options upward, where a RadioGroup would crowd the form.", - "whenNotToUse": "Do not use for two to five options that fit on screen — a RadioGroup shows them all without a click. Do not use when more than one value may be chosen; Select is single-value, so use checkboxes or a dedicated multi-select. Do not use when the user needs to type to filter a long list or to enter a value that is not in it; use a combobox. When the platform's native picker is what you actually want, on dense mobile forms, use a native select instead.", + "whenNotToUse": "Do not use for two to five options that fit on screen \u2014 a RadioGroup shows them all without a click. Do not use when more than one value may be chosen; Select is single-value, so use checkboxes or a dedicated multi-select. Do not use when the user needs to type to filter a long list or to enter a value that is not in it; use a combobox. When the platform's native picker is what you actually want, on dense mobile forms, use a native select instead.", "props": { "value": { "type": "string", @@ -4762,7 +4762,7 @@ "onValueChange": { "type": "function", "propRole": "handler", - "description": "Fires with the newly selected value. Selection is committed on click, Enter, Space, or typeahead — not while arrowing through the list." + "description": "Fires with the newly selected value. Selection is committed on click, Enter, Space, or typeahead \u2014 not while arrowing through the list." }, "open": { "type": "boolean", @@ -4817,7 +4817,7 @@ { "id": "select-trigger", "name": "SelectTrigger", - "description": "The button that opens the listbox. Renders role combobox with aria-expanded, shows the current value, and draws its own chevron; it accepts a size of \"default\" or \"sm\". Give it an id — this is the element a Label's htmlFor must point at, and the element that carries aria-invalid.", + "description": "The button that opens the listbox. Renders role combobox with aria-expanded, shows the current value, and draws its own chevron; it accepts a size of \"default\" or \"sm\". Give it an id \u2014 this is the element a Label's htmlFor must point at, and the element that carries aria-invalid.", "required": true, "slot": "trigger", "acceptsChildren": "components", @@ -4860,7 +4860,7 @@ { "id": "select-label", "name": "SelectLabel", - "description": "A non-interactive heading naming a SelectGroup. It supplies the group's accessible name only when it sits inside a SelectGroup; used on its own it is decorative text. It is not the form field's label — that is a separate Label bound to the trigger.", + "description": "A non-interactive heading naming a SelectGroup. It supplies the group's accessible name only when it sits inside a SelectGroup; used on its own it is decorative text. It is not the form field's label \u2014 that is a separate Label bound to the trigger.", "acceptsChildren": "text" }, { @@ -4878,7 +4878,7 @@ "select-trigger", "select-content" ], - "notes": "Select takes exactly two children: a SelectTrigger and a SelectContent. SelectValue belongs inside the trigger; SelectItem, SelectGroup, SelectLabel, and SelectSeparator belong inside the content, with SelectLabel nested in the SelectGroup it names. shadcn renders the scrolling viewport and the scroll-up/scroll-down buttons inside SelectContent for you — those parts are exported but are not part of normal composition. The field's own Label lives outside the Select and points at the trigger's id." + "notes": "Select takes exactly two children: a SelectTrigger and a SelectContent. SelectValue belongs inside the trigger; SelectItem, SelectGroup, SelectLabel, and SelectSeparator belong inside the content, with SelectLabel nested in the SelectGroup it names. shadcn renders the scrolling viewport and the scroll-up/scroll-down buttons inside SelectContent for you \u2014 those parts are exported but are not part of normal composition. The field's own Label lives outside the Select and points at the trigger's id." }, "accessibility": { "role": "combobox", @@ -4926,7 +4926,7 @@ "description": "Reference the ids of that text so it is announced with the control." } ], - "focusManagement": "Focus starts on the trigger. Opening moves focus into the listbox, onto the selected item or the first item when nothing is selected, and contains it there; choosing an item or pressing Escape closes the listbox and returns focus to the trigger. Unlike RadioGroup, selection does not follow focus — arrow keys only move the highlight until Enter, Space, or a click commits.", + "focusManagement": "Focus starts on the trigger. Opening moves focus into the listbox, onto the selected item or the first item when nothing is selected, and contains it there; choosing an item or pressing Escape closes the listbox and returns focus to the trigger. Unlike RadioGroup, selection does not follow focus \u2014 arrow keys only move the highlight until Enter, Space, or a click commits.", "notes": "The roles are split across the parts: trigger is combobox with aria-expanded and aria-controls, content is listbox, item is option, and SelectGroup is a group labelled by its SelectLabel. Radix keeps a hidden native select in sync for form submission; it is aria-hidden, so never label, style, or target it. The listbox renders in a portal, so it escapes ancestor overflow but also any ancestor that assumed it could clip its children." }, "constraints": [ @@ -4952,12 +4952,12 @@ }, { "context": "Every SelectItem", - "rule": "Give it a unique, non-empty value — the empty string is how Radix represents nothing selected, so an item using it can never be shown as the selection", + "rule": "Give it a unique, non-empty value \u2014 the empty string is how Radix represents nothing selected, so an item using it can never be shown as the selection", "severity": "must" }, { "context": "An empty, \"None\", or \"All\" choice", - "rule": "Express the empty state with the SelectValue placeholder, or add an item with a real value such as \"none\" — never an item with an empty value", + "rule": "Express the empty state with the SelectValue placeholder, or add an item with a real value such as \"none\" \u2014 never an item with an empty value", "severity": "must-not" }, { @@ -5013,7 +5013,7 @@ "description": "A static, in-page callout that draws attention to a short message without interrupting the flow. Renders a div with a hardcoded role=\"alert\" and lays out an optional icon, a title, a description, and an optional action on a grid. Alert is presentational: it has no open state, no dismiss affordance, and no portal, and it stays wherever you place it in the document.", "status": "stable", "whenToUse": "Use for a persistent message that belongs to a region of the page: a form-level error summary above the submit button, a plan-limit warning on a settings panel, an informational note explaining a feature. Reach for Alert when the message must stay on screen for as long as the condition holds and the user should be able to re-read it after looking away.", - "whenNotToUse": "Do not use for a transient confirmation of something the user just did — Sonner clears itself, Alert does not. Do not use to block a workflow or demand a decision; that is AlertDialog. Do not use it as a generic bordered container for arbitrary content; that is Card. Do not use it for a short status label attached to a row or item; that is Badge.", + "whenNotToUse": "Do not use for a transient confirmation of something the user just did \u2014 Sonner clears itself, Alert does not. Do not use to block a workflow or demand a decision; that is AlertDialog. Do not use it as a generic bordered container for arbitrary content; that is Card. Do not use it for a short status label attached to a row or item; that is Badge.", "props": { "variant": { "type": "enum", @@ -5029,7 +5029,7 @@ } ], "default": "default", - "description": "Visual treatment. shadcn ships only these two variants — there is no success or warning variant — so other tones are built by adding utility classes through className." + "description": "Visual treatment. shadcn ships only these two variants \u2014 there is no success or warning variant \u2014 so other tones are built by adding utility classes through className." }, "className": { "type": "string", @@ -5044,7 +5044,7 @@ { "attribute": "role", "condition": "when the Alert is part of the initial page render rather than appearing in response to an event", - "description": "Override the built-in role with role=\"status\" or role=\"note\". role=\"alert\" is an assertive live region that interrupts whatever the screen reader is currently saying — correct for an error that appears after a failed submit, wrong for a callout that was always on the page. The component spreads incoming props after its own role, so the value you pass wins." + "description": "Override the built-in role with role=\"status\" or role=\"note\". role=\"alert\" is an assertive live region that interrupts whatever the screen reader is currently saying \u2014 correct for an error that appears after a failed submit, wrong for a callout that was always on the page. The component spreads incoming props after its own role, so the value you pass wins." }, { "attribute": "aria-hidden", @@ -5064,14 +5064,14 @@ } ], "focusManagement": "Alert never moves, traps, or restores focus. When one appears after a failed submit, leave focus where the user put it and let role=\"alert\" do the announcing; move focus into the alert only when the user's next action genuinely lives there.", - "notes": "role=\"alert\" is hardcoded on the root element, which makes every Alert an assertive, atomic live region. Reserve that for messages the user must hear immediately and downgrade the rest with an explicit role. AlertTitle and AlertDescription are plain divs, not headings, and are not wired to the container with aria-labelledby or aria-describedby — the whole alert is announced as a single run of text, so write the title and description to read well when concatenated." + "notes": "role=\"alert\" is hardcoded on the root element, which makes every Alert an assertive, atomic live region. Reserve that for messages the user must hear immediately and downgrade the rest with an explicit role. AlertTitle and AlertDescription are plain divs, not headings, and are not wired to the container with aria-labelledby or aria-describedby \u2014 the whole alert is announced as a single run of text, so write the title and description to read well when concatenated." }, "composition": { "subComponents": [ { "id": "alert-title", "name": "AlertTitle", - "description": "The one-line headline of the alert. Rendered as a div with a medium font weight — not a heading — and moved into the second grid column when an icon is present. Write it as the message itself, not a category label: \"Your trial ends in 3 days\" rather than \"Warning\".", + "description": "The one-line headline of the alert. Rendered as a div with a medium font weight \u2014 not a heading \u2014 and moved into the second grid column when an icon is present. Write it as the message itself, not a category label: \"Your trial ends in 3 days\" rather than \"Warning\".", "required": true, "slot": "title", "acceptsChildren": "text" @@ -5094,7 +5094,7 @@ ] } ], - "notes": "Order the children as icon, AlertTitle, AlertDescription, AlertAction. The grid switches to a two-column layout only when a direct svg child is present, and it spans that icon across both text rows, so pass at most one icon and pass it as a direct child of Alert rather than nesting it inside the title. AlertAction is positioned absolutely wherever it appears in the markup; keep it last so the DOM order still matches the reading order. Alert has no close button and no dismiss state — if the message should go away on its own, it is a toast, not an alert." + "notes": "Order the children as icon, AlertTitle, AlertDescription, AlertAction. The grid switches to a two-column layout only when a direct svg child is present, and it spans that icon across both text rows, so pass at most one icon and pass it as a direct child of Alert rather than nesting it inside the title. AlertAction is positioned absolutely wherever it appears in the markup; keep it last so the DOM order still matches the reading order. Alert has no close button and no dismiss state \u2014 if the message should go away on its own, it is a toast, not an alert." }, "constraints": [ { @@ -5178,24 +5178,24 @@ "description": "A determinate progress bar built on Radix UI Progress. Renders a track with role=\"progressbar\" and an indicator whose horizontal offset is driven by the value prop, and publishes aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext automatically. The shadcn wrapper renders both the track and the indicator itself and exports no sub-components.", "status": "stable", "whenToUse": "Use when completion can be expressed as a number the user can watch move: a file upload, a multi-step form, a quota filling up, an import job reporting processed records. Progress earns its place when quantifying the remaining work tells the user something a spinner cannot.", - "whenNotToUse": "Do not use when you cannot compute a percentage — an operation of unknown length needs a Spinner, and a page or panel waiting on its content needs a Skeleton. Do not use it as a gauge for a static quantity: a battery level, a score, or a disk-usage readout is a meter, not progress, and must not claim role=\"progressbar\". Do not use it as a decorative rule or as the connector line in a stepper.", + "whenNotToUse": "Do not use when you cannot compute a percentage \u2014 an operation of unknown length needs a Spinner, and a page or panel waiting on its content needs a Skeleton. Do not use it as a gauge for a static quantity: a battery level, a score, or a disk-usage readout is a meter, not progress, and must not claim role=\"progressbar\". Do not use it as a decorative rule or as the connector line in a stepper.", "props": { "value": { "type": "number", "propRole": "state", "required": true, - "description": "How much of the task is complete, on a 0–100 scale. Radix treats a missing or null value as indeterminate and drops aria-valuenow, but the shadcn wrapper computes the indicator offset as translateX(-${100 - (value || 0)}%), so omitting value paints a permanently empty track instead of an indeterminate animation. Always pass a number; when the duration is unknown, use Spinner instead." + "description": "How much of the task is complete, on a 0\u2013100 scale. Radix treats a missing or null value as indeterminate and drops aria-valuenow, but the shadcn wrapper computes the indicator offset as translateX(-${100 - (value || 0)}%), so omitting value paints a permanently empty track instead of an indeterminate animation. Always pass a number; when the duration is unknown, use Spinner instead." }, "max": { "type": "number", "propRole": "dimension", "default": 100, - "description": "Upper bound of value, published as aria-valuemax. Radix falls back to 100 when max is absent or not greater than zero. Note that the shipped indicator transform is hardcoded to a 0–100 scale: setting max to anything else keeps the ARIA values correct while rendering the bar at the wrong width. Normalise your value to a percentage before passing it, or edit the transform in progress.tsx." + "description": "Upper bound of value, published as aria-valuemax. Radix falls back to 100 when max is absent or not greater than zero. Note that the shipped indicator transform is hardcoded to a 0\u2013100 scale: setting max to anything else keeps the ARIA values correct while rendering the bar at the wrong width. Normalise your value to a percentage before passing it, or edit the transform in progress.tsx." }, "getValueLabel": { "type": "function", "propRole": "content", - "description": "(value, max) => string. Produces the aria-valuetext announced in place of the raw number; Radix's default returns a rounded percentage. Override it when a percentage is not the useful unit — \"3 of 8 files\", \"about 2 minutes left\" — so the announcement matches whatever is written next to the bar." + "description": "(value, max) => string. Produces the aria-valuetext announced in place of the raw number; Radix's default returns a rounded percentage. Override it when a percentage is not the useful unit \u2014 \"3 of 8 files\", \"about 2 minutes left\" \u2014 so the announcement matches whatever is written next to the bar." }, "className": { "type": "string", @@ -5219,15 +5219,15 @@ }, { "attribute": "aria-valuenow", - "condition": "always — applied automatically, never by hand", + "condition": "always \u2014 applied automatically, never by hand", "description": "Radix derives aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext from value, max, and getValueLabel. A hand-written value disagrees with the rendered indicator the moment state changes, so change the value prop instead." } ], "focusManagement": "Progress is not focusable and takes no part in the tab order. When the operation finishes and the bar is replaced, move focus deliberately to whatever the user should do next rather than letting it fall to the body.", - "notes": "Radix sets data-state to loading, complete, or indeterminate, which is available for styling. Screen readers do not reliably re-announce a progressbar as its value changes, and they do not announce completion — do not rely on the bar alone to report that a task finished; say so in a polite live region or in visible copy. If the bar reports a static quantity rather than an operation in flight, role=\"meter\" is the correct role and this is the wrong component." + "notes": "Radix sets data-state to loading, complete, or indeterminate, which is available for styling. Screen readers do not reliably re-announce a progressbar as its value changes, and they do not announce completion \u2014 do not rely on the bar alone to report that a task finished; say so in a polite live region or in visible copy. If the bar reports a static quantity rather than an operation in flight, role=\"meter\" is the correct role and this is the wrong component." }, "composition": { - "notes": "Progress renders the Radix Root and Indicator itself and exports no sub-components, so nothing goes inside it. Build the label and the numeric readout as siblings — shadcn's documented example composes Progress with Field for exactly this — and point aria-labelledby at the visible label." + "notes": "Progress renders the Radix Root and Indicator itself and exports no sub-components, so nothing goes inside it. Build the label and the numeric readout as siblings \u2014 shadcn's documented example composes Progress with Field for exactly this \u2014 and point aria-labelledby at the visible label." }, "constraints": [ { @@ -5246,7 +5246,7 @@ "severity": "must" }, { - "context": "A value on a scale other than 0–100", + "context": "A value on a scale other than 0\u2013100", "rule": "Normalise the value to a percentage before passing it; the shipped indicator transform ignores max", "severity": "must" }, @@ -5292,10 +5292,10 @@ }, "skeleton": { "name": "Skeleton", - "description": "A pulsing placeholder block that stands in for content that has not arrived yet. It is a single muted, rounded div with animate-pulse and no intrinsic size — you give it the shape of the content it replaces using utility classes. It carries no ARIA role and no loading semantics of its own; those belong to the region that contains it.", + "description": "A pulsing placeholder block that stands in for content that has not arrived yet. It is a single muted, rounded div with animate-pulse and no intrinsic size \u2014 you give it the shape of the content it replaces using utility classes. It carries no ARIA role and no loading semantics of its own; those belong to the region that contains it.", "status": "stable", "whenToUse": "Use when you already know the shape of the content that is loading and the wait is short enough that showing the layout reassures more than a spinner would: a card grid, the first page of a table, a profile header, a form being hydrated. Skeletons are most valuable when they hold the layout still so nothing jumps when the real content lands.", - "whenNotToUse": "Do not use for feedback on an action the user just triggered — a submitting button wants a Spinner inside it, not a placeholder where the control used to be. Do not use when real progress is measurable; that is Progress. Do not use for long or open-ended waits, where a repeating pulse becomes noise: show an explanatory empty or error state instead. Do not ship a skeleton whose dimensions differ from the content that replaces it.", + "whenNotToUse": "Do not use for feedback on an action the user just triggered \u2014 a submitting button wants a Spinner inside it, not a placeholder where the control used to be. Do not use when real progress is measurable; that is Progress. Do not use for long or open-ended waits, where a repeating pulse becomes noise: show an explanatory empty or error state instead. Do not ship a skeleton whose dimensions differ from the content that replaces it.", "props": { "className": { "type": "string", @@ -5306,7 +5306,7 @@ "aria-hidden": { "type": "boolean", "propRole": "flag", - "description": "Removes the placeholder from the accessibility tree. Skeletons carry no information — a screen reader walking a tree of empty divs learns nothing — so hide them and announce the loading state once on the containing region instead. The component does not set this for you." + "description": "Removes the placeholder from the accessibility tree. Skeletons carry no information \u2014 a screen reader walking a tree of empty divs learns nothing \u2014 so hide them and announce the loading state once on the containing region instead. The component does not set this for you." } }, "accessibility": { @@ -5325,11 +5325,11 @@ { "attribute": "aria-live", "condition": "on the region that reports the loading state", - "description": "Announce the transition once — a polite live region, or a visually hidden element with role=\"status\" containing text such as \"Loading results\", swapped for the count when the data arrives. Skeletons announce nothing themselves." + "description": "Announce the transition once \u2014 a polite live region, or a visually hidden element with role=\"status\" containing text such as \"Loading results\", swapped for the count when the data arrives. Skeletons announce nothing themselves." } ], - "focusManagement": "Skeletons are not focusable and must not be made focusable. If a placeholder replaces a control the user had focused, focus is dropped to the body when the skeleton mounts — prefer disabling the control in place over swapping it for a skeleton, and restore focus deliberately when the content arrives.", - "notes": "The pulse comes from Tailwind's animate-pulse, which runs regardless of the user's motion preference. Add motion-reduce:animate-none (or scope the animation in CSS) so users who have asked for reduced motion get a static placeholder. Because the component is a bare div, everything about how the loading state is communicated — the busy state, the announcement, the eventual result — is the caller's responsibility." + "focusManagement": "Skeletons are not focusable and must not be made focusable. If a placeholder replaces a control the user had focused, focus is dropped to the body when the skeleton mounts \u2014 prefer disabling the control in place over swapping it for a skeleton, and restore focus deliberately when the content arrives.", + "notes": "The pulse comes from Tailwind's animate-pulse, which runs regardless of the user's motion preference. Add motion-reduce:animate-none (or scope the animation in CSS) so users who have asked for reduced motion get a static placeholder. Because the component is a bare div, everything about how the loading state is communicated \u2014 the busy state, the announcement, the eventual result \u2014 is the caller's responsibility." }, "composition": { "notes": "Skeleton has no sub-components. Build a placeholder by composing several Skeletons inside the same layout primitives the real content uses, so the two are structurally interchangeable and the swap causes no reflow. shadcn documents this pattern for avatars, cards, text runs, forms, and tables." @@ -5397,26 +5397,26 @@ }, "spinner": { "name": "Spinner", - "description": "An indeterminate loading indicator. It is a single lucide icon — Loader2Icon as shipped — rendered with animate-spin and a hardcoded role=\"status\" and aria-label=\"Loading\". There are no variants and no size prop: size and colour come from utility classes, and the icon is changed by editing the component rather than through a prop.", + "description": "An indeterminate loading indicator. It is a single lucide icon \u2014 Loader2Icon as shipped \u2014 rendered with animate-spin and a hardcoded role=\"status\" and aria-label=\"Loading\". There are no variants and no size prop: size and colour come from utility classes, and the icon is changed by editing the component rather than through a prop.", "status": "stable", "whenToUse": "Use for a wait whose length you cannot predict and whose shape you cannot draw: a button that has just submitted, a field validating against the server, an input group fetching suggestions, a small region refreshing in place. Spinner works best inside or beside the control that started the work, so the indicator is where the user is already looking.", - "whenNotToUse": "Do not use when completion is measurable — that is Progress. Do not use for the first paint of a page or panel whose layout you already know; Skeleton holds the layout and reads as faster. Do not scatter several spinners across one view: one indicator per in-flight operation, placed at the operation.", + "whenNotToUse": "Do not use when completion is measurable \u2014 that is Progress. Do not use for the first paint of a page or panel whose layout you already know; Skeleton holds the layout and reads as faster. Do not scatter several spinners across one view: one indicator per in-flight operation, placed at the operation.", "props": { "className": { "type": "string", "propRole": "content", - "description": "Utility classes. Size the spinner with size-* — it ships at size-4 — and colour it with a text-* class. The icon draws with currentColor, so by default it inherits the colour of whatever it sits inside, which is usually what you want inside a Button or Badge." + "description": "Utility classes. Size the spinner with size-* \u2014 it ships at size-4 \u2014 and colour it with a text-* class. The icon draws with currentColor, so by default it inherits the colour of whatever it sits inside, which is usually what you want inside a Button or Badge." }, "aria-label": { "type": "string", "propRole": "content", "default": "Loading", - "description": "Accessible name of the indicator. The component sets \"Loading\" and spreads incoming props afterwards, so passing aria-label replaces it. Make it specific when more than one thing on the page can be loading (\"Loading search results\"), and localise it — the built-in string is English and is not translated." + "description": "Accessible name of the indicator. The component sets \"Loading\" and spreads incoming props afterwards, so passing aria-label replaces it. Make it specific when more than one thing on the page can be loading (\"Loading search results\"), and localise it \u2014 the built-in string is English and is not translated." }, "aria-hidden": { "type": "boolean", "propRole": "flag", - "description": "Removes the spinner from the accessibility tree. Set it to true whenever the surrounding control already announces the state — a Button whose accessible name changes to \"Saving…\", or a region with its own role=\"status\" — so the wait is announced once rather than twice." + "description": "Removes the spinner from the accessibility tree. Set it to true whenever the surrounding control already announces the state \u2014 a Button whose accessible name changes to \"Saving\u2026\", or a region with its own role=\"status\" \u2014 so the wait is announced once rather than twice." } }, "accessibility": { @@ -5426,7 +5426,7 @@ { "attribute": "aria-label", "condition": "when the built-in \"Loading\" is ambiguous, or the interface is not in English", - "description": "Replace the hardcoded English default with a localised, specific name. The component supplies a name, so the requirement is already met by default — but the default is neither translated nor scoped to what is actually loading." + "description": "Replace the hardcoded English default with a localised, specific name. The component supplies a name, so the requirement is already met by default \u2014 but the default is neither translated nor scoped to what is actually loading." }, { "attribute": "aria-hidden", @@ -5435,10 +5435,10 @@ } ], "focusManagement": "Spinner is not focusable and takes no part in the tab order. When it appears inside a Button, keep the Button mounted and set disabled rather than unmounting or replacing it, so focus is not dropped to the body while the operation runs.", - "notes": "role=\"status\" makes the icon a polite live region, but its content never changes after mount, and screen readers vary in whether they announce a live region that is itself inserted into the DOM. Do not rely on the spinner alone to tell a screen-reader user that work has started: change the control's accessible name (\"Save\" to \"Saving…\"), set aria-busy on the region, or announce the state in a live region you own — and mark the spinner aria-hidden so it does not duplicate that. animate-spin does not honour prefers-reduced-motion, so a continuously rotating indicator keeps rotating for users who have asked for less motion." + "notes": "role=\"status\" makes the icon a polite live region, but its content never changes after mount, and screen readers vary in whether they announce a live region that is itself inserted into the DOM. Do not rely on the spinner alone to tell a screen-reader user that work has started: change the control's accessible name (\"Save\" to \"Saving\u2026\"), set aria-busy on the region, or announce the state in a live region you own \u2014 and mark the spinner aria-hidden so it does not duplicate that. animate-spin does not honour prefers-reduced-motion, so a continuously rotating indicator keeps rotating for users who have asked for less motion." }, "composition": { - "notes": "Spinner has no sub-components — it is the thing that gets composed. shadcn documents it inside Button, Badge, InputGroup, and Empty; place it before the label with data-icon=\"inline-start\" or after it with data-icon=\"inline-end\" to control which side it sits on. To use a different icon, swap the lucide import in spinner.tsx; there is no icon prop and no size prop." + "notes": "Spinner has no sub-components \u2014 it is the thing that gets composed. shadcn documents it inside Button, Badge, InputGroup, and Empty; place it before the label with data-icon=\"inline-start\" or after it with data-icon=\"inline-end\" to control which side it sits on. To use a different icon, swap the lucide import in spinner.tsx; there is no icon prop and no size prop." }, "constraints": [ { @@ -5507,10 +5507,10 @@ }, "sonner": { "name": "Sonner", - "description": "The toast system shadcn ships, wrapping the sonner library. The registry item installs exactly one component — Toaster — which you mount once at the root of the app and theme through next-themes. Individual toasts are not written as JSX: you call toast() from the sonner package anywhere in your code and the mounted Toaster renders, stacks, and expires them. A toast's title, description, icon, action, and cancel button are all options on that call.", + "description": "The toast system shadcn ships, wrapping the sonner library. The registry item installs exactly one component \u2014 Toaster \u2014 which you mount once at the root of the app and theme through next-themes. Individual toasts are not written as JSX: you call toast() from the sonner package anywhere in your code and the mounted Toaster renders, stacks, and expires them. A toast's title, description, icon, action, and cancel button are all options on that call.", "status": "stable", "whenToUse": "Use to confirm the outcome of something the user just did, when the message is short, needs no response, and can be missed without consequence: \"Event created\", \"Copied to clipboard\", \"Changes saved\". Use toast.promise for an async action that should report loading and then success or failure from a single call site.", - "whenNotToUse": "Do not use for anything the user must read or act on. A toast disappears on a timer, is announced only politely, and sits outside the tab order — an error that blocks progress belongs in an Alert beside the thing that failed, and a decision belongs in an AlertDialog. Do not use it for field-level validation errors; put those on the field. Do not narrate a process with a run of toasts; only a few are visible at a time and the rest collapse.", + "whenNotToUse": "Do not use for anything the user must read or act on. A toast disappears on a timer, is announced only politely, and sits outside the tab order \u2014 an error that blocks progress belongs in an Alert beside the thing that failed, and a decision belongs in an AlertDialog. Do not use it for field-level validation errors; put those on the field. Do not narrate a process with a run of toasts; only a few are visible at a time and the rest collapse.", "props": { "position": { "type": "enum", @@ -5568,7 +5568,7 @@ "type": "boolean", "propRole": "flag", "default": false, - "description": "Tints success, error, warning, and info toasts with their semantic colours instead of the neutral popover surface. The built-in icons already distinguish the types, so treat this as emphasis rather than as the signal — and check the tinted surfaces for contrast in both themes before enabling it." + "description": "Tints success, error, warning, and info toasts with their semantic colours instead of the neutral popover surface. The built-in icons already distinguish the types, so treat this as emphasis rather than as the signal \u2014 and check the tinted surfaces for contrast in both themes before enabling it." }, "closeButton": { "type": "boolean", @@ -5598,7 +5598,7 @@ "type": "string | number | object", "propRole": "dimension", "default": "32px", - "description": "Distance from the viewport edge. Accepts one value or an object of top/right/bottom/left. Increase it when fixed chrome — a bottom bar, a consent banner, a floating action button — would otherwise sit under the toasts." + "description": "Distance from the viewport edge. Accepts one value or an object of top/right/bottom/left. Increase it when fixed chrome \u2014 a bottom bar, a consent banner, a floating action button \u2014 would otherwise sit under the toasts." }, "mobileOffset": { "type": "string | number | object", @@ -5644,7 +5644,7 @@ "icons": { "type": "object", "propRole": "content", - "description": "Map of toast type to the node rendered as its icon — success, info, warning, error, and loading. The shadcn wrapper already fills all five with lucide icons and animates the loading one; override it only to match a different icon set, and keep all five so the types stay distinguishable." + "description": "Map of toast type to the node rendered as its icon \u2014 success, info, warning, error, and loading. The shadcn wrapper already fills all five with lucide icons and animates the loading one; override it only to match a different icon set, and keep all five so the types stay distinguishable." }, "toastOptions": { "type": "object", @@ -5669,8 +5669,8 @@ "requiredAttributes": [ { "attribute": "aria-live", - "condition": "always — applied by sonner to the notification region", - "description": "Sonner puts aria-live=\"polite\", aria-relevant=\"additions text\", and aria-atomic=\"false\" on the wrapping region and leaves the individual toasts without any role. Every toast — including error toasts — is therefore announced politely, after whatever the screen reader is currently saying. There is no assertive path; if a message must interrupt, it should not be a toast." + "condition": "always \u2014 applied by sonner to the notification region", + "description": "Sonner puts aria-live=\"polite\", aria-relevant=\"additions text\", and aria-atomic=\"false\" on the wrapping region and leaves the individual toasts without any role. Every toast \u2014 including error toasts \u2014 is therefore announced politely, after whatever the screen reader is currently saying. There is no assertive path; if a message must interrupt, it should not be a toast." }, { "attribute": "aria-label", @@ -5700,11 +5700,11 @@ "description": "Activates the focused action, cancel, or close button." } ], - "focusManagement": "Toasts never take focus when they appear — that is the point of the pattern, and it is why they must not carry anything essential. Focus enters the region only through the hotkey, and sonner restores focus to the previously focused element when focus leaves the region or the region unmounts. A toast can auto-dismiss while a keyboard user is still tabbing through it, so never put the only copy of an action inside one.", - "notes": "Individual toasts carry no role of their own — not role=\"alert\", not role=\"status\" — and are announced only because they are inserted into the wrapping polite live region. That makes toasts unsuitable for urgent content: an error that must interrupt belongs in an Alert next to the failure, or in an AlertDialog if a decision is needed. Because toasts sit outside the tab order, a keyboard user who does not know the hotkey cannot reach an action inside one before it expires. Duration is an accessibility constraint, not a style preference: WCAG SC 2.2.1 (Timing Adjustable) covers content that disappears on a timer, so raise duration and enable closeButton for anything actionable." + "focusManagement": "Toasts never take focus when they appear \u2014 that is the point of the pattern, and it is why they must not carry anything essential. Focus enters the region only through the hotkey, and sonner restores focus to the previously focused element when focus leaves the region or the region unmounts. A toast can auto-dismiss while a keyboard user is still tabbing through it, so never put the only copy of an action inside one.", + "notes": "Individual toasts carry no role of their own \u2014 not role=\"alert\", not role=\"status\" \u2014 and are announced only because they are inserted into the wrapping polite live region. That makes toasts unsuitable for urgent content: an error that must interrupt belongs in an Alert next to the failure, or in an AlertDialog if a decision is needed. Because toasts sit outside the tab order, a keyboard user who does not know the hotkey cannot reach an action inside one before it expires. Duration is an accessibility constraint, not a style preference: WCAG SC 2.2.1 (Timing Adjustable) covers content that disappears on a timer, so raise duration and enable closeButton for anything actionable." }, "composition": { - "notes": "There are no sub-components. A toast's anatomy — type icon, title, description, action button, cancel button, and the optional close button — is data passed to the toast() call, not JSX you nest. The surface is toast(title, options) plus toast.success, toast.info, toast.warning, toast.error, toast.loading, toast.message, toast.promise, toast.custom, and toast.dismiss(id), imported from the sonner package rather than from your components directory; the registry item's only export is Toaster. The rendered structure is Toaster > notification region > toast list > toast, and none of those levels is addressable as a component. Mount exactly one Toaster in the root layout, outside any conditionally rendered subtree, and every toast() call in the app renders into it." + "notes": "There are no sub-components. A toast's anatomy \u2014 type icon, title, description, action button, cancel button, and the optional close button \u2014 is data passed to the toast() call, not JSX you nest. The surface is toast(title, options) plus toast.success, toast.info, toast.warning, toast.error, toast.loading, toast.message, toast.promise, toast.custom, and toast.dismiss(id), imported from the sonner package rather than from your components directory; the registry item's only export is Toaster. The rendered structure is Toaster > notification region > toast list > toast, and none of those levels is addressable as a component. Mount exactly one Toaster in the root layout, outside any conditionally rendered subtree, and every toast() call in the app renders into it." }, "constraints": [ { @@ -5754,7 +5754,7 @@ }, { "context": "A sequence of steps the user should be able to review", - "rule": "Write the outcome into the page — a log, a status column, an Alert — instead of emitting a run of toasts that expire unread", + "rule": "Write the outcome into the page \u2014 a log, a status column, an Alert \u2014 instead of emitting a run of toasts that expire unread", "severity": "should-not" } ], @@ -5802,7 +5802,7 @@ }, "content": { "name": "Content", - "description": "Presents content directly — text, media, labels, and identity: avatars and their images and fallbacks, disclosure panels, captions." + "description": "Presents content directly \u2014 text, media, labels, and identity: avatars and their images and fallbacks, disclosure panels, captions." }, "feedback": { "name": "Feedback", @@ -5877,8 +5877,8 @@ { "id": "data-table-with-row-actions", "name": "Data Table with Row Actions", - "description": "An enterprise data table that presents records with a sortable header, a status column, and a trailing column of per-row actions — including destructive ones routed through a confirmation. Composes the Table primitives with Badge for status, a DropdownMenu (via the contextual-actions-menu pattern) for the actions column, and AlertDialog (via the destructive-action-confirmation pattern) for destructive actions.", - "intent": "Let users scan and act on a list of records — reviewing each row's status and reaching its actions — without sacrificing the semantics, keyboard access, or safety guarantees that the underlying components provide.", + "description": "An enterprise data table that presents records with a sortable header, a status column, and a trailing column of per-row actions \u2014 including destructive ones routed through a confirmation. Composes the Table primitives with Badge for status, a DropdownMenu (via the contextual-actions-menu pattern) for the actions column, and AlertDialog (via the destructive-action-confirmation pattern) for destructive actions.", + "intent": "Let users scan and act on a list of records \u2014 reviewing each row's status and reaching its actions \u2014 without sacrificing the semantics, keyboard access, or safety guarantees that the underlying components provide.", "context": "Use for access-management and administrative surfaces: a members table, a users-and-roles list, an API-keys table, or any view where each row is an entity with a status and a set of actions, some of which (remove, revoke access) are destructive.", "components": [ "table", @@ -5887,7 +5887,7 @@ "dropdown-menu", "alert-dialog" ], - "guidance": "Build the table from the Table primitives: a TableHeader row of TableHead cells, a TableBody of one TableRow per record, and a TableCaption (or aria-label) naming the table. Make sortable columns real controls — render a ghost Button inside the TableHead and set aria-sort on that TableHead to ascending, descending, or none, with only one column sorted at a time. Render the status column with a Badge whose variant reflects state (for example default or secondary for active and pending, destructive for suspended or revoked); because Badge is presentational, keep the status readable as text so it is announced by assistive technology, not conveyed by color alone. Put per-row actions in a trailing TableCell as a contextual-actions-menu: a ghost icon Button (a vertical ellipsis) as the DropdownMenuTrigger opening a DropdownMenu of actions, with an aria-label on the trigger. Route every destructive row action — remove, revoke access — through the destructive-action-confirmation pattern using AlertDialog, never Dialog, so the action cannot be dismissed by accident; the AlertDialogDescription should name the specific record affected (for example 'This revokes Jordan Lee's access to the Acme workspace'). Do not make the entire TableRow a single clickable target while it also contains its own action controls — that is the nested-interactive-elements anti-pattern, which produces ambiguous click targets and unpredictable behavior for keyboard and screen-reader users; if rows need a primary navigation, expose it as a distinct link or cell control rather than wrapping the row.", + "guidance": "Build the table from the Table primitives: a TableHeader row of TableHead cells, a TableBody of one TableRow per record, and a TableCaption (or aria-label) naming the table. Make sortable columns real controls \u2014 render a ghost Button inside the TableHead and set aria-sort on that TableHead to ascending, descending, or none, with only one column sorted at a time. Render the status column with a Badge whose variant reflects state (for example default or secondary for active and pending, destructive for suspended or revoked); because Badge is presentational, keep the status readable as text so it is announced by assistive technology, not conveyed by color alone. Put per-row actions in a trailing TableCell as a contextual-actions-menu: a ghost icon Button (a vertical ellipsis) as the DropdownMenuTrigger opening a DropdownMenu of actions, with an aria-label on the trigger. Route every destructive row action \u2014 remove, revoke access \u2014 through the destructive-action-confirmation pattern using AlertDialog, never Dialog, so the action cannot be dismissed by accident; the AlertDialogDescription should name the specific record affected (for example 'This revokes Jordan Lee's access to the Acme workspace'). Do not make the entire TableRow a single clickable target while it also contains its own action controls \u2014 that is the nested-interactive-elements anti-pattern, which produces ambiguous click targets and unpredictable behavior for keyboard and screen-reader users; if rows need a primary navigation, expose it as a distinct link or cell control rather than wrapping the row.", "relatedPatterns": [ "contextual-actions-menu", "destructive-action-confirmation" @@ -5940,7 +5940,7 @@ "id": "div-as-button", "name": "Using a Styled Div as a Button", "description": "Creating a clickable element by adding an onClick handler and visual styles to a
or instead of using the Button component or a native
wrapped in a horizontally scrollable container; the sub-components map directly to the native thead, tbody, tfoot, tr, th, td, and caption elements. The primitives are presentational only: sorting, filtering, pagination, and row selection are typically composed with TanStack Table (@tanstack/react-table) on top of them, where the headless library owns the data state and these components render the resulting rows and cells.", + "guidance": "Pure HTML/CSS table primitives \u2014 no Radix dependency. Table renders a
wrapped in a horizontally scrollable container; the sub-components map directly to the native thead, tbody, tfoot, tr, th, td, and caption elements. The primitives are presentational only: sorting, filtering, pagination, and row selection are typically composed with TanStack Table (@tanstack/react-table) on top of them, where the headless library owns the data state and these components render the resulting rows and cells.", "subComponents": { "table-header": { "exportName": "TableHeader" diff --git a/lib/validate.mjs b/lib/validate.mjs index b7d759f..77ec169 100644 --- a/lib/validate.mjs +++ b/lib/validate.mjs @@ -148,7 +148,12 @@ function ruleComponentRefs(rule) { // required-props (v0.4) is the one type whose `component` accepts a // sub-component id (spec v0.4 §4.1); `within` accepts either kind. if (rule.component) { - refs.push({ kind: rule.type === "required-props" ? "componentOrSub" : "component", id: rule.component }); + // required-props (v0.4 §4.1) and required-composition (v0.4 §4.3, the + // requiredCategories amendment) both anchor on nodes AT LINT TIME, where + // sub-components appear as ordinary nodes — their `component` accepts a + // sub-component id. The other types keep top-level-only anchors. + const subAnchored = rule.type === "required-props" || rule.type === "required-composition"; + refs.push({ kind: subAnchored ? "componentOrSub" : "component", id: rule.component }); } if (rule.within) refs.push({ kind: "componentOrSub", id: rule.within }); push("forbiddenDescendants", rule.forbiddenDescendants); diff --git a/spec/dspack-v0.4.md b/spec/dspack-v0.4.md index a2f2cf6..51a2497 100644 --- a/spec/dspack-v0.4.md +++ b/spec/dspack-v0.4.md @@ -132,7 +132,7 @@ Fields: | Field | Type | Required | Description | | --- | --- | --- | --- | -| `component` | string | yes | Component **or sub-component** id whose instances are checked. This is the one rule type whose `component` accepts a sub-component id. | +| `component` | string | yes | Component **or sub-component** id whose instances are checked. (Shared with §4.3's amended `required-composition`: both anchor on lint-time nodes, where sub-components appear as ordinary nodes.) | | `within` | string | no | Component or sub-component id scoping the check (see below). | | `requiredText` | `true` | one of these two | The node MUST carry non-empty text — its own `text` field by default; see `textScope`. | | `textScope` | `self` \| `subtree` | no (default `self`) | Where `requiredText` looks: `self` = the node's own `text` field only; `subtree` = direct text on the node **or any of its descendants** — for compound wrappers whose documented projections lift a label from within. Only meaningful with `requiredText`. | @@ -228,7 +228,12 @@ does above). `requiredCategories?: {id, min=1}[]` joins `requiredSubComponents` and `requiredProps` (at least one of the three MUST be present). Each entry's `id` MUST be registered in the document's `categories` — the same consistency check -`forbiddenCategories` carries. +`forbiddenCategories` carries. With this amendment, `required-composition`'s +`component` also accepts a **sub-component** id (as §4.1's `required-props` +already did): the governed location of a category requirement is very often a +compound's sub-component — `form-control` being the motivating case — and +both types anchor on lint-time nodes, where sub-components appear as ordinary +nodes. **Normative evaluation semantics.** For **every** node matching `component`: each `requiredCategories` entry MUST have ≥ `min` descendants whose contract