@loworbitstudio/visor 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/CHANGELOG.json +31 -1
- package/dist/index.js +3 -3
- package/dist/registry.json +155 -29
- package/dist/visor-manifest.json +587 -76
- package/package.json +2 -2
package/dist/registry.json
CHANGED
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
{
|
|
147
147
|
"path": "components/ui/button/button.module.css",
|
|
148
148
|
"type": "registry:ui",
|
|
149
|
-
"content": "/* Button base styles */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n white-space: nowrap;\n border-radius: var(--radius-md, 0.375rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: 1;\n cursor: pointer;\n border: 1px solid transparent;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n text-decoration: none;\n outline: none;\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.base:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n.base:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, var(--primary, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, #f3f4f6);\n color: var(--interactive-secondary-text, #111827);\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-strong, #e5e7eb);\n}\n\n.variantGhost {\n background-color: var(--interactive-ghost-bg, transparent);\n color: var(--text-primary, #111827);\n}\n\n.variantDestructive {\n background-color: var(--interactive-destructive-bg, #ef4444);\n color: var(--interactive-destructive-text, #fef2f2);\n}\n\n@media (hover: hover) {\n .variantDefault:hover {\n background-color: var(--interactive-primary-bg-hover, #1f2937);\n }\n\n .variantSecondary:hover {\n background-color: var(--interactive-secondary-bg-hover, #e5e7eb);\n }\n\n .variantOutline:hover {\n background-color: var(--surface-muted, #f3f4f6);\n }\n\n .variantGhost:hover {\n background-color: var(--interactive-ghost-bg-hover, var(--surface-muted, #f3f4f6));\n }\n\n .variantDestructive:hover {\n background-color: var(--interactive-destructive-bg-hover, #dc2626);\n }\n}\n\n/* Gated state — orthogonal to variant, works with all variants */\n.base[data-gated=\"true\"] {\n opacity: var(--opacity-
|
|
149
|
+
"content": "/* Button base styles */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--spacing-2, 0.5rem);\n white-space: nowrap;\n border-radius: var(--radius-md, 0.375rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: 1;\n cursor: pointer;\n border: 1px solid transparent;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n text-decoration: none;\n outline: none;\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.base:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n.base:disabled {\n pointer-events: none;\n opacity: 0.5;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, var(--primary, #111827));\n color: var(--interactive-primary-text, #f9fafb);\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, #f3f4f6);\n color: var(--interactive-secondary-text, #111827);\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-strong, #e5e7eb);\n}\n\n.variantGhost {\n background-color: var(--interactive-ghost-bg, transparent);\n color: var(--text-primary, #111827);\n}\n\n.variantDestructive {\n background-color: var(--interactive-destructive-bg, #ef4444);\n color: var(--interactive-destructive-text, #fef2f2);\n}\n\n@media (hover: hover) {\n .variantDefault:hover {\n background-color: var(--interactive-primary-bg-hover, #1f2937);\n }\n\n .variantSecondary:hover {\n background-color: var(--interactive-secondary-bg-hover, #e5e7eb);\n }\n\n .variantOutline:hover {\n background-color: var(--surface-muted, #f3f4f6);\n }\n\n .variantGhost:hover {\n background-color: var(--interactive-ghost-bg-hover, var(--surface-muted, #f3f4f6));\n }\n\n .variantDestructive:hover {\n background-color: var(--interactive-destructive-bg-hover, #dc2626);\n }\n}\n\n/* Gated state — orthogonal to variant, works with all variants */\n.base[data-gated=\"true\"] {\n opacity: var(--opacity-40, 0.4);\n cursor: not-allowed;\n border-color: var(--border-default, #e5e7eb);\n}\n\n/* Suppress active transform when gated */\n.base[data-gated=\"true\"]:active {\n transform: none;\n transition: opacity var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Sizes */\n.sizeSm {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeMd {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n}\n\n.sizeLg {\n height: 3rem;\n padding: 0 var(--spacing-6, 1.5rem);\n font-size: var(--font-size-base, 1rem);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.base {\n font-family: inherit;\n}\n"
|
|
150
150
|
}
|
|
151
151
|
]
|
|
152
152
|
},
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
{
|
|
173
173
|
"path": "components/ui/input/input.module.css",
|
|
174
174
|
"type": "registry:ui",
|
|
175
|
-
"content": "/* Input base styles */\n.base {\n width: 100%;\n min-width: 0;\n border: 1px solid var(--input-border, var(--border-default, #e5e7eb));\n background-color: var(--input-bg, var(--surface-interactive-default, #f9fafb));\n line-height: 1.5;\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n}\n\n.base::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .base:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.base:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.base:disabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.base[aria-invalid=\"true\"] {\n border-color: transparent;\n background-color: var(--input-bg-invalid, var(--surface-error-subtle, color-mix(in srgb, var(--border-error, #ef4444) 6%, var(--input-bg, #f9fafb))));\n box-shadow: inset 0 0 0 1.5px var(--border-error, #ef4444);\n}\n\n.base[aria-invalid=\"true\"]:focus-visible {\n border-color: transparent;\n outline-offset: var(--focus-ring-offset, 2px);\n box-shadow: inset 0 0 0 1.5px var(--border-error, #ef4444),\n 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 30%, transparent);\n}\n\n/* File input styles */\n.base[type=\"file\"] {\n font-size: var(--font-size-sm, 0.875rem);\n}\n\n/* Leading-icon wrapper\n *\n * When the consumer passes a leadingIcon node, Input wraps its <input> in a\n * <span> and positions the icon absolutely inside it. The <input> itself\n * picks up .hasLeadingIcon, which adds enough left padding to clear the\n * icon (sized in em so it scales with the field's font-size). */\n.leadingIconWrapper {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-width: 0;\n}\n\n.leadingIcon {\n position: absolute;\n left: var(--spacing-3, 0.75rem);\n top: 50%;\n transform: translateY(-50%);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: var(--text-secondary, #9ca3af);\n pointer-events: none;\n font-size: 1.125em;\n line-height: 1;\n}\n\n/* Size variants */\n.sizeSm {\n height: 2.25rem;\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n.sizeMd {\n height: auto;\n padding: var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.sizeLg {\n height: auto;\n padding: var(--spacing-4_5, 1.125rem) var(--spacing-5, 1.25rem);\n font-size: var(--font-size-base, 1rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n/* Leading-icon left-padding override.\n *\n * Declared AFTER the size variants so the padding-left value wins the\n * cascade against the shorthand `padding` each size sets. Double-class\n * selector bumps specificity above the size classes for good measure. */\n.sizeSm.hasLeadingIcon {\n padding-left: calc(var(--spacing-3, 0.75rem) * 2 + 1.125em);\n}\n\n.sizeMd.hasLeadingIcon {\n padding-left: calc(var(--spacing-4, 1rem) + var(--spacing-3, 0.75rem) + 1.125em);\n}\n\n.sizeLg.hasLeadingIcon {\n padding-left: calc(var(--spacing-5, 1.25rem) + var(--spacing-3, 0.75rem) + 1.125em);\n}\n"
|
|
175
|
+
"content": "/* Input base styles */\n.base {\n width: 100%;\n min-width: 0;\n border: 1px solid var(--input-border, var(--border-default, #e5e7eb));\n background-color: var(--input-bg, var(--surface-interactive-default, #f9fafb));\n line-height: 1.5;\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n}\n\n.base::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .base:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.base:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.base:disabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.base[aria-invalid=\"true\"] {\n border-color: transparent;\n background-color: var(--input-bg-invalid, var(--surface-error-subtle, color-mix(in srgb, var(--border-error, #ef4444) 6%, var(--input-bg, #f9fafb))));\n box-shadow: inset 0 0 0 1.5px var(--border-error, #ef4444);\n}\n\n.base[aria-invalid=\"true\"]:focus-visible {\n border-color: transparent;\n outline-offset: var(--focus-ring-offset, 2px);\n box-shadow: inset 0 0 0 1.5px var(--border-error, #ef4444),\n 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 30%, transparent);\n}\n\n/* File input styles */\n.base[type=\"file\"] {\n font-size: var(--font-size-sm, 0.875rem);\n}\n\n/* Leading-icon wrapper\n *\n * When the consumer passes a leadingIcon node, Input wraps its <input> in a\n * <span> and positions the icon absolutely inside it. The <input> itself\n * picks up .hasLeadingIcon, which adds enough left padding to clear the\n * icon (sized in em so it scales with the field's font-size). */\n.leadingIconWrapper {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-width: 0;\n}\n\n.leadingIcon {\n position: absolute;\n left: var(--spacing-3, 0.75rem);\n top: 50%;\n transform: translateY(-50%);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n color: var(--text-secondary, #9ca3af);\n pointer-events: none;\n font-size: 1.125em;\n line-height: 1;\n}\n\n/* Size variants */\n.sizeSm {\n height: 2.25rem;\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n.sizeMd {\n height: auto;\n padding: var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.sizeLg {\n height: auto;\n padding: var(--spacing-4_5, 1.125rem) var(--spacing-5, 1.25rem);\n font-size: var(--font-size-base, 1rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n/* Leading-icon left-padding override.\n *\n * Declared AFTER the size variants so the padding-left value wins the\n * cascade against the shorthand `padding` each size sets. Double-class\n * selector bumps specificity above the size classes for good measure. */\n.sizeSm.hasLeadingIcon {\n padding-left: calc(var(--spacing-3, 0.75rem) * 2 + 1.125em);\n}\n\n.sizeMd.hasLeadingIcon {\n padding-left: calc(var(--spacing-4, 1rem) + var(--spacing-3, 0.75rem) + 1.125em);\n}\n\n.sizeLg.hasLeadingIcon {\n padding-left: calc(var(--spacing-5, 1.25rem) + var(--spacing-3, 0.75rem) + 1.125em);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.base {\n font-family: inherit;\n}\n"
|
|
176
176
|
}
|
|
177
177
|
]
|
|
178
178
|
},
|
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
{
|
|
245
245
|
"path": "components/ui/checkbox/checkbox.tsx",
|
|
246
246
|
"type": "registry:ui",
|
|
247
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { CheckIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./checkbox.module.css\"\n\nexport type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n\nconst Checkbox = React.forwardRef<\n React.ElementRef<typeof CheckboxPrimitive.Root>,\n CheckboxProps\n>(({ className, ...props }, ref) => {\n return (\n <CheckboxPrimitive.Root\n data-slot=\"checkbox\"\n className={cn(styles.root, className)}\n ref={ref}\n {...props}\n >\n <CheckboxPrimitive.Indicator\n data-slot=\"checkbox-indicator\"\n className={styles.indicator}\n >\n <CheckIcon className={styles.icon} />\n </CheckboxPrimitive.Indicator>\n </CheckboxPrimitive.Root>\n )\n})\nCheckbox.displayName = \"Checkbox\"\n\nexport { Checkbox }\n"
|
|
247
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as CheckboxPrimitive from \"@radix-ui/react-checkbox\"\nimport { CheckIcon, Minus as MinusIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./checkbox.module.css\"\n\nexport type CheckboxProps = React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>\n\nconst Checkbox = React.forwardRef<\n React.ElementRef<typeof CheckboxPrimitive.Root>,\n CheckboxProps\n>(({ className, checked, ...props }, ref) => {\n return (\n <CheckboxPrimitive.Root\n data-slot=\"checkbox\"\n className={cn(styles.root, className)}\n ref={ref}\n checked={checked}\n {...props}\n >\n <CheckboxPrimitive.Indicator\n data-slot=\"checkbox-indicator\"\n className={styles.indicator}\n >\n {checked === \"indeterminate\" ? (\n <MinusIcon className={styles.icon} />\n ) : (\n <CheckIcon className={styles.icon} />\n )}\n </CheckboxPrimitive.Indicator>\n </CheckboxPrimitive.Root>\n )\n})\nCheckbox.displayName = \"Checkbox\"\n\nexport { Checkbox }\n"
|
|
248
248
|
},
|
|
249
249
|
{
|
|
250
250
|
"path": "components/ui/checkbox/checkbox.module.css",
|
|
@@ -275,7 +275,7 @@
|
|
|
275
275
|
{
|
|
276
276
|
"path": "components/ui/select/select.module.css",
|
|
277
277
|
"type": "registry:ui",
|
|
278
|
-
"content": "/* Select Trigger */\n.trigger {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: calc(var(--spacing-1, 0.25rem) * 1.5);\n width: fit-content;\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--select-border, var(--border-default, #e5e7eb));\n background-color: var(--select-bg, var(--surface-interactive-default, #f9fafb));\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n /* Match Input's line-height so the trigger's height tracks the text field at\n the same size. Without this the trigger inherited line-height 2.0, rendering\n ~7px taller than a same-size Input. */\n line-height: 1.5;\n white-space: nowrap;\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .trigger:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.trigger:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.trigger:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.trigger[aria-invalid=\"true\"] {\n border-color: var(--border-error, #ef4444);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 15%, transparent);\n}\n\n.trigger[data-placeholder] {\n color: var(--text-secondary, #9ca3af);\n}\n\n.triggerSizeSm {\n height: 2.25rem;\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n.triggerSizeMd {\n height: auto;\n padding: var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.triggerSizeLg {\n height: auto;\n padding: var(--spacing-4_5, 1.125rem) var(--spacing-5, 1.25rem);\n font-size: var(--font-size-base, 1rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.triggerIcon {\n pointer-events: none;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Select Content */\n.content {\n position: relative;\n z-index: 50;\n max-height: var(--radix-select-content-available-height, 300px);\n min-width: 9rem;\n overflow: hidden;\n border-radius: var(--radius-lg, 0.5rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-lg);\n}\n\n/* Select Viewport */\n.viewport {\n padding: var(--spacing-1, 0.25rem);\n}\n\n.viewportPopper {\n height: var(--radix-select-trigger-height);\n width: 100%;\n min-width: var(--radix-select-trigger-width);\n}\n\n/* Select Label */\n.label {\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Select Item */\n.item {\n position: relative;\n display: flex;\n width: 100%;\n cursor: default;\n align-items: center;\n gap: calc(var(--spacing-2, 0.5rem) + var(--spacing-1, 0.25rem) / 2);\n border-radius: var(--radius-sm, 0.25rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-8, 2rem) var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n outline: none;\n user-select: none;\n}\n\n.item:focus,\n.item[data-highlighted] {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.item[data-disabled] {\n pointer-events: none;\n opacity: 0.5;\n}\n\n.itemIndicatorWrapper {\n position: absolute;\n right: 0.5rem;\n display: flex;\n width: 1rem;\n height: 1rem;\n align-items: center;\n justify-content: center;\n}\n\n.itemIndicatorIcon {\n width: 0.875rem;\n height: 0.875rem;\n pointer-events: none;\n}\n\n/* Select Separator */\n.separator {\n pointer-events: none;\n margin: var(--spacing-1, 0.25rem) calc(-1 * var(--spacing-1, 0.25rem));\n height: 1px;\n background-color: var(--border-default, #e5e7eb);\n}\n\n/* Scroll Buttons */\n.scrollButton {\n display: flex;\n cursor: default;\n align-items: center;\n justify-content: center;\n padding: var(--spacing-1, 0.25rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n}\n\n.scrollButtonIcon {\n width: 1rem;\n height: 1rem;\n}\n"
|
|
278
|
+
"content": "/* Select Trigger */\n.trigger {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: calc(var(--spacing-1, 0.25rem) * 1.5);\n width: fit-content;\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--select-border, var(--border-default, #e5e7eb));\n background-color: var(--select-bg, var(--surface-interactive-default, #f9fafb));\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n /* Match Input's line-height so the trigger's height tracks the text field at\n the same size. Without this the trigger inherited line-height 2.0, rendering\n ~7px taller than a same-size Input. */\n line-height: 1.5;\n white-space: nowrap;\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .trigger:hover:not(:focus-visible):not(:disabled):not([aria-invalid=\"true\"]) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.trigger:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.trigger:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.trigger[aria-invalid=\"true\"] {\n border-color: var(--border-error, #ef4444);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-error, #ef4444) 15%, transparent);\n}\n\n.trigger[data-placeholder] {\n color: var(--text-secondary, #9ca3af);\n}\n\n.triggerSizeSm {\n height: 2.25rem;\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n.triggerSizeMd {\n height: auto;\n padding: var(--spacing-3_5, 0.875rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.875rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.triggerSizeLg {\n height: auto;\n padding: var(--spacing-4_5, 1.125rem) var(--spacing-5, 1.25rem);\n font-size: var(--font-size-base, 1rem);\n border-radius: var(--radius-sm, 0.5rem);\n}\n\n.triggerIcon {\n pointer-events: none;\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Select Content */\n.content {\n position: relative;\n z-index: 50;\n max-height: var(--radix-select-content-available-height, 300px);\n min-width: 9rem;\n overflow: hidden;\n border-radius: var(--radius-lg, 0.5rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-lg);\n}\n\n/* Select Viewport */\n.viewport {\n padding: var(--spacing-1, 0.25rem);\n}\n\n.viewportPopper {\n height: var(--radix-select-trigger-height);\n width: 100%;\n min-width: var(--radix-select-trigger-width);\n}\n\n/* Select Label */\n.label {\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Select Item */\n.item {\n position: relative;\n display: flex;\n width: 100%;\n cursor: default;\n align-items: center;\n gap: calc(var(--spacing-2, 0.5rem) + var(--spacing-1, 0.25rem) / 2);\n border-radius: var(--radius-sm, 0.25rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-8, 2rem) var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n outline: none;\n user-select: none;\n}\n\n.item:focus,\n.item[data-highlighted] {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.item[data-disabled] {\n pointer-events: none;\n opacity: 0.5;\n}\n\n.itemIndicatorWrapper {\n position: absolute;\n right: 0.5rem;\n display: flex;\n width: 1rem;\n height: 1rem;\n align-items: center;\n justify-content: center;\n}\n\n.itemIndicatorIcon {\n width: 0.875rem;\n height: 0.875rem;\n pointer-events: none;\n}\n\n/* Select Separator */\n.separator {\n pointer-events: none;\n margin: var(--spacing-1, 0.25rem) calc(-1 * var(--spacing-1, 0.25rem));\n height: 1px;\n background-color: var(--border-default, #e5e7eb);\n}\n\n/* Scroll Buttons */\n.scrollButton {\n display: flex;\n cursor: default;\n align-items: center;\n justify-content: center;\n padding: var(--spacing-1, 0.25rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n}\n\n.scrollButtonIcon {\n width: 1rem;\n height: 1rem;\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.trigger {\n font-family: inherit;\n}\n"
|
|
279
279
|
}
|
|
280
280
|
]
|
|
281
281
|
},
|
|
@@ -396,12 +396,12 @@
|
|
|
396
396
|
{
|
|
397
397
|
"path": "components/ui/badge/badge.tsx",
|
|
398
398
|
"type": "registry:ui",
|
|
399
|
-
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./badge.module.css\"\n\nconst badgeVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n secondary: styles.variantSecondary,\n outline: styles.variantOutline,\n destructive: styles.variantDestructive,\n success: styles.variantSuccess,\n warning: styles.variantWarning,\n info: styles.variantInfo,\n neutral: styles.variantNeutral,\n \"filled-destructive\": styles.variantFilledDestructive,\n \"filled-success\": styles.variantFilledSuccess,\n \"filled-warning\": styles.variantFilledWarning,\n \"filled-info\": styles.variantFilledInfo,\n \"filled-neutral\": styles.variantFilledNeutral,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface BadgeProps\n extends React.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof badgeVariants> {}\n\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\n ({ className, variant, size, ...props }, ref) => {\n return (\n <span\n ref={ref}\n data-slot=\"badge\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(badgeVariants({ variant, size })
|
|
399
|
+
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./badge.module.css\"\n\nconst badgeVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n secondary: styles.variantSecondary,\n outline: styles.variantOutline,\n destructive: styles.variantDestructive,\n success: styles.variantSuccess,\n warning: styles.variantWarning,\n info: styles.variantInfo,\n neutral: styles.variantNeutral,\n \"filled-destructive\": styles.variantFilledDestructive,\n \"filled-success\": styles.variantFilledSuccess,\n \"filled-warning\": styles.variantFilledWarning,\n \"filled-info\": styles.variantFilledInfo,\n \"filled-neutral\": styles.variantFilledNeutral,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface BadgeProps\n extends React.HTMLAttributes<HTMLSpanElement>,\n VariantProps<typeof badgeVariants> {\n /** Render the label in uppercase. Also controllable via the\n * `--badge-text-transform` CSS custom property from a theme. */\n uppercase?: boolean\n}\n\nconst Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(\n ({ className, variant, size, uppercase, ...props }, ref) => {\n return (\n <span\n ref={ref}\n data-slot=\"badge\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(\n badgeVariants({ variant, size }),\n uppercase && styles.uppercase,\n className\n )}\n {...props}\n />\n )\n }\n)\nBadge.displayName = \"Badge\"\n\nexport { Badge, badgeVariants }\n"
|
|
400
400
|
},
|
|
401
401
|
{
|
|
402
402
|
"path": "components/ui/badge/badge.module.css",
|
|
403
403
|
"type": "registry:ui",
|
|
404
|
-
"content": "/* Badge base — sizing (height, padding, font-size, gap) is driven by the\n * size classes below. Height is intrinsic (padding + line-height) so badges\n * stay correct when text wraps or icon slots are introduced. */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: fit-content;\n flex-shrink: 0;\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n border: 1px solid transparent;\n line-height: 1;\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Sizes — token-driven padding + font-size + gap on the 4px grid.\n * No --spacing-1.5 token exists, so the 6px step uses\n * calc(--spacing-1 + --spacing-1/2) — mirrors the pattern used in kbd. */\n.sizeSm {\n gap: var(--spacing-1, 0.25rem);\n padding: 0 calc(var(--spacing-1, 0.25rem) + var(--spacing-1, 0.25rem) / 2);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* md — matches today's rendering byte-for-byte (1.25rem total height). */\n.sizeMd {\n gap: var(--spacing-1, 0.25rem);\n padding: calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeLg {\n gap: calc(var(--spacing-1, 0.25rem) + var(--spacing-1, 0.25rem) / 2);\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n}\n\n/* Icon scaling — leading glyphs track the per-size font-size step. */\n.sizeSm > svg {\n width: 0.75rem;\n height: 0.75rem;\n}\n\n.sizeMd > svg {\n width: 0.875rem;\n height: 0.875rem;\n}\n\n.sizeLg > svg {\n width: 1rem;\n height: 1rem;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, currentColor);\n color: var(--interactive-primary-text, var(--text-inverse, #f9fafb));\n border-color: transparent;\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, var(--surface-subtle, transparent));\n color: var(--interactive-secondary-text, var(--text-primary, currentColor));\n border-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-default, #e5e7eb);\n}\n\n.variantDestructive {\n background-color: var(--surface-error-subtle, transparent);\n color: var(--text-error, currentColor);\n border-color: transparent;\n}\n\n.variantSuccess {\n background-color: var(--surface-success-subtle, transparent);\n color: var(--text-success, currentColor);\n border-color: transparent;\n}\n\n.variantWarning {\n background-color: var(--surface-warning-subtle, transparent);\n color: var(--text-warning, currentColor);\n border-color: transparent;\n}\n\n.variantInfo {\n background-color: var(--surface-info-subtle, transparent);\n color: var(--text-info, currentColor);\n border-color: transparent;\n}\n\n.variantNeutral {\n background-color: var(--surface-muted, #f3f4f6);\n color: var(--text-secondary, var(--text-primary, #374151));\n border-color: transparent;\n}\n\n/* Filled variants — step 9 bg + contrast text */\n.variantFilledDestructive {\n background-color: var(--surface-error-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledSuccess {\n background-color: var(--surface-success-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledWarning {\n background-color: var(--surface-warning-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledInfo {\n background-color: var(--surface-info-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n/* filled-neutral — the saturated counterpart to `neutral`. Neutral has no\n * brand `-default` surface, so it uses a fixed neutral-600 fill. Because that\n * fill is dark in BOTH modes, text must stay white (not `--text-inverse`,\n * which flips to a dark value in dark mode). */\n.variantFilledNeutral {\n background-color: var(--color-neutral-600, #4b5563);\n color: var(--color-white, #ffffff);\n border-color: transparent;\n}\n"
|
|
404
|
+
"content": "/* Badge base — sizing (height, padding, font-size, gap) is driven by the\n * size classes below. Height is intrinsic (padding + line-height) so badges\n * stay correct when text wraps or icon slots are introduced. */\n.base {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: fit-content;\n flex-shrink: 0;\n overflow: hidden;\n border-radius: var(--radius-full, 9999px);\n border: 1px solid transparent;\n line-height: 1;\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n text-transform: var(--badge-text-transform, none);\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out), border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* uppercase — a local class (not a bare attr selector, per Turbopack\n * pure-selector rule) sets the --badge-text-transform token so the prop and\n * a theme override share the same CSS hook. */\n.base.uppercase {\n --badge-text-transform: uppercase;\n}\n\n.base:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n/* Sizes — token-driven padding + font-size + gap on the 4px grid.\n * No --spacing-1.5 token exists, so the 6px step uses\n * calc(--spacing-1 + --spacing-1/2) — mirrors the pattern used in kbd. */\n.sizeSm {\n gap: var(--spacing-1, 0.25rem);\n padding: 0 calc(var(--spacing-1, 0.25rem) + var(--spacing-1, 0.25rem) / 2);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* md — matches today's rendering byte-for-byte (1.25rem total height). */\n.sizeMd {\n gap: var(--spacing-1, 0.25rem);\n padding: calc(var(--spacing-1, 0.25rem) / 2) var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.sizeLg {\n gap: calc(var(--spacing-1, 0.25rem) + var(--spacing-1, 0.25rem) / 2);\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n}\n\n/* Icon scaling — leading glyphs track the per-size font-size step. */\n.sizeSm > svg {\n width: 0.75rem;\n height: 0.75rem;\n}\n\n.sizeMd > svg {\n width: 0.875rem;\n height: 0.875rem;\n}\n\n.sizeLg > svg {\n width: 1rem;\n height: 1rem;\n}\n\n/* Variants */\n.variantDefault {\n background-color: var(--interactive-primary-bg, currentColor);\n color: var(--interactive-primary-text, var(--text-inverse, #f9fafb));\n border-color: transparent;\n}\n\n.variantSecondary {\n background-color: var(--interactive-secondary-bg, var(--surface-subtle, transparent));\n color: var(--interactive-secondary-text, var(--text-primary, currentColor));\n border-color: transparent;\n}\n\n.variantOutline {\n background-color: transparent;\n color: var(--text-primary, #111827);\n border-color: var(--border-default, #e5e7eb);\n}\n\n.variantDestructive {\n background-color: var(--surface-error-subtle, transparent);\n color: var(--text-error, currentColor);\n border-color: transparent;\n}\n\n.variantSuccess {\n background-color: var(--surface-success-subtle, transparent);\n color: var(--text-success, currentColor);\n border-color: transparent;\n}\n\n.variantWarning {\n background-color: var(--surface-warning-subtle, transparent);\n color: var(--text-warning, currentColor);\n border-color: transparent;\n}\n\n.variantInfo {\n background-color: var(--surface-info-subtle, transparent);\n color: var(--text-info, currentColor);\n border-color: transparent;\n}\n\n.variantNeutral {\n background-color: var(--surface-muted, #f3f4f6);\n color: var(--text-secondary, var(--text-primary, #374151));\n border-color: transparent;\n}\n\n/* Filled variants — step 9 bg + contrast text */\n.variantFilledDestructive {\n background-color: var(--surface-error-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledSuccess {\n background-color: var(--surface-success-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledWarning {\n background-color: var(--surface-warning-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n.variantFilledInfo {\n background-color: var(--surface-info-default, transparent);\n color: var(--text-inverse, currentColor);\n border-color: transparent;\n}\n\n/* filled-neutral — the saturated counterpart to `neutral`. Neutral has no\n * brand `-default` surface, so it uses a fixed neutral-600 fill. Because that\n * fill is dark in BOTH modes, text must stay white (not `--text-inverse`,\n * which flips to a dark value in dark mode). */\n.variantFilledNeutral {\n background-color: var(--color-neutral-600, #4b5563);\n color: var(--color-white, #ffffff);\n border-color: transparent;\n}\n"
|
|
405
405
|
}
|
|
406
406
|
]
|
|
407
407
|
},
|
|
@@ -443,12 +443,12 @@
|
|
|
443
443
|
{
|
|
444
444
|
"path": "components/ui/chip/chip.tsx",
|
|
445
445
|
"type": "registry:ui",
|
|
446
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { XIcon } from \"@phosphor-icons/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./chip.module.css\"\n\n/* ─── Chip (base) ────────────────────────────────────────────────────── */\n\nconst chipVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n outlined: styles.variantOutlined,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface ChipProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof chipVariants> {\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label (after avatar). */\n leadingIcon?: React.ReactNode\n /** Label content. Renders as children if omitted. */\n label?: React.ReactNode\n /** Custom delete icon. Defaults to XIcon. */\n deleteIcon?: React.ReactNode\n /** Called when the delete button is activated. When provided the delete button is shown. */\n onDeleted?: () => void\n /** Aria label for the delete button. Defaults to \"Remove\". */\n deleteLabel?: string\n}\n\nconst Chip = React.forwardRef<HTMLDivElement, ChipProps>(\n (\n {\n className,\n variant,\n size,\n avatar,\n leadingIcon,\n label,\n children,\n deleteIcon,\n onDeleted,\n deleteLabel = \"Remove\",\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <div\n ref={ref}\n data-slot=\"chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(chipVariants({ variant, size }), className)}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n {onDeleted ? (\n <button\n type=\"button\"\n aria-label={deleteLabel}\n className={styles.deleteButton}\n onClick={(e) => {\n e.stopPropagation()\n onDeleted()\n }}\n tabIndex={0}\n >\n {deleteIcon ?? (\n <XIcon\n size={12}\n weight=\"bold\"\n aria-hidden=\"true\"\n className={styles.deleteIcon}\n />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nChip.displayName = \"Chip\"\n\n/* ─── ChoiceChip (radio-style single-select) ─────────────────────────── */\n\nexport interface ChoiceChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently selected. */\n selected?: boolean\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is pressed. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n}\n\nconst ChoiceChip = React.forwardRef<HTMLButtonElement, ChoiceChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n avatar,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n data-slot=\"choice-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && styles.selected,\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n </button>\n )\n },\n)\nChoiceChip.displayName = \"ChoiceChip\"\n\n/* ─── FilterChip (multi-select toggle-style) ─────────────────────────── */\n\nexport interface FilterChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently active/selected. */\n selected?: boolean\n /** Optional leading icon. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is toggled. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n /** Optional count pill rendered after the label (e.g. \"47\", 132). */\n count?: React.ReactNode\n /** Tone for the count pill. \"neutral\" uses muted surface; \"primary\" uses accent surface. Defaults to \"neutral\". */\n countTone?: \"primary\" | \"neutral\"\n}\n\nconst FilterChip = React.forwardRef<HTMLButtonElement, FilterChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n count,\n countTone = \"neutral\",\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"checkbox\"\n aria-checked={selected}\n data-slot=\"filter-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && styles.selected,\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n {count != null ? (\n <span\n data-slot=\"filter-chip-count\"\n data-tone={countTone}\n aria-hidden=\"false\"\n className={cn(\n styles.count,\n countTone === \"primary\"
|
|
446
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { XIcon } from \"@phosphor-icons/react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./chip.module.css\"\n\n/* ─── Chip (base) ────────────────────────────────────────────────────── */\n\nconst chipVariants = cva(styles.base, {\n variants: {\n variant: {\n default: styles.variantDefault,\n outlined: styles.variantOutlined,\n },\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"md\",\n },\n})\n\nexport interface ChipProps\n extends React.HTMLAttributes<HTMLDivElement>,\n VariantProps<typeof chipVariants> {\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label (after avatar). */\n leadingIcon?: React.ReactNode\n /** Label content. Renders as children if omitted. */\n label?: React.ReactNode\n /** Custom delete icon. Defaults to XIcon. */\n deleteIcon?: React.ReactNode\n /** Called when the delete button is activated. When provided the delete button is shown. */\n onDeleted?: () => void\n /** Aria label for the delete button. Defaults to \"Remove\". */\n deleteLabel?: string\n}\n\nconst Chip = React.forwardRef<HTMLDivElement, ChipProps>(\n (\n {\n className,\n variant,\n size,\n avatar,\n leadingIcon,\n label,\n children,\n deleteIcon,\n onDeleted,\n deleteLabel = \"Remove\",\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <div\n ref={ref}\n data-slot=\"chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n className={cn(chipVariants({ variant, size }), className)}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n {onDeleted ? (\n <button\n type=\"button\"\n aria-label={deleteLabel}\n className={styles.deleteButton}\n onClick={(e) => {\n e.stopPropagation()\n onDeleted()\n }}\n tabIndex={0}\n >\n {deleteIcon ?? (\n <XIcon\n size={12}\n weight=\"bold\"\n aria-hidden=\"true\"\n className={styles.deleteIcon}\n />\n )}\n </button>\n ) : null}\n </div>\n )\n },\n)\nChip.displayName = \"Chip\"\n\n/* ─── ChoiceChip (radio-style single-select) ─────────────────────────── */\n\nexport interface ChoiceChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently selected. */\n selected?: boolean\n /** Optional avatar/icon rendered before the label. */\n avatar?: React.ReactNode\n /** Optional leading icon rendered before the label. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is pressed. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n}\n\nconst ChoiceChip = React.forwardRef<HTMLButtonElement, ChoiceChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n avatar,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n data-slot=\"choice-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && styles.selected,\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {avatar ? (\n <span className={styles.avatar} aria-hidden=\"true\">\n {avatar}\n </span>\n ) : null}\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n </button>\n )\n },\n)\nChoiceChip.displayName = \"ChoiceChip\"\n\n/* ─── FilterChip (multi-select toggle-style) ─────────────────────────── */\n\nexport interface FilterChipProps\n extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"onClick\">,\n VariantProps<typeof chipVariants> {\n /** Whether this chip is currently active/selected. */\n selected?: boolean\n /** Optional leading icon. */\n leadingIcon?: React.ReactNode\n /** Label text or content. */\n label?: React.ReactNode\n /** Called when the chip is toggled. */\n onPressed?: () => void\n /** Value used when in a ChipGroup. */\n value?: string\n /** Optional count pill rendered after the label (e.g. \"47\", 132). */\n count?: React.ReactNode\n /** Tone for the count pill. \"neutral\" uses muted surface; \"primary\" uses accent surface. Defaults to \"neutral\". */\n countTone?: \"primary\" | \"neutral\"\n /**\n * Selected-state visual treatment.\n * - `\"accent\"` (default) — accent-tinted background + accent border + link text colour.\n * - `\"neutral\"` — neutral-elevated surface (--surface-card) + neutral border + primary text;\n * the count pill renders as a solid mint pill (--surface-success-default) when selected.\n * Use for editorial / admin contexts where accent bleed across shared tokens is undesirable.\n */\n selectedTreatment?: \"accent\" | \"neutral\"\n}\n\nconst FilterChip = React.forwardRef<HTMLButtonElement, FilterChipProps>(\n (\n {\n className,\n variant,\n size,\n selected = false,\n leadingIcon,\n label,\n children,\n onPressed,\n value,\n disabled,\n count,\n countTone = \"neutral\",\n selectedTreatment = \"accent\",\n ...props\n },\n ref,\n ) => {\n const content = label ?? children\n const isNeutral = selectedTreatment === \"neutral\"\n\n return (\n <button\n ref={ref}\n type=\"button\"\n role=\"checkbox\"\n aria-checked={selected}\n data-slot=\"filter-chip\"\n data-variant={variant ?? \"default\"}\n data-size={size ?? \"md\"}\n data-selected={selected ? \"true\" : \"false\"}\n data-selected-treatment={selectedTreatment}\n data-value={value}\n disabled={disabled}\n className={cn(\n chipVariants({ variant, size }),\n styles.interactive,\n selected && (isNeutral ? styles.selectedNeutral : styles.selected),\n className,\n )}\n onClick={onPressed}\n {...props}\n >\n {leadingIcon ? (\n <span className={styles.leadingIcon} aria-hidden=\"true\">\n {leadingIcon}\n </span>\n ) : null}\n <span className={styles.label}>{content}</span>\n {count != null ? (\n <span\n data-slot=\"filter-chip-count\"\n data-tone={countTone}\n aria-hidden=\"false\"\n className={cn(\n styles.count,\n isNeutral && selected\n ? styles.countSolid\n : countTone === \"primary\"\n ? styles.countPrimary\n : styles.countNeutral,\n )}\n >\n {count}\n </span>\n ) : null}\n </button>\n )\n },\n)\nFilterChip.displayName = \"FilterChip\"\n\nexport { Chip, chipVariants, ChoiceChip, FilterChip }\n"
|
|
447
447
|
},
|
|
448
448
|
{
|
|
449
449
|
"path": "components/ui/chip/chip.module.css",
|
|
450
450
|
"type": "registry:ui",
|
|
451
|
-
"content": "/* Chip base */\n.base {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n width: fit-content;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n color: var(--text-primary, #111827);\n font-family: var(--font-body, inherit);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n user-select: none;\n transition:\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Variant: default (filled/tonal surface) */\n.variantDefault {\n background-color: var(--surface-muted, #f3f4f6);\n border-color: transparent;\n color: var(--text-primary, #111827);\n}\n\n/* Variant: outlined */\n.variantOutlined {\n background-color: transparent;\n border-color: var(--border-default, #e5e7eb);\n color: var(--text-primary, #111827);\n}\n\n/* Sizes */\n.sizeSm {\n height: 1.5rem;\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeMd {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n font-size: var(--font-size-base, 1rem);\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Sub-parts */\n.avatar {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.leadingIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.deleteButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n background: transparent;\n border: none;\n padding: 0;\n cursor: pointer;\n color: var(--text-secondary, #6b7280);\n border-radius: var(--radius-full, 9999px);\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n width: 1rem;\n height: 1rem;\n}\n\n@media (hover: hover) {\n .deleteButton:hover {\n color: var(--text-primary, #111827);\n background-color: color-mix(in srgb, var(--surface-interactive-hover, #f3f4f6) 60%, transparent);\n }\n}\n\n.deleteButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6b7280);\n outline-offset: var(--focus-ring-offset, 1px);\n}\n\n.deleteIcon {\n display: block;\n}\n\n/* Count pill (FilterChip only) */\n.count {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n font-variant-numeric: tabular-nums;\n line-height: 1;\n align-self: baseline;\n}\n\n.countNeutral {\n background-color: var(--surface-muted, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.countPrimary {\n background-color: var(--surface-accent-subtle, #eff6ff);\n color: var(--text-link, #2563eb);\n}\n\n
|
|
451
|
+
"content": "/* Chip base */\n.base {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n width: fit-content;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n color: var(--text-primary, #111827);\n font-family: var(--font-body, inherit);\n font-weight: var(--font-weight-medium, 500);\n white-space: nowrap;\n user-select: none;\n transition:\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n/* Variant: default (filled/tonal surface) */\n.variantDefault {\n background-color: var(--surface-muted, #f3f4f6);\n border-color: transparent;\n color: var(--text-primary, #111827);\n}\n\n/* Variant: outlined */\n.variantOutlined {\n background-color: transparent;\n border-color: var(--border-default, #e5e7eb);\n color: var(--text-primary, #111827);\n}\n\n/* Sizes */\n.sizeSm {\n height: 1.5rem;\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeMd {\n height: 2rem;\n padding: 0 var(--spacing-3, 0.75rem);\n font-size: var(--font-size-sm, 0.875rem);\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg {\n height: 2.5rem;\n padding: 0 var(--spacing-4, 1rem);\n font-size: var(--font-size-base, 1rem);\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Sub-parts */\n.avatar {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.leadingIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.label {\n flex: 1 1 auto;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.deleteButton {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n background: transparent;\n border: none;\n padding: 0;\n cursor: pointer;\n color: var(--text-secondary, #6b7280);\n border-radius: var(--radius-full, 9999px);\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n width: 1rem;\n height: 1rem;\n}\n\n@media (hover: hover) {\n .deleteButton:hover {\n color: var(--text-primary, #111827);\n background-color: color-mix(in srgb, var(--surface-interactive-hover, #f3f4f6) 60%, transparent);\n }\n}\n\n.deleteButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6b7280);\n outline-offset: var(--focus-ring-offset, 1px);\n}\n\n.deleteIcon {\n display: block;\n}\n\n/* Count pill (FilterChip only) */\n.count {\n display: inline-flex;\n align-items: center;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n font-variant-numeric: tabular-nums;\n line-height: 1;\n align-self: baseline;\n}\n\n.countNeutral {\n background-color: var(--surface-muted, #f3f4f6);\n color: var(--text-primary, #111827);\n}\n\n.countPrimary {\n background-color: var(--surface-accent-subtle, #eff6ff);\n color: var(--text-link, #2563eb);\n}\n\n/* Accent selected state overrides count pill (default behaviour) */\n.selected .count {\n background-color: var(--surface-card, #ffffff);\n color: var(--text-link, #2563eb);\n}\n\n/* Solid mint count pill — editorial/neutral selected treatment */\n.countSolid {\n background-color: var(--surface-success-default, #22c55e);\n color: var(--text-inverse, #ffffff);\n}\n\n/* Interactive chips (ChoiceChip, FilterChip) */\n.interactive {\n cursor: pointer;\n border: var(--stroke-width-thin, 1px) solid transparent;\n}\n\nbutton.base,\n.interactive {\n /* Inherit base layout; add button-specific resets */\n text-align: left;\n appearance: none;\n -webkit-appearance: none;\n}\n\n.interactive:disabled {\n cursor: not-allowed;\n opacity: var(--opacity-40, 0.4);\n pointer-events: none;\n}\n\n.interactive:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #6b7280);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n@media (hover: hover) {\n .interactive:hover:not(:disabled):not([data-selected=\"true\"]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n border-color: var(--border-default, #e5e7eb);\n }\n}\n\n.interactive:active:not(:disabled) {\n transform: translateY(1px);\n transition: none;\n}\n\n/* Selected state */\n.selected {\n background-color: var(--surface-accent-subtle, #eff6ff);\n border-color: var(--surface-accent-default, #3b82f6);\n color: var(--text-link, #2563eb);\n}\n\n@media (hover: hover) {\n .interactive.selected:hover:not(:disabled) {\n background-color: color-mix(in srgb, var(--surface-accent-subtle, #eff6ff) 80%, var(--surface-interactive-hover, #f3f4f6) 20%);\n }\n}\n\n/* Editorial / neutral-elevated selected treatment\n * Uses --surface-card bg + neutral border instead of accent tint.\n * Compose with .selected to override the accent colours. */\n.selectedNeutral {\n background-color: var(--surface-card, #ffffff);\n border-color: var(--border-strong, #d1d5db);\n color: var(--text-primary, #111827);\n}\n\n.selectedNeutral .count {\n background-color: var(--surface-success-default, #22c55e);\n color: var(--text-inverse, #ffffff);\n}\n\n@media (hover: hover) {\n .interactive.selectedNeutral:hover:not(:disabled) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n }\n}\n"
|
|
452
452
|
}
|
|
453
453
|
]
|
|
454
454
|
},
|
|
@@ -1148,7 +1148,7 @@
|
|
|
1148
1148
|
{
|
|
1149
1149
|
"path": "components/ui/combobox/combobox.module.css",
|
|
1150
1150
|
"type": "registry:ui",
|
|
1151
|
-
"content": "/* Combobox root */\n.root {\n position: relative;\n display: inline-flex;\n flex-direction: column;\n width: fit-content;\n}\n\n/* Input wrapper */\n.inputWrapper {\n position: relative;\n display: flex;\n align-items: center;\n}\n\n/* Input */\n.input {\n width: 100%;\n height: 2.25rem;\n padding: var(--spacing-2, 0.5rem) calc(var(--spacing-8, 2rem) + var(--spacing-1, 0.25rem)) var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--combobox-border, var(--border-default, #e5e7eb));\n background-color: var(--combobox-bg, var(--surface-interactive-default, #f9fafb));\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .input:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.input:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.input:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n/* Caret icon */\n.icon {\n position: absolute;\n right: var(--spacing-3, 0.75rem);\n top: 50%;\n transform: translateY(-50%);\n width: 1rem;\n height: 1rem;\n pointer-events: none;\n color: var(--text-secondary, #9ca3af);\n transition: transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.iconOpen {\n transform: translateY(-50%) rotate(180deg);\n}\n\n/* Content popover */\n.content {\n z-index: 50;\n min-width: var(--radix-popover-trigger-width, 12rem);\n max-height: 18rem;\n overflow: hidden auto;\n border-radius: var(--radius-lg, 0.5rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-lg);\n padding: var(--spacing-1, 0.25rem);\n}\n\n/* Listbox */\n.listbox {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n/* Item */\n.item {\n position: relative;\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-sm, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n cursor: default;\n outline: none;\n user-select: none;\n}\n\n.item:focus:not([data-disabled]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .item:hover:not([data-disabled]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n cursor: pointer;\n }\n}\n\n.item[data-disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.item[data-selected] {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n font-weight: var(--font-weight-medium, 500);\n}\n\n.itemCheck {\n display: inline-flex;\n width: 1rem;\n height: 1rem;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.itemCheckIcon {\n width: 0.875rem;\n height: 0.875rem;\n color: var(--interactive-primary-bg, var(--primary, #111827));\n}\n\n/* Empty */\n.empty {\n list-style: none;\n padding: var(--spacing-4, 1rem) var(--spacing-3, 0.75rem);\n text-align: center;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Group */\n.group {\n list-style: none;\n}\n\n.group ul {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.groupHeading {\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem) var(--spacing-1, 0.25rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-secondary, #9ca3af);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n}\n\n/* Separator */\n.separator {\n list-style: none;\n height: 1px;\n margin: var(--spacing-1, 0.25rem) calc(-1 * var(--spacing-1, 0.25rem));\n background-color: var(--border-default, #e5e7eb);\n}\n"
|
|
1151
|
+
"content": "/* Combobox root */\n.root {\n position: relative;\n display: inline-flex;\n flex-direction: column;\n width: fit-content;\n}\n\n/* Input wrapper */\n.inputWrapper {\n position: relative;\n display: flex;\n align-items: center;\n}\n\n/* Input */\n.input {\n width: 100%;\n height: 2.25rem;\n padding: var(--spacing-2, 0.5rem) calc(var(--spacing-8, 2rem) + var(--spacing-1, 0.25rem)) var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--combobox-border, var(--border-default, #e5e7eb));\n background-color: var(--combobox-bg, var(--surface-interactive-default, #f9fafb));\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .input:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.input:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.input:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n/* Caret icon */\n.icon {\n position: absolute;\n right: var(--spacing-3, 0.75rem);\n top: 50%;\n transform: translateY(-50%);\n width: 1rem;\n height: 1rem;\n pointer-events: none;\n color: var(--text-secondary, #9ca3af);\n transition: transform var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.iconOpen {\n transform: translateY(-50%) rotate(180deg);\n}\n\n/* Content popover */\n.content {\n z-index: 50;\n min-width: var(--radix-popover-trigger-width, 12rem);\n max-height: 18rem;\n overflow: hidden auto;\n border-radius: var(--radius-lg, 0.5rem);\n background-color: var(--field-menu-bg, var(--surface-popover, var(--surface-card, #ffffff)));\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-lg);\n padding: var(--spacing-1, 0.25rem);\n}\n\n/* Listbox */\n.listbox {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n/* Item */\n.item {\n position: relative;\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);\n border-radius: var(--radius-sm, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n cursor: default;\n outline: none;\n user-select: none;\n}\n\n.item:focus:not([data-disabled]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n cursor: pointer;\n}\n\n@media (hover: hover) {\n .item:hover:not([data-disabled]) {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n cursor: pointer;\n }\n}\n\n.item[data-disabled] {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.item[data-selected] {\n background-color: var(--surface-interactive-hover, #f3f4f6);\n font-weight: var(--font-weight-medium, 500);\n}\n\n.itemCheck {\n display: inline-flex;\n width: 1rem;\n height: 1rem;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n}\n\n.itemCheckIcon {\n width: 0.875rem;\n height: 0.875rem;\n color: var(--interactive-primary-bg, var(--primary, #111827));\n}\n\n/* Empty */\n.empty {\n list-style: none;\n padding: var(--spacing-4, 1rem) var(--spacing-3, 0.75rem);\n text-align: center;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #9ca3af);\n}\n\n/* Group */\n.group {\n list-style: none;\n}\n\n.group ul {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n\n.groupHeading {\n padding: var(--spacing-1, 0.25rem) var(--spacing-3, 0.75rem) var(--spacing-1, 0.25rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-secondary, #9ca3af);\n text-transform: uppercase;\n letter-spacing: 0.05em;\n}\n\n/* Separator */\n.separator {\n list-style: none;\n height: 1px;\n margin: var(--spacing-1, 0.25rem) calc(-1 * var(--spacing-1, 0.25rem));\n background-color: var(--border-default, #e5e7eb);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.root :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1152
1152
|
}
|
|
1153
1153
|
]
|
|
1154
1154
|
},
|
|
@@ -1574,7 +1574,7 @@
|
|
|
1574
1574
|
{
|
|
1575
1575
|
"path": "components/ui/search-input/search-input.module.css",
|
|
1576
1576
|
"type": "registry:ui",
|
|
1577
|
-
"content": "/* Search input wrapper\n *\n * The leading magnifier is provided by Input's leadingIcon prop; this\n * wrapper only exists to overlay the optional clear button on top of\n * the field when there's a value to clear. */\n.wrapper {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n}\n\n/* Reserve right padding on the input when the clear button is rendered\n * so the text doesn't slide under the button. */\n.inputWithClear {\n padding-right: var(--spacing-8, 2rem);\n}\n\n/* Hide browser-native search clear button — we ship our own */\n.wrapper input[type=\"search\"]::-webkit-search-cancel-button {\n display: none;\n}\n\n/* Clear button */\n.clearButton {\n position: absolute;\n right: var(--spacing-1, 0.25rem);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 1.75rem;\n height: 1.75rem;\n padding: 0;\n border: none;\n border-radius: var(--radius-sm, 0.25rem);\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .clearButton:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.clearButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #111827);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.clearButton svg {\n width: 0.875rem;\n height: 0.875rem;\n}\n"
|
|
1577
|
+
"content": "/* Search input wrapper\n *\n * The leading magnifier is provided by Input's leadingIcon prop; this\n * wrapper only exists to overlay the optional clear button on top of\n * the field when there's a value to clear. */\n.wrapper {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n}\n\n/* Reserve right padding on the input when the clear button is rendered\n * so the text doesn't slide under the button. */\n.inputWithClear {\n padding-right: var(--spacing-8, 2rem);\n}\n\n/* Hide browser-native search clear button — we ship our own */\n.wrapper input[type=\"search\"]::-webkit-search-cancel-button {\n display: none;\n}\n\n/* Clear button */\n.clearButton {\n position: absolute;\n right: var(--spacing-1, 0.25rem);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 1.75rem;\n height: 1.75rem;\n padding: 0;\n border: none;\n border-radius: var(--radius-sm, 0.25rem);\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .clearButton:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.clearButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #111827);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.clearButton svg {\n width: 0.875rem;\n height: 0.875rem;\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.wrapper :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1578
1578
|
}
|
|
1579
1579
|
]
|
|
1580
1580
|
},
|
|
@@ -1600,7 +1600,32 @@
|
|
|
1600
1600
|
{
|
|
1601
1601
|
"path": "components/ui/password-input/password-input.module.css",
|
|
1602
1602
|
"type": "registry:ui",
|
|
1603
|
-
"content": "/* Password input wrapper */\n.wrapper {\n width: 100%;\n}\n\n.inputWrapper {\n position: relative;\n display: flex;\n align-items: center;\n}\n\n/* Input override — pad right for toggle button */\n.input {\n padding-right: var(--spacing-10, 2.5rem);\n}\n\n/* Toggle visibility button */\n.toggleButton {\n position: absolute;\n right: var(--spacing-1, 0.25rem);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 1.75rem;\n height: 1.75rem;\n padding: 0;\n border: none;\n border-radius: var(--radius-sm, 0.25rem);\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .toggleButton:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.toggleButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #111827);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.toggleButton svg {\n width: 1rem;\n height: 1rem;\n}\n\n/* Strength meter */\n.strengthMeter {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin-top: var(--spacing-1, 0.25rem);\n}\n\n.strengthTrack {\n display: flex;\n flex: 1;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.strengthSegment {\n height: 0.25rem;\n flex: 1;\n border-radius: var(--radius-full, 9999px);\n background-color: var(--border-default, #e5e7eb);\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.strengthWeak {\n background-color: var(--border-error, #ef4444);\n}\n\n.strengthFair {\n background-color: var(--surface-warning-default, #f59e0b);\n}\n\n.strengthGood {\n background-color: var(--surface-success-default, #10b981);\n}\n\n.strengthStrong {\n background-color: var(--surface-success-default, #10b981);\n}\n\n.strengthLabel {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-secondary, #9ca3af);\n min-width: 3rem;\n}\n"
|
|
1603
|
+
"content": "/* Password input wrapper */\n.wrapper {\n width: 100%;\n}\n\n.inputWrapper {\n position: relative;\n display: flex;\n align-items: center;\n}\n\n/* Input override — pad right for toggle button */\n.input {\n padding-right: var(--spacing-10, 2.5rem);\n}\n\n/* Toggle visibility button */\n.toggleButton {\n position: absolute;\n right: var(--spacing-1, 0.25rem);\n display: flex;\n align-items: center;\n justify-content: center;\n width: 1.75rem;\n height: 1.75rem;\n padding: 0;\n border: none;\n border-radius: var(--radius-sm, 0.25rem);\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .toggleButton:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.toggleButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, #111827);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.toggleButton svg {\n width: 1rem;\n height: 1rem;\n}\n\n/* Strength meter */\n.strengthMeter {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n margin-top: var(--spacing-1, 0.25rem);\n}\n\n.strengthTrack {\n display: flex;\n flex: 1;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.strengthSegment {\n height: 0.25rem;\n flex: 1;\n border-radius: var(--radius-full, 9999px);\n background-color: var(--border-default, #e5e7eb);\n transition: background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.strengthWeak {\n background-color: var(--border-error, #ef4444);\n}\n\n.strengthFair {\n background-color: var(--surface-warning-default, #f59e0b);\n}\n\n.strengthGood {\n background-color: var(--surface-success-default, #10b981);\n}\n\n.strengthStrong {\n background-color: var(--surface-success-default, #10b981);\n}\n\n.strengthLabel {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-secondary, #9ca3af);\n min-width: 3rem;\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.wrapper :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1604
|
+
}
|
|
1605
|
+
]
|
|
1606
|
+
},
|
|
1607
|
+
{
|
|
1608
|
+
"name": "matrix-table",
|
|
1609
|
+
"type": "registry:ui",
|
|
1610
|
+
"description": "A fixed assignment grid for members×roles patterns. Sticky-left identity column with hover-tracking background, centered 22px boolean cells (filled-success check when active / muted empty when not), and multi-line centered column headers (label over count) that opt out of uppercase treatment. No list machinery.",
|
|
1611
|
+
"category": "admin",
|
|
1612
|
+
"dependencies": [
|
|
1613
|
+
"@phosphor-icons/react",
|
|
1614
|
+
"@loworbitstudio/visor-core"
|
|
1615
|
+
],
|
|
1616
|
+
"registryDependencies": [
|
|
1617
|
+
"utils"
|
|
1618
|
+
],
|
|
1619
|
+
"files": [
|
|
1620
|
+
{
|
|
1621
|
+
"path": "components/ui/matrix-table/matrix-table.tsx",
|
|
1622
|
+
"type": "registry:ui",
|
|
1623
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { CheckIcon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./matrix-table.module.css\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface MatrixColumn {\n /** Stable unique key used to look up cell values and keying. */\n id: string\n /** Display label for the column header. */\n label: string\n /** Optional sub-label rendered below the column label (e.g. member count). */\n count?: number\n}\n\n/**\n * A single matrix cell value.\n *\n * - `true` → renders the active checkmark indicator\n * - `false` → renders the empty/inactive indicator\n * - `string` → renders the text in the standard cell typography\n */\nexport type MatrixCellValue = string | boolean\n\nexport interface MatrixRow<TIdentity = React.ReactNode> {\n /** Stable unique key for the row. */\n id: string\n /** The identity data passed to `renderIdentity`. Typically a name/avatar record. */\n identity: TIdentity\n /**\n * Set of column ids where the boolean cell is \"active\" (checked).\n * Columns not present in this set render as empty/inactive.\n *\n * For string cell values, or to set per-column values explicitly, use `cells`.\n * When both are present, an entry in `cells` takes precedence over `activeColumns`\n * for that column.\n */\n activeColumns: Set<string> | string[]\n /**\n * Optional per-column cell values keyed by column id. Each value is a\n * {@link MatrixCellValue}: `true`/`false` render the boolean indicator,\n * a `string` renders as plain text in the standard cell style.\n *\n * A column present here overrides its `activeColumns` membership; columns\n * absent here fall back to the `activeColumns` boolean model.\n */\n cells?: Record<string, MatrixCellValue>\n}\n\nexport interface MatrixTableProps<TIdentity = React.ReactNode>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** Column definitions — drives the boolean cell columns. */\n columns: MatrixColumn[]\n /** Row data — each row has an identity + a set of active column ids. */\n rows: MatrixRow<TIdentity>[]\n /**\n * Render slot for the sticky-left identity cell content.\n * Receives the row's `identity` value.\n */\n renderIdentity: (identity: TIdentity) => React.ReactNode\n /** Accessible label for the table element. */\n \"aria-label\"?: string\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nfunction MatrixTableInner<TIdentity = React.ReactNode>(\n props: MatrixTableProps<TIdentity>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns,\n rows,\n renderIdentity,\n className,\n \"aria-label\": ariaLabel,\n ...rest\n } = props\n\n return (\n <div\n ref={ref}\n data-slot=\"matrix-table\"\n className={cn(styles.root, className)}\n {...rest}\n >\n <div className={styles.scrollContainer}>\n <table\n className={styles.table}\n aria-label={ariaLabel}\n >\n <thead className={styles.thead}>\n <tr className={styles.headerRow}>\n {/* Sticky identity header — empty, no label */}\n <th\n scope=\"col\"\n className={cn(styles.th, styles.identityTh)}\n aria-label=\"Member\"\n />\n {columns.map((col) => (\n <th\n key={col.id}\n scope=\"col\"\n className={cn(styles.th, styles.booleanTh)}\n >\n <div className={styles.colHeader}>\n <span className={styles.colLabel}>{col.label}</span>\n {col.count != null && (\n <span className={styles.colCount}>{col.count}</span>\n )}\n </div>\n </th>\n ))}\n </tr>\n </thead>\n <tbody className={styles.tbody}>\n {rows.map((row) => {\n const activeSet =\n row.activeColumns instanceof Set\n ? row.activeColumns\n : new Set(row.activeColumns)\n return (\n <tr\n key={row.id}\n data-slot=\"matrix-table-row\"\n className={styles.bodyRow}\n >\n {/* Sticky identity cell */}\n <td className={cn(styles.td, styles.identityTd)}>\n <div className={styles.identityCell}>\n {renderIdentity(row.identity)}\n </div>\n </td>\n {/* Cells — string cells render text; boolean cells render the indicator */}\n {columns.map((col) => {\n // A `cells` entry takes precedence over `activeColumns` for this column.\n const cellValue: MatrixCellValue =\n row.cells != null && col.id in row.cells\n ? row.cells[col.id]\n : activeSet.has(col.id)\n\n // String cell: render plain text with the standard cell typography.\n if (typeof cellValue !== \"boolean\") {\n return (\n <td\n key={col.id}\n className={cn(styles.td, styles.textTd)}\n aria-label={`${col.label}: ${cellValue}`}\n >\n <span className={styles.textCell}>{cellValue}</span>\n </td>\n )\n }\n\n // Boolean cell: render the existing active/inactive indicator.\n const active = cellValue\n return (\n <td\n key={col.id}\n className={cn(styles.td, styles.booleanTd)}\n aria-label={`${col.label}: ${active ? \"assigned\" : \"not assigned\"}`}\n >\n <div className={styles.booleanCell}>\n <span\n className={cn(\n styles.booleanIndicator,\n active\n ? styles.booleanIndicatorActive\n : styles.booleanIndicatorInactive\n )}\n aria-hidden=\"true\"\n >\n {active && (\n <CheckIcon\n weight=\"bold\"\n className={styles.checkIcon}\n />\n )}\n </span>\n </div>\n </td>\n )\n })}\n </tr>\n )\n })}\n </tbody>\n </table>\n </div>\n </div>\n )\n}\n\n// forwardRef with generics — preserve TIdentity through the cast\nconst MatrixTable = React.forwardRef(MatrixTableInner) as <\n TIdentity = React.ReactNode,\n>(\n props: MatrixTableProps<TIdentity> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof MatrixTableInner>\n\n;(MatrixTable as unknown as { displayName: string }).displayName =\n \"MatrixTable\"\n\nexport { MatrixTable }\n"
|
|
1624
|
+
},
|
|
1625
|
+
{
|
|
1626
|
+
"path": "components/ui/matrix-table/matrix-table.module.css",
|
|
1627
|
+
"type": "registry:ui",
|
|
1628
|
+
"content": "/* MatrixTable — members×roles fixed assignment grid.\n *\n * Layout: sticky-left identity column + centered boolean columns.\n * No list machinery: no selection column, no sort buttons, no pagination footer.\n * Theme-agnostic: all values via CSS custom property tokens.\n */\n\n/* Root wrapper — provides the scroll container context */\n.root {\n display: block;\n width: 100%;\n min-width: 0;\n color: var(--text-primary, #111827);\n}\n\n/* Horizontal scroll container so sticky column works correctly */\n.scrollContainer {\n width: 100%;\n overflow-x: auto;\n overflow-y: hidden;\n border-radius: var(--radius-lg, 0.5rem);\n box-shadow: var(--shadow-sm);\n}\n\n/* Table */\n.table {\n width: 100%;\n border-collapse: collapse;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n}\n\n/* ─── Header ─────────────────────────────────────────────────────────────── */\n\n.thead {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n}\n\n.headerRow {\n /* no extra styles needed; border is on thead */\n}\n\n/* All header cells share base styles */\n.th {\n padding: var(--spacing-3, 0.75rem);\n vertical-align: middle;\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n background-color: var(--surface-card, #ffffff);\n}\n\n/* Identity column header — sticky-left, left-aligned, min-width to accommodate identity content */\n.identityTh {\n position: sticky;\n left: 0;\n z-index: 3; /* above body cells (z-index: 1) and body identity cells (z-index: 2) */\n text-align: left;\n min-width: 12rem;\n /* Match the body identity td bg so no gap shows on scroll */\n background-color: var(--surface-card, #ffffff);\n /* Right border to visually separate from boolean columns */\n border-right: 1px solid var(--border-default, #e5e7eb);\n}\n\n/* Boolean column headers — centered, multi-line */\n.booleanTh {\n text-align: center;\n min-width: 4rem;\n /* No uppercase treatment — explicitly normal case via token */\n text-transform: none;\n}\n\n/* Multi-line column header: role label stacked over count */\n.colHeader {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.colLabel {\n /* Normal sentence-case, no uppercase override */\n text-transform: none;\n font-weight: var(--font-weight-medium, 500);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n line-height: var(--line-height-tight, 1.25);\n white-space: nowrap;\n}\n\n/* Count sub-label — intentional 11px (no exact token; documented as spec-literal) */\n.colCount {\n font-size: 0.6875rem; /* 11px: spec-literal from VI-523 — closest tokens are font-size-xs=12px, no 11px token */\n font-weight: var(--font-weight-normal, 400);\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* ─── Body ───────────────────────────────────────────────────────────────── */\n\n.tbody tr.bodyRow:last-child {\n border-bottom: none;\n}\n\n.bodyRow {\n border-bottom: 1px solid var(--border-default, #e5e7eb);\n transition: background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.bodyRow:hover {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n/* All body cells share base padding */\n.td {\n padding: var(--spacing-3, 0.75rem);\n vertical-align: middle;\n}\n\n/* Identity cell — sticky-left, bg tracks row hover via CSS custom property */\n.identityTd {\n position: sticky;\n left: 0;\n z-index: 2; /* above boolean cells in the same row (z-index: 1 default stacking) */\n background-color: var(--surface-card, #ffffff);\n border-right: 1px solid var(--border-default, #e5e7eb);\n /* Track row hover: when .bodyRow:hover, background mirrors the row bg */\n transition: background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* When the row is hovered, the identity cell bg matches the row hover bg */\n.bodyRow:hover .identityTd {\n background-color: var(--surface-muted, #f3f4f6);\n}\n\n.identityCell {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n}\n\n/* Boolean cells — centered */\n.booleanTd {\n text-align: center;\n}\n\n/* Text cells — centered like boolean cells; inherit the standard cell typography */\n.textTd {\n text-align: center;\n}\n\n.textCell {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n line-height: var(--line-height-tight, 1.25);\n}\n\n.booleanCell {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n/* Boolean indicator circle — 22px spec-literal from VI-523 (no exact --spacing token at 22px; closest are --spacing-5=20px, --spacing-6=24px; 22px is spec-literal, intentional). */\n.booleanIndicator {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 1.375rem; /* 22px: spec-literal from VI-523 */\n height: 1.375rem; /* 22px: spec-literal from VI-523 */\n border-radius: var(--radius-full, 9999px);\n flex-shrink: 0;\n}\n\n/* Active (checked) — filled success circle */\n.booleanIndicatorActive {\n background-color: var(--surface-success-default, #16a34a);\n color: var(--text-inverse, #f9fafb);\n}\n\n/* Inactive — muted empty circle with hairline border */\n.booleanIndicatorInactive {\n background-color: transparent;\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n color: transparent;\n}\n\n/* Check icon inside the active indicator */\n.checkIcon {\n /* 12px — proportional to the 22px container; no exact token, intentional literal */\n width: 0.75rem;\n height: 0.75rem;\n}\n"
|
|
1604
1629
|
}
|
|
1605
1630
|
]
|
|
1606
1631
|
},
|
|
@@ -1649,7 +1674,7 @@
|
|
|
1649
1674
|
{
|
|
1650
1675
|
"path": "components/ui/number-input/number-input.module.css",
|
|
1651
1676
|
"type": "registry:ui",
|
|
1652
|
-
"content": "/* Number input wrapper */\n.wrapper {\n display: inline-flex;\n align-items: center;\n border: 1px solid var(--number-input-border, var(--border-default, #e5e7eb));\n border-radius: var(--radius-md, 0.375rem);\n background-color: var(--number-input-bg, var(--surface-interactive-default, #f9fafb));\n overflow: hidden;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .button:hover {\n color: var(--text-primary, #111827);\n background-color: var(--surface-muted, #f3f4f6);\n }\n}\n\n.wrapper:focus-within {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.wrapperDisabled {\n opacity: 0.5;\n pointer-events: none;\n}\n\n/* Stepper buttons */\n.button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n flex-shrink: 0;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.button:active {\n background-color: var(--surface-interactive-hover, #e5e7eb);\n transition: none;\n}\n\n.button:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.button:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.buttonIcon {\n width: 0.875rem;\n height: 0.875rem;\n}\n\n/* Input */\n.input {\n width: 4rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n background: transparent;\n text-align: center;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n"
|
|
1677
|
+
"content": "/* Number input wrapper */\n.wrapper {\n display: inline-flex;\n align-items: center;\n border: 1px solid var(--number-input-border, var(--border-default, #e5e7eb));\n border-radius: var(--radius-md, 0.375rem);\n background-color: var(--number-input-bg, var(--surface-interactive-default, #f9fafb));\n overflow: hidden;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .button:hover {\n color: var(--text-primary, #111827);\n background-color: var(--surface-muted, #f3f4f6);\n }\n}\n\n.wrapper:focus-within {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.wrapperDisabled {\n opacity: 0.5;\n pointer-events: none;\n}\n\n/* Stepper buttons */\n.button {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 2.25rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n flex-shrink: 0;\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n background-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.button:active {\n background-color: var(--surface-interactive-hover, #e5e7eb);\n transition: none;\n}\n\n.button:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, -2px);\n}\n\n.button:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.buttonIcon {\n width: 0.875rem;\n height: 0.875rem;\n}\n\n/* Input */\n.input {\n width: 4rem;\n height: 2.25rem;\n padding: 0;\n border: none;\n background: transparent;\n text-align: center;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.wrapper :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1653
1678
|
}
|
|
1654
1679
|
]
|
|
1655
1680
|
},
|
|
@@ -1703,7 +1728,7 @@
|
|
|
1703
1728
|
{
|
|
1704
1729
|
"path": "components/ui/otp-input/otp-input.module.css",
|
|
1705
1730
|
"type": "registry:ui",
|
|
1706
|
-
"content": "/* OTP input wrapper */\n.wrapper {\n display: inline-flex;\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Individual digit cell */\n.cell {\n width: 2.5rem;\n height: 2.75rem;\n border: 1px solid var(--otp-border, var(--border-default, #e5e7eb));\n border-radius: var(--radius-md, 0.375rem);\n background-color: var(--otp-bg, var(--surface-interactive-default, #f9fafb));\n text-align: center;\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n outline: none;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.cell::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .cell:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.cell:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.cell:disabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.cellFilled {\n border-color: var(--border-focus, #111827);\n}\n"
|
|
1731
|
+
"content": "/* OTP input wrapper */\n.wrapper {\n display: inline-flex;\n gap: var(--spacing-2, 0.5rem);\n}\n\n/* Individual digit cell */\n.cell {\n width: 2.5rem;\n height: 2.75rem;\n border: 1px solid var(--otp-border, var(--border-default, #e5e7eb));\n border-radius: var(--radius-md, 0.375rem);\n background-color: var(--otp-bg, var(--surface-interactive-default, #f9fafb));\n text-align: center;\n font-size: var(--font-size-lg, 1.125rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n outline: none;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n.cell::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n@media (hover: hover) {\n .cell:hover:not(:focus-visible):not(:disabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.cell:focus-visible {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.cell:disabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.cellFilled {\n border-color: var(--border-focus, #111827);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.wrapper :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1707
1732
|
}
|
|
1708
1733
|
]
|
|
1709
1734
|
},
|
|
@@ -1728,7 +1753,7 @@
|
|
|
1728
1753
|
{
|
|
1729
1754
|
"path": "components/ui/tag-input/tag-input.module.css",
|
|
1730
1755
|
"type": "registry:ui",
|
|
1731
|
-
"content": "/* Tag input wrapper — styled like an Input */\n.wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n min-height: 2.25rem;\n width: 100%;\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--tag-input-border, var(--border-default, #e5e7eb));\n background-color: var(--tag-input-bg, var(--surface-interactive-default, #f9fafb));\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n cursor: text;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .wrapper:hover:not(.wrapperFocused):not(.wrapperDisabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.wrapperFocused {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.wrapperDisabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n/* Individual tag chip */\n.tag {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: calc(var(--spacing-1, 0.25rem) / 4) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-full, 9999px);\n background-color: var(--surface-muted, #f3f4f6);\n border: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-xs, 0.75rem);\n line-height: 1.5;\n color: var(--text-primary, #111827);\n}\n\n.tagLabel {\n white-space: nowrap;\n}\n\n.tagRemove {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 0.875rem;\n height: 0.875rem;\n padding: 0;\n border: none;\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n border-radius: var(--radius-full, 9999px);\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .tagRemove:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.tagRemove svg {\n width: 0.625rem;\n height: 0.625rem;\n}\n\n/* Inline text input */\n.input {\n flex: 1;\n min-width: 4rem;\n height: 1.5rem;\n padding: 0;\n border: none;\n background: transparent;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n"
|
|
1756
|
+
"content": "/* Tag input wrapper — styled like an Input */\n.wrapper {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n min-height: 2.25rem;\n width: 100%;\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--tag-input-border, var(--border-default, #e5e7eb));\n background-color: var(--tag-input-bg, var(--surface-interactive-default, #f9fafb));\n padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);\n cursor: text;\n transition: border-color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out),\n box-shadow var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .wrapper:hover:not(.wrapperFocused):not(.wrapperDisabled) {\n border-color: var(--border-strong, #d1d5db);\n }\n}\n\n.wrapperFocused {\n border-color: var(--border-focus, #111827);\n box-shadow: 0 0 0 var(--focus-ring-width, 2px) color-mix(in srgb, var(--border-focus, #111827) 15%, transparent);\n}\n\n.wrapperDisabled {\n pointer-events: none;\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n/* Individual tag chip */\n.tag {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: calc(var(--spacing-1, 0.25rem) / 4) var(--spacing-2, 0.5rem);\n border-radius: var(--radius-full, 9999px);\n background-color: var(--surface-muted, #f3f4f6);\n border: 1px solid var(--border-default, #e5e7eb);\n font-size: var(--font-size-xs, 0.75rem);\n line-height: 1.5;\n color: var(--text-primary, #111827);\n}\n\n.tagLabel {\n white-space: nowrap;\n}\n\n.tagRemove {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 0.875rem;\n height: 0.875rem;\n padding: 0;\n border: none;\n background: transparent;\n color: var(--text-secondary, #9ca3af);\n cursor: pointer;\n border-radius: var(--radius-full, 9999px);\n transition: color var(--motion-duration-150, 150ms) var(--motion-easing-default, ease-in-out);\n}\n\n@media (hover: hover) {\n .tagRemove:hover {\n color: var(--text-primary, #111827);\n }\n}\n\n.tagRemove svg {\n width: 0.625rem;\n height: 0.625rem;\n}\n\n/* Inline text input */\n.input {\n flex: 1;\n min-width: 4rem;\n height: 1.5rem;\n padding: 0;\n border: none;\n background: transparent;\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-primary, #111827);\n outline: none;\n}\n\n.input::placeholder {\n color: var(--text-secondary, #9ca3af);\n}\n\n/* VI-510: form controls do not inherit font-family from the page; opt back in (matches textarea). */\n.wrapper :is(input, button, textarea) {\n font-family: inherit;\n}\n"
|
|
1732
1757
|
}
|
|
1733
1758
|
]
|
|
1734
1759
|
},
|
|
@@ -1982,6 +2007,31 @@
|
|
|
1982
2007
|
}
|
|
1983
2008
|
]
|
|
1984
2009
|
},
|
|
2010
|
+
{
|
|
2011
|
+
"name": "surface-scale-stack",
|
|
2012
|
+
"type": "registry:ui",
|
|
2013
|
+
"description": "Multi-tier stacked surface aggregator block. Composes ordered SurfaceRow specimens into a rounded vertical stack with an optional use-note column.",
|
|
2014
|
+
"category": "specimen",
|
|
2015
|
+
"dependencies": [
|
|
2016
|
+
"@loworbitstudio/visor-core"
|
|
2017
|
+
],
|
|
2018
|
+
"registryDependencies": [
|
|
2019
|
+
"utils",
|
|
2020
|
+
"surface-row"
|
|
2021
|
+
],
|
|
2022
|
+
"files": [
|
|
2023
|
+
{
|
|
2024
|
+
"path": "components/ui/surface-scale-stack/surface-scale-stack.tsx",
|
|
2025
|
+
"type": "registry:ui",
|
|
2026
|
+
"content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { SurfaceRow } from \"../surface-row/surface-row\"\nimport styles from \"./surface-scale-stack.module.css\"\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface SurfaceScaleItem {\n /** CSS custom property token for the surface background (e.g. \"--surface-page\") */\n token: string\n /** Display name (e.g. \"Page\", \"Card\") */\n name: string\n /** Optional use-note shown in the right-aligned column */\n note?: string\n /** When true, text renders in inverse/light colors for dark surfaces */\n lightText?: boolean\n}\n\nexport interface SurfaceScaleStackProps {\n /** Ordered array of surface tiers to render */\n surfaces: SurfaceScaleItem[]\n className?: string\n}\n\n// ─── SurfaceScaleStack ───────────────────────────────────────────────────────\n\nfunction SurfaceScaleStack({ surfaces, className }: SurfaceScaleStackProps) {\n const hasNotes = surfaces.some((s) => s.note !== undefined)\n\n return (\n <div\n data-slot=\"surface-scale-stack\"\n className={cn(styles.stack, hasNotes && styles.withNotes, className)}\n >\n {surfaces.map((surface) => (\n <div key={surface.token} className={styles.rowWrapper}>\n <div className={styles.rowContent}>\n <SurfaceRow\n token={surface.token}\n name={surface.name}\n lightText={surface.lightText}\n />\n </div>\n {hasNotes && (\n <div className={styles.noteCell}>\n {surface.note && (\n <span className={styles.note}>{surface.note}</span>\n )}\n </div>\n )}\n </div>\n ))}\n </div>\n )\n}\n\nexport { SurfaceScaleStack }\n"
|
|
2027
|
+
},
|
|
2028
|
+
{
|
|
2029
|
+
"path": "components/ui/surface-scale-stack/surface-scale-stack.module.css",
|
|
2030
|
+
"type": "registry:ui",
|
|
2031
|
+
"content": ".stack {\n border-radius: var(--radius-md, 6px);\n overflow: hidden;\n}\n\n.rowWrapper {\n display: flex;\n align-items: stretch;\n}\n\n/* Remove SurfaceRow's own border and radius so the stack owns the chrome */\n.rowContent {\n flex: 1;\n min-width: 0;\n}\n\n.rowContent [data-slot=\"surface-row\"] {\n border-radius: 0;\n border: none;\n}\n\n/* Note column — only rendered when at least one row has a note */\n.noteCell {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n padding: var(--spacing-4, 1rem);\n min-width: 10rem;\n flex-shrink: 0;\n}\n\n.note {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #9ca3af);\n text-align: right;\n}\n"
|
|
2032
|
+
}
|
|
2033
|
+
]
|
|
2034
|
+
},
|
|
1985
2035
|
{
|
|
1986
2036
|
"name": "font-showcase",
|
|
1987
2037
|
"type": "registry:ui",
|
|
@@ -2007,6 +2057,31 @@
|
|
|
2007
2057
|
}
|
|
2008
2058
|
]
|
|
2009
2059
|
},
|
|
2060
|
+
{
|
|
2061
|
+
"name": "type-scale-stack",
|
|
2062
|
+
"type": "registry:ui",
|
|
2063
|
+
"description": "Discrete N-tier type-scale aggregator that wraps an ordered list of TypeSpecimen rows in a rounded, screen-tier, borderless vertical stack.",
|
|
2064
|
+
"category": "specimen",
|
|
2065
|
+
"dependencies": [
|
|
2066
|
+
"@loworbitstudio/visor-core"
|
|
2067
|
+
],
|
|
2068
|
+
"registryDependencies": [
|
|
2069
|
+
"utils",
|
|
2070
|
+
"type-specimen"
|
|
2071
|
+
],
|
|
2072
|
+
"files": [
|
|
2073
|
+
{
|
|
2074
|
+
"path": "components/ui/type-scale-stack/type-scale-stack.tsx",
|
|
2075
|
+
"type": "registry:ui",
|
|
2076
|
+
"content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { TypeSpecimen } from \"../type-specimen/type-specimen\"\nimport styles from \"./type-scale-stack.module.css\"\n\n// ─── Types ──────────────────────────────────────────────────────────────────\n\nexport interface TypeScaleStepProps {\n /** CSS custom property token for font size (e.g. \"--font-size-xl\") */\n token: string\n /** Label for this type step (e.g. \"xl\", \"base\") */\n label: string\n /** Font size in pixels */\n sizePx: number\n /** Sample text to render at this size */\n sample: string\n}\n\nexport interface TypeScaleStackProps {\n /** Ordered array of type scale steps */\n steps: TypeScaleStepProps[]\n className?: string\n}\n\n// ─── TypeScaleStack ─────────────────────────────────────────────────────────\n\nfunction TypeScaleStack({ steps, className }: TypeScaleStackProps) {\n return (\n <div data-slot=\"type-scale-stack\" className={cn(styles.stack, className)}>\n {steps.map((step, index) => (\n <TypeSpecimen\n key={step.token}\n token={step.token}\n label={step.label}\n sizePx={step.sizePx}\n sampleText={step.sample}\n className={index === steps.length - 1 ? styles.stackRowLast : styles.stackRow}\n />\n ))}\n </div>\n )\n}\n\nexport { TypeScaleStack }\n"
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
"path": "components/ui/type-scale-stack/type-scale-stack.module.css",
|
|
2080
|
+
"type": "registry:ui",
|
|
2081
|
+
"content": ".stack {\n background: var(--surface-screen, #f9fafb);\n border-radius: var(--radius-lg, 8px);\n overflow: hidden;\n padding: var(--spacing-4, 1rem);\n}\n\n/* Override TypeSpecimen row chrome: no inter-row borders */\n.stackRow {\n border-bottom: none;\n padding-bottom: var(--spacing-3, 0.75rem);\n}\n\n.stackRowLast {\n border-bottom: none;\n padding-bottom: 0;\n}\n"
|
|
2082
|
+
}
|
|
2083
|
+
]
|
|
2084
|
+
},
|
|
2010
2085
|
{
|
|
2011
2086
|
"name": "type-specimen",
|
|
2012
2087
|
"type": "registry:ui",
|
|
@@ -2047,12 +2122,12 @@
|
|
|
2047
2122
|
{
|
|
2048
2123
|
"path": "components/ui/activity-feed/activity-feed.tsx",
|
|
2049
2124
|
"type": "registry:ui",
|
|
2050
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./activity-feed.module.css\"\n\ntype ActivityFeedVariant = \"default\" | \"compact\" | \"timeline\"\n\ninterface ActivityFeedContextValue {\n variant: ActivityFeedVariant\n}\n\nconst ActivityFeedContext = React.createContext<ActivityFeedContextValue>({\n variant: \"default\",\n})\n\nexport interface ActivityFeedProps\n extends React.HTMLAttributes<HTMLOListElement> {\n /** Display mode for the feed. */\n variant?: ActivityFeedVariant\n /** ActivityFeedItem children. */\n children?: React.ReactNode\n}\n\nconst variantClass: Record<ActivityFeedVariant, string> = {\n default: styles.variantDefault,\n compact: styles.variantCompact,\n timeline: styles.variantTimeline,\n}\n\nconst ActivityFeedRoot = React.forwardRef<HTMLOListElement, ActivityFeedProps>(\n ({ className, variant = \"default\", children, ...props }, ref) => {\n const contextValue = React.useMemo(() => ({ variant }), [variant])\n return (\n <ActivityFeedContext.Provider value={contextValue}>\n <ol\n ref={ref}\n data-slot=\"activity-feed\"\n data-variant={variant}\n className={cn(styles.base, variantClass[variant], className)}\n {...props}\n >\n {children}\n </ol>\n </ActivityFeedContext.Provider>\n )\n }\n)\nActivityFeedRoot.displayName = \"ActivityFeed\"\n\nexport interface ActivityFeedItemProps\n extends Omit<React.LiHTMLAttributes<HTMLLIElement>, \"title\"> {\n /** Leading visual — icon, avatar, or colored dot. */\n leading?: React.ReactNode\n /** Primary event description. Required. */\n title: React.ReactNode\n /** Optional secondary detail. */\n description?: React.ReactNode\n /** Who performed the event (e.g. name or avatar + name cluster). */\n actor?: React.ReactNode\n /**\n * Pre-formatted timestamp. Pass a string, or pass your own `<time>` element\n * for full control over the `dateTime` attribute and semantics.\n */\n timestamp: React.ReactNode\n /** Right-aligned meta slot — status badge, link, etc. */\n trailing?: React.ReactNode\n}\n\nconst ActivityFeedItem = React.forwardRef<HTMLLIElement, ActivityFeedItemProps>(\n (\n {\n className,\n leading,\n title,\n description,\n actor,\n timestamp,\n trailing,\n ...props\n },\n ref\n ) => {\n const { variant } = React.useContext(ActivityFeedContext)\n\n return (\n <li\n ref={ref}\n data-slot=\"activity-feed-item\"\n data-variant={variant}\n className={cn(styles.item, variantClass[variant], className)}\n {...props}\n >\n <span data-slot=\"activity-feed-leading\" className={styles.leading}>\n
|
|
2125
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./activity-feed.module.css\"\n\ntype ActivityFeedVariant = \"default\" | \"compact\" | \"timeline\" | \"compact-3col\"\n\ninterface ActivityFeedContextValue {\n variant: ActivityFeedVariant\n}\n\nconst ActivityFeedContext = React.createContext<ActivityFeedContextValue>({\n variant: \"default\",\n})\n\nexport interface ActivityFeedProps\n extends React.HTMLAttributes<HTMLOListElement> {\n /** Display mode for the feed. */\n variant?: ActivityFeedVariant\n /** ActivityFeedItem children. */\n children?: React.ReactNode\n}\n\nconst variantClass: Record<ActivityFeedVariant, string> = {\n default: styles.variantDefault,\n compact: styles.variantCompact,\n timeline: styles.variantTimeline,\n \"compact-3col\": styles.variantCompact3col,\n}\n\nconst ActivityFeedRoot = React.forwardRef<HTMLOListElement, ActivityFeedProps>(\n ({ className, variant = \"default\", children, ...props }, ref) => {\n const contextValue = React.useMemo(() => ({ variant }), [variant])\n return (\n <ActivityFeedContext.Provider value={contextValue}>\n <ol\n ref={ref}\n data-slot=\"activity-feed\"\n data-variant={variant}\n className={cn(styles.base, variantClass[variant], className)}\n {...props}\n >\n {children}\n </ol>\n </ActivityFeedContext.Provider>\n )\n }\n)\nActivityFeedRoot.displayName = \"ActivityFeed\"\n\nexport interface ActivityFeedItemProps\n extends Omit<React.LiHTMLAttributes<HTMLLIElement>, \"title\"> {\n /** Leading visual — icon, avatar, or colored dot. */\n leading?: React.ReactNode\n /** Primary event description. Required. */\n title: React.ReactNode\n /** Optional secondary detail. */\n description?: React.ReactNode\n /** Who performed the event (e.g. name or avatar + name cluster). */\n actor?: React.ReactNode\n /**\n * Pre-formatted timestamp. Pass a string, or pass your own `<time>` element\n * for full control over the `dateTime` attribute and semantics.\n */\n timestamp: React.ReactNode\n /** Right-aligned meta slot — status badge, link, etc. */\n trailing?: React.ReactNode\n}\n\nconst ActivityFeedItem = React.forwardRef<HTMLLIElement, ActivityFeedItemProps>(\n (\n {\n className,\n leading,\n title,\n description,\n actor,\n timestamp,\n trailing,\n ...props\n },\n ref\n ) => {\n const { variant } = React.useContext(ActivityFeedContext)\n\n const is3col = variant === \"compact-3col\"\n\n return (\n <li\n ref={ref}\n data-slot=\"activity-feed-item\"\n data-variant={variant}\n className={cn(styles.item, variantClass[variant], className)}\n {...props}\n >\n {is3col ? (\n <>\n <span\n data-slot=\"activity-feed-timestamp\"\n className={cn(styles.timestamp, styles.timestampCol)}\n >\n {timestamp}\n </span>\n <span data-slot=\"activity-feed-leading\" className={cn(styles.leading, styles.leadingCol)}>\n {leading}\n </span>\n <div data-slot=\"activity-feed-header\" className={cn(styles.header, styles.headerCol)}>\n <div data-slot=\"activity-feed-heading\" className={styles.heading}>\n <span data-slot=\"activity-feed-title\" className={styles.title}>\n {title}\n </span>\n {actor ? (\n <span\n data-slot=\"activity-feed-actor\"\n className={styles.actor}\n >\n {actor}\n </span>\n ) : null}\n </div>\n {trailing ? (\n <span\n data-slot=\"activity-feed-trailing\"\n className={styles.trailing}\n >\n {trailing}\n </span>\n ) : null}\n </div>\n </>\n ) : (\n <>\n <span data-slot=\"activity-feed-leading\" className={styles.leading}>\n {leading}\n </span>\n <div data-slot=\"activity-feed-body\" className={styles.body}>\n <div data-slot=\"activity-feed-header\" className={styles.header}>\n <div data-slot=\"activity-feed-heading\" className={styles.heading}>\n <span data-slot=\"activity-feed-title\" className={styles.title}>\n {title}\n </span>\n {actor ? (\n <span\n data-slot=\"activity-feed-actor\"\n className={styles.actor}\n >\n {actor}\n </span>\n ) : null}\n </div>\n {trailing ? (\n <span\n data-slot=\"activity-feed-trailing\"\n className={styles.trailing}\n >\n {trailing}\n </span>\n ) : null}\n </div>\n {description ? (\n <p\n data-slot=\"activity-feed-description\"\n className={styles.description}\n >\n {description}\n </p>\n ) : null}\n <span\n data-slot=\"activity-feed-timestamp\"\n className={styles.timestamp}\n >\n {timestamp}\n </span>\n </div>\n </>\n )}\n </li>\n )\n }\n)\nActivityFeedItem.displayName = \"ActivityFeedItem\"\n\ntype ActivityFeedComponent = typeof ActivityFeedRoot & {\n Item: typeof ActivityFeedItem\n}\n\nconst ActivityFeed = ActivityFeedRoot as ActivityFeedComponent\nActivityFeed.Item = ActivityFeedItem\n\nexport { ActivityFeed, ActivityFeedItem }\n"
|
|
2051
2126
|
},
|
|
2052
2127
|
{
|
|
2053
2128
|
"path": "components/ui/activity-feed/activity-feed.module.css",
|
|
2054
2129
|
"type": "registry:ui",
|
|
2055
|
-
"content": "/* Activity Feed — vertical list of timestamped events */\n.base {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n color: var(--text-primary, #111827);\n}\n\n.item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Leading slot — wraps consumer-provided icon/avatar/dot */\n.leading {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n color: var(--text-tertiary, #6b7280);\n line-height: 1;\n}\n\n.leading svg {\n width: var(--spacing-5, 1.25rem);\n height: var(--spacing-5, 1.25rem);\n}\n\n/* Body wraps everything right of the leading slot */\n.body {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: heading cluster + trailing meta */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Heading cluster: title + optional actor */\n.heading {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n}\n\n.title {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-snug, 1.375);\n color: var(--text-primary, #111827);\n}\n\n.actor {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.trailing {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n margin-left: auto;\n}\n\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n}\n\n.timestamp {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-snug, 1.375);\n}\n\n/* ============================================================\n * Variant: default — padded rows separated by borders\n * ============================================================ */\n.variantDefault.base > .item + .item {\n border-top: 1px solid var(--border-default, #e5e7eb);\n}\n\n.variantDefault.item {\n padding: var(--spacing-4, 1rem) 0;\n}\n\n.variantDefault.base > .item:first-child {\n padding-top: 0;\n}\n\n.variantDefault.base > .item:last-child {\n padding-bottom: 0;\n}\n\n/* ============================================================\n * Variant: compact — denser spacing, smaller text\n * ============================================================ */\n.variantCompact.base > .item + .item {\n border-top: 1px solid var(--border-muted, #e5e7eb);\n}\n\n.variantCompact.item {\n padding: var(--spacing-2, 0.5rem) 0;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.variantCompact .title,\n.variantCompact .actor,\n.variantCompact .description {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantCompact .leading svg {\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n}\n\n.variantCompact.base > .item:first-child {\n padding-top: 0;\n}\n\n.variantCompact.base > .item:last-child {\n padding-bottom: 0;\n}\n\n/* ============================================================\n * Variant: timeline — left rail with connecting line\n * ============================================================ */\n.variantTimeline.base {\n gap: var(--spacing-5, 1.25rem);\n}\n\n.variantTimeline.item {\n padding-bottom: var(--spacing-1, 0.25rem);\n}\n\n/* Connecting line between leading indicators.\n * The rail runs from the bottom edge of the leading slot to the\n * bottom of the item. The final item does not get a rail because\n * the flex gap above collapses past the last child. We use a CSS\n * selector to hide the rail on the last item. */\n.variantTimeline .leading {\n position: relative;\n /* Ensure the line is consistent even when leading is raw text */\n min-width: var(--spacing-5, 1.25rem);\n min-height: var(--spacing-5, 1.25rem);\n z-index: 0;\n}\n\n.variantTimeline .leading::before {\n content: \"\";\n position: absolute;\n left: 50%;\n top: 100%;\n width: 1px;\n /* Extend through the flex gap into the next item's leading slot */\n height: calc(100% + var(--spacing-5, 1.25rem));\n background-color: var(--border-default, #e5e7eb);\n transform: translateX(-50%);\n z-index: -1;\n}\n\n.variantTimeline.base > .item:last-child .leading::before {\n display: none;\n}\n"
|
|
2130
|
+
"content": "/* Activity Feed — vertical list of timestamped events */\n.base {\n list-style: none;\n margin: 0;\n padding: 0;\n display: flex;\n flex-direction: column;\n color: var(--text-primary, #111827);\n}\n\n.item {\n position: relative;\n display: flex;\n align-items: flex-start;\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Leading slot — wraps consumer-provided icon/avatar/dot */\n.leading {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n color: var(--text-tertiary, #6b7280);\n line-height: 1;\n}\n\n.leading svg {\n width: var(--spacing-5, 1.25rem);\n height: var(--spacing-5, 1.25rem);\n}\n\n/* Body wraps everything right of the leading slot */\n.body {\n flex: 1 1 auto;\n min-width: 0;\n display: flex;\n flex-direction: column;\n gap: var(--spacing-1, 0.25rem);\n}\n\n/* Header row: heading cluster + trailing meta */\n.header {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Heading cluster: title + optional actor */\n.heading {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n}\n\n.title {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-snug, 1.375);\n color: var(--text-primary, #111827);\n}\n\n.actor {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n}\n\n.trailing {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n margin-left: auto;\n}\n\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n}\n\n.timestamp {\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-snug, 1.375);\n}\n\n/* ============================================================\n * Variant: default — padded rows separated by borders\n * ============================================================ */\n.variantDefault.base > .item + .item {\n border-top: 1px solid var(--border-default, #e5e7eb);\n}\n\n.variantDefault.item {\n padding: var(--spacing-4, 1rem) 0;\n}\n\n.variantDefault.base > .item:first-child {\n padding-top: 0;\n}\n\n.variantDefault.base > .item:last-child {\n padding-bottom: 0;\n}\n\n/* ============================================================\n * Variant: compact — denser spacing, smaller text\n * ============================================================ */\n.variantCompact.base > .item + .item {\n border-top: 1px solid var(--border-muted, #e5e7eb);\n}\n\n.variantCompact.item {\n padding: var(--spacing-2, 0.5rem) 0;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.variantCompact .title,\n.variantCompact .actor,\n.variantCompact .description {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n.variantCompact .leading svg {\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n}\n\n.variantCompact.base > .item:first-child {\n padding-top: 0;\n}\n\n.variantCompact.base > .item:last-child {\n padding-bottom: 0;\n}\n\n/* ============================================================\n * Variant: compact-3col — [time] [dot] [text] row layout\n * ============================================================ */\n.variantCompact3col.base > .item + .item {\n border-top: 1px solid var(--border-muted, #e5e7eb);\n}\n\n.variantCompact3col.item {\n display: grid;\n grid-template-columns: var(--af-time-col, auto) 16px 1fr;\n align-items: baseline;\n gap: 0 var(--spacing-2, 0.5rem);\n padding: var(--spacing-2, 0.5rem) 0;\n}\n\n.variantCompact3col.base > .item:first-child {\n padding-top: 0;\n}\n\n.variantCompact3col.base > .item:last-child {\n padding-bottom: 0;\n}\n\n/* Column 1: timestamp pinned to the left */\n.timestampCol {\n grid-column: 1;\n font-size: var(--font-size-xs, 0.75rem);\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-snug, 1.375);\n white-space: nowrap;\n}\n\n/* Column 2: leading dot/icon, centered in its fixed-width column */\n.leadingCol {\n grid-column: 2;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 16px;\n}\n\n.leadingCol svg {\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n}\n\n/* Column 3: header (title + actor + trailing) */\n.headerCol {\n grid-column: 3;\n}\n\n.variantCompact3col .title,\n.variantCompact3col .actor {\n font-size: var(--font-size-xs, 0.75rem);\n}\n\n/* ============================================================\n * Variant: timeline — left rail with connecting line\n * ============================================================ */\n.variantTimeline.base {\n gap: var(--spacing-5, 1.25rem);\n}\n\n.variantTimeline.item {\n padding-bottom: var(--spacing-1, 0.25rem);\n}\n\n/* Connecting line between leading indicators.\n * The rail runs from the bottom edge of the leading slot to the\n * bottom of the item. The final item does not get a rail because\n * the flex gap above collapses past the last child. We use a CSS\n * selector to hide the rail on the last item. */\n.variantTimeline .leading {\n position: relative;\n /* Ensure the line is consistent even when leading is raw text */\n min-width: var(--spacing-5, 1.25rem);\n min-height: var(--spacing-5, 1.25rem);\n z-index: 0;\n}\n\n.variantTimeline .leading::before {\n content: \"\";\n position: absolute;\n left: 50%;\n top: 100%;\n width: 1px;\n /* Extend through the flex gap into the next item's leading slot */\n height: calc(100% + var(--spacing-5, 1.25rem));\n background-color: var(--border-default, #e5e7eb);\n transform: translateX(-50%);\n z-index: -1;\n}\n\n.variantTimeline.base > .item:last-child .leading::before {\n display: none;\n}\n"
|
|
2056
2131
|
}
|
|
2057
2132
|
]
|
|
2058
2133
|
},
|
|
@@ -2073,12 +2148,12 @@
|
|
|
2073
2148
|
{
|
|
2074
2149
|
"path": "components/ui/bulk-action-bar/bulk-action-bar.tsx",
|
|
2075
2150
|
"type": "registry:ui",
|
|
2076
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport { Button } from \"../button/button\"\nimport styles from \"./bulk-action-bar.module.css\"\n\nexport interface BulkActionBarProps\n extends React.HTMLAttributes<HTMLDivElement> {\n /** Number of selected items. Bar renders only when count > 0. */\n count: number\n /** Action buttons cluster — typically one or more `<Button>` instances. */\n children: React.ReactNode\n\n /** Render inline (non-sticky) instead of fixed to the viewport bottom. */\n inline?: boolean\n /** Selection label renderer. Defaults to `(n) => `${n} selected``. */\n label?: (count: number) => React.ReactNode\n /** Aria-label and tooltip for the dismiss button. Defaults to \"Clear selection\". */\n clearLabel?: React.ReactNode\n\n /** Fired by the Escape key and the dismiss button. */\n onClear?: () => void\n /** Show the dismiss (X) button. Defaults to `true`. */\n dismissible?: boolean\n /** Auto-focus the first action button on mount. Defaults to `true`. */\n autoFocus?: boolean\n}\n\nconst defaultLabel = (count: number) => `${count} selected`\n\nconst BulkActionBar = React.forwardRef<HTMLDivElement, BulkActionBarProps>(\n (\n {\n className,\n count,\n children,\n inline = false,\n label = defaultLabel,\n clearLabel = \"Clear selection\",\n onClear,\n dismissible = true,\n autoFocus = true,\n ...props\n },\n ref\n ) => {\n const actionsRef = React.useRef<HTMLDivElement>(null)\n\n // Escape-to-clear — attached only when `onClear` is provided.\n React.useEffect(() => {\n if (!onClear) return\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n onClear()\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown)\n return () => {\n document.removeEventListener(\"keydown\", handleKeyDown)\n }\n }, [onClear])\n\n // Auto-focus first action button on mount (not on every count change).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n React.useEffect(() => {\n if (!autoFocus) return\n const root = actionsRef.current\n if (!root) return\n const firstButton = root.querySelector<HTMLButtonElement>(\n \"button:not([disabled])\"\n )\n firstButton?.focus()\n }, [])\n\n if (count <= 0) return null\n\n const showDismiss = dismissible && typeof onClear === \"function\"\n\n return (\n <div\n ref={ref}\n role=\"toolbar\"\n aria-label=\"Bulk actions\"\n data-slot=\"bulk-action-bar\"\n data-inline={inline ? \"true\" : undefined}\n className={cn(\n styles.base,\n inline ? styles.inline : styles.sticky,\n className\n )}\n {...props}\n >\n <span\n data-slot=\"bulk-action-bar-count\"\n className={styles.count}\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {label(count)}\n </span>\n\n <div\n ref={actionsRef}\n data-slot=\"bulk-action-bar-actions\"\n className={styles.actions}\n >\n {children}\n </div>\n\n {showDismiss ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onClear}\n data-slot=\"bulk-action-bar-dismiss\"\n className={styles.dismiss}\n aria-label={\n typeof clearLabel === \"string\" ? clearLabel : \"Clear selection\"\n }\n >\n <X aria-hidden=\"true\" />\n </Button>\n ) : null}\n </div>\n )\n }\n)\nBulkActionBar.displayName = \"BulkActionBar\"\n\nexport { BulkActionBar }\n"
|
|
2151
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { X } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport { Button } from \"../button/button\"\nimport styles from \"./bulk-action-bar.module.css\"\n\nexport interface BulkActionBarProps\n extends React.HTMLAttributes<HTMLDivElement> {\n /** Number of selected items. Bar renders only when count > 0. */\n count: number\n /** Action buttons cluster — typically one or more `<Button>` instances. */\n children: React.ReactNode\n\n /** Render inline (non-sticky) instead of fixed to the viewport bottom. */\n inline?: boolean\n /** Render flat: no shadow, no radius, border-top only. Use inside table cards or panels. */\n flat?: boolean\n /** Selection label renderer. Defaults to `(n) => `${n} selected``. */\n label?: (count: number) => React.ReactNode\n /** Aria-label and tooltip for the dismiss button. Defaults to \"Clear selection\". */\n clearLabel?: React.ReactNode\n\n /** Fired by the Escape key and the dismiss button. */\n onClear?: () => void\n /** Show the dismiss (X) button. Defaults to `true`. */\n dismissible?: boolean\n /** Auto-focus the first action button on mount. Defaults to `true`. */\n autoFocus?: boolean\n}\n\nconst defaultLabel = (count: number) => `${count} selected`\n\nconst BulkActionBar = React.forwardRef<HTMLDivElement, BulkActionBarProps>(\n (\n {\n className,\n count,\n children,\n inline = false,\n flat = false,\n label = defaultLabel,\n clearLabel = \"Clear selection\",\n onClear,\n dismissible = true,\n autoFocus = true,\n ...props\n },\n ref\n ) => {\n const actionsRef = React.useRef<HTMLDivElement>(null)\n\n // Escape-to-clear — attached only when `onClear` is provided.\n React.useEffect(() => {\n if (!onClear) return\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.key === \"Escape\") {\n onClear()\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown)\n return () => {\n document.removeEventListener(\"keydown\", handleKeyDown)\n }\n }, [onClear])\n\n // Auto-focus first action button on mount (not on every count change).\n // eslint-disable-next-line react-hooks/exhaustive-deps\n React.useEffect(() => {\n if (!autoFocus) return\n const root = actionsRef.current\n if (!root) return\n const firstButton = root.querySelector<HTMLButtonElement>(\n \"button:not([disabled])\"\n )\n firstButton?.focus()\n }, [])\n\n if (count <= 0) return null\n\n const showDismiss = dismissible && typeof onClear === \"function\"\n\n return (\n <div\n ref={ref}\n role=\"toolbar\"\n aria-label=\"Bulk actions\"\n data-slot=\"bulk-action-bar\"\n data-inline={inline ? \"true\" : undefined}\n data-flat={flat ? \"true\" : undefined}\n className={cn(\n styles.base,\n inline ? styles.inline : styles.sticky,\n flat && styles.flat,\n className\n )}\n {...props}\n >\n <span\n data-slot=\"bulk-action-bar-count\"\n className={styles.count}\n aria-live=\"polite\"\n aria-atomic=\"true\"\n >\n {label(count)}\n </span>\n\n <div\n ref={actionsRef}\n data-slot=\"bulk-action-bar-actions\"\n className={styles.actions}\n >\n {children}\n </div>\n\n {showDismiss ? (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={onClear}\n data-slot=\"bulk-action-bar-dismiss\"\n className={styles.dismiss}\n aria-label={\n typeof clearLabel === \"string\" ? clearLabel : \"Clear selection\"\n }\n >\n <X aria-hidden=\"true\" />\n </Button>\n ) : null}\n </div>\n )\n }\n)\nBulkActionBar.displayName = \"BulkActionBar\"\n\nexport { BulkActionBar }\n"
|
|
2077
2152
|
},
|
|
2078
2153
|
{
|
|
2079
2154
|
"path": "components/ui/bulk-action-bar/bulk-action-bar.module.css",
|
|
2080
2155
|
"type": "registry:ui",
|
|
2081
|
-
"content": "/* Bulk Action Bar: appears when rows are selected. Sticky by default. */\n.base {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);\n background-color: var(--surface-card, #ffffff);\n border-top: 1px solid var(--border-default, #e5e7eb);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));\n border-radius: var(--radius-lg, 0.75rem);\n}\n\n/* Sticky variant — pinned to the viewport bottom. */\n.sticky {\n position: fixed;\n bottom: var(--spacing-4, 1rem);\n left: var(--spacing-4, 1rem);\n right: var(--spacing-4, 1rem);\n /*\n * Toolbar layer. No --z-index-toolbar token exists yet in\n * @loworbitstudio/visor-core — promote to a token once the z-index scale\n * is formalized.\n */\n z-index: 50;\n animation: bulk-action-bar-enter var(--motion-duration-moderate, 300ms)\n var(--motion-easing-standard, ease-out);\n}\n\n/* Inline variant — flows in the document, no fixed positioning. */\n.inline {\n position: relative;\n width: 100%;\n}\n\n/* Selection count label */\n.count {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n line-height: var(--line-height-tight, 1.25);\n white-space: nowrap;\n flex: 0 0 auto;\n}\n\n/* Actions cluster — horizontal, wraps on narrow rows */\n.actions {\n display: flex;\n flex-direction: row;\n align-items: center;\n flex-wrap: wrap;\n gap: var(--spacing-2, 0.5rem);\n flex: 1 1 auto;\n min-width: 0;\n}\n\n/* Dismiss button — sits flush on the right */\n.dismiss {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.dismiss svg {\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n}\n\n/* Entrance animation: slide up + fade in. */\n@keyframes bulk-action-bar-enter {\n from {\n opacity: 0;\n transform: translateY(var(--spacing-4, 1rem));\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Respect prefers-reduced-motion — skip the slide, keep it mounted. */\n@media (prefers-reduced-motion: reduce) {\n .sticky {\n animation: none;\n }\n}\n"
|
|
2156
|
+
"content": "/* Bulk Action Bar: appears when rows are selected. Sticky by default. */\n.base {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);\n background-color: var(--surface-card, #ffffff);\n border-top: 1px solid var(--border-default, #e5e7eb);\n color: var(--text-primary, #111827);\n box-shadow: var(--shadow-md, 0 4px 6px -1px rgb(0 0 0 / 0.1));\n border-radius: var(--radius-lg, 0.75rem);\n}\n\n/* Sticky variant — pinned to the viewport bottom. */\n.sticky {\n position: fixed;\n bottom: var(--spacing-4, 1rem);\n left: var(--spacing-4, 1rem);\n right: var(--spacing-4, 1rem);\n /*\n * Toolbar layer. No --z-index-toolbar token exists yet in\n * @loworbitstudio/visor-core — promote to a token once the z-index scale\n * is formalized.\n */\n z-index: 50;\n animation: bulk-action-bar-enter var(--motion-duration-moderate, 300ms)\n var(--motion-easing-standard, ease-out);\n}\n\n/* Inline variant — flows in the document, no fixed positioning. */\n.inline {\n position: relative;\n width: 100%;\n}\n\n/* Flat/embedded variant — no shadow, no radius, border-top hairline only.\n Use inside table cards or panels where the floating card look is wrong. */\n.flat {\n box-shadow: none;\n border-radius: 0;\n}\n\n/* Selection count label */\n.count {\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-primary, #111827);\n line-height: var(--line-height-tight, 1.25);\n white-space: nowrap;\n flex: 0 0 auto;\n}\n\n/* Actions cluster — horizontal, wraps on narrow rows */\n.actions {\n display: flex;\n flex-direction: row;\n align-items: center;\n flex-wrap: wrap;\n gap: var(--spacing-2, 0.5rem);\n flex: 1 1 auto;\n min-width: 0;\n}\n\n/* Dismiss button — sits flush on the right */\n.dismiss {\n flex: 0 0 auto;\n margin-left: auto;\n}\n\n.dismiss svg {\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n}\n\n/* Entrance animation: slide up + fade in. */\n@keyframes bulk-action-bar-enter {\n from {\n opacity: 0;\n transform: translateY(var(--spacing-4, 1rem));\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n/* Respect prefers-reduced-motion — skip the slide, keep it mounted. */\n@media (prefers-reduced-motion: reduce) {\n .sticky {\n animation: none;\n }\n}\n"
|
|
2082
2157
|
}
|
|
2083
2158
|
]
|
|
2084
2159
|
},
|
|
@@ -2160,12 +2235,12 @@
|
|
|
2160
2235
|
{
|
|
2161
2236
|
"path": "components/ui/data-table/data-table.tsx",
|
|
2162
2237
|
"type": "registry:ui",
|
|
2163
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n useReactTable,\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n type Table as TanstackTable,\n} from \"@tanstack/react-table\"\nimport {\n CaretDownIcon,\n CaretLeftIcon,\n CaretRightIcon,\n CaretUpIcon,\n CaretUpDownIcon,\n} from \"@phosphor-icons/react\"\n\nimport { cn } from \"../../../lib/utils\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../table/table\"\nimport { Button } from \"../button/button\"\nimport { Checkbox } from \"../checkbox/checkbox\"\nimport { Skeleton } from \"../skeleton/skeleton\"\nimport { EmptyState } from \"../empty-state/empty-state\"\nimport styles from \"./data-table.module.css\"\n\nexport type {\n ColumnDef,\n SortingState,\n RowSelectionState,\n PaginationState,\n OnChangeFn,\n}\n\nexport interface DataTableGroupRow {\n kind: \"group\"\n id: string\n label: string\n count?: number\n}\n\nexport interface DataTableDataRow<TData> {\n kind: \"data\"\n id: string\n row: TData\n}\n\nexport type DataTableRow<TData> = DataTableGroupRow | DataTableDataRow<TData>\n\n/**\n * Semantic per-row tone keys. Map to subtle background tints via CSS — see\n * `data-table.module.css`. Mirrors the tone vocabulary used by `StatusBadge`\n * / `StatusDot` so a row tagged \"live\" reads as one signal with a \"live\"\n * badge inside the row.\n */\nexport type DataTableRowTone =\n | \"live\"\n | \"warn\"\n | \"scheduled\"\n | \"sold\"\n | \"draft\"\n | \"danger\"\n | \"info\"\n\nexport interface DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data?: TData[]\n\n // Mixed render order with group-head separators. When provided, the caller\n // owns sort/grouping/windowing — sort UI and pagination footer are\n // suppressed. Group rows are excluded from selection state.\n rows?: DataTableRow<TData>[]\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n\n /**\n * Map each data row to a semantic tone for a subtle background tint. When\n * the callback returns `undefined`, the row renders on the default surface.\n * Tones resolve to Visor surface tokens at the CSS layer — see\n * `data-table.module.css`.\n */\n rowTone?: (row: TData) => DataTableRowTone | undefined\n\n /**\n * When supplied, every data row becomes a keyboard-activatable target:\n * `role=\"button\"`, `tabIndex={0}`, click + Enter/Space dispatch the\n * handler, and a `data-clickable=\"true\"` attribute drives the hover/focus\n * affordance. The injected selection checkbox cell stops propagation, so\n * clicking it does not trigger `onRowClick`.\n */\n onRowClick?: (row: TData) => void\n\n // Sorting\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n defaultSorting?: SortingState\n\n // Pagination\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n // Selection\n enableRowSelection?: boolean\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n getRowId?: (row: TData, index: number) => string\n\n // Global filter\n globalFilter?: string\n onGlobalFilterChange?: (value: string) => void\n\n // States\n loading?: boolean\n emptyState?: React.ReactNode\n\n // Layout\n stickyHeader?: boolean\n\n /**\n * Vertical row padding step. Maps to a `data-density` attribute on the root\n * which drives the `--dt-row-py` custom property the table's `<td>` cells\n * consume. Themes can override per-density values without forking the\n * component — see `data-table.module.css`.\n *\n * - `\"compact\"` — 8px (sub-content density: long lists, narrow viewports)\n * - `\"default\"` — 12px (current behaviour; no visual regression for existing\n * consumers)\n * - `\"editorial\"` — 20px (generous; each row reads as a card; high-design\n * admin patterns)\n */\n density?: \"compact\" | \"default\" | \"editorial\"\n}\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]\n\nfunction DataTableInner<TData, TValue = unknown>(\n props: DataTableProps<TData, TValue>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns: userColumns,\n data,\n rows,\n groupRowRenderer,\n sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n rowTone,\n onRowClick,\n density = \"default\",\n className,\n ...rest\n } = props\n\n // When rows is provided, the caller owns sort/grouping/windowing. We bypass\n // TanStack pagination and column sort UI, but keep the table instance for\n // selection state and cell rendering on data rows.\n const hasRows = rows != null\n const dataItems = React.useMemo(() => {\n if (hasRows) {\n return rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.row] : []\n )\n }\n return data ?? []\n }, [hasRows, rows, data])\n\n const internalGetRowId = React.useMemo(() => {\n if (getRowId) return getRowId\n if (hasRows) {\n const ids = rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.id] : []\n )\n return (_row: TData, index: number) => ids[index] ?? String(index)\n }\n return undefined\n }, [getRowId, hasRows, rows])\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n // Stop click/keydown from bubbling to the parent <tr>, otherwise\n // toggling the checkbox would also fire `onRowClick`. The wrapper\n // is presentational — focus and ARIA continue to live on the\n // underlying Checkbox.\n <div\n data-slot=\"data-table-selection-cell\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation()\n }}\n >\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n </div>\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data: dataItems,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId: internalGetRowId,\n onSortingChange: handleSortingChange,\n onPaginationChange: handlePaginationChange,\n onRowSelectionChange: handleRowSelectionChange,\n onGlobalFilterChange: handleGlobalFilterChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n getFilteredRowModel: getFilteredRowModel(),\n })\n\n const totalRows = table.getFilteredRowModel().rows.length\n const pageRows = table.getRowModel().rows\n const colCount = columns.length\n const pageIndex = table.getState().pagination.pageIndex\n const currentPageSize = table.getState().pagination.pageSize\n const pageCount = table.getPageCount()\n const firstRow = totalRows === 0 ? 0 : pageIndex * currentPageSize + 1\n const lastRow = Math.min((pageIndex + 1) * currentPageSize, totalRows)\n\n const isEmpty = !loading && dataItems.length === 0 && !hasRows\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\n\n const defaultGroupRowContent = (group: DataTableGroupRow) => (\n <span data-slot=\"data-table-group-label\" className={styles.groupLabel}>\n {group.label}\n {group.count != null && (\n <span className={styles.groupCount}>{group.count}</span>\n )}\n </span>\n )\n\n // Build the per-data-row props (tone, clickable affordance, keyboard\n // activation). Shared between the `rows`-driven path and the standard\n // pageRows path so the two stay in lockstep.\n //\n // Note on role=\"button\": axe flags nested-interactive when a `<tr>` carries\n // `role=\"button\"` and also contains an interactive control (the selection\n // checkbox cell). When selection is enabled, the row stays semantically a\n // table row — click + keyboard activation still work via the explicit\n // handlers, but the role override is dropped to keep `<tr>` semantics and\n // satisfy WCAG nested-interactive. When selection is off, the row is a\n // pure click target and `role=\"button\"` is safe.\n const getDataRowProps = (rowData: TData) => {\n const tone = rowTone?.(rowData)\n const clickable = onRowClick != null\n const handleClick = clickable\n ? () => onRowClick!(rowData)\n : undefined\n const handleKeyDown = clickable\n ? (e: React.KeyboardEvent<HTMLTableRowElement>) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault()\n onRowClick!(rowData)\n }\n }\n : undefined\n const useButtonRole = clickable && !enableRowSelection\n return {\n className: styles.dataRow,\n \"data-tone\": tone,\n \"data-clickable\": clickable ? \"true\" : undefined,\n role: useButtonRole ? (\"button\" as const) : undefined,\n tabIndex: clickable ? 0 : undefined,\n onClick: handleClick,\n onKeyDown: handleKeyDown,\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-table\"\n data-density={density}\n className={cn(styles.root, className)}\n {...rest}\n >\n <Table>\n <TableHeader\n className={cn(stickyHeader && styles.stickyHeader)}\n data-sticky={stickyHeader || undefined}\n >\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id}>\n {headerGroup.headers.map((header) => {\n const canSort = header.column.getCanSort() && !hasRows\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] = hasRows\n ? undefined\n : sortDir === \"asc\"\n ? \"ascending\"\n : sortDir === \"desc\"\n ? \"descending\"\n : canSort\n ? \"none\"\n : undefined\n const headerContent = header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )\n const columnLabel =\n typeof header.column.columnDef.header === \"string\"\n ? (header.column.columnDef.header as string)\n : header.column.id\n const nextSortStateLabel =\n sortDir === \"asc\"\n ? \"descending\"\n : sortDir === \"desc\"\n ? \"unsorted\"\n : \"ascending\"\n return (\n <TableHead\n key={header.id}\n aria-sort={ariaSort}\n style={{\n width:\n header.column.id === \"__select\" ? \"40px\" : undefined,\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n className={styles.sortButton}\n onClick={header.column.getToggleSortingHandler()}\n aria-label={`${columnLabel}, sort ${nextSortStateLabel}`}\n >\n <span className={styles.sortLabel}>\n {headerContent}\n </span>\n <span className={styles.sortIcon} aria-hidden=\"true\">\n {sortDir === \"asc\" ? (\n <CaretUpIcon weight=\"bold\" />\n ) : sortDir === \"desc\" ? (\n <CaretDownIcon weight=\"bold\" />\n ) : (\n <CaretUpDownIcon weight=\"bold\" />\n )}\n </span>\n </button>\n ) : (\n headerContent\n )}\n </TableHead>\n )\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody>\n {loading ? (\n Array.from({ length: currentPageSize }).map((_, rowIdx) => (\n <TableRow key={`skeleton-${rowIdx}`} data-slot=\"data-table-skeleton-row\">\n {columns.map((_col, colIdx) => (\n <TableCell key={`skeleton-${rowIdx}-${colIdx}`}>\n <Skeleton className={styles.skeletonCell} />\n </TableCell>\n ))}\n </TableRow>\n ))\n ) : isEmpty ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : hasRows ? (\n rows!.map((item) => {\n if (item.kind === \"group\") {\n return (\n <TableRow\n key={`group-${item.id}`}\n data-slot=\"data-table-group-row\"\n className={styles.groupRow}\n >\n <TableCell\n colSpan={colCount}\n className={styles.groupCell}\n >\n {groupRowRenderer\n ? groupRowRenderer(item)\n : defaultGroupRowContent(item)}\n </TableCell>\n </TableRow>\n )\n }\n const tsRow = table.getRow(item.id)\n const rowProps = getDataRowProps(item.row)\n return (\n <TableRow\n key={item.id}\n data-state={tsRow.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {tsRow.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => {\n const rowProps = getDataRowProps(row.original)\n return (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n\n {!hasRows && (\n <div className={styles.footer} data-slot=\"data-table-footer\">\n <div className={styles.footerInfo} aria-live=\"polite\">\n {totalRows === 0\n ? \"No results\"\n : `Showing ${firstRow} to ${lastRow} of ${totalRows}`}\n </div>\n <div className={styles.footerControls}>\n <label className={styles.pageSizeLabel}>\n <span className={styles.pageSizeLabelText}>Rows per page</span>\n <select\n className={styles.pageSizeSelect}\n value={currentPageSize}\n onChange={(e) => table.setPageSize(Number(e.target.value))}\n aria-label=\"Rows per page\"\n >\n {pageSizeOptions.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n </label>\n <div className={styles.pageNav}>\n <span className={styles.pageCounter}>\n Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <CaretLeftIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <CaretRightIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n// forwardRef with generics — preserve TData through the cast\nconst DataTable = React.forwardRef(DataTableInner) as <\n TData,\n TValue = unknown,\n>(\n props: DataTableProps<TData, TValue> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof DataTableInner>\n\n;(DataTable as unknown as { displayName: string }).displayName = \"DataTable\"\n\nexport { DataTable }\n"
|
|
2238
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n flexRender,\n getCoreRowModel,\n getFilteredRowModel,\n getPaginationRowModel,\n getSortedRowModel,\n useReactTable,\n type ColumnDef,\n type OnChangeFn,\n type PaginationState,\n type RowSelectionState,\n type SortingState,\n type Table as TanstackTable,\n} from \"@tanstack/react-table\"\nimport {\n CaretDownIcon,\n CaretLeftIcon,\n CaretRightIcon,\n CaretUpIcon,\n CaretUpDownIcon,\n} from \"@phosphor-icons/react\"\n\nimport { cn } from \"../../../lib/utils\"\nimport {\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n} from \"../table/table\"\nimport { Button } from \"../button/button\"\nimport { Checkbox } from \"../checkbox/checkbox\"\nimport { Skeleton } from \"../skeleton/skeleton\"\nimport { EmptyState } from \"../empty-state/empty-state\"\nimport styles from \"./data-table.module.css\"\n\nexport type {\n ColumnDef,\n SortingState,\n RowSelectionState,\n PaginationState,\n OnChangeFn,\n}\n\nexport interface DataTableGroupRow {\n kind: \"group\"\n id: string\n label: string\n count?: number\n}\n\nexport interface DataTableDataRow<TData> {\n kind: \"data\"\n id: string\n row: TData\n}\n\nexport type DataTableRow<TData> = DataTableGroupRow | DataTableDataRow<TData>\n\n/**\n * Semantic per-row tone keys. Map to subtle background tints via CSS — see\n * `data-table.module.css`. Mirrors the tone vocabulary used by `StatusBadge`\n * / `StatusDot` so a row tagged \"live\" reads as one signal with a \"live\"\n * badge inside the row.\n */\nexport type DataTableRowTone =\n | \"live\"\n | \"warn\"\n | \"scheduled\"\n | \"sold\"\n | \"draft\"\n | \"danger\"\n | \"info\"\n\nexport interface DataTableProps<TData, TValue = unknown>\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n columns: ColumnDef<TData, TValue>[]\n data?: TData[]\n\n // Mixed render order with group-head separators. When provided, the caller\n // owns sort/grouping/windowing — sort UI and pagination footer are\n // suppressed. Group rows are excluded from selection state.\n rows?: DataTableRow<TData>[]\n groupRowRenderer?: (group: DataTableGroupRow) => React.ReactNode\n\n /**\n * Map each data row to a semantic tone for a subtle background tint. When\n * the callback returns `undefined`, the row renders on the default surface.\n * Tones resolve to Visor surface tokens at the CSS layer — see\n * `data-table.module.css`.\n */\n rowTone?: (row: TData) => DataTableRowTone | undefined\n\n /**\n * When supplied, every data row becomes a keyboard-activatable target:\n * `role=\"button\"`, `tabIndex={0}`, click + Enter/Space dispatch the\n * handler, and a `data-clickable=\"true\"` attribute drives the hover/focus\n * affordance. The injected selection checkbox cell stops propagation, so\n * clicking it does not trigger `onRowClick`.\n */\n onRowClick?: (row: TData) => void\n\n // Sorting\n sorting?: SortingState\n onSortingChange?: OnChangeFn<SortingState>\n defaultSorting?: SortingState\n\n // Pagination\n pagination?: PaginationState\n onPaginationChange?: OnChangeFn<PaginationState>\n pageSize?: number\n pageSizeOptions?: number[]\n\n // Selection\n enableRowSelection?: boolean\n rowSelection?: RowSelectionState\n onRowSelectionChange?: OnChangeFn<RowSelectionState>\n getRowId?: (row: TData, index: number) => string\n\n // Global filter\n globalFilter?: string\n onGlobalFilterChange?: (value: string) => void\n\n // States\n loading?: boolean\n emptyState?: React.ReactNode\n\n // Layout\n stickyHeader?: boolean\n\n /**\n * Vertical row padding step. Maps to a `data-density` attribute on the root\n * which drives the `--dt-row-py` custom property the table's `<td>` cells\n * consume. Themes can override per-density values without forking the\n * component — see `data-table.module.css`.\n *\n * - `\"compact\"` — 8px (sub-content density: long lists, narrow viewports)\n * - `\"default\"` — 12px (current behaviour; no visual regression for existing\n * consumers)\n * - `\"editorial\"` — 20px (generous; each row reads as a card; high-design\n * admin patterns); column headers also render uppercase, `--font-size-xs`,\n * `--text-tertiary`, `letter-spacing: 0.08em`\n */\n density?: \"compact\" | \"default\" | \"editorial\"\n}\n\nconst DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]\n\nfunction DataTableInner<TData, TValue = unknown>(\n props: DataTableProps<TData, TValue>,\n ref: React.ForwardedRef<HTMLDivElement>\n) {\n const {\n columns: userColumns,\n data,\n rows,\n groupRowRenderer,\n sorting: controlledSorting,\n onSortingChange,\n defaultSorting,\n pagination: controlledPagination,\n onPaginationChange,\n pageSize = 10,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n enableRowSelection = false,\n rowSelection: controlledRowSelection,\n onRowSelectionChange,\n getRowId,\n globalFilter: controlledGlobalFilter,\n onGlobalFilterChange,\n loading = false,\n emptyState,\n stickyHeader = false,\n rowTone,\n onRowClick,\n density = \"default\",\n className,\n ...rest\n } = props\n\n // When rows is provided, the caller owns sort/grouping/windowing. We bypass\n // TanStack pagination and column sort UI, but keep the table instance for\n // selection state and cell rendering on data rows.\n const hasRows = rows != null\n const dataItems = React.useMemo(() => {\n if (hasRows) {\n return rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.row] : []\n )\n }\n return data ?? []\n }, [hasRows, rows, data])\n\n const internalGetRowId = React.useMemo(() => {\n if (getRowId) return getRowId\n if (hasRows) {\n const ids = rows!.flatMap((item) =>\n item.kind === \"data\" ? [item.id] : []\n )\n return (_row: TData, index: number) => ids[index] ?? String(index)\n }\n return undefined\n }, [getRowId, hasRows, rows])\n\n // Uncontrolled sorting state\n const [internalSorting, setInternalSorting] = React.useState<SortingState>(\n defaultSorting ?? []\n )\n const sortingIsControlled = controlledSorting !== undefined\n const sorting = sortingIsControlled ? controlledSorting : internalSorting\n const handleSortingChange: OnChangeFn<SortingState> = (updater) => {\n if (!sortingIsControlled) {\n setInternalSorting((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onSortingChange?.(updater)\n }\n\n // Uncontrolled pagination state\n const [internalPagination, setInternalPagination] =\n React.useState<PaginationState>({ pageIndex: 0, pageSize })\n const paginationIsControlled = controlledPagination !== undefined\n const pagination = paginationIsControlled\n ? controlledPagination\n : internalPagination\n const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {\n if (!paginationIsControlled) {\n setInternalPagination((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onPaginationChange?.(updater)\n }\n\n // Uncontrolled selection state\n const [internalRowSelection, setInternalRowSelection] =\n React.useState<RowSelectionState>({})\n const selectionIsControlled = controlledRowSelection !== undefined\n const rowSelection = selectionIsControlled\n ? controlledRowSelection\n : internalRowSelection\n const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {\n if (!selectionIsControlled) {\n setInternalRowSelection((prev) =>\n typeof updater === \"function\" ? updater(prev) : updater\n )\n }\n onRowSelectionChange?.(updater)\n }\n\n // Global filter\n const [internalGlobalFilter, setInternalGlobalFilter] = React.useState(\"\")\n const globalFilterIsControlled = controlledGlobalFilter !== undefined\n const globalFilter = globalFilterIsControlled\n ? controlledGlobalFilter\n : internalGlobalFilter\n const handleGlobalFilterChange = (value: unknown) => {\n const next = typeof value === \"function\" ? (value as (p: string) => string)(globalFilter) : (value as string)\n if (!globalFilterIsControlled) {\n setInternalGlobalFilter(next ?? \"\")\n }\n onGlobalFilterChange?.(next ?? \"\")\n }\n\n // Inject a selection column when enabled\n const columns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {\n if (!enableRowSelection) return userColumns\n const selectionColumn: ColumnDef<TData, TValue> = {\n id: \"__select\",\n enableSorting: false,\n size: 40,\n header: ({ table }) => (\n <Checkbox\n aria-label=\"Select all rows\"\n checked={\n table.getIsAllPageRowsSelected()\n ? true\n : table.getIsSomePageRowsSelected()\n ? \"indeterminate\"\n : false\n }\n onCheckedChange={(value) =>\n table.toggleAllPageRowsSelected(value === true)\n }\n />\n ),\n cell: ({ row }) => (\n // Stop click/keydown from bubbling to the parent <tr>, otherwise\n // toggling the checkbox would also fire `onRowClick`. The wrapper\n // is presentational — focus and ARIA continue to live on the\n // underlying Checkbox.\n <div\n data-slot=\"data-table-selection-cell\"\n onClick={(e) => e.stopPropagation()}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") e.stopPropagation()\n }}\n >\n <Checkbox\n aria-label=\"Select row\"\n checked={row.getIsSelected()}\n disabled={!row.getCanSelect()}\n onCheckedChange={(value) => row.toggleSelected(value === true)}\n />\n </div>\n ),\n }\n return [selectionColumn, ...userColumns]\n }, [enableRowSelection, userColumns])\n\n const table: TanstackTable<TData> = useReactTable<TData>({\n data: dataItems,\n columns,\n state: {\n sorting,\n pagination,\n rowSelection,\n globalFilter,\n },\n enableRowSelection,\n getRowId: internalGetRowId,\n onSortingChange: handleSortingChange,\n onPaginationChange: handlePaginationChange,\n onRowSelectionChange: handleRowSelectionChange,\n onGlobalFilterChange: handleGlobalFilterChange,\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n getPaginationRowModel: getPaginationRowModel(),\n getFilteredRowModel: getFilteredRowModel(),\n })\n\n const totalRows = table.getFilteredRowModel().rows.length\n const pageRows = table.getRowModel().rows\n const colCount = columns.length\n const pageIndex = table.getState().pagination.pageIndex\n const currentPageSize = table.getState().pagination.pageSize\n const pageCount = table.getPageCount()\n const firstRow = totalRows === 0 ? 0 : pageIndex * currentPageSize + 1\n const lastRow = Math.min((pageIndex + 1) * currentPageSize, totalRows)\n\n const isEmpty = !loading && dataItems.length === 0 && !hasRows\n const defaultEmpty = <EmptyState heading=\"No results\" tone=\"subtle\" />\n\n const defaultGroupRowContent = (group: DataTableGroupRow) => (\n <span data-slot=\"data-table-group-label\" className={styles.groupLabel}>\n {group.label}\n {group.count != null && (\n <span className={styles.groupCount}>{group.count}</span>\n )}\n </span>\n )\n\n // Build the per-data-row props (tone, clickable affordance, keyboard\n // activation). Shared between the `rows`-driven path and the standard\n // pageRows path so the two stay in lockstep.\n //\n // Note on role=\"button\": axe flags nested-interactive when a `<tr>` carries\n // `role=\"button\"` and also contains an interactive control (the selection\n // checkbox cell). When selection is enabled, the row stays semantically a\n // table row — click + keyboard activation still work via the explicit\n // handlers, but the role override is dropped to keep `<tr>` semantics and\n // satisfy WCAG nested-interactive. When selection is off, the row is a\n // pure click target and `role=\"button\"` is safe.\n const getDataRowProps = (rowData: TData) => {\n const tone = rowTone?.(rowData)\n const clickable = onRowClick != null\n const handleClick = clickable\n ? () => onRowClick!(rowData)\n : undefined\n const handleKeyDown = clickable\n ? (e: React.KeyboardEvent<HTMLTableRowElement>) => {\n if (e.key === \"Enter\" || e.key === \" \") {\n e.preventDefault()\n onRowClick!(rowData)\n }\n }\n : undefined\n const useButtonRole = clickable && !enableRowSelection\n return {\n className: styles.dataRow,\n \"data-tone\": tone,\n \"data-clickable\": clickable ? \"true\" : undefined,\n role: useButtonRole ? (\"button\" as const) : undefined,\n tabIndex: clickable ? 0 : undefined,\n onClick: handleClick,\n onKeyDown: handleKeyDown,\n }\n }\n\n return (\n <div\n ref={ref}\n data-slot=\"data-table\"\n data-density={density}\n className={cn(styles.root, className)}\n {...rest}\n >\n <Table>\n <TableHeader\n className={cn(stickyHeader && styles.stickyHeader)}\n data-sticky={stickyHeader || undefined}\n >\n {table.getHeaderGroups().map((headerGroup) => (\n <TableRow key={headerGroup.id} className={styles.sortBar}>\n {headerGroup.headers.map((header) => {\n const canSort = header.column.getCanSort() && !hasRows\n const sortDir = header.column.getIsSorted()\n const ariaSort: React.AriaAttributes[\"aria-sort\"] = hasRows\n ? undefined\n : sortDir === \"asc\"\n ? \"ascending\"\n : sortDir === \"desc\"\n ? \"descending\"\n : canSort\n ? \"none\"\n : undefined\n const headerContent = header.isPlaceholder\n ? null\n : flexRender(\n header.column.columnDef.header,\n header.getContext()\n )\n const columnLabel =\n typeof header.column.columnDef.header === \"string\"\n ? (header.column.columnDef.header as string)\n : header.column.id\n const nextSortStateLabel =\n sortDir === \"asc\"\n ? \"descending\"\n : sortDir === \"desc\"\n ? \"unsorted\"\n : \"ascending\"\n return (\n <TableHead\n key={header.id}\n aria-sort={ariaSort}\n style={{\n width:\n header.column.id === \"__select\" ? \"40px\" : undefined,\n }}\n >\n {canSort ? (\n <button\n type=\"button\"\n className={styles.sortButton}\n onClick={header.column.getToggleSortingHandler()}\n aria-label={`${columnLabel}, sort ${nextSortStateLabel}`}\n >\n <span className={styles.sortLabel}>\n {headerContent}\n </span>\n <span className={styles.sortIcon} aria-hidden=\"true\">\n {sortDir === \"asc\" ? (\n <CaretUpIcon weight=\"bold\" />\n ) : sortDir === \"desc\" ? (\n <CaretDownIcon weight=\"bold\" />\n ) : (\n <CaretUpDownIcon weight=\"bold\" />\n )}\n </span>\n </button>\n ) : (\n headerContent\n )}\n </TableHead>\n )\n })}\n </TableRow>\n ))}\n </TableHeader>\n <TableBody>\n {loading ? (\n Array.from({ length: currentPageSize }).map((_, rowIdx) => (\n <TableRow key={`skeleton-${rowIdx}`} data-slot=\"data-table-skeleton-row\">\n {columns.map((_col, colIdx) => (\n <TableCell key={`skeleton-${rowIdx}-${colIdx}`}>\n <Skeleton className={styles.skeletonCell} />\n </TableCell>\n ))}\n </TableRow>\n ))\n ) : isEmpty ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : hasRows ? (\n rows!.map((item) => {\n if (item.kind === \"group\") {\n return (\n <TableRow\n key={`group-${item.id}`}\n data-slot=\"data-table-group-row\"\n className={styles.groupRow}\n >\n <TableCell\n colSpan={colCount}\n className={styles.groupCell}\n >\n {groupRowRenderer\n ? groupRowRenderer(item)\n : defaultGroupRowContent(item)}\n </TableCell>\n </TableRow>\n )\n }\n const tsRow = table.getRow(item.id)\n const rowProps = getDataRowProps(item.row)\n return (\n <TableRow\n key={item.id}\n data-state={tsRow.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {tsRow.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n ) : pageRows.length === 0 ? (\n <TableRow data-slot=\"data-table-empty-row\">\n <TableCell colSpan={colCount} className={styles.emptyCell}>\n {emptyState ?? defaultEmpty}\n </TableCell>\n </TableRow>\n ) : (\n pageRows.map((row) => {\n const rowProps = getDataRowProps(row.original)\n return (\n <TableRow\n key={row.id}\n data-state={row.getIsSelected() ? \"selected\" : undefined}\n {...rowProps}\n >\n {row.getVisibleCells().map((cell) => (\n <TableCell key={cell.id}>\n {flexRender(\n cell.column.columnDef.cell,\n cell.getContext()\n )}\n </TableCell>\n ))}\n </TableRow>\n )\n })\n )}\n </TableBody>\n </Table>\n\n {!hasRows && (\n <div className={styles.footer} data-slot=\"data-table-footer\">\n <div className={styles.footerInfo} aria-live=\"polite\">\n {totalRows === 0\n ? \"No results\"\n : `Showing ${firstRow} to ${lastRow} of ${totalRows}`}\n </div>\n <div className={styles.footerControls}>\n <label className={styles.pageSizeLabel}>\n <span className={styles.pageSizeLabelText}>Rows per page</span>\n <select\n className={styles.pageSizeSelect}\n value={currentPageSize}\n onChange={(e) => table.setPageSize(Number(e.target.value))}\n aria-label=\"Rows per page\"\n >\n {pageSizeOptions.map((opt) => (\n <option key={opt} value={opt}>\n {opt}\n </option>\n ))}\n </select>\n </label>\n <div className={styles.pageNav}>\n <span className={styles.pageCounter}>\n Page {pageCount === 0 ? 0 : pageIndex + 1} of {pageCount}\n </span>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.previousPage()}\n disabled={!table.getCanPreviousPage()}\n aria-label=\"Previous page\"\n >\n <CaretLeftIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n <Button\n type=\"button\"\n variant=\"outline\"\n size=\"sm\"\n onClick={() => table.nextPage()}\n disabled={!table.getCanNextPage()}\n aria-label=\"Next page\"\n >\n <CaretRightIcon weight=\"bold\" aria-hidden=\"true\" />\n </Button>\n </div>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n// forwardRef with generics — preserve TData through the cast\nconst DataTable = React.forwardRef(DataTableInner) as <\n TData,\n TValue = unknown,\n>(\n props: DataTableProps<TData, TValue> & {\n ref?: React.ForwardedRef<HTMLDivElement>\n }\n) => ReturnType<typeof DataTableInner>\n\n;(DataTable as unknown as { displayName: string }).displayName = \"DataTable\"\n\nexport { DataTable }\n"
|
|
2164
2239
|
},
|
|
2165
2240
|
{
|
|
2166
2241
|
"path": "components/ui/data-table/data-table.module.css",
|
|
2167
2242
|
"type": "registry:ui",
|
|
2168
|
-
"content": "/* DataTable root — inline-size container for responsive collapse */\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-3, 0.75rem);\n container-type: inline-size;\n container-name: data-table;\n color: var(--text-primary, #111827);\n}\n\n/* Density — vertical row padding step driven by `data-density` on the root.\n Cells consume `--dt-row-py` via the `.root td` rule below. Horizontal cell\n padding stays at TableCell's `padding: var(--spacing-3)` shorthand cascade\n — we only override the top/bottom longhand properties here, so `default`\n density renders identically to pre-VI-425 markup.\n\n Themes can override per-density values via their own selectors. Example:\n [data-theme=\"entr\"] [data-density=\"editorial\"] { --dt-row-py: var(--spacing-6); } */\n.root[data-density=\"compact\"] { --dt-row-py: var(--spacing-2, 0.5rem); }\n.root[data-density=\"default\"] { --dt-row-py: var(--spacing-3, 0.75rem); }\n.root[data-density=\"editorial\"] { --dt-row-py: var(--spacing-5, 1.25rem); }\n\n.root td {\n padding-top: var(--dt-row-py, var(--spacing-3, 0.75rem));\n padding-bottom: var(--dt-row-py, var(--spacing-3, 0.75rem));\n}\n\n/* Sticky header wrapper — toggled when stickyHeader prop is true */\n.stickyHeader {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-card, #ffffff);\n}\n\n/* Sort button — renders inside the <th> */\n.sortButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: 0;\n margin: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font: inherit;\n font-weight: var(--font-weight-semibold, 600);\n cursor: pointer;\n line-height: var(--line-height-tight, 1.25);\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sortButton:hover {\n color: var(--text-primary, #111827);\n}\n\n.sortButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.sortLabel {\n display: inline-block;\n}\n\n.sortIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n color: var(--text-tertiary, #6b7280);\n flex-shrink: 0;\n}\n\n.sortIcon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Skeleton cell fills the cell width */\n.skeletonCell {\n width: 100%;\n height: var(--spacing-4, 1rem);\n}\n\n/* Empty-row cell centers the EmptyState slot */\n.emptyCell {\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);\n text-align: center;\n}\n\n/* Footer — info on the left, controls on the right */\n.footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-1, 0.25rem);\n min-width: 0;\n flex-wrap: wrap;\n}\n\n.footerInfo {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.footerControls {\n display: flex;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n flex-wrap: wrap;\n}\n\n.pageSizeLabel {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n\n.pageSizeLabelText {\n white-space: nowrap;\n}\n\n/* Native <select> styled to match Visor input controls */\n.pageSizeSelect {\n appearance: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n height: var(--spacing-8, 2rem);\n padding: 0 var(--spacing-6, 1.5rem) 0 var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n background-image: linear-gradient(\n 45deg,\n transparent 50%,\n var(--text-tertiary, #6b7280) 50%\n ),\n linear-gradient(\n 135deg,\n var(--text-tertiary, #6b7280) 50%,\n transparent 50%\n );\n background-position:\n right var(--spacing-3, 0.75rem) center,\n right var(--spacing-2, 0.5rem) center;\n background-size:\n 5px 5px,\n 5px 5px;\n background-repeat: no-repeat;\n color: var(--text-primary, #111827);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-tight, 1.25);\n cursor: pointer;\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n box-shadow var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.pageSizeSelect:hover {\n border-color: var(--border-strong, #d1d5db);\n}\n\n.pageSizeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.pageNav {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.pageCounter {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n white-space: nowrap;\n}\n\n/* Data row — base layer for the per-row data-state / data-tone / data-clickable\n variants below. The selectors are scoped to `.dataRow` so the tone/selected\n tints never bleed onto group-head rows or skeleton/empty rows. */\n.dataRow {\n transition: background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Selected row — wires the latent `data-state=\"selected\"` attribute (already\n emitted by TanStack on selected rows) to a subtle accent fill. Fixes a\n latent bug where selection had no visual feedback. */\n.dataRow[data-state=\"selected\"] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Per-row tone tints — semantic background hints for editorial status.\n Mirrors the tone token mapping used by StatusBadge / StatusDot so a\n row tinted \"live\" reads consistently with a \"live\" badge inline.\n `scheduled` and `draft` intentionally have no tint — they render on\n the default surface to keep visual signal focused on actionable rows. */\n.dataRow[data-tone=\"live\"],\n.dataRow[data-tone=\"sold\"] {\n background-color: var(--surface-success-subtle, #ecfdf5);\n}\n\n.dataRow[data-tone=\"warn\"] {\n background-color: var(--surface-warning-subtle, #fffbeb);\n}\n\n.dataRow[data-tone=\"danger\"] {\n background-color: var(--surface-error-subtle, #fef2f2);\n}\n\n.dataRow[data-tone=\"info\"] {\n background-color: var(--surface-info-subtle, #eff6ff);\n}\n\n/* Selected state should win over tone tint — selection is a stronger\n signal than editorial status. */\n.dataRow[data-state=\"selected\"][data-tone] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Clickable rows — opt-in affordance when `onRowClick` is supplied. */\n.dataRow[data-clickable=\"true\"] {\n cursor: pointer;\n}\n\n.dataRow[data-clickable=\"true\"]:hover {\n background-color: var(--surface-interactive-default, #f3f4f6);\n}\n\n.dataRow[data-clickable=\"true\"]:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: calc(var(--focus-ring-offset, 2px) * -1);\n}\n\n/* Group-head row — full-width separator inside the table body. The colSpan\n cell carries the visual; the inner span owns the sticky positioning so it\n pins to the top of the scroll container as data rows scroll beneath. */\n.groupRow {\n background-color: transparent;\n cursor: default;\n}\n\n.groupRow:hover {\n background-color: transparent;\n}\n\n.groupCell {\n padding: 0;\n}\n\n.groupLabel {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 28px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f3f4f6);\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n\n.groupCount {\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-quaternary, #9ca3af);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0;\n}\n\n/* Narrow containers — stack footer sections */\n@container data-table (max-width: 560px) {\n .footer {\n flex-direction: column;\n align-items: stretch;\n }\n\n .footerControls {\n justify-content: space-between;\n }\n}\n"
|
|
2243
|
+
"content": "/* DataTable root — inline-size container for responsive collapse */\n.root {\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-3, 0.75rem);\n container-type: inline-size;\n container-name: data-table;\n color: var(--text-primary, #111827);\n}\n\n/* Density — vertical row padding step driven by `data-density` on the root.\n Cells consume `--dt-row-py` via the `.root td` rule below. Horizontal cell\n padding stays at TableCell's `padding: var(--spacing-3)` shorthand cascade\n — we only override the top/bottom longhand properties here, so `default`\n density renders identically to pre-VI-425 markup.\n\n Themes can override per-density values via their own selectors. Example:\n [data-theme=\"entr\"] [data-density=\"editorial\"] { --dt-row-py: var(--spacing-6); } */\n.root[data-density=\"compact\"] { --dt-row-py: var(--spacing-2, 0.5rem); }\n.root[data-density=\"default\"] { --dt-row-py: var(--spacing-3, 0.75rem); }\n.root[data-density=\"editorial\"] { --dt-row-py: var(--spacing-5, 1.25rem); }\n\n/* Editorial column-header treatment — uppercase, xs scale, tertiary color,\n tracked type (0.08em). Anchored on .root to avoid a bare attribute selector\n (Turbopack pure-selector rule). Overrides the TableHead default styles and\n the sort-button font-weight rule below. */\n.root[data-density=\"editorial\"] th {\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n color: var(--text-tertiary, #6b7280);\n letter-spacing: 0.08em;\n text-transform: uppercase;\n}\n\n/* The sort button inherits font from its <th>, but explicitly overrides\n font-weight to semibold — reset to medium for the editorial treatment. */\n.root[data-density=\"editorial\"] .sortButton {\n font-weight: var(--font-weight-medium, 500);\n}\n\n.root td {\n padding-top: var(--dt-row-py, var(--spacing-3, 0.75rem));\n padding-bottom: var(--dt-row-py, var(--spacing-3, 0.75rem));\n}\n\n/* Sticky header wrapper — toggled when stickyHeader prop is true */\n.stickyHeader {\n position: sticky;\n top: 0;\n z-index: 1;\n background: var(--surface-card, #ffffff);\n}\n\n/* Sort-bar row — the thead <tr> that houses the column headers.\n `--data-table-sort-bar-radius` controls the top-corner radius of the first\n and last <th> cells. Defaults to the container's border-radius so the sort\n bar visually follows the table card's rounded top corners.\n\n Set `--data-table-sort-bar-radius: 0` on a parent scope for the\n borderless-admin pattern — straight corners without touching the container\n itself. */\n.sortBar th:first-child {\n border-top-left-radius: var(--data-table-sort-bar-radius, var(--radius-lg, 0.5rem));\n}\n\n.sortBar th:last-child {\n border-top-right-radius: var(--data-table-sort-bar-radius, var(--radius-lg, 0.5rem));\n}\n\n/* Sort button — renders inside the <th> */\n.sortButton {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-1, 0.25rem);\n padding: 0;\n margin: 0;\n background: transparent;\n border: 0;\n color: inherit;\n font: inherit;\n font-weight: var(--font-weight-semibold, 600);\n cursor: pointer;\n line-height: var(--line-height-tight, 1.25);\n border-radius: var(--radius-sm, 0.25rem);\n transition:\n color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.sortButton:hover {\n color: var(--text-primary, #111827);\n}\n\n.sortButton:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.sortLabel {\n display: inline-block;\n}\n\n.sortIcon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--spacing-4, 1rem);\n height: var(--spacing-4, 1rem);\n color: var(--text-tertiary, #6b7280);\n flex-shrink: 0;\n}\n\n.sortIcon svg {\n width: 100%;\n height: 100%;\n}\n\n/* Skeleton cell fills the cell width */\n.skeletonCell {\n width: 100%;\n height: var(--spacing-4, 1rem);\n}\n\n/* Empty-row cell centers the EmptyState slot */\n.emptyCell {\n padding: var(--spacing-6, 1.5rem) var(--spacing-4, 1rem);\n text-align: center;\n}\n\n/* Footer — info on the left, controls on the right */\n.footer {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-4, 1rem);\n padding: var(--spacing-2, 0.5rem) var(--spacing-1, 0.25rem);\n min-width: 0;\n flex-wrap: wrap;\n}\n\n.footerInfo {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n min-width: 0;\n}\n\n.footerControls {\n display: flex;\n align-items: center;\n gap: var(--spacing-4, 1rem);\n flex-wrap: wrap;\n}\n\n.pageSizeLabel {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n}\n\n.pageSizeLabelText {\n white-space: nowrap;\n}\n\n/* Native <select> styled to match Visor input controls */\n.pageSizeSelect {\n appearance: none;\n -webkit-appearance: none;\n -moz-appearance: none;\n height: var(--spacing-8, 2rem);\n padding: 0 var(--spacing-6, 1.5rem) 0 var(--spacing-2, 0.5rem);\n border-radius: var(--radius-md, 0.375rem);\n border: 1px solid var(--border-default, #e5e7eb);\n background-color: var(--surface-card, #ffffff);\n background-image: linear-gradient(\n 45deg,\n transparent 50%,\n var(--text-tertiary, #6b7280) 50%\n ),\n linear-gradient(\n 135deg,\n var(--text-tertiary, #6b7280) 50%,\n transparent 50%\n );\n background-position:\n right var(--spacing-3, 0.75rem) center,\n right var(--spacing-2, 0.5rem) center;\n background-size:\n 5px 5px,\n 5px 5px;\n background-repeat: no-repeat;\n color: var(--text-primary, #111827);\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-tight, 1.25);\n cursor: pointer;\n transition:\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n box-shadow var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.pageSizeSelect:hover {\n border-color: var(--border-strong, #d1d5db);\n}\n\n.pageSizeSelect:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: var(--focus-ring-offset, 2px);\n}\n\n.pageNav {\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n}\n\n.pageCounter {\n font-size: var(--font-size-sm, 0.875rem);\n color: var(--text-secondary, #6b7280);\n white-space: nowrap;\n}\n\n/* Data row — base layer for the per-row data-state / data-tone / data-clickable\n variants below. The selectors are scoped to `.dataRow` so the tone/selected\n tints never bleed onto group-head rows or skeleton/empty rows. */\n.dataRow {\n transition: background-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n/* Selected row — wires the latent `data-state=\"selected\"` attribute (already\n emitted by TanStack on selected rows) to a subtle accent fill. Fixes a\n latent bug where selection had no visual feedback. */\n.dataRow[data-state=\"selected\"] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Per-row tone tints — semantic background hints for editorial status.\n Mirrors the tone token mapping used by StatusBadge / StatusDot so a\n row tinted \"live\" reads consistently with a \"live\" badge inline.\n `scheduled` and `draft` intentionally have no tint — they render on\n the default surface to keep visual signal focused on actionable rows. */\n.dataRow[data-tone=\"live\"],\n.dataRow[data-tone=\"sold\"] {\n background-color: var(--surface-success-subtle, #ecfdf5);\n}\n\n.dataRow[data-tone=\"warn\"] {\n background-color: var(--surface-warning-subtle, #fffbeb);\n}\n\n.dataRow[data-tone=\"danger\"] {\n background-color: var(--surface-error-subtle, #fef2f2);\n}\n\n.dataRow[data-tone=\"info\"] {\n background-color: var(--surface-info-subtle, #eff6ff);\n}\n\n/* Selected state should win over tone tint — selection is a stronger\n signal than editorial status. */\n.dataRow[data-state=\"selected\"][data-tone] {\n background-color: var(\n --surface-selected,\n var(--surface-interactive-active, #e5e7eb)\n );\n}\n\n/* Clickable rows — opt-in affordance when `onRowClick` is supplied. */\n.dataRow[data-clickable=\"true\"] {\n cursor: pointer;\n}\n\n.dataRow[data-clickable=\"true\"]:hover {\n background-color: var(--surface-interactive-default, #f3f4f6);\n}\n\n.dataRow[data-clickable=\"true\"]:focus-visible {\n outline: var(--focus-ring-width, 2px) solid\n var(--focus-ring-color, #2563eb);\n outline-offset: calc(var(--focus-ring-offset, 2px) * -1);\n}\n\n/* Group-head row — full-width separator inside the table body. The colSpan\n cell carries the visual; the inner span owns the sticky positioning so it\n pins to the top of the scroll container as data rows scroll beneath. */\n.groupRow {\n background-color: transparent;\n cursor: default;\n}\n\n.groupRow:hover {\n background-color: transparent;\n}\n\n.groupCell {\n padding: 0;\n}\n\n.groupLabel {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n height: 28px;\n padding: 0 var(--spacing-4, 1rem);\n background: var(--surface-subtle, #f3f4f6);\n font-size: var(--font-size-xs, 0.6875rem);\n font-weight: var(--font-weight-medium, 500);\n letter-spacing: 0.14em;\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n\n.groupCount {\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-quaternary, #9ca3af);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0;\n}\n\n/* Narrow containers — stack footer sections */\n@container data-table (max-width: 560px) {\n .footer {\n flex-direction: column;\n align-items: stretch;\n }\n\n .footerControls {\n justify-content: space-between;\n }\n}\n"
|
|
2169
2244
|
}
|
|
2170
2245
|
]
|
|
2171
2246
|
},
|
|
@@ -2263,12 +2338,12 @@
|
|
|
2263
2338
|
{
|
|
2264
2339
|
"path": "components/ui/page-header/page-header.tsx",
|
|
2265
2340
|
"type": "registry:ui",
|
|
2266
|
-
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./page-header.module.css\"\n\nconst pageHeaderVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\ntype PageHeaderElement = \"header\" | \"section\" | \"div\"\ntype TitleElement = \"h1\" | \"h2\" | \"h3\"\n\n/** Token preset values for the `titleSize` prop. */\nconst TITLE_SIZE_TOKENS = [\"default\", \"marquee\"] as const\ntype TitleSizeToken = (typeof TITLE_SIZE_TOKENS)[number]\n\n/** Token preset values for the `titleFamily` prop. */\nconst TITLE_FAMILY_TOKENS = [\"heading\", \"display\"] as const\ntype TitleFamilyToken = (typeof TITLE_FAMILY_TOKENS)[number]\n\nfunction isTitleSizeToken(value: string): value is TitleSizeToken {\n return (TITLE_SIZE_TOKENS as readonly string[]).includes(value)\n}\n\nfunction isTitleFamilyToken(value: string): value is TitleFamilyToken {\n return (TITLE_FAMILY_TOKENS as readonly string[]).includes(value)\n}\n\nexport interface PageHeaderProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"title\">,\n VariantProps<typeof pageHeaderVariants> {\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Page heading content. Rendered in the element given by `titleAs`. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional ReactNode rendered above the title row (typically a Breadcrumb). */\n breadcrumb?: React.ReactNode\n /** Optional ReactNode rendered on the right side of the title row. */\n actions?: React.ReactNode\n /** Root element tag. Defaults to `header`. */\n as?: PageHeaderElement\n /** Heading level for the title. Defaults to `h1`. */\n titleAs?: TitleElement\n /**\n * Title font-size override. Token presets (`\"default\" | \"marquee\"`) map to\n * `data-title-size` attributes; any other string is forwarded as a raw CSS\n * length on the `--page-header-title-size` custom property.\n */\n titleSize?: \"default\" | \"marquee\" | (string & {})\n /**\n * Title font-family override. Token presets (`\"heading\" | \"display\"`) map\n * to `data-title-family` attributes; any other string is forwarded as a\n * raw CSS family on the `--page-header-title-family` custom property.\n */\n titleFamily?: \"heading\" | \"display\" | (string & {})\n}\n\nconst PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(\n (\n {\n className,\n size,\n eyebrow,\n title,\n description,\n breadcrumb,\n actions,\n as = \"header\",\n titleAs = \"h1\",\n titleSize,\n titleFamily,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n const Title = titleAs as React.ElementType\n\n const titleSizeToken =\n typeof titleSize === \"string\" && isTitleSizeToken(titleSize)\n ? titleSize\n : undefined\n const titleFamilyToken =\n typeof titleFamily === \"string\" && isTitleFamilyToken(titleFamily)\n ? titleFamily\n : undefined\n\n const rawTitleSize =\n typeof titleSize === \"string\" && !titleSizeToken ? titleSize : undefined\n const rawTitleFamily =\n typeof titleFamily === \"string\" && !titleFamilyToken\n ? titleFamily\n : undefined\n\n const titleStyle: React.CSSProperties | undefined =\n rawTitleSize || rawTitleFamily\n ? {\n ...(rawTitleSize\n ? ({\n \"--page-header-title-size\": rawTitleSize,\n } as React.CSSProperties)\n : null),\n ...(rawTitleFamily\n ? ({\n \"--page-header-title-family\": rawTitleFamily,\n } as React.CSSProperties)\n : null),\n }\n : undefined\n\n // When a raw string is supplied, switch the title into the \"marquee\" /\n // \"display\" rules that consume the custom property so the override\n // actually takes effect. Token values map directly to data-attributes.\n const resolvedTitleSizeAttr =\n titleSizeToken ?? (rawTitleSize ? \"marquee\" : undefined)\n const resolvedTitleFamilyAttr =\n titleFamilyToken ?? (rawTitleFamily ? \"display\" : undefined)\n\n return (\n <Root\n ref={ref}\n data-slot=\"page-header\"\n className={cn(pageHeaderVariants({ size }), className)}\n {...props}\n >\n {breadcrumb ? (\n <div data-slot=\"page-header-breadcrumb\" className={styles.breadcrumb}>\n {breadcrumb}\n </div>\n ) : null}\n <div data-slot=\"page-header-row\" className={styles.row}>\n <div data-slot=\"page-header-text\" className={styles.text}>\n {eyebrow ? (\n <div data-slot=\"page-header-eyebrow\" className={styles.eyebrow}>\n {eyebrow}\n </div>\n ) : null}\n <Title\n data-slot=\"page-header-title\"\n data-title-size={resolvedTitleSizeAttr}\n data-title-family={resolvedTitleFamilyAttr}\n className={styles.title}\n style={titleStyle}\n >\n {title}\n </Title>\n {description ? (\n <p\n data-slot=\"page-header-description\"\n className={styles.description}\n >\n {description}\n </p>\n ) : null}\n </div>\n {actions ? (\n <div data-slot=\"page-header-actions\" className={styles.actions}>\n {actions}\n </div>\n ) : null}\n </div>\n </Root>\n )\n }\n)\nPageHeader.displayName = \"PageHeader\"\n\nexport { PageHeader, pageHeaderVariants }\n"
|
|
2341
|
+
"content": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./page-header.module.css\"\n\nconst pageHeaderVariants = cva(styles.base, {\n variants: {\n size: {\n sm: styles.sizeSm,\n md: styles.sizeMd,\n lg: styles.sizeLg,\n },\n },\n defaultVariants: {\n size: \"md\",\n },\n})\n\ntype PageHeaderElement = \"header\" | \"section\" | \"div\"\ntype TitleElement = \"h1\" | \"h2\" | \"h3\"\n\n/** Token preset values for the `titleSize` prop. */\nconst TITLE_SIZE_TOKENS = [\"default\", \"marquee\"] as const\ntype TitleSizeToken = (typeof TITLE_SIZE_TOKENS)[number]\n\n/** Token preset values for the `titleFamily` prop. */\nconst TITLE_FAMILY_TOKENS = [\"heading\", \"display\"] as const\ntype TitleFamilyToken = (typeof TITLE_FAMILY_TOKENS)[number]\n\nfunction isTitleSizeToken(value: string): value is TitleSizeToken {\n return (TITLE_SIZE_TOKENS as readonly string[]).includes(value)\n}\n\nfunction isTitleFamilyToken(value: string): value is TitleFamilyToken {\n return (TITLE_FAMILY_TOKENS as readonly string[]).includes(value)\n}\n\nexport interface PageHeaderProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"title\">,\n VariantProps<typeof pageHeaderVariants> {\n /** Optional small uppercase label rendered above the title. */\n eyebrow?: React.ReactNode\n /** Page heading content. Rendered in the element given by `titleAs`. */\n title: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional ReactNode rendered above the title row (typically a Breadcrumb). */\n breadcrumb?: React.ReactNode\n /** Optional ReactNode rendered on the right side of the title row. */\n actions?: React.ReactNode\n /** Root element tag. Defaults to `header`. */\n as?: PageHeaderElement\n /** Heading level for the title. Defaults to `h1`. */\n titleAs?: TitleElement\n /**\n * Title font-size override. Token presets (`\"default\" | \"marquee\"`) map to\n * `data-title-size` attributes; any other string is forwarded as a raw CSS\n * length on the `--page-header-title-size` custom property.\n */\n titleSize?: \"default\" | \"marquee\" | (string & {})\n /**\n * Title font-family override. Token presets (`\"heading\" | \"display\"`) map\n * to `data-title-family` attributes; any other string is forwarded as a\n * raw CSS family on the `--page-header-title-family` custom property.\n */\n titleFamily?: \"heading\" | \"display\" | (string & {})\n /**\n * Title line-height override. Forwarded as a raw CSS `line-height` value\n * on the `--page-header-title-leading` custom property (a unitless number\n * like `1.05` or any CSS length). When omitted, the title falls back to its\n * default line-height (tight for the standard title, `1` for the marquee\n * scale), so existing call sites render unchanged.\n */\n leading?: string | number\n}\n\nconst PageHeader = React.forwardRef<HTMLElement, PageHeaderProps>(\n (\n {\n className,\n size,\n eyebrow,\n title,\n description,\n breadcrumb,\n actions,\n as = \"header\",\n titleAs = \"h1\",\n titleSize,\n titleFamily,\n leading,\n ...props\n },\n ref\n ) => {\n const Root = as as React.ElementType\n const Title = titleAs as React.ElementType\n\n const titleSizeToken =\n typeof titleSize === \"string\" && isTitleSizeToken(titleSize)\n ? titleSize\n : undefined\n const titleFamilyToken =\n typeof titleFamily === \"string\" && isTitleFamilyToken(titleFamily)\n ? titleFamily\n : undefined\n\n const rawTitleSize =\n typeof titleSize === \"string\" && !titleSizeToken ? titleSize : undefined\n const rawTitleFamily =\n typeof titleFamily === \"string\" && !titleFamilyToken\n ? titleFamily\n : undefined\n\n const hasLeading = leading !== undefined && leading !== null\n\n const titleStyle: React.CSSProperties | undefined =\n rawTitleSize || rawTitleFamily || hasLeading\n ? {\n ...(rawTitleSize\n ? ({\n \"--page-header-title-size\": rawTitleSize,\n } as React.CSSProperties)\n : null),\n ...(rawTitleFamily\n ? ({\n \"--page-header-title-family\": rawTitleFamily,\n } as React.CSSProperties)\n : null),\n ...(hasLeading\n ? ({\n \"--page-header-title-leading\": String(leading),\n } as React.CSSProperties)\n : null),\n }\n : undefined\n\n // When a raw string is supplied, switch the title into the \"marquee\" /\n // \"display\" rules that consume the custom property so the override\n // actually takes effect. Token values map directly to data-attributes.\n const resolvedTitleSizeAttr =\n titleSizeToken ?? (rawTitleSize ? \"marquee\" : undefined)\n const resolvedTitleFamilyAttr =\n titleFamilyToken ?? (rawTitleFamily ? \"display\" : undefined)\n\n return (\n <Root\n ref={ref}\n data-slot=\"page-header\"\n className={cn(pageHeaderVariants({ size }), className)}\n {...props}\n >\n {breadcrumb ? (\n <div data-slot=\"page-header-breadcrumb\" className={styles.breadcrumb}>\n {breadcrumb}\n </div>\n ) : null}\n <div data-slot=\"page-header-row\" className={styles.row}>\n <div data-slot=\"page-header-text\" className={styles.text}>\n {eyebrow ? (\n <div data-slot=\"page-header-eyebrow\" className={styles.eyebrow}>\n {eyebrow}\n </div>\n ) : null}\n <Title\n data-slot=\"page-header-title\"\n data-title-size={resolvedTitleSizeAttr}\n data-title-family={resolvedTitleFamilyAttr}\n className={styles.title}\n style={titleStyle}\n >\n {title}\n </Title>\n {description ? (\n <p\n data-slot=\"page-header-description\"\n className={styles.description}\n >\n {description}\n </p>\n ) : null}\n </div>\n {actions ? (\n <div data-slot=\"page-header-actions\" className={styles.actions}>\n {actions}\n </div>\n ) : null}\n </div>\n </Root>\n )\n }\n)\nPageHeader.displayName = \"PageHeader\"\n\nexport { PageHeader, pageHeaderVariants }\n"
|
|
2267
2342
|
},
|
|
2268
2343
|
{
|
|
2269
2344
|
"path": "components/ui/page-header/page-header.module.css",
|
|
2270
2345
|
"type": "registry:ui",
|
|
2271
|
-
"content": "/* Page Header base: container query wrapper */\n.base {\n /* Override hooks for title typography. Themes (or consumers via a wrapping\n className) can set these to retune the marquee title without forking the\n component. Props are sugar over the same custom properties. */\n --page-header-title-size: 3.5rem;\n --page-header-title-family: var(--font-display, var(--font-family-heading, inherit));\n\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-4, 1rem);\n container-type: inline-size;\n container-name: page-header;\n color: var(--text-primary, #111827);\n}\n\n/* Size variants control vertical rhythm */\n.sizeSm {\n gap: var(--spacing-3, 0.75rem);\n}\n\n.sizeMd {\n gap: var(--spacing-4, 1rem);\n}\n\n.sizeLg {\n gap: var(--spacing-5, 1.25rem);\n}\n\n/* Breadcrumb slot sits above the title row */\n.breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main row: text block on the left, actions on the right */\n.row {\n display: flex;\n align-items: flex-end;\n justify-content: space-between;\n gap: var(--spacing-6, 1.5rem);\n min-width: 0;\n}\n\n/* Text block: eyebrow, title, description stacked */\n.text {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.sizeSm .text {\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg .text {\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Eyebrow: small uppercase label above the title */\n.eyebrow {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Title */\n.title {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-2xl, 1.5rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--line-height-tight, 1.2);\n color: var(--text-primary, #111827);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n.sizeSm .title {\n font-size: var(--font-size-xl, 1.25rem);\n}\n\n.sizeLg .title {\n font-size: var(--font-size-3xl, 1.875rem);\n}\n\n/* Title typography overrides (orthogonal to size variants).\n `data-title-size=\"marquee\"` pulls the title up to a hero scale via the\n `--page-header-title-size` custom property. Raw-string consumers pass an\n inline `--page-header-title-size` and rely on the same rule. */\n.title[data-title-size=\"marquee\"] {\n font-size: var(--page-header-title-size, 3.5rem);\n line-height: 1;\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n/* `data-title-family=\"display\"` switches to the display family with a\n graceful fallback to the heading family when no display font is themed. */\n.title[data-title-family=\"display\"] {\n font-family: var(--page-header-title-family, var(--font-display, var(--font-family-heading, inherit)));\n}\n\n/* Description */\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n max-width: 65ch;\n}\n\n.sizeLg .description {\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Actions slot: cluster on the right */\n.actions {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n flex: 0 0 auto;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n\n/* Container query: stack to column below a small container width */\n@container page-header (max-width: 480px) {\n .row {\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-4, 1rem);\n }\n\n .actions {\n width: 100%;\n justify-content: flex-start;\n }\n}\n"
|
|
2346
|
+
"content": "/* Page Header base: container query wrapper */\n.base {\n /* Override hooks for title typography. Themes (or consumers via a wrapping\n className) can set these to retune the marquee title without forking the\n component. Props are sugar over the same custom properties.\n `--page-header-title-leading` is intentionally left unset here so the\n title falls back to its per-rule default line-height (tight for the\n standard title, `1` for the marquee scale); the `leading` prop sets it. */\n --page-header-title-size: 3.5rem;\n --page-header-title-family: var(--font-display, var(--font-family-heading, inherit));\n\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n gap: var(--spacing-4, 1rem);\n container-type: inline-size;\n container-name: page-header;\n color: var(--text-primary, #111827);\n}\n\n/* Size variants control vertical rhythm */\n.sizeSm {\n gap: var(--spacing-3, 0.75rem);\n}\n\n.sizeMd {\n gap: var(--spacing-4, 1rem);\n}\n\n.sizeLg {\n gap: var(--spacing-5, 1.25rem);\n}\n\n/* Breadcrumb slot sits above the title row */\n.breadcrumb {\n display: flex;\n align-items: center;\n min-width: 0;\n}\n\n/* Main row: text block on the left, actions on the right */\n.row {\n display: flex;\n align-items: flex-end;\n justify-content: space-between;\n gap: var(--spacing-6, 1.5rem);\n min-width: 0;\n}\n\n/* Text block: eyebrow, title, description stacked */\n.text {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-2, 0.5rem);\n min-width: 0;\n flex: 1 1 auto;\n}\n\n.sizeSm .text {\n gap: var(--spacing-1, 0.25rem);\n}\n\n.sizeLg .text {\n gap: var(--spacing-3, 0.75rem);\n}\n\n/* Eyebrow: small uppercase label above the title */\n.eyebrow {\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-semibold, 600);\n letter-spacing: var(--letter-spacing-wide, 0.05em);\n text-transform: uppercase;\n color: var(--text-tertiary, #6b7280);\n line-height: var(--line-height-tight, 1.25);\n}\n\n/* Title */\n.title {\n margin: 0;\n font-family: var(--font-family-heading, inherit);\n font-size: var(--font-size-2xl, 1.5rem);\n font-weight: var(--font-weight-semibold, 600);\n line-height: var(--page-header-title-leading, var(--line-height-tight, 1.2));\n color: var(--text-primary, #111827);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n.sizeSm .title {\n font-size: var(--font-size-xl, 1.25rem);\n}\n\n.sizeLg .title {\n font-size: var(--font-size-3xl, 1.875rem);\n}\n\n/* Title typography overrides (orthogonal to size variants).\n `data-title-size=\"marquee\"` pulls the title up to a hero scale via the\n `--page-header-title-size` custom property. Raw-string consumers pass an\n inline `--page-header-title-size` and rely on the same rule. */\n.title[data-title-size=\"marquee\"] {\n font-size: var(--page-header-title-size, 3.5rem);\n line-height: var(--page-header-title-leading, 1);\n letter-spacing: var(--letter-spacing-tight, -0.01em);\n}\n\n/* `data-title-family=\"display\"` switches to the display family with a\n graceful fallback to the heading family when no display font is themed. */\n.title[data-title-family=\"display\"] {\n font-family: var(--page-header-title-family, var(--font-display, var(--font-family-heading, inherit)));\n}\n\n/* Description */\n.description {\n margin: 0;\n font-size: var(--font-size-sm, 0.875rem);\n line-height: var(--line-height-relaxed, 1.6);\n color: var(--text-secondary, #6b7280);\n max-width: 65ch;\n}\n\n.sizeLg .description {\n font-size: var(--font-size-base, 1rem);\n}\n\n/* Actions slot: cluster on the right */\n.actions {\n display: flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n flex: 0 0 auto;\n flex-wrap: wrap;\n justify-content: flex-end;\n}\n\n/* Container query: stack to column below a small container width */\n@container page-header (max-width: 480px) {\n .row {\n flex-direction: column;\n align-items: flex-start;\n gap: var(--spacing-4, 1rem);\n }\n\n .actions {\n width: 100%;\n justify-content: flex-start;\n }\n}\n"
|
|
2272
2347
|
}
|
|
2273
2348
|
]
|
|
2274
2349
|
},
|
|
@@ -2321,6 +2396,32 @@
|
|
|
2321
2396
|
}
|
|
2322
2397
|
]
|
|
2323
2398
|
},
|
|
2399
|
+
{
|
|
2400
|
+
"name": "section-nav",
|
|
2401
|
+
"type": "registry:ui",
|
|
2402
|
+
"description": "A link/anchor-based section sub-navigation strip — leading Phosphor icon + label + optional trailing count pill per item, with a static 2px primary underline on the active item. Items navigate via href (asChild + next/link). Distinct from Tabs: no button triggers, no content panels, no animated indicator.",
|
|
2403
|
+
"category": "navigation",
|
|
2404
|
+
"dependencies": [
|
|
2405
|
+
"@radix-ui/react-slot",
|
|
2406
|
+
"@phosphor-icons/react",
|
|
2407
|
+
"@loworbitstudio/visor-core"
|
|
2408
|
+
],
|
|
2409
|
+
"registryDependencies": [
|
|
2410
|
+
"utils"
|
|
2411
|
+
],
|
|
2412
|
+
"files": [
|
|
2413
|
+
{
|
|
2414
|
+
"path": "components/ui/section-nav/section-nav.tsx",
|
|
2415
|
+
"type": "registry:ui",
|
|
2416
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Slot, Slottable } from \"@radix-ui/react-slot\"\nimport type { Icon } from \"@phosphor-icons/react\"\nimport { cn } from \"../../../lib/utils\"\nimport styles from \"./section-nav.module.css\"\n\n// ─── SectionNav (root) ───────────────────────────────────────────────────────\n\nconst SectionNav = React.forwardRef<HTMLElement, React.ComponentProps<\"nav\">>(\n ({ className, \"aria-label\": ariaLabel, ...props }, ref) => (\n <nav\n ref={ref}\n aria-label={ariaLabel ?? \"section\"}\n data-slot=\"section-nav\"\n className={cn(styles.root, className)}\n {...props}\n />\n )\n)\nSectionNav.displayName = \"SectionNav\"\n\n// ─── SectionNavItem ──────────────────────────────────────────────────────────\n\nexport interface SectionNavItemProps extends React.ComponentProps<\"a\"> {\n /**\n * When true, merge the item's chrome onto the immediate child element instead\n * of rendering an `<a>`. Use with `next/link` for client-side navigation:\n * `<SectionNavItem asChild isActive label=\"Members\"><Link href=\"/members\" /></SectionNavItem>`.\n */\n asChild?: boolean\n /** Marks the item as the current section — text-primary, 2px primary underline, primary-tinted count pill. */\n isActive?: boolean\n /** Leading Phosphor icon component (e.g. `UsersIcon`). */\n icon?: Icon\n /** Item label text. */\n label: React.ReactNode\n /** Optional trailing count pill value. `0` is rendered; `undefined`/`null` hides the pill. */\n count?: number\n}\n\nconst SectionNavItem = React.forwardRef<HTMLAnchorElement, SectionNavItemProps>(\n (\n { className, asChild, isActive, icon: IconComp, label, count, children, ...props },\n ref\n ) => {\n const Comp = asChild ? Slot : \"a\"\n const showCount = count != null\n\n const chrome = (\n <>\n {IconComp && (\n <IconComp className={styles.icon} weight=\"regular\" aria-hidden=\"true\" />\n )}\n <span className={styles.label}>{label}</span>\n {showCount && (\n <span\n className={cn(\n styles.count,\n isActive ? styles.countActive : styles.countNeutral\n )}\n >\n {count}\n </span>\n )}\n </>\n )\n\n return (\n <Comp\n ref={ref}\n data-slot=\"section-nav-item\"\n data-active={isActive || undefined}\n aria-current={isActive ? \"page\" : undefined}\n className={cn(styles.item, isActive && styles.itemActive, className)}\n {...props}\n >\n {chrome}\n <Slottable>{children}</Slottable>\n </Comp>\n )\n }\n)\nSectionNavItem.displayName = \"SectionNavItem\"\n\nexport { SectionNav, SectionNavItem }\n"
|
|
2417
|
+
},
|
|
2418
|
+
{
|
|
2419
|
+
"path": "components/ui/section-nav/section-nav.module.css",
|
|
2420
|
+
"type": "registry:ui",
|
|
2421
|
+
"content": "/* SectionNav — link/anchor-based section sub-navigation.\n *\n * A horizontal strip of links: leading Phosphor icon + label + optional\n * trailing count pill. The active item gets text-primary, a static 2px\n * primary underline, and a primary-tinted count pill.\n *\n * Distinct from Tabs: items navigate via href (next/link asChild), there are\n * no content panels, and the underline is static (not an animated indicator).\n *\n * Theme-agnostic: all values reference CSS custom property tokens.\n */\n\n/* Root — the nav strip with a hairline baseline the active underline sits on. */\n.root {\n display: flex;\n align-items: stretch;\n gap: var(--spacing-1, 0.25rem);\n border-bottom: var(--stroke-width-thin, 1px) solid\n var(--border-default, #e5e7eb);\n overflow-x: auto;\n}\n\n/* Item — a single link: icon + label + optional count pill. */\n.item {\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: var(--spacing-2, 0.5rem);\n padding: var(--spacing-3, 0.75rem) var(--spacing-1, 0.25rem);\n font-size: var(--font-size-sm, 0.875rem);\n font-weight: var(--font-weight-medium, 500);\n line-height: var(--line-height-tight, 1.25);\n white-space: nowrap;\n color: var(--text-secondary, #6b7280);\n text-decoration: none;\n cursor: pointer;\n /* Reserve the 2px underline so resting/active items don't shift vertically. */\n border-bottom: 2px solid transparent;\n margin-bottom: calc(-1 * var(--stroke-width-thin, 1px));\n background: transparent;\n transition: color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease),\n border-color var(--motion-duration-fast, 150ms)\n var(--motion-easing-standard, ease);\n}\n\n.item:hover {\n color: var(--text-primary, #111827);\n}\n\n.item:focus-visible {\n outline: var(--focus-ring-width, 2px) solid var(--border-focus, currentColor);\n outline-offset: var(--focus-ring-offset, 2px);\n border-radius: var(--radius-sm, 0.25rem);\n}\n\n/* Active — text-primary + 2px primary underline. */\n.itemActive {\n color: var(--text-primary, #111827);\n border-bottom-color: var(--primary, #2563eb);\n}\n\n/* Leading icon. */\n.icon {\n width: 1rem;\n height: 1rem;\n flex-shrink: 0;\n}\n\n/* Label text. */\n.label {\n min-width: 0;\n}\n\n/* Trailing count pill. */\n.count {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n border-radius: var(--radius-full, 9999px);\n padding: 0 var(--spacing-2, 0.5rem);\n font-size: var(--font-size-xs, 0.75rem);\n font-weight: var(--font-weight-medium, 500);\n font-variant-numeric: tabular-nums;\n line-height: 1.5;\n}\n\n/* Neutral (resting) count pill. */\n.countNeutral {\n background-color: var(--surface-muted, #f3f4f6);\n color: var(--text-secondary, #6b7280);\n}\n\n/* Primary-tinted count pill — active item. */\n.countActive {\n background-color: var(--surface-accent-subtle, #eff6ff);\n color: var(--text-link, #2563eb);\n}\n"
|
|
2422
|
+
}
|
|
2423
|
+
]
|
|
2424
|
+
},
|
|
2324
2425
|
{
|
|
2325
2426
|
"name": "key-value-list",
|
|
2326
2427
|
"type": "registry:ui",
|
|
@@ -2371,6 +2472,31 @@
|
|
|
2371
2472
|
}
|
|
2372
2473
|
]
|
|
2373
2474
|
},
|
|
2475
|
+
{
|
|
2476
|
+
"name": "infographic-bar",
|
|
2477
|
+
"type": "registry:ui",
|
|
2478
|
+
"description": "Composable continuous stat-card band — lays out N stat-cards as a single infographic strip with outer-rounded/inner-square corners and hairline dividers, no full per-card borders. Theme-portable across bordered and borderless palettes.",
|
|
2479
|
+
"category": "admin",
|
|
2480
|
+
"dependencies": [
|
|
2481
|
+
"@loworbitstudio/visor-core"
|
|
2482
|
+
],
|
|
2483
|
+
"registryDependencies": [
|
|
2484
|
+
"utils",
|
|
2485
|
+
"stat-card"
|
|
2486
|
+
],
|
|
2487
|
+
"files": [
|
|
2488
|
+
{
|
|
2489
|
+
"path": "components/ui/infographic-bar/infographic-bar.tsx",
|
|
2490
|
+
"type": "registry:ui",
|
|
2491
|
+
"content": "import * as React from \"react\"\nimport { cn } from \"../../../lib/utils\"\nimport { StatCard, type StatCardProps } from \"../stat-card/stat-card\"\nimport styles from \"./infographic-bar.module.css\"\n\n/**\n * One cell in the band. A focused subset of {@link StatCardProps} — the bar\n * owns layout-level concerns (`size`, chrome), so those props live on the bar,\n * not the cell. Picking from `StatCardProps` keeps the cell API in lockstep\n * with stat-card and avoids prop drift.\n */\nexport type InfographicBarStat = Pick<\n StatCardProps,\n | \"label\"\n | \"value\"\n | \"delta\"\n | \"trend\"\n | \"trendPosition\"\n | \"footer\"\n | \"valueAs\"\n | \"variant\"\n> & {\n /** Stable React key for the cell. Falls back to the array index when omitted. */\n id?: string\n}\n\nexport interface InfographicBarProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n /** Ordered stat cells, rendered left-to-right as a single continuous band. */\n stats: InfographicBarStat[]\n /** Dimensional density forwarded to every cell. Defaults to `\"md\"`. */\n size?: StatCardProps[\"size\"]\n}\n\n/**\n * InfographicBar composes N {@link StatCard}s into one continuous band: outer\n * corners rounded, inner corners square, hairline dividers between cells, and\n * no full per-card borders. Theme-agnostic — the outer frame follows\n * `--border-default` (so borderless themes drop it) and dividers follow\n * `--hairline` (retunable via `--infographic-bar-divider`).\n */\nconst InfographicBar = React.forwardRef<HTMLDivElement, InfographicBarProps>(\n ({ className, stats, size = \"md\", ...props }, ref) => {\n return (\n <div\n ref={ref}\n data-slot=\"infographic-bar\"\n className={cn(styles.bar, className)}\n {...props}\n >\n {stats.map(({ id, ...stat }, index) => (\n <StatCard\n key={id ?? index}\n size={size}\n className={styles.cell}\n {...stat}\n />\n ))}\n </div>\n )\n }\n)\nInfographicBar.displayName = \"InfographicBar\"\n\nexport { InfographicBar }\n"
|
|
2492
|
+
},
|
|
2493
|
+
{
|
|
2494
|
+
"path": "components/ui/infographic-bar/infographic-bar.module.css",
|
|
2495
|
+
"type": "registry:ui",
|
|
2496
|
+
"content": "/* InfographicBar: lays out N stat-cards as a single continuous band.\n *\n * Corner strategy: the band owns the frame, radius, and elevation. A rounded\n * `border-radius` plus `overflow: hidden` clips the cells so the OUTER corners\n * round and the INNER corners stay square — no per-cell :first/:last radius\n * math, and it holds for any N. Each cell sheds its own border/radius/shadow\n * (see `.cell`) so the band reads as one surface, not bordered cards butting\n * together.\n *\n * Equal columns without knowing N: `grid-auto-flow: column` +\n * `grid-auto-columns: minmax(0, 1fr)`. The `minmax(0, …)` floor lets long\n * values shrink instead of forcing the band to overflow.\n */\n.bar {\n display: grid;\n grid-auto-flow: column;\n grid-auto-columns: minmax(0, 1fr);\n background-color: var(--surface-card, #ffffff);\n border: var(--stroke-width-thin, 1px) solid var(--border-default, #e5e7eb);\n border-radius: var(--radius-lg, 0.75rem);\n box-shadow: var(--shadow-xs, 0 1px 2px 0 rgb(0 0 0 / 0.05));\n overflow: hidden;\n}\n\n/* Cell chrome reset — the band owns the chrome, so each stat-card drops its own\n * border, radius, and shadow. Scoped under `.bar` (a local class) to satisfy\n * the Turbopack pure-selector rule and to out-specify stat-card's own\n * base and variant rules. The cell BACKGROUND is left untouched so the\n * stat-card highlight variant still tints its cell.\n */\n.bar .cell {\n border: none;\n border-radius: 0;\n box-shadow: none;\n}\n\n/* Hairline divider on the leading edge of every cell after the first — this is\n * what makes the middle cells read as continuous rather than separate. Uses\n * `--hairline` (the house soft-separator token, softer than a full border) so\n * it stays subtle, and exposes `--infographic-bar-divider` so a theme or\n * consumer can retune it — including to `transparent` for a fully seamless\n * borderless band. The outer frame above references `--border-default`, which\n * borderless themes set to `transparent`, so the band melts into the page.\n */\n.bar .cell + .cell {\n border-left: var(--stroke-width-thin, 1px) solid\n var(--infographic-bar-divider, var(--hairline, #e5e7eb));\n}\n"
|
|
2497
|
+
}
|
|
2498
|
+
]
|
|
2499
|
+
},
|
|
2374
2500
|
{
|
|
2375
2501
|
"name": "stat-card",
|
|
2376
2502
|
"type": "registry:ui",
|
|
@@ -3382,7 +3508,7 @@
|
|
|
3382
3508
|
{
|
|
3383
3509
|
"path": "blocks/admin-list-page/admin-list-page.module.css",
|
|
3384
3510
|
"type": "registry:block",
|
|
3385
|
-
"content": ".root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-6, 1.5rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-list-page;\n color: var(--text-primary, #111827);\n}\n\n.header {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n.tableSection {\n background-color: var(--admin-list-page-table-bg, transparent);\n border-radius: var(--admin-list-page-table-radius, 0);\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n.footerStatus {\n background-color: var(--admin-list-page-footer-bg, transparent);\n border-radius: var(--admin-list-page-footer-radius, 0);\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--admin-list-page-footer-padding, var(--spacing-3, 0.75rem) 0);\n border-top: var(\n --admin-list-page-footer-border-top,\n var(--stroke-width-thin, 1px) solid\n var(--border-subtle, var(--border-default, #e5e7eb))\n );\n color: var(--text-tertiary, var(--text-muted, #6b7280));\n font-size: var(--text-xs, 0.6875rem);\n line-height: var(--leading-snug, 1.4);\n min-width: 0;\n}\n\n@container admin-list-page (min-width: 56rem) {\n .root {\n gap: var(--spacing-8, 2rem);\n }\n\n .header {\n gap: var(--spacing-6, 1.5rem);\n }\n}\n"
|
|
3511
|
+
"content": ".root {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-6, 1.5rem);\n width: 100%;\n container-type: inline-size;\n container-name: admin-list-page;\n color: var(--text-primary, #111827);\n}\n\n.header {\n display: flex;\n flex-direction: column;\n gap: var(--spacing-5, 1.25rem);\n min-width: 0;\n}\n\n.tableSection {\n background-color: var(--admin-list-page-table-bg, transparent);\n border-radius: var(--admin-list-page-table-radius, 0);\n /* Pipe the header-radius hook into the DataTable sort-bar token so\n consumers can opt into square top corners (borderless flush pattern)\n without forking the block.\n Default is `initial` — DataTable's own --data-table-sort-bar-radius\n default (var(--radius-lg)) is preserved unless the hook is set.\n Example: set --admin-list-page-table-header-radius: 0 on the block\n root for a flush header treatment. */\n --data-table-sort-bar-radius: var(--admin-list-page-table-header-radius, initial);\n display: flex;\n flex-direction: column;\n gap: var(--spacing-4, 1rem);\n min-width: 0;\n}\n\n.footerStatus {\n background-color: var(--admin-list-page-footer-bg, transparent);\n border-radius: var(--admin-list-page-footer-radius, 0);\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--admin-list-page-footer-padding, var(--spacing-3, 0.75rem) 0);\n border-top: var(\n --admin-list-page-footer-border-top,\n var(--stroke-width-thin, 1px) solid\n var(--border-subtle, var(--border-default, #e5e7eb))\n );\n color: var(--text-tertiary, var(--text-muted, #6b7280));\n font-size: var(--text-xs, 0.6875rem);\n line-height: var(--leading-snug, 1.4);\n min-width: 0;\n}\n\n@container admin-list-page (min-width: 56rem) {\n .root {\n gap: var(--spacing-8, 2rem);\n }\n\n .header {\n gap: var(--spacing-6, 1.5rem);\n }\n}\n"
|
|
3386
3512
|
}
|
|
3387
3513
|
]
|
|
3388
3514
|
},
|
|
@@ -3464,7 +3590,7 @@
|
|
|
3464
3590
|
{
|
|
3465
3591
|
"path": "blocks/admin-tabbed-editor/admin-tabbed-editor.tsx",
|
|
3466
3592
|
"type": "registry:block",
|
|
3467
|
-
"content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport { PageHeader } from \"../../components/ui/page-header/page-header\"\nimport {\n Tabs,\n TabsContent,\n TabsList,\n TabsTrigger,\n} from \"../../components/ui/tabs/tabs\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-tabbed-editor.module.css\"\n\nexport interface AdminTabbedEditorTab {\n /** Stable identifier used as the Tabs value. */\n id: string\n /** Tab trigger label. */\n label: React.ReactNode\n /** Optional leading icon rendered before the label inside the trigger. */\n icon?: React.ReactNode\n /** Panel content rendered when this tab is active. */\n content: React.ReactNode\n /** Optional badge rendered after the label (e.g. unsaved indicator). */\n badge?: React.ReactNode\n /** Disable the tab trigger. */\n disabled?: boolean\n}\n\nexport interface AdminTabbedEditorProps\n extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"title\" | \"content\"\n > {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Editor title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n\n // ── Tabs ────────────────────────────────────────────────────────────────\n /** Ordered list of tabs. */\n tabs: AdminTabbedEditorTab[]\n /** Controlled active tab id. */\n activeTab?: string\n /** Uncontrolled default active tab id. Defaults to the first tab. */\n defaultActiveTab?: string\n /** Handler called when the active tab changes. */\n onActiveTabChange?: (id: string) => void\n\n // ── Save / cancel actions ───────────────────────────────────────────────\n /** Save handler. Async-aware: a returned Promise puts the save button into a pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n\n // ── State ───────────────────────────────────────────────────────────────\n /** If true, tab switching and cancel are intercepted by the unsaved-changes guard. */\n dirty?: boolean\n /** Externally-controlled busy state — overrides internal async pending detection. */\n busy?: boolean\n /** Disable the save button. */\n disabled?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the footer entirely. */\n hideFooter?: boolean\n\n // ── Unsaved guard customization ─────────────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this tab.\"\n\nconst AdminTabbedEditor = React.forwardRef<\n HTMLDivElement,\n AdminTabbedEditorProps\n>(function AdminTabbedEditor(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n tabs,\n activeTab,\n defaultActiveTab,\n onActiveTabChange,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n disabled = false,\n footerStatus,\n hideFooter = false,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n const firstTabId = tabs[0]?.id\n const [internalActive, setInternalActive] = React.useState<string | undefined>(\n defaultActiveTab ?? firstTabId\n )\n const [isPending, setIsPending] = React.useState(false)\n const [pendingTabId, setPendingTabId] = React.useState<string | null>(null)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n\n const isControlled = activeTab !== undefined\n const currentTab = isControlled ? activeTab : internalActive\n const effectiveBusy = busy ?? isPending\n const showUnsavedGuard = pendingTabId !== null || pendingCancel\n\n const commitTabChange = React.useCallback(\n (next: string) => {\n if (!isControlled) {\n setInternalActive(next)\n }\n onActiveTabChange?.(next)\n },\n [isControlled, onActiveTabChange]\n )\n\n const handleTabsValueChange = React.useCallback(\n (next: string) => {\n if (next === currentTab) return\n if (dirty) {\n setPendingTabId(next)\n return\n }\n commitTabChange(next)\n },\n [currentTab, dirty, commitTabChange]\n )\n\n const handleCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsPending(true)\n try {\n await result\n setIsPending(false)\n } catch (err) {\n setIsPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingTabId !== null) {\n const next = pendingTabId\n setPendingTabId(null)\n commitTabChange(next)\n return\n }\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingTabId, pendingCancel, commitTabChange, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingTabId(null)\n setPendingCancel(false)\n }, [])\n\n const saveDisabled = disabled || effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(styles.root, className)}\n data-slot=\"admin-tabbed-editor\"\n {...rest}\n >\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n />\n\n <Tabs\n className={styles.tabs}\n value={currentTab}\n onValueChange={handleTabsValueChange}\n >\n <TabsList className={styles.tabsList}>\n {tabs.map((tab) => (\n <TabsTrigger\n key={tab.id}\n value={tab.id}\n disabled={tab.disabled}\n className={styles.tabsTrigger}\n data-slot=\"admin-tabbed-editor-trigger\"\n >\n {tab.icon ? (\n <span\n className={styles.triggerIcon}\n data-slot=\"admin-tabbed-editor-trigger-icon\"\n aria-hidden=\"true\"\n >\n {tab.icon}\n </span>\n ) : null}\n <span className={styles.triggerLabel}>{tab.label}</span>\n {tab.badge ? (\n <span\n className={styles.triggerBadge}\n data-slot=\"admin-tabbed-editor-trigger-badge\"\n >\n {tab.badge}\n </span>\n ) : null}\n </TabsTrigger>\n ))}\n </TabsList>\n\n {tabs.map((tab) => (\n <TabsContent\n key={tab.id}\n value={tab.id}\n className={styles.tabsContent}\n data-slot=\"admin-tabbed-editor-content\"\n >\n {tab.content}\n </TabsContent>\n ))}\n </Tabs>\n\n {hideFooter ? null : (\n <div\n className={styles.footer}\n data-slot=\"admin-tabbed-editor-footer\"\n role=\"group\"\n aria-label=\"Editor actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-tabbed-editor-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-tabbed-editor-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleSaveClick}\n disabled={saveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-tabbed-editor-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n )}\n </div>\n\n <ConfirmDialog\n open={showUnsavedGuard}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n </>\n )\n})\n\nAdminTabbedEditor.displayName = \"AdminTabbedEditor\"\n\nexport { AdminTabbedEditor }\n"
|
|
3593
|
+
"content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport {\n PageHeader,\n type PageHeaderProps,\n} from \"../../components/ui/page-header/page-header\"\nimport {\n Tabs,\n TabsContent,\n TabsList,\n TabsTrigger,\n} from \"../../components/ui/tabs/tabs\"\nimport { Button } from \"../../components/ui/button/button\"\nimport { ConfirmDialog } from \"../../components/ui/confirm-dialog/confirm-dialog\"\nimport styles from \"./admin-tabbed-editor.module.css\"\n\nexport interface AdminTabbedEditorTab {\n /** Stable identifier used as the Tabs value. */\n id: string\n /** Tab trigger label. */\n label: React.ReactNode\n /** Optional leading icon rendered before the label inside the trigger. */\n icon?: React.ReactNode\n /** Panel content rendered when this tab is active. */\n content: React.ReactNode\n /** Optional badge rendered after the label (e.g. unsaved indicator). */\n badge?: React.ReactNode\n /** Disable the tab trigger. */\n disabled?: boolean\n}\n\nexport interface AdminTabbedEditorProps\n extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"title\" | \"content\"\n > {\n // ── Header ──────────────────────────────────────────────────────────────\n /** Editor title rendered inside the PageHeader. */\n title: React.ReactNode\n /** Optional eyebrow rendered above the title. */\n eyebrow?: React.ReactNode\n /** Optional supporting copy rendered below the title. */\n description?: React.ReactNode\n /** Optional breadcrumb node rendered above the title row. */\n breadcrumb?: React.ReactNode\n /** Optional actions slot rendered on the right side of the header. */\n headerActions?: React.ReactNode\n /**\n * Header vertical rhythm — forwarded to the internal PageHeader's `size`.\n * Omit to keep the PageHeader default (`\"md\"`).\n */\n headerSize?: PageHeaderProps[\"size\"]\n /**\n * Title font-size override — forwarded to the internal PageHeader's\n * `titleSize` (`\"default\" | \"marquee\"` token, or a raw CSS length).\n * Omit to keep the default title scale.\n */\n titleSize?: PageHeaderProps[\"titleSize\"]\n /**\n * Title font-family override — forwarded to the internal PageHeader's\n * `titleFamily` (`\"heading\" | \"display\"` token, or a raw CSS family).\n * Omit to keep the heading family.\n */\n titleFamily?: PageHeaderProps[\"titleFamily\"]\n /**\n * Title line-height override — forwarded to the internal PageHeader's\n * `leading`. Omit to keep the default line-height.\n */\n leading?: PageHeaderProps[\"leading\"]\n\n // ── Tabs ────────────────────────────────────────────────────────────────\n /** Ordered list of tabs. */\n tabs: AdminTabbedEditorTab[]\n /** Controlled active tab id. */\n activeTab?: string\n /** Uncontrolled default active tab id. Defaults to the first tab. */\n defaultActiveTab?: string\n /** Handler called when the active tab changes. */\n onActiveTabChange?: (id: string) => void\n\n // ── Save / cancel actions ───────────────────────────────────────────────\n /** Save handler. Async-aware: a returned Promise puts the save button into a pending state. */\n onSave?: () => void | Promise<void>\n /** Cancel handler. Protected by the unsaved-changes guard when `dirty` is true. */\n onCancel?: () => void\n /** Save button label. Defaults to \"Save changes\". */\n saveLabel?: React.ReactNode\n /** Cancel button label. Defaults to \"Cancel\". */\n cancelLabel?: React.ReactNode\n\n // ── State ───────────────────────────────────────────────────────────────\n /** If true, tab switching and cancel are intercepted by the unsaved-changes guard. */\n dirty?: boolean\n /** Externally-controlled busy state — overrides internal async pending detection. */\n busy?: boolean\n /** Disable the save button. */\n disabled?: boolean\n /** Middle slot inside the sticky footer — e.g. \"Last saved 2 minutes ago\". */\n footerStatus?: React.ReactNode\n /** Hide the footer entirely. */\n hideFooter?: boolean\n\n // ── Unsaved guard customization ─────────────────────────────────────────\n /** Title of the unsaved-changes confirm dialog. */\n unsavedGuardTitle?: React.ReactNode\n /** Description of the unsaved-changes confirm dialog. */\n unsavedGuardDescription?: React.ReactNode\n /** Confirm (discard) label. Defaults to \"Discard\". */\n unsavedGuardConfirmLabel?: React.ReactNode\n /** Cancel (keep editing) label. Defaults to \"Keep editing\". */\n unsavedGuardCancelLabel?: React.ReactNode\n}\n\nconst DEFAULT_UNSAVED_DESCRIPTION =\n \"You have unsaved changes that will be lost if you leave this tab.\"\n\nconst AdminTabbedEditor = React.forwardRef<\n HTMLDivElement,\n AdminTabbedEditorProps\n>(function AdminTabbedEditor(\n {\n title,\n eyebrow,\n description,\n breadcrumb,\n headerActions,\n headerSize,\n titleSize,\n titleFamily,\n leading,\n tabs,\n activeTab,\n defaultActiveTab,\n onActiveTabChange,\n onSave,\n onCancel,\n saveLabel = \"Save changes\",\n cancelLabel = \"Cancel\",\n dirty = false,\n busy,\n disabled = false,\n footerStatus,\n hideFooter = false,\n unsavedGuardTitle = \"Discard unsaved changes?\",\n unsavedGuardDescription = DEFAULT_UNSAVED_DESCRIPTION,\n unsavedGuardConfirmLabel = \"Discard\",\n unsavedGuardCancelLabel = \"Keep editing\",\n className,\n ...rest\n },\n ref\n) {\n const firstTabId = tabs[0]?.id\n const [internalActive, setInternalActive] = React.useState<string | undefined>(\n defaultActiveTab ?? firstTabId\n )\n const [isPending, setIsPending] = React.useState(false)\n const [pendingTabId, setPendingTabId] = React.useState<string | null>(null)\n const [pendingCancel, setPendingCancel] = React.useState(false)\n\n const isControlled = activeTab !== undefined\n const currentTab = isControlled ? activeTab : internalActive\n const effectiveBusy = busy ?? isPending\n const showUnsavedGuard = pendingTabId !== null || pendingCancel\n\n const commitTabChange = React.useCallback(\n (next: string) => {\n if (!isControlled) {\n setInternalActive(next)\n }\n onActiveTabChange?.(next)\n },\n [isControlled, onActiveTabChange]\n )\n\n const handleTabsValueChange = React.useCallback(\n (next: string) => {\n if (next === currentTab) return\n if (dirty) {\n setPendingTabId(next)\n return\n }\n commitTabChange(next)\n },\n [currentTab, dirty, commitTabChange]\n )\n\n const handleCancelClick = React.useCallback(() => {\n if (effectiveBusy) return\n if (dirty) {\n setPendingCancel(true)\n return\n }\n onCancel?.()\n }, [dirty, effectiveBusy, onCancel])\n\n const handleSaveClick = React.useCallback(async () => {\n if (!onSave) return\n const result = onSave()\n if (result && typeof (result as Promise<void>).then === \"function\") {\n setIsPending(true)\n try {\n await result\n setIsPending(false)\n } catch (err) {\n setIsPending(false)\n throw err\n }\n }\n }, [onSave])\n\n const handleGuardConfirm = React.useCallback(() => {\n if (pendingTabId !== null) {\n const next = pendingTabId\n setPendingTabId(null)\n commitTabChange(next)\n return\n }\n if (pendingCancel) {\n setPendingCancel(false)\n onCancel?.()\n }\n }, [pendingTabId, pendingCancel, commitTabChange, onCancel])\n\n const handleGuardCancel = React.useCallback(() => {\n setPendingTabId(null)\n setPendingCancel(false)\n }, [])\n\n const saveDisabled = disabled || effectiveBusy || !dirty\n\n return (\n <>\n <div\n ref={ref}\n className={cn(styles.root, className)}\n data-slot=\"admin-tabbed-editor\"\n {...rest}\n >\n <PageHeader\n className={styles.header}\n eyebrow={eyebrow}\n title={title}\n description={description}\n breadcrumb={breadcrumb}\n actions={headerActions}\n size={headerSize}\n titleSize={titleSize}\n titleFamily={titleFamily}\n leading={leading}\n />\n\n <Tabs\n className={styles.tabs}\n value={currentTab}\n onValueChange={handleTabsValueChange}\n >\n <TabsList className={styles.tabsList}>\n {tabs.map((tab) => (\n <TabsTrigger\n key={tab.id}\n value={tab.id}\n disabled={tab.disabled}\n className={styles.tabsTrigger}\n data-slot=\"admin-tabbed-editor-trigger\"\n >\n {tab.icon ? (\n <span\n className={styles.triggerIcon}\n data-slot=\"admin-tabbed-editor-trigger-icon\"\n aria-hidden=\"true\"\n >\n {tab.icon}\n </span>\n ) : null}\n <span className={styles.triggerLabel}>{tab.label}</span>\n {tab.badge ? (\n <span\n className={styles.triggerBadge}\n data-slot=\"admin-tabbed-editor-trigger-badge\"\n >\n {tab.badge}\n </span>\n ) : null}\n </TabsTrigger>\n ))}\n </TabsList>\n\n {tabs.map((tab) => (\n <TabsContent\n key={tab.id}\n value={tab.id}\n className={styles.tabsContent}\n data-slot=\"admin-tabbed-editor-content\"\n >\n {tab.content}\n </TabsContent>\n ))}\n </Tabs>\n\n {hideFooter ? null : (\n <div\n className={styles.footer}\n data-slot=\"admin-tabbed-editor-footer\"\n role=\"group\"\n aria-label=\"Editor actions\"\n >\n <div className={styles.footerCancel}>\n <Button\n type=\"button\"\n variant=\"outline\"\n onClick={handleCancelClick}\n disabled={effectiveBusy}\n data-slot=\"admin-tabbed-editor-cancel\"\n >\n {cancelLabel}\n </Button>\n </div>\n {footerStatus ? (\n <div\n className={styles.footerStatus}\n data-slot=\"admin-tabbed-editor-status\"\n >\n {footerStatus}\n </div>\n ) : null}\n <div className={styles.footerSave}>\n <Button\n type=\"button\"\n onClick={handleSaveClick}\n disabled={saveDisabled}\n aria-busy={effectiveBusy || undefined}\n data-slot=\"admin-tabbed-editor-save\"\n >\n {saveLabel}\n </Button>\n </div>\n </div>\n )}\n </div>\n\n <ConfirmDialog\n open={showUnsavedGuard}\n onOpenChange={(next) => {\n if (!next) handleGuardCancel()\n }}\n severity=\"warning\"\n title={unsavedGuardTitle}\n description={unsavedGuardDescription}\n confirmLabel={unsavedGuardConfirmLabel}\n cancelLabel={unsavedGuardCancelLabel}\n onConfirm={handleGuardConfirm}\n onCancel={handleGuardCancel}\n />\n </>\n )\n})\n\nAdminTabbedEditor.displayName = \"AdminTabbedEditor\"\n\nexport { AdminTabbedEditor }\n"
|
|
3468
3594
|
},
|
|
3469
3595
|
{
|
|
3470
3596
|
"path": "blocks/admin-tabbed-editor/admin-tabbed-editor.module.css",
|
|
@@ -3546,12 +3672,12 @@
|
|
|
3546
3672
|
{
|
|
3547
3673
|
"path": "blocks/right-rail-list/right-rail-list.tsx",
|
|
3548
3674
|
"type": "registry:block",
|
|
3549
|
-
"content": "import * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./right-rail-list.module.css\"\n\nexport type RightRailListTone =\n | \"default\"\n | \"mint\"\n | \"muted\"\n | \"warn\"\n\nexport interface RightRailRow {\n /** Stable key for the row. */\n id: string\n /**\n * Optional leading slot — short label (e.g. \"Sun\"), avatar, badge,\n * status dot, or any other compact stamp anchoring the row's left edge.\n */\n leading?: React.ReactNode\n /**\n * Primary content — typically a link or plain text. Truncates with\n * ellipsis when overflowing the row.\n */\n primary: React.ReactNode\n /**\n * Optional trailing slot — count, value, or status word rendered at the\n * row's right edge.\n */\n trailing?: React.ReactNode\n /**\n * Tone applied to the trailing element via `data-tone`. Defaults to\n * `\"default\"`. Tones bind to the shared semantic text tokens so they\n * resolve correctly under every theme.\n */\n trailingTone?: RightRailListTone\n}\n\ntype RootElement = \"ul\" | \"ol\" | \"div\"\n\nexport interface RightRailListProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n /** Row data — each entry renders as a single list row. */\n rows: RightRailRow[]\n /**\n * Tighter vertical padding for high-density rails. Defaults to `false`.\n */\n compact?: boolean\n /**\n * Root element. Defaults to `\"ul\"`. Use `\"ol\"` for ordered rankings\n * (e.g. top promoters) or `\"div\"` when the surrounding context already\n * provides list semantics.\n */\n as?: RootElement\n}\n\nconst RightRailList = React.forwardRef<HTMLElement, RightRailListProps>(\n function RightRailList(\n { rows, compact = false, as = \"ul\", className, ...rest },\n ref\n ) {\n const Root = as as React.ElementType\n const RowTag: React.ElementType = as === \"div\" ? \"div\" : \"li\"\n\n return (\n <Root\n ref={ref as React.Ref<HTMLElement>}\n className={cn(styles.root, compact && styles.rootCompact, className)}\n data-slot=\"right-rail-list\"\n data-compact={compact ? \"true\" : undefined}\n {...rest}\n >\n {rows.map((row) => (\n <RowTag\n key={row.id}\n className={styles.row}\n data-slot=\"right-rail-list-row\"\n >\n {row.leading !== undefined && row.leading !== null ? (\n <span\n className={styles.leading}\n data-slot=\"right-rail-list-leading\"\n >\n {row.leading}\n </span>\n ) : null}\n <span\n className={styles.primary}\n data-slot=\"right-rail-list-primary\"\n >\n {row.primary}\n </span>\n {row.trailing !== undefined && row.trailing !== null ? (\n <span\n className={styles.trailing}\n data-slot=\"right-rail-list-trailing\"\n data-tone={row.trailingTone ?? \"default\"}\n >\n {row.trailing}\n </span>\n ) : null}\n </RowTag>\n ))}\n </Root>\n )\n }\n)\n\nRightRailList.displayName = \"RightRailList\"\n\nexport { RightRailList }\n"
|
|
3675
|
+
"content": "import * as React from \"react\"\n\nimport { cn } from \"../../lib/utils\"\nimport styles from \"./right-rail-list.module.css\"\n\nexport type RightRailListTone =\n | \"default\"\n | \"mint\"\n | \"muted\"\n | \"warn\"\n\nexport interface RightRailRow {\n /** Stable key for the row. */\n id: string\n /**\n * Optional leading slot — short label (e.g. \"Sun\"), avatar, badge,\n * status dot, or any other compact stamp anchoring the row's left edge.\n */\n leading?: React.ReactNode\n /**\n * Primary content — typically a link or plain text. Truncates with\n * ellipsis when overflowing the row.\n */\n primary: React.ReactNode\n /**\n * Optional trailing slot — count, value, or status word rendered at the\n * row's right edge.\n */\n trailing?: React.ReactNode\n /**\n * Tone applied to the trailing element via `data-tone`. Defaults to\n * `\"default\"`. Tones bind to the shared semantic text tokens so they\n * resolve correctly under every theme.\n */\n trailingTone?: RightRailListTone\n}\n\ntype RootElement = \"ul\" | \"ol\" | \"div\"\n\nexport interface RightRailListProps\n extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n /** Row data — each entry renders as a single list row. */\n rows: RightRailRow[]\n /**\n * Tighter vertical padding for high-density rails. Defaults to `false`.\n */\n compact?: boolean\n /**\n * Row text size. `\"sm\"` (~13 px) is the default; `\"xs\"` (~11 px) is\n * recommended for dense admin side-rails where the default reads one\n * notch too large.\n */\n rowSize?: \"sm\" | \"xs\"\n /**\n * Root element. Defaults to `\"ul\"`. Use `\"ol\"` for ordered rankings\n * (e.g. top promoters) or `\"div\"` when the surrounding context already\n * provides list semantics.\n */\n as?: RootElement\n}\n\nconst RightRailList = React.forwardRef<HTMLElement, RightRailListProps>(\n function RightRailList(\n { rows, compact = false, rowSize = \"sm\", as = \"ul\", className, ...rest },\n ref\n ) {\n const Root = as as React.ElementType\n const RowTag: React.ElementType = as === \"div\" ? \"div\" : \"li\"\n\n return (\n <Root\n ref={ref as React.Ref<HTMLElement>}\n className={cn(styles.root, compact && styles.rootCompact, className)}\n data-slot=\"right-rail-list\"\n data-compact={compact ? \"true\" : undefined}\n data-row-size={rowSize !== \"sm\" ? rowSize : undefined}\n {...rest}\n >\n {rows.map((row) => (\n <RowTag\n key={row.id}\n className={styles.row}\n data-slot=\"right-rail-list-row\"\n >\n {row.leading !== undefined && row.leading !== null ? (\n <span\n className={styles.leading}\n data-slot=\"right-rail-list-leading\"\n >\n {row.leading}\n </span>\n ) : null}\n <span\n className={styles.primary}\n data-slot=\"right-rail-list-primary\"\n >\n {row.primary}\n </span>\n {row.trailing !== undefined && row.trailing !== null ? (\n <span\n className={styles.trailing}\n data-slot=\"right-rail-list-trailing\"\n data-tone={row.trailingTone ?? \"default\"}\n >\n {row.trailing}\n </span>\n ) : null}\n </RowTag>\n ))}\n </Root>\n )\n }\n)\n\nRightRailList.displayName = \"RightRailList\"\n\nexport { RightRailList }\n"
|
|
3550
3676
|
},
|
|
3551
3677
|
{
|
|
3552
3678
|
"path": "blocks/right-rail-list/right-rail-list.module.css",
|
|
3553
3679
|
"type": "registry:block",
|
|
3554
|
-
"content": "/* Right Rail List\n * Compact vertical list block tuned for dashboard side rails.\n * Each row pairs an optional leading stamp (label / avatar / badge),\n * a primary label (typically a link), and an optional trailing meta\n * value (count / value / tone-tinted status word).\n *\n * Ports the r3 admin-v7-r3 RightRailList component into a theme-portable\n * block — every color, size, and spacing value binds to a Visor token so\n * the block adopts the active theme without modification.\n */\n\n.root {\n display: flex;\n flex-direction: column;\n background: var(--surface-card);\n /* Reset list semantics chrome when root is a <ul> / <ol>. */\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-2-5, 0.625rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.8125rem);\n /* Hairline divider between rows — matches the subtle separator pattern\n * used in the r3 reference. The first row has no top border so the\n * block sits flush against any panel chrome above it. */\n border-top: var(--stroke-width-thin, 1px) solid var(--border-subtle, transparent);\n}\n\n.row:first-child {\n border-top: 0;\n}\n\n/* Subtle hover affordance — rows are not always interactive, but a\n * gentle background shift signals where focus would land if a primary\n * link is present. */\n.row:hover {\n background-color: var(--surface-hover, transparent);\n}\n\n/* Compact density — shaves vertical padding for tighter rails. */\n.rootCompact .row {\n padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);\n}\n\n.leading {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-tertiary);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n min-width: var(--spacing-9, 2.25rem);\n}\n\n.primary {\n flex: 1;\n min-width: 0;\n color: var(--text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.trailing {\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n color: var(--text-tertiary);\n}\n\n/* Tone variants — each tone binds to a Visor semantic text token so the\n * block adopts the active theme's color palette without modification.\n * Pattern mirrors status-badge.module.css. */\n.trailing[data-tone=\"mint\"] {\n color: var(--text-success);\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n font-size: var(--font-size-xs, 0.6875rem);\n}\n\n.trailing[data-tone=\"muted\"] {\n color: var(--text-tertiary);\n}\n\n.trailing[data-tone=\"warn\"] {\n color: var(--text-warning);\n font-weight: 500;\n}\n\n.trailing[data-tone=\"danger\"] {\n color: var(--text-danger, var(--text-error));\n font-weight: 500;\n}\n\n.trailing[data-tone=\"info\"] {\n color: var(--text-info);\n font-weight: 500;\n}\n\n/* Respect users who prefer reduced motion — the hover transition is\n * cosmetic, but we omit it under the preference. */\n@media (prefers-reduced-motion: no-preference) {\n .row {\n transition: background-color var(--motion-duration-100, 120ms)\n var(--motion-easing-standard, ease-out);\n }\n}\n"
|
|
3680
|
+
"content": "/* Right Rail List\n * Compact vertical list block tuned for dashboard side rails.\n * Each row pairs an optional leading stamp (label / avatar / badge),\n * a primary label (typically a link), and an optional trailing meta\n * value (count / value / tone-tinted status word).\n *\n * Ports the r3 admin-v7-r3 RightRailList component into a theme-portable\n * block — every color, size, and spacing value binds to a Visor token so\n * the block adopts the active theme without modification.\n */\n\n.root {\n display: flex;\n flex-direction: column;\n background: var(--surface-card);\n /* Reset list semantics chrome when root is a <ul> / <ol>. */\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.row {\n display: flex;\n align-items: center;\n gap: var(--spacing-3, 0.75rem);\n padding: var(--spacing-2-5, 0.625rem) var(--spacing-4, 1rem);\n font-size: var(--font-size-sm, 0.8125rem);\n /* Hairline divider between rows — matches the subtle separator pattern\n * used in the r3 reference. The first row has no top border so the\n * block sits flush against any panel chrome above it. */\n border-top: var(--stroke-width-thin, 1px) solid var(--border-subtle, transparent);\n}\n\n.row:first-child {\n border-top: 0;\n}\n\n/* Subtle hover affordance — rows are not always interactive, but a\n * gentle background shift signals where focus would land if a primary\n * link is present. */\n.row:hover {\n background-color: var(--surface-hover, transparent);\n}\n\n/* Compact density — shaves vertical padding for tighter rails. */\n.rootCompact .row {\n padding: var(--spacing-2, 0.5rem) var(--spacing-4, 1rem);\n}\n\n/* xs row size — one notch smaller than the default sm (~13 px → ~11 px).\n * Recommended for dense admin side-rails where sm reads too large.\n * Anchored on .root to satisfy Turbopack's pure-selector rule. */\n.root[data-row-size=\"xs\"] .row {\n font-size: var(--font-size-xs, 0.6875rem);\n}\n\n.leading {\n flex-shrink: 0;\n display: inline-flex;\n align-items: center;\n font-size: var(--font-size-xs, 0.6875rem);\n color: var(--text-tertiary);\n font-variant-numeric: tabular-nums;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n min-width: var(--spacing-9, 2.25rem);\n}\n\n.primary {\n flex: 1;\n min-width: 0;\n color: var(--text-primary);\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n\n.trailing {\n flex-shrink: 0;\n font-variant-numeric: tabular-nums;\n color: var(--text-tertiary);\n}\n\n/* Tone variants — each tone binds to a Visor semantic text token so the\n * block adopts the active theme's color palette without modification.\n * Pattern mirrors status-badge.module.css. */\n.trailing[data-tone=\"mint\"] {\n color: var(--text-success);\n font-weight: 500;\n letter-spacing: 0.06em;\n text-transform: uppercase;\n font-size: var(--font-size-xs, 0.6875rem);\n}\n\n.trailing[data-tone=\"muted\"] {\n color: var(--text-tertiary);\n}\n\n.trailing[data-tone=\"warn\"] {\n color: var(--text-warning);\n font-weight: 500;\n}\n\n.trailing[data-tone=\"danger\"] {\n color: var(--text-danger, var(--text-error));\n font-weight: 500;\n}\n\n.trailing[data-tone=\"info\"] {\n color: var(--text-info);\n font-weight: 500;\n}\n\n/* Respect users who prefer reduced motion — the hover transition is\n * cosmetic, but we omit it under the preference. */\n@media (prefers-reduced-motion: no-preference) {\n .row {\n transition: background-color var(--motion-duration-100, 120ms)\n var(--motion-easing-standard, ease-out);\n }\n}\n"
|
|
3555
3681
|
}
|
|
3556
3682
|
]
|
|
3557
3683
|
},
|
|
@@ -4291,7 +4417,7 @@
|
|
|
4291
4417
|
{
|
|
4292
4418
|
"path": "components/devtools/source-inspector/visor-component-names.generated.ts",
|
|
4293
4419
|
"type": "registry:devtool",
|
|
4294
|
-
"content": "// THIS FILE IS GENERATED BY scripts/generate-visor-component-names.ts.\n// Do not edit by hand. Re-run `npm run generate:component-names` after\n// adding, removing, or renaming a Visor component.\n//\n// Source of truth: registry/registry-{ui,blocks,deck,visual,devtools}.ts\n// Used by: components/devtools/source-inspector/* (VI-311)\n\nexport const VISOR_COMPONENT_NAMES: ReadonlySet<string> = new Set([\n \"AccessibilitySection\",\n \"AccessibilitySlide\",\n \"AccessibilitySpecimen\",\n \"Accordion\",\n \"AccordionContent\",\n \"AccordionItem\",\n \"AccordionTrigger\",\n \"ActivityFeed\",\n \"ActivityFeedContext\",\n \"ActivityFeedItem\",\n \"ActivityFeedRoot\",\n \"AdminDashboard\",\n \"AdminDetailDrawer\",\n \"AdminListPage\",\n \"AdminListPageInner\",\n \"AdminSettingsPage\",\n \"AdminShell\",\n \"AdminTabbedEditor\",\n \"AdminWizard\",\n \"Alert\",\n \"AlertActions\",\n \"AlertDescription\",\n \"AlertTitle\",\n \"Avatar\",\n \"AvatarFallback\",\n \"AvatarImage\",\n \"AvatarStack\",\n \"Badge\",\n \"Banner\",\n \"BannerAction\",\n \"BannerDescription\",\n \"BannerTitle\",\n \"BentoGrid\",\n \"BentoTile\",\n \"BentoTileBody\",\n \"BentoTileDescription\",\n \"BentoTileFigure\",\n \"BentoTileHeadline\",\n \"BentoTileMedia\",\n \"BentoTileMeta\",\n \"BentoTileTitle\",\n \"Box\",\n \"Breadcrumb\",\n \"BreadcrumbEllipsis\",\n \"BreadcrumbItem\",\n \"BreadcrumbLink\",\n \"BreadcrumbList\",\n \"BreadcrumbPage\",\n \"BreadcrumbSeparator\",\n \"BulkActionBar\",\n \"Button\",\n \"ButtonSpecimenSection\",\n \"ButtonSpecimenSlide\",\n \"Calendar\",\n \"Card\",\n \"CardContent\",\n \"CardDescription\",\n \"CardFooter\",\n \"CardGrid\",\n \"CardHeader\",\n \"CardTitle\",\n \"Carousel\",\n \"CarouselContent\",\n \"CarouselContext\",\n \"CarouselGallery\",\n \"CarouselItem\",\n \"CarouselNext\",\n \"CarouselPrevious\",\n \"ChartContainer\",\n \"ChartContext\",\n \"ChartLegend\",\n \"ChartLegendContent\",\n \"ChartStyle\",\n \"ChartTooltip\",\n \"ChartTooltipContent\",\n \"Checkbox\",\n \"Chip\",\n \"ChipGroup\",\n \"ChipGroupContext\",\n \"ChipGroupItem\",\n \"ChoiceChip\",\n \"ChromeButton\",\n \"ClosingSlide\",\n \"CodeBlock\",\n \"Collapsible\",\n \"CollapsibleContent\",\n \"CollapsibleTrigger\",\n \"ColorBar\",\n \"ColorPaletteSection\",\n \"ColorPicker\",\n \"ColorPickerSurface\",\n \"ColorSwatch\",\n \"ColorSwatchGrid\",\n \"Combobox\",\n \"ComboboxContent\",\n \"ComboboxContext\",\n \"ComboboxEmpty\",\n \"ComboboxGroup\",\n \"ComboboxInput\",\n \"ComboboxItem\",\n \"ComboboxSeparator\",\n \"Command\",\n \"CommandDialog\",\n \"CommandEmpty\",\n \"CommandGroup\",\n \"CommandInput\",\n \"CommandItem\",\n \"CommandList\",\n \"CommandLoading\",\n \"CommandSeparator\",\n \"CommandShortcut\",\n \"ComponentShowcaseContent\",\n \"ComponentShowcaseSection\",\n \"ComponentShowcaseSlide\",\n \"ConceptSlide\",\n \"ConfigurationPanel\",\n \"ConfirmDialog\",\n \"Container\",\n \"ContextMenu\",\n \"ContextMenuCheckboxItem\",\n \"ContextMenuContent\",\n \"ContextMenuGroup\",\n \"ContextMenuItem\",\n \"ContextMenuLabel\",\n \"ContextMenuPortal\",\n \"ContextMenuRadioGroup\",\n \"ContextMenuRadioItem\",\n \"ContextMenuSeparator\",\n \"ContextMenuShortcut\",\n \"ContextMenuSub\",\n \"ContextMenuSubContent\",\n \"ContextMenuSubTrigger\",\n \"ContextMenuTrigger\",\n \"Controls\",\n \"CtaSection\",\n \"DataTable\",\n \"DataTableInner\",\n \"DatePicker\",\n \"DateRangePicker\",\n \"DeckContext\",\n \"DeckFooter\",\n \"DeckLayout\",\n \"DeckProvider\",\n \"DeckRenderer\",\n \"DesignSystemDeck\",\n \"DesignSystemSpecimen\",\n \"Dialog\",\n \"DialogClose\",\n \"DialogContent\",\n \"DialogDescription\",\n \"DialogHeader\",\n \"DialogOverlay\",\n \"DialogPortal\",\n \"DialogTitle\",\n \"DialogTrigger\",\n \"DotNav\",\n \"DropdownMenu\",\n \"DropdownMenuCheckboxItem\",\n \"DropdownMenuContent\",\n \"DropdownMenuGroup\",\n \"DropdownMenuItem\",\n \"DropdownMenuLabel\",\n \"DropdownMenuPortal\",\n \"DropdownMenuRadioGroup\",\n \"DropdownMenuRadioItem\",\n \"DropdownMenuSeparator\",\n \"DropdownMenuShortcut\",\n \"DropdownMenuSub\",\n \"DropdownMenuSubContent\",\n \"DropdownMenuSubTrigger\",\n \"DropdownMenuTrigger\",\n \"ElevationCard\",\n \"ElevationSlide\",\n \"EmptyState\",\n \"ExportMenu\",\n \"FeaturesGrid\",\n \"Field\",\n \"FieldDescription\",\n \"FieldError\",\n \"FieldLabel\",\n \"Fieldset\",\n \"FieldsetLegend\",\n \"FileUpload\",\n \"FilterBar\",\n \"FilterChip\",\n \"FontShowcase\",\n \"FontShowcaseGrid\",\n \"Footer\",\n \"FooterSection\",\n \"Form\",\n \"FormField\",\n \"FormSpecimenSection\",\n \"FormSpecimenSlide\",\n \"FullscreenOverlay\",\n \"FullscreenOverlayContent\",\n \"FullscreenOverlayTrigger\",\n \"Grid\",\n \"Header\",\n \"Heading\",\n \"HeroSection\",\n \"HeroSlide\",\n \"HoverCard\",\n \"HoverCardContent\",\n \"HoverCardTrigger\",\n \"IconGrid\",\n \"IconGridSection\",\n \"IconSizeRow\",\n \"IconsSlide\",\n \"Image\",\n \"Inline\",\n \"Input\",\n \"Kbd\",\n \"KeyValueList\",\n \"Label\",\n \"Landing\",\n \"Lightbox\",\n \"LightboxContent\",\n \"LightboxContext\",\n \"LightboxTrigger\",\n \"LoginForm\",\n \"Marquee\",\n \"MarqueeBandRenderer\",\n \"Menubar\",\n \"MenubarCheckboxItem\",\n \"MenubarContent\",\n \"MenubarGroup\",\n \"MenubarItem\",\n \"MenubarLabel\",\n \"MenubarMenu\",\n \"MenubarRadioGroup\",\n \"MenubarRadioItem\",\n \"MenubarSeparator\",\n \"MenubarShortcut\",\n \"MenubarSub\",\n \"MenubarSubContent\",\n \"MenubarSubTrigger\",\n \"MenubarTrigger\",\n \"MotionDuration\",\n \"MotionDurationSection\",\n \"MotionEasing\",\n \"MotionEasingSection\",\n \"MotionSlide\",\n \"NameRoster\",\n \"NameRosterItem\",\n \"Navbar\",\n \"NavbarBrand\",\n \"NavbarContent\",\n \"NavbarItem\",\n \"NavbarLink\",\n \"NavbarToggle\",\n \"NumberInput\",\n \"OpacityBar\",\n \"OpacitySlide\",\n \"OTPInput\",\n \"PageHeader\",\n \"Pagination\",\n \"PaginationContent\",\n \"PaginationEllipsis\",\n \"PaginationItem\",\n \"PaginationLink\",\n \"PaginationNext\",\n \"PaginationPrevious\",\n \"PasswordInput\",\n \"PhoneInput\",\n \"Popover\",\n \"PopoverAnchor\",\n \"PopoverContent\",\n \"PopoverFooter\",\n \"PopoverSelectionItem\",\n \"PopoverSelectionLabel\",\n \"PopoverSelectionList\",\n \"PopoverTrigger\",\n \"PricingSection\",\n \"ProfileMenu\",\n \"Progress\",\n \"PrototypeReview\",\n \"QuickActions\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"RightRailList\",\n \"ScoreIndicator\",\n \"ScrollArea\",\n \"ScrollBar\",\n \"SearchInput\",\n \"SectionHeader\",\n \"Select\",\n \"SelectContent\",\n \"SelectGroup\",\n \"SelectionListContext\",\n \"SelectItem\",\n \"SelectLabel\",\n \"SelectScrollDownButton\",\n \"SelectScrollUpButton\",\n \"SelectSeparator\",\n \"SelectTrigger\",\n \"SelectValue\",\n \"SemanticColorGrid\",\n \"SemanticColorItem\",\n \"SemanticTokensSlide\",\n \"Separator\",\n \"ShadowSection\",\n \"Sheet\",\n \"SheetClose\",\n \"SheetContent\",\n \"SheetDescription\",\n \"SheetFooter\",\n \"SheetHeader\",\n \"SheetOverlay\",\n \"SheetPortal\",\n \"SheetTitle\",\n \"SheetTrigger\",\n \"Sidebar\",\n \"SidebarContent\",\n \"SidebarContext\",\n \"SidebarFooter\",\n \"SidebarGroup\",\n \"SidebarGroupAction\",\n \"SidebarGroupContent\",\n \"SidebarGroupLabel\",\n \"SidebarHeader\",\n \"SidebarInset\",\n \"SidebarMenu\",\n \"SidebarMenuAction\",\n \"SidebarMenuBadge\",\n \"SidebarMenuButton\",\n \"SidebarMenuItem\",\n \"SidebarMenuSub\",\n \"SidebarMenuSubButton\",\n \"SidebarMenuSubItem\",\n \"SidebarProvider\",\n \"SidebarRail\",\n \"SidebarSeparator\",\n \"SidebarTrigger\",\n \"Skeleton\",\n \"Slide\",\n \"SlideHeader\",\n \"Slider\",\n \"SliderControl\",\n \"SlideThemeContext\",\n \"SlideThemeProvider\",\n \"SourceInspector\",\n \"SourceInspectorContext\",\n \"SourceInspectorDevImpl\",\n \"SourceInspectorProvider\",\n \"SourceInspectorRunner\",\n \"SourceInspectorToggle\",\n \"SpacingScale\",\n \"SpacingSection\",\n \"SpacingSlide\",\n \"Sparkline\",\n \"Sphere\",\n \"SpherePlayground\",\n \"Stack\",\n \"Stage\",\n \"StatCard\",\n \"StatHero\",\n \"StationSpectrum\",\n \"StatusBadge\",\n \"StatusColorsSlide\",\n \"StatusDot\",\n \"Stepper\",\n \"StepperContext\",\n \"StepperDescription\",\n \"StepperItem\",\n \"StepperSeparator\",\n \"StepperTitle\",\n \"StepperTrigger\",\n \"StepsSection\",\n \"SurfaceRow\",\n \"SurfaceSection\",\n \"Switch\",\n \"Table\",\n \"TableBody\",\n \"TableCaption\",\n \"TableCell\",\n \"TableFooter\",\n \"TableHead\",\n \"TableHeader\",\n \"TableRow\",\n \"Tabs\",\n \"TabsContent\",\n \"TabsList\",\n \"TabsTrigger\",\n \"TagInput\",\n \"TestimonialAttribution\",\n \"TestimonialSection\",\n \"Text\",\n \"Textarea\",\n \"ThemeArchitectureSlide\",\n \"ThemeColorsSlide\",\n \"ThemeSwitcher\",\n \"Timeline\",\n \"TimelineContent\",\n \"TimelineDescription\",\n \"TimelineIcon\",\n \"TimelineItem\",\n \"TimelineTimestamp\",\n \"TimelineTitle\",\n \"TitleSlide\",\n \"Toaster\",\n \"TOCSlide\",\n \"ToggleButton\",\n \"ToggleDevImpl\",\n \"ToggleGroup\",\n \"ToggleGroupContext\",\n \"ToggleGroupItem\",\n \"Tooltip\",\n \"TooltipContent\",\n \"TooltipProvider\",\n \"TooltipTrigger\",\n \"TypeBodySlide\",\n \"TypeDisplaySlide\",\n \"TypeSpecimen\",\n \"TypographySection\",\n \"WorkspaceSwitcher\",\n])\n"
|
|
4420
|
+
"content": "// THIS FILE IS GENERATED BY scripts/generate-visor-component-names.ts.\n// Do not edit by hand. Re-run `npm run generate:component-names` after\n// adding, removing, or renaming a Visor component.\n//\n// Source of truth: registry/registry-{ui,blocks,deck,visual,devtools}.ts\n// Used by: components/devtools/source-inspector/* (VI-311)\n\nexport const VISOR_COMPONENT_NAMES: ReadonlySet<string> = new Set([\n \"AccessibilitySection\",\n \"AccessibilitySlide\",\n \"AccessibilitySpecimen\",\n \"Accordion\",\n \"AccordionContent\",\n \"AccordionItem\",\n \"AccordionTrigger\",\n \"ActivityFeed\",\n \"ActivityFeedContext\",\n \"ActivityFeedItem\",\n \"ActivityFeedRoot\",\n \"AdminDashboard\",\n \"AdminDetailDrawer\",\n \"AdminListPage\",\n \"AdminListPageInner\",\n \"AdminSettingsPage\",\n \"AdminShell\",\n \"AdminTabbedEditor\",\n \"AdminWizard\",\n \"Alert\",\n \"AlertActions\",\n \"AlertDescription\",\n \"AlertTitle\",\n \"Avatar\",\n \"AvatarFallback\",\n \"AvatarImage\",\n \"AvatarStack\",\n \"Badge\",\n \"Banner\",\n \"BannerAction\",\n \"BannerDescription\",\n \"BannerTitle\",\n \"BentoGrid\",\n \"BentoTile\",\n \"BentoTileBody\",\n \"BentoTileDescription\",\n \"BentoTileFigure\",\n \"BentoTileHeadline\",\n \"BentoTileMedia\",\n \"BentoTileMeta\",\n \"BentoTileTitle\",\n \"Box\",\n \"Breadcrumb\",\n \"BreadcrumbEllipsis\",\n \"BreadcrumbItem\",\n \"BreadcrumbLink\",\n \"BreadcrumbList\",\n \"BreadcrumbPage\",\n \"BreadcrumbSeparator\",\n \"BulkActionBar\",\n \"Button\",\n \"ButtonSpecimenSection\",\n \"ButtonSpecimenSlide\",\n \"Calendar\",\n \"Card\",\n \"CardContent\",\n \"CardDescription\",\n \"CardFooter\",\n \"CardGrid\",\n \"CardHeader\",\n \"CardTitle\",\n \"Carousel\",\n \"CarouselContent\",\n \"CarouselContext\",\n \"CarouselGallery\",\n \"CarouselItem\",\n \"CarouselNext\",\n \"CarouselPrevious\",\n \"ChartContainer\",\n \"ChartContext\",\n \"ChartLegend\",\n \"ChartLegendContent\",\n \"ChartStyle\",\n \"ChartTooltip\",\n \"ChartTooltipContent\",\n \"Checkbox\",\n \"Chip\",\n \"ChipGroup\",\n \"ChipGroupContext\",\n \"ChipGroupItem\",\n \"ChoiceChip\",\n \"ChromeButton\",\n \"ClosingSlide\",\n \"CodeBlock\",\n \"Collapsible\",\n \"CollapsibleContent\",\n \"CollapsibleTrigger\",\n \"ColorBar\",\n \"ColorPaletteSection\",\n \"ColorPicker\",\n \"ColorPickerSurface\",\n \"ColorSwatch\",\n \"ColorSwatchGrid\",\n \"Combobox\",\n \"ComboboxContent\",\n \"ComboboxContext\",\n \"ComboboxEmpty\",\n \"ComboboxGroup\",\n \"ComboboxInput\",\n \"ComboboxItem\",\n \"ComboboxSeparator\",\n \"Command\",\n \"CommandDialog\",\n \"CommandEmpty\",\n \"CommandGroup\",\n \"CommandInput\",\n \"CommandItem\",\n \"CommandList\",\n \"CommandLoading\",\n \"CommandSeparator\",\n \"CommandShortcut\",\n \"ComponentShowcaseContent\",\n \"ComponentShowcaseSection\",\n \"ComponentShowcaseSlide\",\n \"ConceptSlide\",\n \"ConfigurationPanel\",\n \"ConfirmDialog\",\n \"Container\",\n \"ContextMenu\",\n \"ContextMenuCheckboxItem\",\n \"ContextMenuContent\",\n \"ContextMenuGroup\",\n \"ContextMenuItem\",\n \"ContextMenuLabel\",\n \"ContextMenuPortal\",\n \"ContextMenuRadioGroup\",\n \"ContextMenuRadioItem\",\n \"ContextMenuSeparator\",\n \"ContextMenuShortcut\",\n \"ContextMenuSub\",\n \"ContextMenuSubContent\",\n \"ContextMenuSubTrigger\",\n \"ContextMenuTrigger\",\n \"Controls\",\n \"CtaSection\",\n \"DataTable\",\n \"DataTableInner\",\n \"DatePicker\",\n \"DateRangePicker\",\n \"DeckContext\",\n \"DeckFooter\",\n \"DeckLayout\",\n \"DeckProvider\",\n \"DeckRenderer\",\n \"DesignSystemDeck\",\n \"DesignSystemSpecimen\",\n \"Dialog\",\n \"DialogClose\",\n \"DialogContent\",\n \"DialogDescription\",\n \"DialogHeader\",\n \"DialogOverlay\",\n \"DialogPortal\",\n \"DialogTitle\",\n \"DialogTrigger\",\n \"DotNav\",\n \"DropdownMenu\",\n \"DropdownMenuCheckboxItem\",\n \"DropdownMenuContent\",\n \"DropdownMenuGroup\",\n \"DropdownMenuItem\",\n \"DropdownMenuLabel\",\n \"DropdownMenuPortal\",\n \"DropdownMenuRadioGroup\",\n \"DropdownMenuRadioItem\",\n \"DropdownMenuSeparator\",\n \"DropdownMenuShortcut\",\n \"DropdownMenuSub\",\n \"DropdownMenuSubContent\",\n \"DropdownMenuSubTrigger\",\n \"DropdownMenuTrigger\",\n \"ElevationCard\",\n \"ElevationSlide\",\n \"EmptyState\",\n \"ExportMenu\",\n \"FeaturesGrid\",\n \"Field\",\n \"FieldDescription\",\n \"FieldError\",\n \"FieldLabel\",\n \"Fieldset\",\n \"FieldsetLegend\",\n \"FileUpload\",\n \"FilterBar\",\n \"FilterChip\",\n \"FontShowcase\",\n \"FontShowcaseGrid\",\n \"Footer\",\n \"FooterSection\",\n \"Form\",\n \"FormField\",\n \"FormSpecimenSection\",\n \"FormSpecimenSlide\",\n \"FullscreenOverlay\",\n \"FullscreenOverlayContent\",\n \"FullscreenOverlayTrigger\",\n \"Grid\",\n \"Header\",\n \"Heading\",\n \"HeroSection\",\n \"HeroSlide\",\n \"HoverCard\",\n \"HoverCardContent\",\n \"HoverCardTrigger\",\n \"IconGrid\",\n \"IconGridSection\",\n \"IconSizeRow\",\n \"IconsSlide\",\n \"Image\",\n \"InfographicBar\",\n \"Inline\",\n \"Input\",\n \"Kbd\",\n \"KeyValueList\",\n \"Label\",\n \"Landing\",\n \"Lightbox\",\n \"LightboxContent\",\n \"LightboxContext\",\n \"LightboxTrigger\",\n \"LoginForm\",\n \"Marquee\",\n \"MarqueeBandRenderer\",\n \"Menubar\",\n \"MenubarCheckboxItem\",\n \"MenubarContent\",\n \"MenubarGroup\",\n \"MenubarItem\",\n \"MenubarLabel\",\n \"MenubarMenu\",\n \"MenubarRadioGroup\",\n \"MenubarRadioItem\",\n \"MenubarSeparator\",\n \"MenubarShortcut\",\n \"MenubarSub\",\n \"MenubarSubContent\",\n \"MenubarSubTrigger\",\n \"MenubarTrigger\",\n \"MotionDuration\",\n \"MotionDurationSection\",\n \"MotionEasing\",\n \"MotionEasingSection\",\n \"MotionSlide\",\n \"NameRoster\",\n \"NameRosterItem\",\n \"Navbar\",\n \"NavbarBrand\",\n \"NavbarContent\",\n \"NavbarItem\",\n \"NavbarLink\",\n \"NavbarToggle\",\n \"NumberInput\",\n \"OpacityBar\",\n \"OpacitySlide\",\n \"OTPInput\",\n \"PageHeader\",\n \"Pagination\",\n \"PaginationContent\",\n \"PaginationEllipsis\",\n \"PaginationItem\",\n \"PaginationLink\",\n \"PaginationNext\",\n \"PaginationPrevious\",\n \"PasswordInput\",\n \"PhoneInput\",\n \"Popover\",\n \"PopoverAnchor\",\n \"PopoverContent\",\n \"PopoverFooter\",\n \"PopoverSelectionItem\",\n \"PopoverSelectionLabel\",\n \"PopoverSelectionList\",\n \"PopoverTrigger\",\n \"PricingSection\",\n \"ProfileMenu\",\n \"Progress\",\n \"PrototypeReview\",\n \"QuickActions\",\n \"RadioGroup\",\n \"RadioGroupItem\",\n \"RadiusScale\",\n \"RadiusSection\",\n \"RadiusSlide\",\n \"RightRailList\",\n \"ScoreIndicator\",\n \"ScrollArea\",\n \"ScrollBar\",\n \"SearchInput\",\n \"SectionHeader\",\n \"Select\",\n \"SelectContent\",\n \"SelectGroup\",\n \"SelectionListContext\",\n \"SelectItem\",\n \"SelectLabel\",\n \"SelectScrollDownButton\",\n \"SelectScrollUpButton\",\n \"SelectSeparator\",\n \"SelectTrigger\",\n \"SelectValue\",\n \"SemanticColorGrid\",\n \"SemanticColorItem\",\n \"SemanticTokensSlide\",\n \"Separator\",\n \"ShadowSection\",\n \"Sheet\",\n \"SheetClose\",\n \"SheetContent\",\n \"SheetDescription\",\n \"SheetFooter\",\n \"SheetHeader\",\n \"SheetOverlay\",\n \"SheetPortal\",\n \"SheetTitle\",\n \"SheetTrigger\",\n \"Sidebar\",\n \"SidebarContent\",\n \"SidebarContext\",\n \"SidebarFooter\",\n \"SidebarGroup\",\n \"SidebarGroupAction\",\n \"SidebarGroupContent\",\n \"SidebarGroupLabel\",\n \"SidebarHeader\",\n \"SidebarInset\",\n \"SidebarMenu\",\n \"SidebarMenuAction\",\n \"SidebarMenuBadge\",\n \"SidebarMenuButton\",\n \"SidebarMenuItem\",\n \"SidebarMenuSub\",\n \"SidebarMenuSubButton\",\n \"SidebarMenuSubItem\",\n \"SidebarProvider\",\n \"SidebarRail\",\n \"SidebarSeparator\",\n \"SidebarTrigger\",\n \"Skeleton\",\n \"Slide\",\n \"SlideHeader\",\n \"Slider\",\n \"SliderControl\",\n \"SlideThemeContext\",\n \"SlideThemeProvider\",\n \"SourceInspector\",\n \"SourceInspectorContext\",\n \"SourceInspectorDevImpl\",\n \"SourceInspectorProvider\",\n \"SourceInspectorRunner\",\n \"SourceInspectorToggle\",\n \"SpacingScale\",\n \"SpacingSection\",\n \"SpacingSlide\",\n \"Sparkline\",\n \"Sphere\",\n \"SpherePlayground\",\n \"Stack\",\n \"Stage\",\n \"StatCard\",\n \"StatHero\",\n \"StationSpectrum\",\n \"StatusBadge\",\n \"StatusColorsSlide\",\n \"StatusDot\",\n \"Stepper\",\n \"StepperContext\",\n \"StepperDescription\",\n \"StepperItem\",\n \"StepperSeparator\",\n \"StepperTitle\",\n \"StepperTrigger\",\n \"StepsSection\",\n \"SurfaceRow\",\n \"SurfaceScaleStack\",\n \"SurfaceSection\",\n \"Switch\",\n \"Table\",\n \"TableBody\",\n \"TableCaption\",\n \"TableCell\",\n \"TableFooter\",\n \"TableHead\",\n \"TableHeader\",\n \"TableRow\",\n \"Tabs\",\n \"TabsContent\",\n \"TabsList\",\n \"TabsTrigger\",\n \"TagInput\",\n \"TestimonialAttribution\",\n \"TestimonialSection\",\n \"Text\",\n \"Textarea\",\n \"ThemeArchitectureSlide\",\n \"ThemeColorsSlide\",\n \"ThemeSwitcher\",\n \"Timeline\",\n \"TimelineContent\",\n \"TimelineDescription\",\n \"TimelineIcon\",\n \"TimelineItem\",\n \"TimelineTimestamp\",\n \"TimelineTitle\",\n \"TitleSlide\",\n \"Toaster\",\n \"TOCSlide\",\n \"ToggleButton\",\n \"ToggleDevImpl\",\n \"ToggleGroup\",\n \"ToggleGroupContext\",\n \"ToggleGroupItem\",\n \"Tooltip\",\n \"TooltipContent\",\n \"TooltipProvider\",\n \"TooltipTrigger\",\n \"TypeBodySlide\",\n \"TypeDisplaySlide\",\n \"TypeScaleStack\",\n \"TypeSpecimen\",\n \"TypographySection\",\n \"WorkspaceSwitcher\",\n])\n"
|
|
4295
4421
|
}
|
|
4296
4422
|
]
|
|
4297
4423
|
},
|
|
@@ -4677,7 +4803,7 @@
|
|
|
4677
4803
|
{
|
|
4678
4804
|
"path": "components/flutter/visor_otp_input/visor_otp_input.dart",
|
|
4679
4805
|
"type": "registry:ui",
|
|
4680
|
-
"content": "import 'package:flutter/foundation.dart' show kIsWeb;\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// A one-time-password input widget composed of [digitCount] individual digit\n/// boxes.\n///\n/// ## Basic usage\n///\n/// ```dart\n/// VisorOtpInput(\n/// digitCount: 6,\n/// onCodeComplete: (code) => _verify(code),\n/// onCodeChanged: (partial) => _handleChange(partial),\n/// )\n/// ```\n///\n/// ## Programmatic clear\n///\n/// Expose a [GlobalKey] to call [VisorOtpInputState.clear]:\n///\n/// ```dart\n/// final _otpKey = GlobalKey<VisorOtpInputState>();\n///\n/// VisorOtpInput(key: _otpKey, digitCount: 6, onCodeComplete: _verify)\n///\n/// // later:\n/// _otpKey.currentState?.clear();\n/// ```\n///\n/// ## Platform notes\n///\n/// On web (`kIsWeb`) a single hidden [TextField] is used to capture all input.\n/// This avoids browsers intercepting backspace before Flutter sees it — a\n/// regression observed in SoleSpark and Veronica without this branch.\n///\n/// On native, a [KeyboardListener] + per-digit [FocusNode] handles input\n/// directly.\n///\n/// ## Theming\n///\n/// All colors, spacing, and radius values are read from:\n/// - `context.visorColors` (`surfaceInteractiveDefault`, `surfaceAccentSubtle`,\n/// `borderFocus`, `borderDefault`, `textPrimary`)\n/// - `context.visorSpacing` (gap between digits)\n/// - `context.visorRadius` (`sm` radius for digit boxes)\n///\n/// Zero hard-coded [Color] or [double] literals.\nclass VisorOtpInput extends StatefulWidget {\n const VisorOtpInput({\n super.key,\n this.digitCount = 6,\n this.onCodeComplete,\n this.onCodeChanged,\n this.enabled = true,\n this.autofocus = false,\n this.semanticLabel,\n }) : assert(digitCount > 0, 'digitCount must be at least 1');\n\n /// Number of digit boxes to render. Defaults to 6.\n final int digitCount;\n\n /// Fires once when all [digitCount] digits are filled in.\n /// Will not re-fire if the user modifies a digit after completion.\n final ValueChanged<String>? onCodeComplete;\n\n /// Fires on every individual digit entry, with the partial code so far.\n final ValueChanged<String>? onCodeChanged;\n\n /// When false all input is ignored. Useful for loading/submitting states.\n final bool enabled;\n\n /// Whether to request focus when the widget first mounts.\n final bool autofocus;\n\n /// Optional override for the row-level Semantics container label.\n ///\n /// Defaults to `'OTP code, $digitCount digits'`. Pass a domain-specific\n /// label (e.g. `'Two-factor authentication code, 6 digits'`) when the\n /// generic label would lack context for screen-reader users.\n final String? semanticLabel;\n\n @override\n VisorOtpInputState createState() => VisorOtpInputState();\n}\n\n/// Exposes [clear] so host screens can reset the OTP without rebuilding.\nclass VisorOtpInputState extends State<VisorOtpInput> {\n late List<String> _digits;\n late List<FocusNode> _focusNodes;\n late List<TextEditingController> _controllers;\n\n // Web-only: single hidden TextField that captures all keyboard input.\n late final TextEditingController _webController;\n late final FocusNode _webFocusNode;\n\n // Tracks which digit is currently active (used for visual state).\n int _focusedIndex = 0;\n\n // Guard so onCodeComplete fires only once per completion cycle.\n bool _completionFired = false;\n\n // Guards against recursive onChanged callbacks when we programmatically\n // update controller text inside _setDigit.\n bool _ignoreTextChanges = false;\n\n @override\n void initState() {\n super.initState();\n _digits = List.filled(widget.digitCount, '');\n _focusNodes = List.generate(widget.digitCount, (_) => FocusNode());\n _controllers =\n List.generate(widget.digitCount, (_) => TextEditingController());\n\n for (var i = 0; i < widget.digitCount; i++) {\n _focusNodes[i].addListener(() {\n if (_focusNodes[i].hasFocus) {\n setState(() => _focusedIndex = i);\n }\n });\n }\n\n _webController = TextEditingController();\n _webFocusNode = FocusNode();\n }\n\n @override\n void dispose() {\n for (final n in _focusNodes) {\n n.dispose();\n }\n for (final c in _controllers) {\n c.dispose();\n }\n _webController.dispose();\n _webFocusNode.dispose();\n super.dispose();\n }\n\n // ---------------------------------------------------------------------------\n // Public API\n // ---------------------------------------------------------------------------\n\n /// Resets all digit boxes to empty and moves focus to the first digit.\n void clear() {\n _ignoreTextChanges = true;\n setState(() {\n _digits = List.filled(widget.digitCount, '');\n for (final c in _controllers) {\n c.clear();\n }\n _focusedIndex = 0;\n _completionFired = false;\n });\n _ignoreTextChanges = false;\n if (widget.enabled) {\n if (kIsWeb) {\n _webFocusNode.requestFocus();\n } else {\n _focusNodes[0].requestFocus();\n }\n }\n widget.onCodeChanged?.call('');\n }\n\n // ---------------------------------------------------------------------------\n // Input handling — native\n // ---------------------------------------------------------------------------\n\n void _onNativeKeyEvent(int index, KeyEvent event) {\n if (_ignoreTextChanges) return;\n if (!widget.enabled) return;\n if (event is! KeyDownEvent && event is! KeyRepeatEvent) return;\n\n if (event.logicalKey == LogicalKeyboardKey.backspace) {\n if (_digits[index].isNotEmpty) {\n _setDigit(index, '');\n } else if (index > 0) {\n _focusNodes[index - 1].requestFocus();\n _setDigit(index - 1, '');\n }\n }\n }\n\n void _onNativeTextChanged(int index, String text) {\n if (_ignoreTextChanges) return;\n if (!widget.enabled) return;\n if (text.isEmpty) return;\n\n // Accept only the last character to handle paste gracefully (D6).\n final char = text.substring(text.length - 1);\n if (!RegExp(r'\\d').hasMatch(char)) {\n _ignoreTextChanges = true;\n _controllers[index].clear();\n _ignoreTextChanges = false;\n return;\n }\n\n _setDigit(index, char);\n\n if (index < widget.digitCount - 1) {\n _focusNodes[index + 1].requestFocus();\n } else {\n _focusNodes[index].unfocus();\n }\n }\n\n // ---------------------------------------------------------------------------\n // Input handling — web\n // ---------------------------------------------------------------------------\n\n void _onWebTextChanged(String text) {\n if (!widget.enabled) return;\n\n // The hidden TextField accumulates input. Process each new character.\n // We only care about the trailing characters not yet consumed.\n for (final char in text.characters) {\n if (!RegExp(r'\\d').hasMatch(char)) continue;\n final nextEmpty = _digits.indexOf('');\n if (nextEmpty == -1) break;\n _setDigit(nextEmpty, char);\n }\n\n // Reset the hidden field so next keypress starts fresh.\n _webController.clear();\n }\n\n void _onWebKeyEvent(KeyEvent event) {\n if (!widget.enabled) return;\n if (event is! KeyDownEvent && event is! KeyRepeatEvent) return;\n\n if (event.logicalKey == LogicalKeyboardKey.backspace) {\n // Find the last filled digit and clear it.\n int target = -1;\n for (var i = widget.digitCount - 1; i >= 0; i--) {\n if (_digits[i].isNotEmpty) {\n target = i;\n break;\n }\n }\n if (target >= 0) {\n _setDigit(target, '');\n }\n }\n }\n\n // ---------------------------------------------------------------------------\n // Shared state mutation\n // ---------------------------------------------------------------------------\n\n void _setDigit(int index, String value) {\n // Sync the controller text programmatically. Guard against the controller's\n // own onChanged listener firing recursively.\n _ignoreTextChanges = true;\n _controllers[index].text = value;\n _controllers[index].selection = TextSelection.fromPosition(\n TextPosition(offset: value.length),\n );\n _ignoreTextChanges = false;\n\n setState(() {\n _digits[index] = value;\n _focusedIndex = index;\n });\n\n final partial = _digits.join();\n widget.onCodeChanged?.call(partial);\n\n final allFilled = _digits.every((d) => d.isNotEmpty);\n if (value.isNotEmpty && allFilled) {\n if (!_completionFired) {\n _completionFired = true;\n widget.onCodeComplete?.call(partial);\n }\n } else {\n // Reset completion guard if a digit is cleared after full entry.\n _completionFired = false;\n }\n }\n\n // ---------------------------------------------------------------------------\n // Build\n // ---------------------------------------------------------------------------\n\n @override\n Widget build(BuildContext context) {\n final spacing = context.visorSpacing;\n\n if (kIsWeb) {\n return _buildWeb(context, spacing);\n }\n return _buildNative(context, spacing);\n }\n\n Widget _buildNative(BuildContext context, VisorSpacingData spacing) {\n return LayoutBuilder(\n builder: (context, constraints) {\n final sizes = _calculateSizing(constraints.maxWidth, spacing);\n return Semantics(\n container: true,\n label: widget.semanticLabel ??\n 'OTP code, ${widget.digitCount} digits',\n child: Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n for (var i = 0; i < widget.digitCount; i++) ...[\n if (i > 0) SizedBox(width: sizes.gap),\n KeyboardListener(\n focusNode: FocusNode(skipTraversal: true),\n onKeyEvent: (event) => _onNativeKeyEvent(i, event),\n child: _VisorOtpDigit(\n controller: _controllers[i],\n focusNode: _focusNodes[i],\n digit: _digits[i],\n isFocused: _focusedIndex == i && _focusNodes[i].hasFocus,\n enabled: widget.enabled,\n size: sizes.digitSize,\n onChanged: (text) => _onNativeTextChanged(i, text),\n autofocus: widget.autofocus && i == 0,\n index: i,\n length: widget.digitCount,\n ),\n ),\n ],\n ],\n ),\n );\n },\n );\n }\n\n Widget _buildWeb(BuildContext context, VisorSpacingData spacing) {\n return LayoutBuilder(\n builder: (context, constraints) {\n final sizes = _calculateSizing(constraints.maxWidth, spacing);\n return Stack(\n alignment: Alignment.center,\n children: [\n // Visible digit row. The hidden capture TextField sits OUTSIDE\n // this Semantics container so its own textField semantics don't\n // leak into the OTP code group announcement.\n Semantics(\n container: true,\n label: widget.semanticLabel ??\n 'OTP code, ${widget.digitCount} digits',\n child: Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n for (var i = 0; i < widget.digitCount; i++) ...[\n if (i > 0) SizedBox(width: sizes.gap),\n GestureDetector(\n onTap: widget.enabled\n ? () {\n setState(() => _focusedIndex = i);\n _webFocusNode.requestFocus();\n }\n : null,\n child: _VisorOtpDigit(\n controller: _controllers[i],\n focusNode: FocusNode(skipTraversal: true),\n digit: _digits[i],\n isFocused:\n _webFocusNode.hasFocus && _focusedIndex == i,\n enabled: widget.enabled,\n size: sizes.digitSize,\n onChanged: (_) {},\n index: i,\n length: widget.digitCount,\n ),\n ),\n ],\n ],\n ),\n ),\n // Transparent hidden TextField that captures all keyboard input.\n Positioned.fill(\n child: Opacity(\n opacity: 0,\n child: KeyboardListener(\n focusNode: FocusNode(skipTraversal: true),\n onKeyEvent: _onWebKeyEvent,\n child: TextField(\n controller: _webController,\n focusNode: _webFocusNode,\n enabled: widget.enabled,\n autofocus: widget.autofocus,\n keyboardType: TextInputType.number,\n inputFormatters: [FilteringTextInputFormatter.digitsOnly],\n onChanged: _onWebTextChanged,\n decoration: const InputDecoration(border: InputBorder.none),\n ),\n ),\n ),\n ),\n ],\n );\n },\n );\n }\n\n /// Computes responsive digit size and gap given the available width.\n ///\n /// On narrow containers the digits shrink proportionally; on wide ones they\n /// cap at a comfortable 56×56 max.\n _OtpSizes _calculateSizing(double available, VisorSpacingData spacing) {\n // Intentional layout constraints: 56px max (comfortable touch target),\n // 32px min (readable digit text). Not design tokens — structural bounds.\n const maxDigitSize = 56.0;\n const minDigitSize = 32.0;\n final gapCount = widget.digitCount - 1;\n // Start with gap = spacing.sm (8px).\n final gap = spacing.sm;\n final totalGap = gapCount * gap;\n final digitSize = ((available - totalGap) / widget.digitCount)\n .clamp(minDigitSize, maxDigitSize);\n return _OtpSizes(digitSize: digitSize, gap: gap);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Sizing record\n// ---------------------------------------------------------------------------\n\nclass _OtpSizes {\n const _OtpSizes({required this.digitSize, required this.gap});\n final double digitSize;\n final double gap;\n}\n\n// ---------------------------------------------------------------------------\n// Private digit widget\n// ---------------------------------------------------------------------------\n\n/// A single OTP digit box. Private — consumed only by [VisorOtpInput].\n///\n/// Visual states:\n/// - Empty default: `surfaceInteractiveDefault` bg, `borderDefault` border\n/// - Focused: `borderFocus` bg fill, `borderFocus` border\n/// - Filled: `surfaceAccentSubtle` bg, `borderDefault` border\n///\n/// Digit text: `textPrimary`.\n/// Border radius: `radius.sm`.\nclass _VisorOtpDigit extends StatelessWidget {\n const _VisorOtpDigit({\n required this.controller,\n required this.focusNode,\n required this.digit,\n required this.isFocused,\n required this.enabled,\n required this.size,\n required this.onChanged,\n required this.index,\n required this.length,\n this.autofocus = false,\n });\n\n final TextEditingController controller;\n final FocusNode focusNode;\n final String digit;\n final bool isFocused;\n final bool enabled;\n final double size;\n final ValueChanged<String> onChanged;\n final int index;\n final int length;\n final bool autofocus;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final opacity = context.visorOpacity;\n final radius = context.visorRadius;\n final textStyles = context.visorTextStyles;\n\n final Color bg;\n final Color border;\n\n if (isFocused && enabled) {\n bg = colors.borderFocus.withValues(alpha: opacity.alpha12);\n border = colors.borderFocus;\n } else if (digit.isNotEmpty) {\n bg = colors.surfaceAccentSubtle;\n border = colors.borderDefault;\n } else {\n bg = colors.surfaceInteractiveDefault;\n border = colors.borderDefault;\n }\n\n final semanticLabel =\n 'OTP digit ${index + 1} of $length, ${digit.isEmpty ? 'empty' : digit}';\n\n return Semantics(\n label: semanticLabel,\n textField: true,\n child: SizedBox(\n width: size,\n height: size,\n child: DecoratedBox(\n decoration: BoxDecoration(\n color: bg,\n border: Border.all(color: border),\n borderRadius: BorderRadius.circular(radius.sm),\n ),\n child: Center(\n child: TextField(\n controller: controller,\n focusNode: focusNode,\n enabled: enabled,\n autofocus: autofocus,\n textAlign: TextAlign.center,\n keyboardType: TextInputType.number,\n maxLength: 1,\n inputFormatters: [FilteringTextInputFormatter.digitsOnly],\n onChanged: onChanged,\n style: textStyles.titleMedium.copyWith(\n color: enabled ? colors.textPrimary : colors.textDisabled,\n ),\n decoration: const InputDecoration(\n border: InputBorder.none,\n counterText: '',\n contentPadding: EdgeInsets.zero,\n ),\n ),\n ),\n ),\n ),\n );\n }\n}\n",
|
|
4806
|
+
"content": "import 'package:flutter/foundation.dart' show kIsWeb;\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:visor_core/visor_core.dart';\n\n/// A one-time-password input widget composed of [digitCount] individual digit\n/// boxes.\n///\n/// ## Basic usage\n///\n/// ```dart\n/// VisorOtpInput(\n/// digitCount: 6,\n/// onCodeComplete: (code) => _verify(code),\n/// onCodeChanged: (partial) => _handleChange(partial),\n/// )\n/// ```\n///\n/// ## Programmatic clear\n///\n/// Expose a [GlobalKey] to call [VisorOtpInputState.clear]:\n///\n/// ```dart\n/// final _otpKey = GlobalKey<VisorOtpInputState>();\n///\n/// VisorOtpInput(key: _otpKey, digitCount: 6, onCodeComplete: _verify)\n///\n/// // later:\n/// _otpKey.currentState?.clear();\n/// ```\n///\n/// ## Platform notes\n///\n/// On web (`kIsWeb`) a single hidden [TextField] is used to capture all input.\n/// This avoids browsers intercepting backspace before Flutter sees it — a\n/// regression observed in two consumer apps without this branch.\n///\n/// On native, a [KeyboardListener] + per-digit [FocusNode] handles input\n/// directly.\n///\n/// ## Theming\n///\n/// All colors, spacing, and radius values are read from:\n/// - `context.visorColors` (`surfaceInteractiveDefault`, `surfaceAccentSubtle`,\n/// `borderFocus`, `borderDefault`, `textPrimary`)\n/// - `context.visorSpacing` (gap between digits)\n/// - `context.visorRadius` (`sm` radius for digit boxes)\n///\n/// Zero hard-coded [Color] or [double] literals.\nclass VisorOtpInput extends StatefulWidget {\n const VisorOtpInput({\n super.key,\n this.digitCount = 6,\n this.onCodeComplete,\n this.onCodeChanged,\n this.enabled = true,\n this.autofocus = false,\n this.semanticLabel,\n }) : assert(digitCount > 0, 'digitCount must be at least 1');\n\n /// Number of digit boxes to render. Defaults to 6.\n final int digitCount;\n\n /// Fires once when all [digitCount] digits are filled in.\n /// Will not re-fire if the user modifies a digit after completion.\n final ValueChanged<String>? onCodeComplete;\n\n /// Fires on every individual digit entry, with the partial code so far.\n final ValueChanged<String>? onCodeChanged;\n\n /// When false all input is ignored. Useful for loading/submitting states.\n final bool enabled;\n\n /// Whether to request focus when the widget first mounts.\n final bool autofocus;\n\n /// Optional override for the row-level Semantics container label.\n ///\n /// Defaults to `'OTP code, $digitCount digits'`. Pass a domain-specific\n /// label (e.g. `'Two-factor authentication code, 6 digits'`) when the\n /// generic label would lack context for screen-reader users.\n final String? semanticLabel;\n\n @override\n VisorOtpInputState createState() => VisorOtpInputState();\n}\n\n/// Exposes [clear] so host screens can reset the OTP without rebuilding.\nclass VisorOtpInputState extends State<VisorOtpInput> {\n late List<String> _digits;\n late List<FocusNode> _focusNodes;\n late List<TextEditingController> _controllers;\n\n // Web-only: single hidden TextField that captures all keyboard input.\n late final TextEditingController _webController;\n late final FocusNode _webFocusNode;\n\n // Tracks which digit is currently active (used for visual state).\n int _focusedIndex = 0;\n\n // Guard so onCodeComplete fires only once per completion cycle.\n bool _completionFired = false;\n\n // Guards against recursive onChanged callbacks when we programmatically\n // update controller text inside _setDigit.\n bool _ignoreTextChanges = false;\n\n @override\n void initState() {\n super.initState();\n _digits = List.filled(widget.digitCount, '');\n _focusNodes = List.generate(widget.digitCount, (_) => FocusNode());\n _controllers =\n List.generate(widget.digitCount, (_) => TextEditingController());\n\n for (var i = 0; i < widget.digitCount; i++) {\n _focusNodes[i].addListener(() {\n if (_focusNodes[i].hasFocus) {\n setState(() => _focusedIndex = i);\n }\n });\n }\n\n _webController = TextEditingController();\n _webFocusNode = FocusNode();\n }\n\n @override\n void dispose() {\n for (final n in _focusNodes) {\n n.dispose();\n }\n for (final c in _controllers) {\n c.dispose();\n }\n _webController.dispose();\n _webFocusNode.dispose();\n super.dispose();\n }\n\n // ---------------------------------------------------------------------------\n // Public API\n // ---------------------------------------------------------------------------\n\n /// Resets all digit boxes to empty and moves focus to the first digit.\n void clear() {\n _ignoreTextChanges = true;\n setState(() {\n _digits = List.filled(widget.digitCount, '');\n for (final c in _controllers) {\n c.clear();\n }\n _focusedIndex = 0;\n _completionFired = false;\n });\n _ignoreTextChanges = false;\n if (widget.enabled) {\n if (kIsWeb) {\n _webFocusNode.requestFocus();\n } else {\n _focusNodes[0].requestFocus();\n }\n }\n widget.onCodeChanged?.call('');\n }\n\n // ---------------------------------------------------------------------------\n // Input handling — native\n // ---------------------------------------------------------------------------\n\n void _onNativeKeyEvent(int index, KeyEvent event) {\n if (_ignoreTextChanges) return;\n if (!widget.enabled) return;\n if (event is! KeyDownEvent && event is! KeyRepeatEvent) return;\n\n if (event.logicalKey == LogicalKeyboardKey.backspace) {\n if (_digits[index].isNotEmpty) {\n _setDigit(index, '');\n } else if (index > 0) {\n _focusNodes[index - 1].requestFocus();\n _setDigit(index - 1, '');\n }\n }\n }\n\n void _onNativeTextChanged(int index, String text) {\n if (_ignoreTextChanges) return;\n if (!widget.enabled) return;\n if (text.isEmpty) return;\n\n // Accept only the last character to handle paste gracefully (D6).\n final char = text.substring(text.length - 1);\n if (!RegExp(r'\\d').hasMatch(char)) {\n _ignoreTextChanges = true;\n _controllers[index].clear();\n _ignoreTextChanges = false;\n return;\n }\n\n _setDigit(index, char);\n\n if (index < widget.digitCount - 1) {\n _focusNodes[index + 1].requestFocus();\n } else {\n _focusNodes[index].unfocus();\n }\n }\n\n // ---------------------------------------------------------------------------\n // Input handling — web\n // ---------------------------------------------------------------------------\n\n void _onWebTextChanged(String text) {\n if (!widget.enabled) return;\n\n // The hidden TextField accumulates input. Process each new character.\n // We only care about the trailing characters not yet consumed.\n for (final char in text.characters) {\n if (!RegExp(r'\\d').hasMatch(char)) continue;\n final nextEmpty = _digits.indexOf('');\n if (nextEmpty == -1) break;\n _setDigit(nextEmpty, char);\n }\n\n // Reset the hidden field so next keypress starts fresh.\n _webController.clear();\n }\n\n void _onWebKeyEvent(KeyEvent event) {\n if (!widget.enabled) return;\n if (event is! KeyDownEvent && event is! KeyRepeatEvent) return;\n\n if (event.logicalKey == LogicalKeyboardKey.backspace) {\n // Find the last filled digit and clear it.\n int target = -1;\n for (var i = widget.digitCount - 1; i >= 0; i--) {\n if (_digits[i].isNotEmpty) {\n target = i;\n break;\n }\n }\n if (target >= 0) {\n _setDigit(target, '');\n }\n }\n }\n\n // ---------------------------------------------------------------------------\n // Shared state mutation\n // ---------------------------------------------------------------------------\n\n void _setDigit(int index, String value) {\n // Sync the controller text programmatically. Guard against the controller's\n // own onChanged listener firing recursively.\n _ignoreTextChanges = true;\n _controllers[index].text = value;\n _controllers[index].selection = TextSelection.fromPosition(\n TextPosition(offset: value.length),\n );\n _ignoreTextChanges = false;\n\n setState(() {\n _digits[index] = value;\n _focusedIndex = index;\n });\n\n final partial = _digits.join();\n widget.onCodeChanged?.call(partial);\n\n final allFilled = _digits.every((d) => d.isNotEmpty);\n if (value.isNotEmpty && allFilled) {\n if (!_completionFired) {\n _completionFired = true;\n widget.onCodeComplete?.call(partial);\n }\n } else {\n // Reset completion guard if a digit is cleared after full entry.\n _completionFired = false;\n }\n }\n\n // ---------------------------------------------------------------------------\n // Build\n // ---------------------------------------------------------------------------\n\n @override\n Widget build(BuildContext context) {\n final spacing = context.visorSpacing;\n\n if (kIsWeb) {\n return _buildWeb(context, spacing);\n }\n return _buildNative(context, spacing);\n }\n\n Widget _buildNative(BuildContext context, VisorSpacingData spacing) {\n return LayoutBuilder(\n builder: (context, constraints) {\n final sizes = _calculateSizing(constraints.maxWidth, spacing);\n return Semantics(\n container: true,\n label: widget.semanticLabel ??\n 'OTP code, ${widget.digitCount} digits',\n child: Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n for (var i = 0; i < widget.digitCount; i++) ...[\n if (i > 0) SizedBox(width: sizes.gap),\n KeyboardListener(\n focusNode: FocusNode(skipTraversal: true),\n onKeyEvent: (event) => _onNativeKeyEvent(i, event),\n child: _VisorOtpDigit(\n controller: _controllers[i],\n focusNode: _focusNodes[i],\n digit: _digits[i],\n isFocused: _focusedIndex == i && _focusNodes[i].hasFocus,\n enabled: widget.enabled,\n size: sizes.digitSize,\n onChanged: (text) => _onNativeTextChanged(i, text),\n autofocus: widget.autofocus && i == 0,\n index: i,\n length: widget.digitCount,\n ),\n ),\n ],\n ],\n ),\n );\n },\n );\n }\n\n Widget _buildWeb(BuildContext context, VisorSpacingData spacing) {\n return LayoutBuilder(\n builder: (context, constraints) {\n final sizes = _calculateSizing(constraints.maxWidth, spacing);\n return Stack(\n alignment: Alignment.center,\n children: [\n // Visible digit row. The hidden capture TextField sits OUTSIDE\n // this Semantics container so its own textField semantics don't\n // leak into the OTP code group announcement.\n Semantics(\n container: true,\n label: widget.semanticLabel ??\n 'OTP code, ${widget.digitCount} digits',\n child: Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n for (var i = 0; i < widget.digitCount; i++) ...[\n if (i > 0) SizedBox(width: sizes.gap),\n GestureDetector(\n onTap: widget.enabled\n ? () {\n setState(() => _focusedIndex = i);\n _webFocusNode.requestFocus();\n }\n : null,\n child: _VisorOtpDigit(\n controller: _controllers[i],\n focusNode: FocusNode(skipTraversal: true),\n digit: _digits[i],\n isFocused:\n _webFocusNode.hasFocus && _focusedIndex == i,\n enabled: widget.enabled,\n size: sizes.digitSize,\n onChanged: (_) {},\n index: i,\n length: widget.digitCount,\n ),\n ),\n ],\n ],\n ),\n ),\n // Transparent hidden TextField that captures all keyboard input.\n Positioned.fill(\n child: Opacity(\n opacity: 0,\n child: KeyboardListener(\n focusNode: FocusNode(skipTraversal: true),\n onKeyEvent: _onWebKeyEvent,\n child: TextField(\n controller: _webController,\n focusNode: _webFocusNode,\n enabled: widget.enabled,\n autofocus: widget.autofocus,\n keyboardType: TextInputType.number,\n inputFormatters: [FilteringTextInputFormatter.digitsOnly],\n onChanged: _onWebTextChanged,\n decoration: const InputDecoration(border: InputBorder.none),\n ),\n ),\n ),\n ),\n ],\n );\n },\n );\n }\n\n /// Computes responsive digit size and gap given the available width.\n ///\n /// On narrow containers the digits shrink proportionally; on wide ones they\n /// cap at a comfortable 56×56 max.\n _OtpSizes _calculateSizing(double available, VisorSpacingData spacing) {\n // Intentional layout constraints: 56px max (comfortable touch target),\n // 32px min (readable digit text). Not design tokens — structural bounds.\n const maxDigitSize = 56.0;\n const minDigitSize = 32.0;\n final gapCount = widget.digitCount - 1;\n // Start with gap = spacing.sm (8px).\n final gap = spacing.sm;\n final totalGap = gapCount * gap;\n final digitSize = ((available - totalGap) / widget.digitCount)\n .clamp(minDigitSize, maxDigitSize);\n return _OtpSizes(digitSize: digitSize, gap: gap);\n }\n}\n\n// ---------------------------------------------------------------------------\n// Sizing record\n// ---------------------------------------------------------------------------\n\nclass _OtpSizes {\n const _OtpSizes({required this.digitSize, required this.gap});\n final double digitSize;\n final double gap;\n}\n\n// ---------------------------------------------------------------------------\n// Private digit widget\n// ---------------------------------------------------------------------------\n\n/// A single OTP digit box. Private — consumed only by [VisorOtpInput].\n///\n/// Visual states:\n/// - Empty default: `surfaceInteractiveDefault` bg, `borderDefault` border\n/// - Focused: `borderFocus` bg fill, `borderFocus` border\n/// - Filled: `surfaceAccentSubtle` bg, `borderDefault` border\n///\n/// Digit text: `textPrimary`.\n/// Border radius: `radius.sm`.\nclass _VisorOtpDigit extends StatelessWidget {\n const _VisorOtpDigit({\n required this.controller,\n required this.focusNode,\n required this.digit,\n required this.isFocused,\n required this.enabled,\n required this.size,\n required this.onChanged,\n required this.index,\n required this.length,\n this.autofocus = false,\n });\n\n final TextEditingController controller;\n final FocusNode focusNode;\n final String digit;\n final bool isFocused;\n final bool enabled;\n final double size;\n final ValueChanged<String> onChanged;\n final int index;\n final int length;\n final bool autofocus;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final opacity = context.visorOpacity;\n final radius = context.visorRadius;\n final textStyles = context.visorTextStyles;\n\n final Color bg;\n final Color border;\n\n if (isFocused && enabled) {\n bg = colors.borderFocus.withValues(alpha: opacity.alpha12);\n border = colors.borderFocus;\n } else if (digit.isNotEmpty) {\n bg = colors.surfaceAccentSubtle;\n border = colors.borderDefault;\n } else {\n bg = colors.surfaceInteractiveDefault;\n border = colors.borderDefault;\n }\n\n final semanticLabel =\n 'OTP digit ${index + 1} of $length, ${digit.isEmpty ? 'empty' : digit}';\n\n return Semantics(\n label: semanticLabel,\n textField: true,\n child: SizedBox(\n width: size,\n height: size,\n child: DecoratedBox(\n decoration: BoxDecoration(\n color: bg,\n border: Border.all(color: border),\n borderRadius: BorderRadius.circular(radius.sm),\n ),\n child: Center(\n child: TextField(\n controller: controller,\n focusNode: focusNode,\n enabled: enabled,\n autofocus: autofocus,\n textAlign: TextAlign.center,\n keyboardType: TextInputType.number,\n maxLength: 1,\n inputFormatters: [FilteringTextInputFormatter.digitsOnly],\n onChanged: onChanged,\n style: textStyles.titleMedium.copyWith(\n color: enabled ? colors.textPrimary : colors.textDisabled,\n ),\n decoration: const InputDecoration(\n border: InputBorder.none,\n counterText: '',\n contentPadding: EdgeInsets.zero,\n ),\n ),\n ),\n ),\n ),\n );\n }\n}\n",
|
|
4681
4807
|
"target": "flutter"
|
|
4682
4808
|
},
|
|
4683
4809
|
{
|
|
@@ -4745,7 +4871,7 @@
|
|
|
4745
4871
|
{
|
|
4746
4872
|
"path": "components/flutter/visor_phone_input/visor_phone_input.dart",
|
|
4747
4873
|
"type": "registry:ui",
|
|
4748
|
-
"content": "import 'dart:async';\n\nimport 'package:country_code_picker/country_code_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_libphonenumber/flutter_libphonenumber.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../visor_text_input/visor_text_input.dart';\n\n/// International phone-number input composed on top of [VisorTextInput] per\n/// the Visor two-layer distribution model.\n///\n/// The country picker (flag + dial code + chevron) occupies the prefix slot\n/// of the underlying [VisorTextInput]. Tapping it opens\n/// [CountryCodePicker]'s search dialog. As the user types, input is formatted\n/// for the selected country via `flutter_libphonenumber`. When the number\n/// parses to a valid mobile/fixed-line number, the field's valid state\n/// triggers the checkmark.\n///\n/// All visual properties read from Visor token extensions — zero hard-coded\n/// values.\n///\n/// ## Basic usage\n///\n/// ```dart\n/// VisorPhoneInput(\n/// labelText: 'Phone number',\n/// onChanged: (value) => print('Phone: $value'),\n/// onCountryChanged: (country) => print('Country: ${country.dialCode}'),\n/// )\n/// ```\n///\n/// ## Form integration\n///\n/// ```dart\n/// VisorPhoneInput(\n/// labelText: 'Phone',\n/// autovalidateMode: AutovalidateMode.onUserInteraction,\n/// validator: (v) =>\n/// v != null && v.isNotEmpty ? null : 'Phone number required',\n/// )\n/// ```\nclass VisorPhoneInput extends StatefulWidget {\n const VisorPhoneInput({\n required this.labelText,\n this.controller,\n this.focusNode,\n this.errorText,\n this.onChanged,\n this.onCountryChanged,\n this.onFieldSubmitted,\n this.validator,\n this.initialCountryCode = 'US',\n this.textInputAction,\n this.autofocus = false,\n this.enabled = true,\n this.autovalidateMode,\n this.semanticLabel,\n super.key,\n });\n\n /// The label that floats to the top when the field is focused or filled.\n final String labelText;\n\n /// Optional external controller. When omitted, an internal controller is\n /// created and managed by the widget.\n final TextEditingController? controller;\n\n /// Optional external focus node. When omitted, an internal node is managed.\n final FocusNode? focusNode;\n\n /// Overrides the error message shown below the field. When non-null this\n /// takes precedence over the string returned by [validator].\n final String? errorText;\n\n /// Called each time the field's text changes.\n final ValueChanged<String>? onChanged;\n\n /// Called when the user picks a different country from the picker.\n final ValueChanged<CountryCode>? onCountryChanged;\n\n /// Called when the user submits the field (keyboard action / done).\n final ValueChanged<String>? onFieldSubmitted;\n\n /// Synchronous validator forwarded to the underlying [VisorTextInput].\n /// Returns `null` for valid; an error string for invalid.\n final String? Function(String?)? validator;\n\n /// ISO country code used as the initial selection (e.g. `'US'`, `'GB'`).\n ///\n /// When this is the default `'US'`, the widget also attempts to detect the\n /// device locale's country code in [State.didChangeDependencies] and uses\n /// that instead if present. Pass any other value to opt out of locale\n /// detection.\n final String initialCountryCode;\n\n /// Keyboard action button type.\n final TextInputAction? textInputAction;\n\n /// Whether the field should request focus on build.\n final bool autofocus;\n\n /// When false the field is rendered with reduced opacity and ignores input.\n final bool enabled;\n\n /// When to run validation. Defaults to [AutovalidateMode.onUserInteraction].\n final AutovalidateMode? autovalidateMode;\n\n /// Accessibility label for screen readers. Defaults to [labelText].\n final String? semanticLabel;\n\n @override\n State<VisorPhoneInput> createState() => _VisorPhoneInputState();\n}\n\nclass _VisorPhoneInputState extends State<VisorPhoneInput> {\n late CountryCode _selectedCountry;\n LibPhonenumberTextFormatter? _formatter;\n bool _isFormatterInitialized = false;\n bool _isLibPhoneValid = false;\n\n TextEditingController? _internalController;\n\n /// Generation counter for [_validateNumber] — bumped on every keystroke\n /// and on country change so stale async parses are discarded.\n int _validationGeneration = 0;\n\n TextEditingController get _effectiveController =>\n widget.controller ?? _internalController!;\n\n @override\n void initState() {\n super.initState();\n _selectedCountry =\n CountryCode.fromCountryCode(widget.initialCountryCode.toUpperCase());\n if (widget.controller == null) {\n _internalController = TextEditingController();\n }\n unawaited(_initializeFormatter());\n }\n\n @override\n void dispose() {\n _internalController?.dispose();\n super.dispose();\n }\n\n @override\n void didChangeDependencies() {\n super.didChangeDependencies();\n if (widget.initialCountryCode == 'US') {\n _detectCountryFromLocale();\n }\n }\n\n Future<void> _initializeFormatter() async {\n try {\n await init();\n } on Exception {\n // libphonenumber init failure: fall through with a null formatter.\n // The field still works — input is just unformatted.\n }\n if (!mounted) return;\n setState(() {\n _isFormatterInitialized = true;\n _formatter = _buildFormatter(_selectedCountry.code ?? 'US');\n });\n }\n\n void _detectCountryFromLocale() {\n try {\n final locale = Localizations.localeOf(context);\n final countryCode = locale.countryCode?.toUpperCase();\n if (countryCode == null) return;\n final detectedCountry = CountryCode.fromCountryCode(countryCode);\n if (detectedCountry.code == null) return;\n setState(() {\n _selectedCountry = detectedCountry;\n if (_isFormatterInitialized) {\n _formatter = _buildFormatter(detectedCountry.code ?? 'US');\n }\n });\n } on Exception {\n // Locale detection failures are silent — initial country stays.\n }\n }\n\n LibPhonenumberTextFormatter? _buildFormatter(String countryCode) {\n if (!_isFormatterInitialized) return null;\n try {\n return LibPhonenumberTextFormatter(\n phoneNumberFormat: PhoneNumberFormat.national,\n country: _countryWithPhoneCode(countryCode),\n );\n } on Exception {\n return LibPhonenumberTextFormatter(\n phoneNumberFormat: PhoneNumberFormat.national,\n country: const CountryWithPhoneCode.us(),\n );\n }\n }\n\n CountryWithPhoneCode _countryWithPhoneCode(String countryCode) {\n // flutter_libphonenumber ships predefined constructors only for US/GB.\n // Other countries fall back to US — formatting will be approximate but\n // the widget remains functional.\n switch (countryCode.toUpperCase()) {\n case 'GB':\n return const CountryWithPhoneCode.gb();\n case 'US':\n default:\n return const CountryWithPhoneCode.us();\n }\n }\n\n /// Per-country max digit count used to cap input length. Mirrors the table\n /// from the SoleSpark/ENTR sources; default 15 covers any country not\n /// listed (E.164 max).\n int _maxDigitsForCountry(String countryCode) {\n switch (countryCode.toUpperCase()) {\n case 'US':\n case 'CA':\n case 'FR':\n case 'IT':\n case 'IN':\n case 'AU':\n case 'MX':\n case 'RU':\n return 10;\n case 'ES':\n return 9;\n case 'GB':\n case 'JP':\n case 'CN':\n case 'BR':\n return 11;\n case 'DE':\n return 12;\n default:\n return 15;\n }\n }\n\n String _digitsOnly(String input) => input.replaceAll(RegExp(r'[^\\d]'), '');\n\n void _onCountryChanged(CountryCode country) {\n // Invalidate any in-flight validations from the previous country so a\n // late-arriving parse() result doesn't flip _isLibPhoneValid back on.\n _validationGeneration++;\n setState(() {\n _selectedCountry = country;\n _formatter = _buildFormatter(country.code ?? 'US');\n _isLibPhoneValid = false;\n });\n _effectiveController.clear();\n widget.onCountryChanged?.call(country);\n }\n\n Future<void> _validateNumber(String value) async {\n final generation = ++_validationGeneration;\n if (!_isFormatterInitialized || value.isEmpty) {\n if (_isLibPhoneValid && mounted) {\n setState(() => _isLibPhoneValid = false);\n }\n return;\n }\n try {\n final region = _selectedCountry.code ?? 'US';\n final fullNumber = '${_selectedCountry.dialCode ?? '+1'}$value';\n final result = await parse(fullNumber, region: region);\n // Drop the result if the user changed country (or kept typing) while\n // this parse was in flight.\n if (!mounted || generation != _validationGeneration) return;\n final phoneType = result['type'] as String?;\n final isValid = phoneType != null &&\n phoneType != 'unknown' &&\n phoneType != 'notParsed';\n if (isValid != _isLibPhoneValid) {\n setState(() => _isLibPhoneValid = isValid);\n }\n } on Exception {\n if (mounted &&\n generation == _validationGeneration &&\n _isLibPhoneValid) {\n setState(() => _isLibPhoneValid = false);\n }\n }\n }\n\n void _handleChanged(String value) {\n unawaited(_validateNumber(value));\n widget.onChanged?.call(value);\n }\n\n @override\n Widget build(BuildContext context) {\n final maxDigits = _maxDigitsForCountry(_selectedCountry.code ?? 'US');\n\n final formatters = <TextInputFormatter>[\n TextInputFormatter.withFunction((oldValue, newValue) {\n if (_digitsOnly(newValue.text).length > maxDigits) {\n return oldValue;\n }\n return newValue;\n }),\n if (_formatter != null) _formatter!,\n ];\n\n return VisorTextInput(\n labelText: widget.labelText,\n controller: _effectiveController,\n focusNode: widget.focusNode,\n errorText: widget.errorText,\n onChanged: _handleChanged,\n onFieldSubmitted: widget.onFieldSubmitted,\n validator: widget.validator,\n keyboardType: TextInputType.phone,\n textInputAction: widget.textInputAction,\n autofocus: widget.autofocus,\n enabled: widget.enabled,\n autocorrect: false,\n enableSuggestions: false,\n autovalidateMode: widget.autovalidateMode,\n // Only override valid/invalid when libphonenumber has a definite answer\n // (it requires init + non-empty input). Null lets VisorTextInput derive\n // validity from the user-supplied [validator].\n isValid: _isLibPhoneValid ? true : null,\n semanticLabel: widget.semanticLabel,\n inputFormatters: formatters,\n prefixIcon: _CountryPickerPrefix(\n selectedCountry: _selectedCountry,\n enabled: widget.enabled,\n onChanged: _onCountryChanged,\n ),\n );\n }\n}\n\n/// Tappable country-picker prefix shown inside [VisorTextInput]'s prefix slot.\n///\n/// Renders the flag, dial code, and a chevron. Tap opens the\n/// [CountryCodePicker] search dialog. Wrapped in [Semantics] for screen-reader\n/// support.\nclass _CountryPickerPrefix extends StatelessWidget {\n const _CountryPickerPrefix({\n required this.selectedCountry,\n required this.enabled,\n required this.onChanged,\n });\n\n final CountryCode selectedCountry;\n final bool enabled;\n final ValueChanged<CountryCode> onChanged;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n\n final dialCode = selectedCountry.dialCode ?? '+1';\n final countryName = selectedCountry.name ?? selectedCountry.code ?? '';\n final semanticLabel =\n 'Country code, $countryName $dialCode. Tap to change.';\n\n return Semantics(\n button: true,\n enabled: enabled,\n label: semanticLabel,\n excludeSemantics: true,\n child: ConstrainedBox(\n constraints: const BoxConstraints(minWidth: 48, minHeight: 48),\n child: CountryCodePicker(\n // Keying on the country code forces a fresh CountryCodePicker\n // (and a fresh initialSelection) when locale detection updates\n // _selectedCountry. CountryCodePicker only honors initialSelection\n // on first build.\n key: ValueKey('country-picker-${selectedCountry.code}'),\n onChanged: onChanged,\n initialSelection: selectedCountry.code,\n enabled: enabled,\n padding: EdgeInsetsDirectional.only(end: spacing.xs),\n flagWidth: 24,\n builder: (country) => _buildPickerButton(\n country: country,\n dialCode: dialCode,\n colors: colors,\n spacing: spacing,\n textStyles: textStyles,\n ),\n ),\n ),\n );\n }\n\n Widget _buildPickerButton({\n required CountryCode? country,\n required String dialCode,\n required VisorColorsData colors,\n required VisorSpacingData spacing,\n required VisorTextStylesData textStyles,\n }) {\n // 24×18 mirrors the country_code_picker package's standard flag asset\n // dimensions; sizing tokens for raster assets are not yet defined.\n const flagWidth = 24.0;\n const flagHeight = 18.0;\n\n return Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n if (country?.flagUri != null)\n SizedBox(\n width: flagWidth,\n height: flagHeight,\n child: Image.asset(\n country!.flagUri!,\n package: 'country_code_picker',\n fit: BoxFit.cover,\n ),\n )\n else\n const SizedBox(width: flagWidth, height: flagHeight),\n SizedBox(width: spacing.xs),\n Text(\n dialCode,\n style: textStyles.bodyMedium.copyWith(color: colors.textPrimary),\n ),\n // Inherit chevron size from the ambient IconTheme (set by\n // VisorTextInput's prefixIcon slot to 20dp); keeps the picker\n // chevron consistent with other prefix icons across the registry.\n Icon(Icons.keyboard_arrow_down, color: colors.textTertiary),\n ],\n );\n }\n}\n",
|
|
4874
|
+
"content": "import 'dart:async';\n\nimport 'package:country_code_picker/country_code_picker.dart';\nimport 'package:flutter/material.dart';\nimport 'package:flutter/services.dart';\nimport 'package:flutter_libphonenumber/flutter_libphonenumber.dart';\nimport 'package:visor_core/visor_core.dart';\n\nimport '../visor_text_input/visor_text_input.dart';\n\n/// International phone-number input composed on top of [VisorTextInput] per\n/// the Visor two-layer distribution model.\n///\n/// The country picker (flag + dial code + chevron) occupies the prefix slot\n/// of the underlying [VisorTextInput]. Tapping it opens\n/// [CountryCodePicker]'s search dialog. As the user types, input is formatted\n/// for the selected country via `flutter_libphonenumber`. When the number\n/// parses to a valid mobile/fixed-line number, the field's valid state\n/// triggers the checkmark.\n///\n/// All visual properties read from Visor token extensions — zero hard-coded\n/// values.\n///\n/// ## Basic usage\n///\n/// ```dart\n/// VisorPhoneInput(\n/// labelText: 'Phone number',\n/// onChanged: (value) => print('Phone: $value'),\n/// onCountryChanged: (country) => print('Country: ${country.dialCode}'),\n/// )\n/// ```\n///\n/// ## Form integration\n///\n/// ```dart\n/// VisorPhoneInput(\n/// labelText: 'Phone',\n/// autovalidateMode: AutovalidateMode.onUserInteraction,\n/// validator: (v) =>\n/// v != null && v.isNotEmpty ? null : 'Phone number required',\n/// )\n/// ```\nclass VisorPhoneInput extends StatefulWidget {\n const VisorPhoneInput({\n required this.labelText,\n this.controller,\n this.focusNode,\n this.errorText,\n this.onChanged,\n this.onCountryChanged,\n this.onFieldSubmitted,\n this.validator,\n this.initialCountryCode = 'US',\n this.textInputAction,\n this.autofocus = false,\n this.enabled = true,\n this.autovalidateMode,\n this.semanticLabel,\n super.key,\n });\n\n /// The label that floats to the top when the field is focused or filled.\n final String labelText;\n\n /// Optional external controller. When omitted, an internal controller is\n /// created and managed by the widget.\n final TextEditingController? controller;\n\n /// Optional external focus node. When omitted, an internal node is managed.\n final FocusNode? focusNode;\n\n /// Overrides the error message shown below the field. When non-null this\n /// takes precedence over the string returned by [validator].\n final String? errorText;\n\n /// Called each time the field's text changes.\n final ValueChanged<String>? onChanged;\n\n /// Called when the user picks a different country from the picker.\n final ValueChanged<CountryCode>? onCountryChanged;\n\n /// Called when the user submits the field (keyboard action / done).\n final ValueChanged<String>? onFieldSubmitted;\n\n /// Synchronous validator forwarded to the underlying [VisorTextInput].\n /// Returns `null` for valid; an error string for invalid.\n final String? Function(String?)? validator;\n\n /// ISO country code used as the initial selection (e.g. `'US'`, `'GB'`).\n ///\n /// When this is the default `'US'`, the widget also attempts to detect the\n /// device locale's country code in [State.didChangeDependencies] and uses\n /// that instead if present. Pass any other value to opt out of locale\n /// detection.\n final String initialCountryCode;\n\n /// Keyboard action button type.\n final TextInputAction? textInputAction;\n\n /// Whether the field should request focus on build.\n final bool autofocus;\n\n /// When false the field is rendered with reduced opacity and ignores input.\n final bool enabled;\n\n /// When to run validation. Defaults to [AutovalidateMode.onUserInteraction].\n final AutovalidateMode? autovalidateMode;\n\n /// Accessibility label for screen readers. Defaults to [labelText].\n final String? semanticLabel;\n\n @override\n State<VisorPhoneInput> createState() => _VisorPhoneInputState();\n}\n\nclass _VisorPhoneInputState extends State<VisorPhoneInput> {\n late CountryCode _selectedCountry;\n LibPhonenumberTextFormatter? _formatter;\n bool _isFormatterInitialized = false;\n bool _isLibPhoneValid = false;\n\n TextEditingController? _internalController;\n\n /// Generation counter for [_validateNumber] — bumped on every keystroke\n /// and on country change so stale async parses are discarded.\n int _validationGeneration = 0;\n\n TextEditingController get _effectiveController =>\n widget.controller ?? _internalController!;\n\n @override\n void initState() {\n super.initState();\n _selectedCountry =\n CountryCode.fromCountryCode(widget.initialCountryCode.toUpperCase());\n if (widget.controller == null) {\n _internalController = TextEditingController();\n }\n unawaited(_initializeFormatter());\n }\n\n @override\n void dispose() {\n _internalController?.dispose();\n super.dispose();\n }\n\n @override\n void didChangeDependencies() {\n super.didChangeDependencies();\n if (widget.initialCountryCode == 'US') {\n _detectCountryFromLocale();\n }\n }\n\n Future<void> _initializeFormatter() async {\n try {\n await init();\n } on Exception {\n // libphonenumber init failure: fall through with a null formatter.\n // The field still works — input is just unformatted.\n }\n if (!mounted) return;\n setState(() {\n _isFormatterInitialized = true;\n _formatter = _buildFormatter(_selectedCountry.code ?? 'US');\n });\n }\n\n void _detectCountryFromLocale() {\n try {\n final locale = Localizations.localeOf(context);\n final countryCode = locale.countryCode?.toUpperCase();\n if (countryCode == null) return;\n final detectedCountry = CountryCode.fromCountryCode(countryCode);\n if (detectedCountry.code == null) return;\n setState(() {\n _selectedCountry = detectedCountry;\n if (_isFormatterInitialized) {\n _formatter = _buildFormatter(detectedCountry.code ?? 'US');\n }\n });\n } on Exception {\n // Locale detection failures are silent — initial country stays.\n }\n }\n\n LibPhonenumberTextFormatter? _buildFormatter(String countryCode) {\n if (!_isFormatterInitialized) return null;\n try {\n return LibPhonenumberTextFormatter(\n phoneNumberFormat: PhoneNumberFormat.national,\n country: _countryWithPhoneCode(countryCode),\n );\n } on Exception {\n return LibPhonenumberTextFormatter(\n phoneNumberFormat: PhoneNumberFormat.national,\n country: const CountryWithPhoneCode.us(),\n );\n }\n }\n\n CountryWithPhoneCode _countryWithPhoneCode(String countryCode) {\n // flutter_libphonenumber ships predefined constructors only for US/GB.\n // Other countries fall back to US — formatting will be approximate but\n // the widget remains functional.\n switch (countryCode.toUpperCase()) {\n case 'GB':\n return const CountryWithPhoneCode.gb();\n case 'US':\n default:\n return const CountryWithPhoneCode.us();\n }\n }\n\n /// Per-country max digit count used to cap input length. Mirrors the table\n /// from the source consumer apps; default 15 covers any country not\n /// listed (E.164 max).\n int _maxDigitsForCountry(String countryCode) {\n switch (countryCode.toUpperCase()) {\n case 'US':\n case 'CA':\n case 'FR':\n case 'IT':\n case 'IN':\n case 'AU':\n case 'MX':\n case 'RU':\n return 10;\n case 'ES':\n return 9;\n case 'GB':\n case 'JP':\n case 'CN':\n case 'BR':\n return 11;\n case 'DE':\n return 12;\n default:\n return 15;\n }\n }\n\n String _digitsOnly(String input) => input.replaceAll(RegExp(r'[^\\d]'), '');\n\n void _onCountryChanged(CountryCode country) {\n // Invalidate any in-flight validations from the previous country so a\n // late-arriving parse() result doesn't flip _isLibPhoneValid back on.\n _validationGeneration++;\n setState(() {\n _selectedCountry = country;\n _formatter = _buildFormatter(country.code ?? 'US');\n _isLibPhoneValid = false;\n });\n _effectiveController.clear();\n widget.onCountryChanged?.call(country);\n }\n\n Future<void> _validateNumber(String value) async {\n final generation = ++_validationGeneration;\n if (!_isFormatterInitialized || value.isEmpty) {\n if (_isLibPhoneValid && mounted) {\n setState(() => _isLibPhoneValid = false);\n }\n return;\n }\n try {\n final region = _selectedCountry.code ?? 'US';\n final fullNumber = '${_selectedCountry.dialCode ?? '+1'}$value';\n final result = await parse(fullNumber, region: region);\n // Drop the result if the user changed country (or kept typing) while\n // this parse was in flight.\n if (!mounted || generation != _validationGeneration) return;\n final phoneType = result['type'] as String?;\n final isValid = phoneType != null &&\n phoneType != 'unknown' &&\n phoneType != 'notParsed';\n if (isValid != _isLibPhoneValid) {\n setState(() => _isLibPhoneValid = isValid);\n }\n } on Exception {\n if (mounted &&\n generation == _validationGeneration &&\n _isLibPhoneValid) {\n setState(() => _isLibPhoneValid = false);\n }\n }\n }\n\n void _handleChanged(String value) {\n unawaited(_validateNumber(value));\n widget.onChanged?.call(value);\n }\n\n @override\n Widget build(BuildContext context) {\n final maxDigits = _maxDigitsForCountry(_selectedCountry.code ?? 'US');\n\n final formatters = <TextInputFormatter>[\n TextInputFormatter.withFunction((oldValue, newValue) {\n if (_digitsOnly(newValue.text).length > maxDigits) {\n return oldValue;\n }\n return newValue;\n }),\n if (_formatter != null) _formatter!,\n ];\n\n return VisorTextInput(\n labelText: widget.labelText,\n controller: _effectiveController,\n focusNode: widget.focusNode,\n errorText: widget.errorText,\n onChanged: _handleChanged,\n onFieldSubmitted: widget.onFieldSubmitted,\n validator: widget.validator,\n keyboardType: TextInputType.phone,\n textInputAction: widget.textInputAction,\n autofocus: widget.autofocus,\n enabled: widget.enabled,\n autocorrect: false,\n enableSuggestions: false,\n autovalidateMode: widget.autovalidateMode,\n // Only override valid/invalid when libphonenumber has a definite answer\n // (it requires init + non-empty input). Null lets VisorTextInput derive\n // validity from the user-supplied [validator].\n isValid: _isLibPhoneValid ? true : null,\n semanticLabel: widget.semanticLabel,\n inputFormatters: formatters,\n prefixIcon: _CountryPickerPrefix(\n selectedCountry: _selectedCountry,\n enabled: widget.enabled,\n onChanged: _onCountryChanged,\n ),\n );\n }\n}\n\n/// Tappable country-picker prefix shown inside [VisorTextInput]'s prefix slot.\n///\n/// Renders the flag, dial code, and a chevron. Tap opens the\n/// [CountryCodePicker] search dialog. Wrapped in [Semantics] for screen-reader\n/// support.\nclass _CountryPickerPrefix extends StatelessWidget {\n const _CountryPickerPrefix({\n required this.selectedCountry,\n required this.enabled,\n required this.onChanged,\n });\n\n final CountryCode selectedCountry;\n final bool enabled;\n final ValueChanged<CountryCode> onChanged;\n\n @override\n Widget build(BuildContext context) {\n final colors = context.visorColors;\n final spacing = context.visorSpacing;\n final textStyles = context.visorTextStyles;\n\n final dialCode = selectedCountry.dialCode ?? '+1';\n final countryName = selectedCountry.name ?? selectedCountry.code ?? '';\n final semanticLabel =\n 'Country code, $countryName $dialCode. Tap to change.';\n\n return Semantics(\n button: true,\n enabled: enabled,\n label: semanticLabel,\n excludeSemantics: true,\n child: ConstrainedBox(\n constraints: const BoxConstraints(minWidth: 48, minHeight: 48),\n child: CountryCodePicker(\n // Keying on the country code forces a fresh CountryCodePicker\n // (and a fresh initialSelection) when locale detection updates\n // _selectedCountry. CountryCodePicker only honors initialSelection\n // on first build.\n key: ValueKey('country-picker-${selectedCountry.code}'),\n onChanged: onChanged,\n initialSelection: selectedCountry.code,\n enabled: enabled,\n padding: EdgeInsetsDirectional.only(end: spacing.xs),\n flagWidth: 24,\n builder: (country) => _buildPickerButton(\n country: country,\n dialCode: dialCode,\n colors: colors,\n spacing: spacing,\n textStyles: textStyles,\n ),\n ),\n ),\n );\n }\n\n Widget _buildPickerButton({\n required CountryCode? country,\n required String dialCode,\n required VisorColorsData colors,\n required VisorSpacingData spacing,\n required VisorTextStylesData textStyles,\n }) {\n // 24×18 mirrors the country_code_picker package's standard flag asset\n // dimensions; sizing tokens for raster assets are not yet defined.\n const flagWidth = 24.0;\n const flagHeight = 18.0;\n\n return Row(\n mainAxisSize: MainAxisSize.min,\n children: [\n if (country?.flagUri != null)\n SizedBox(\n width: flagWidth,\n height: flagHeight,\n child: Image.asset(\n country!.flagUri!,\n package: 'country_code_picker',\n fit: BoxFit.cover,\n ),\n )\n else\n const SizedBox(width: flagWidth, height: flagHeight),\n SizedBox(width: spacing.xs),\n Text(\n dialCode,\n style: textStyles.bodyMedium.copyWith(color: colors.textPrimary),\n ),\n // Inherit chevron size from the ambient IconTheme (set by\n // VisorTextInput's prefixIcon slot to 20dp); keeps the picker\n // chevron consistent with other prefix icons across the registry.\n Icon(Icons.keyboard_arrow_down, color: colors.textTertiary),\n ],\n );\n }\n}\n",
|
|
4749
4875
|
"target": "flutter"
|
|
4750
4876
|
},
|
|
4751
4877
|
{
|