basuicn 0.2.11 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui-cli.cjs CHANGED
@@ -27,7 +27,7 @@ var import_fs = __toESM(require("fs"), 1);
27
27
  var import_path = __toESM(require("path"), 1);
28
28
  var import_child_process = require("child_process");
29
29
  var import_readline = __toESM(require("readline"), 1);
30
- var VERSION = "0.2.11";
30
+ var VERSION = "0.3.0";
31
31
  var REGISTRY_LOCAL = "./registry.json";
32
32
  var REGISTRY_REMOTE = "https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json";
33
33
  var c = {
@@ -572,6 +572,37 @@ var patchMainTsxComponent = (cwd, componentName) => {
572
572
  }
573
573
  ok(`Added <${tagName}> to ${import_path.default.relative(cwd, mainPath)}.`);
574
574
  };
575
+ var UI_INDEX_PATH = "src/components/ui/index.ts";
576
+ var UI_INDEX_DIR = "src/components/ui";
577
+ var pickMainFile = (files) => files.find(
578
+ (f) => f.path.startsWith(UI_INDEX_DIR + "/") && f.path.endsWith(".tsx") && !f.path.includes(".test.") && !f.path.includes(".stories.")
579
+ );
580
+ var addToComponentIndex = (componentFiles, cwd) => {
581
+ const indexPath = import_path.default.join(cwd, UI_INDEX_PATH);
582
+ if (!import_fs.default.existsSync(indexPath)) return;
583
+ const mainFile = pickMainFile(componentFiles);
584
+ if (!mainFile) return;
585
+ const withoutExt = mainFile.path.replace(/\.tsx$/, "");
586
+ const relPath = "./" + import_path.default.relative(UI_INDEX_DIR, withoutExt).replace(/\\/g, "/");
587
+ const exportLine = `export * from '${relPath}';`;
588
+ const content = import_fs.default.readFileSync(indexPath, "utf-8");
589
+ if (content.includes(relPath)) return;
590
+ import_fs.default.writeFileSync(indexPath, content.trimEnd() + "\n" + exportLine + "\n");
591
+ ok(`Updated index.ts: added ${c.dim}${relPath}${c.reset}`);
592
+ };
593
+ var removeFromComponentIndex = (componentFiles, cwd) => {
594
+ const indexPath = import_path.default.join(cwd, UI_INDEX_PATH);
595
+ if (!import_fs.default.existsSync(indexPath)) return;
596
+ const mainFile = pickMainFile(componentFiles);
597
+ if (!mainFile) return;
598
+ const withoutExt = mainFile.path.replace(/\.tsx$/, "");
599
+ const relPath = "./" + import_path.default.relative(UI_INDEX_DIR, withoutExt).replace(/\\/g, "/");
600
+ const content = import_fs.default.readFileSync(indexPath, "utf-8");
601
+ const filtered = content.split("\n").filter((line) => !line.includes(relPath)).join("\n");
602
+ if (filtered === content) return;
603
+ import_fs.default.writeFileSync(indexPath, filtered);
604
+ ok(`Updated index.ts: removed ${c.dim}${relPath}${c.reset}`);
605
+ };
575
606
  var addComponent = (name, registry, cwd, options, added = /* @__PURE__ */ new Set()) => {
576
607
  if (added.has(name)) return;
577
608
  added.add(name);
@@ -607,6 +638,7 @@ var addComponent = (name, registry, cwd, options, added = /* @__PURE__ */ new Se
607
638
  import_fs.default.writeFileSync(targetPath, content);
608
639
  ok(`Created: ${file.path}`);
609
640
  }
641
+ addToComponentIndex(component.files, cwd);
610
642
  };
611
643
  var removeComponent = (name, registry, cwd) => {
612
644
  const component = registry.components[name];
@@ -633,6 +665,7 @@ var removeComponent = (name, registry, cwd) => {
633
665
  warn(`Could not remove directory: ${err instanceof Error ? err.message : err}`);
634
666
  }
635
667
  }
668
+ removeFromComponentIndex(component.files, cwd);
636
669
  };
637
670
  var HELP_MAIN = `
638
671
  ${c.bold}${c.cyan}basuicn${c.reset} ${c.dim}v${VERSION}${c.reset} \u2014 Modern React UI Component CLI
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "basuicn",
3
3
  "private": false,
4
- "version": "0.2.11",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "basuicn": "./dist/ui-cli.cjs"
@@ -101,6 +101,7 @@
101
101
  "dependencies": {
102
102
  "@recharts/devtools": "^0.0.11",
103
103
  "keen-slider": "^6.8.6",
104
- "recharts": "^3.8.1"
104
+ "recharts": "^3.8.1",
105
+ "zustand": "^5.0.12"
105
106
  }
106
107
  }
package/registry.json CHANGED
@@ -257,7 +257,7 @@
257
257
  "files": [
258
258
  {
259
259
  "path": "src/components/ui/combobox/ComboBox.tsx",
260
- "content": "\"use client\" \r\nimport * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst comboboxVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex flex-wrap items-center gap-1.5 min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-50 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\r\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\r\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-10',\r\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\r\n }\r\n});\r\n\r\n/** A single option in the ComboBox dropdown */\r\nexport interface ComboBoxOption {\r\n /** Display text for the option */\r\n label: string;\r\n /** Unique value identifying the option */\r\n value: string;\r\n}\r\n\r\n/** Props for the ComboBox component */\r\nexport interface ComboBoxProps {\r\n /** Array of selectable options */\r\n options: ComboBoxOption[];\r\n /** Label text displayed above the combobox */\r\n label?: string;\r\n placeholder?: string;\r\n /** Controlled selected value (string for single, string[] for multiple) */\r\n value?: string | string[];\r\n /** Initial value for uncontrolled usage */\r\n defaultValue?: string | string[];\r\n /** Callback fired when the selected value changes */\r\n onValueChange?: (value: string | string[]) => void;\r\n /** Enable multi-select mode with chip display */\r\n multiple?: boolean;\r\n /** Shows a loading spinner on the dropdown trigger */\r\n isLoading?: boolean;\r\n className?: string;\r\n /** Enable type-ahead filtering of options (default: true) */\r\n autocomplete?: boolean;\r\n /** Text shown when no options match the filter */\r\n emptyText?: string;\r\n /** Label for the \"select all\" action in multi-select mode */\r\n selectAllText?: string;\r\n /** Label for the \"clear all\" action in multi-select mode */\r\n clearAllText?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\r\n ({ options, label, placeholder, value, defaultValue, onValueChange, multiple, isLoading, className, autocomplete = true, emptyText = 'No results found.', selectAllText = 'Select all', clearAllText = 'Clear all', leftIcon }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) {\r\n setInternalValue(newVal);\r\n }\r\n if (newVal !== null) {\r\n onValueChange?.(newVal);\r\n }\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n // Bỏ qua cập nhật inputValue khi đang chọn item để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(multiple ? [] : null);\r\n setInputValue('');\r\n };\r\n\r\n const hasValue = multiple\r\n ? Array.isArray(activeValue) && activeValue.length > 0\r\n : !!activeValue;\r\n\r\n // Lọc options theo text người dùng đang gõ\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue || !autocomplete) return options;\r\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\r\n if (!multiple && activeValue) {\r\n const selectedOption = options.find((o) => o.value === activeValue);\r\n if (selectedOption && inputValue === selectedOption.label) return options;\r\n }\r\n return options.filter(opt =>\r\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, autocomplete, multiple, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n multiple={multiple}\r\n onInputValueChange={handleInputValueChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find((o) => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n {multiple ? (\r\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\r\n {Array.isArray(activeValue) && activeValue.map((val) => {\r\n const option = options.find(o => o.value === val);\r\n return (\r\n <BaseCombobox.Chip key={val} className={chip()}>\r\n {option?.label || val}\r\n <BaseCombobox.ChipRemove className={chipRemove()}>\r\n <X className=\"h-3 w-3\" />\r\n </BaseCombobox.ChipRemove>\r\n </BaseCombobox.Chip>\r\n );\r\n })}\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\r\n className={input()}\r\n />\r\n </BaseCombobox.Chips>\r\n ) : (\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={placeholder}\r\n className={cn(input(), !autocomplete && 'cursor-pointer')}\r\n />\r\n )}\r\n\r\n <div className=\"flex items-center gap-1 shrink-0 ml-auto text-muted-foreground\">\r\n {hasValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseCombobox.Trigger className=\"transition-transform group-data-open:rotate-180\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\r\n )}\r\n </div>\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)' }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n {multiple && options.length > 0 && (\r\n <div className={actionsHeader()}>\r\n <button\r\n type=\"button\"\r\n aria-label={selectAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(options.map((o) => o.value));\r\n }}\r\n className={actionButton()}\r\n >\r\n {selectAllText}\r\n </button>\r\n <div className=\"w-px h-3 bg-border\" />\r\n <button\r\n type=\"button\"\r\n aria-label={clearAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange([]);\r\n }}\r\n className={actionButton()}\r\n >\r\n {clearAllText}\r\n </button>\r\n </div>\r\n )}\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nComboBox.displayName = 'ComboBox';\r\n\r\nexport { ComboBox };\r\n"
260
+ "content": "\"use client\" \r\nimport * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, ChevronDown, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst comboboxVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex flex-wrap items-center gap-1.5 min-h-10 w-full rounded-lg border border-border bg-background px-3 py-1.5 text-sm focus-within:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-shadow transition-colors',\r\n input: 'flex-1 min-w-[120px] bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed',\r\n popup: 'z-999 w-[var(--anchor-width,var(--reference-width))] max-w-[var(--available-width)] overflow-hidden rounded-lg border border-border bg-background text-popover-foreground shadow-[rgba(0,0,0,0.08)_0px_4px_16px] animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n item: 'cursor-pointer relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50',\r\n indicator: 'absolute left-2 flex h-3.5 w-3.5 items-center justify-center',\r\n chip: 'inline-flex items-center gap-1 rounded bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground outline-none focus:ring-1 focus:ring-primary',\r\n chipRemove: 'hover:bg-primary/20 rounded-full p-0.5 transition-colors cursor-pointer',\r\n actionsHeader: 'flex items-center gap-1 p-1 border-b border-border sticky top-0 bg-background z-999',\r\n actionButton: 'flex-1 text-[10px] uppercase tracking-wider font-bold py-1.5 px-2 rounded-sm hover:bg-accent hover:text-accent-foreground text-muted-foreground transition-colors text-center',\r\n }\r\n});\r\n\r\n/** A single option in the ComboBox dropdown */\r\nexport interface ComboBoxOption {\r\n /** Display text for the option */\r\n label: string;\r\n /** Unique value identifying the option */\r\n value: string;\r\n}\r\n\r\n/** Props for the ComboBox component */\r\nexport interface ComboBoxProps {\r\n /** Array of selectable options */\r\n options: ComboBoxOption[];\r\n /** Label text displayed above the combobox */\r\n label?: string;\r\n placeholder?: string;\r\n /** Controlled selected value (string for single, string[] for multiple) */\r\n value?: string | string[];\r\n /** Initial value for uncontrolled usage */\r\n defaultValue?: string | string[];\r\n /** Callback fired when the selected value changes */\r\n onValueChange?: (value: string | string[]) => void;\r\n /** Enable multi-select mode with chip display */\r\n multiple?: boolean;\r\n /** Shows a loading spinner on the dropdown trigger */\r\n isLoading?: boolean;\r\n className?: string;\r\n /** Enable type-ahead filtering of options (default: true) */\r\n autocomplete?: boolean;\r\n /** Text shown when no options match the filter */\r\n emptyText?: string;\r\n /** Label for the \"select all\" action in multi-select mode */\r\n selectAllText?: string;\r\n /** Label for the \"clear all\" action in multi-select mode */\r\n clearAllText?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst ComboBox = React.forwardRef<HTMLInputElement, ComboBoxProps>(\r\n ({ options, label, placeholder, value, defaultValue, onValueChange, multiple, isLoading, className, autocomplete = true, emptyText = 'No results found.', selectAllText = 'Select all', clearAllText = 'Clear all', leftIcon }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [internalValue, setInternalValue] = React.useState<string | string[] | null>(defaultValue || (multiple ? [] : null));\r\n const isSelectingRef = React.useRef(false);\r\n\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) {\r\n setInternalValue(newVal);\r\n }\r\n if (newVal !== null) {\r\n onValueChange?.(newVal);\r\n }\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n // Bỏ qua cập nhật inputValue khi đang chọn item để tránh nháy popup\r\n if (isSelectingRef.current) {\r\n isSelectingRef.current = false;\r\n return;\r\n }\r\n setInputValue(val);\r\n };\r\n\r\n const handleClear = (e: React.SyntheticEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(multiple ? [] : null);\r\n setInputValue('');\r\n };\r\n\r\n const hasValue = multiple\r\n ? Array.isArray(activeValue) && activeValue.length > 0\r\n : !!activeValue;\r\n\r\n // Lọc options theo text người dùng đang gõ\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue || !autocomplete) return options;\r\n // Khi đã có value được chọn, input hiển thị label → không filter theo label đó\r\n if (!multiple && activeValue) {\r\n const selectedOption = options.find((o) => o.value === activeValue);\r\n if (selectedOption && inputValue === selectedOption.label) return options;\r\n }\r\n return options.filter(opt =>\r\n opt.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, autocomplete, multiple, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator, chip, chipRemove, actionsHeader, actionButton } = comboboxVariants();\r\n const inputGroupRef = React.useRef<HTMLDivElement>(null);\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n multiple={multiple}\r\n onInputValueChange={handleInputValueChange}\r\n autoHighlight\r\n itemToStringLabel={(val: string) => options.find((o) => o.value === val)?.label ?? val}\r\n >\r\n <div className={root({ className })}>\r\n {label && <label className=\"text-sm font-medium text-foreground\">{label}</label>}\r\n\r\n <div className=\"relative w-full group\">\r\n <BaseCombobox.InputGroup ref={inputGroupRef} className={cn(inputContainer(), leftIcon && 'pl-9')}>\r\n {leftIcon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground group-focus-within:text-primary transition-colors pointer-events-none\">\r\n {leftIcon}\r\n </div>\r\n )}\r\n {multiple ? (\r\n <BaseCombobox.Chips className=\"flex flex-wrap items-center gap-1.5 flex-1 w-full min-w-0\">\r\n {Array.isArray(activeValue) && activeValue.map((val) => {\r\n const option = options.find(o => o.value === val);\r\n return (\r\n <BaseCombobox.Chip key={val} className={chip()}>\r\n {option?.label || val}\r\n <BaseCombobox.ChipRemove className={chipRemove()}>\r\n <X className=\"h-3 w-3\" />\r\n </BaseCombobox.ChipRemove>\r\n </BaseCombobox.Chip>\r\n );\r\n })}\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={Array.isArray(activeValue) && activeValue.length > 0 ? '' : placeholder}\r\n className={input()}\r\n />\r\n </BaseCombobox.Chips>\r\n ) : (\r\n <BaseCombobox.Input\r\n ref={ref}\r\n readOnly={!autocomplete}\r\n placeholder={placeholder}\r\n className={cn(input(), !autocomplete && 'cursor-pointer')}\r\n />\r\n )}\r\n\r\n <div className=\"flex items-center gap-1 shrink-0 ml-auto text-muted-foreground\">\r\n {hasValue ? (\r\n <span\r\n role=\"button\"\r\n aria-label=\"Clear selection\"\r\n onPointerDown={(e) => {\r\n e.stopPropagation();\r\n handleClear(e);\r\n }}\r\n onClick={(e) => e.stopPropagation()}\r\n className=\"cursor-pointer flex h-5 w-5 items-center justify-center rounded-full hover:bg-red-50 hover:text-red-500 transition-colors pointer-events-auto\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </span>\r\n ) : (\r\n <BaseCombobox.Trigger className=\"transition-transform group-data-open:rotate-180\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\r\n )}\r\n </div>\r\n </BaseCombobox.InputGroup>\r\n\r\n <BaseCombobox.Portal>\r\n <BaseCombobox.Positioner\r\n anchor={inputGroupRef}\r\n sideOffset={4}\r\n style={{ width: 'var(--anchor-width)', zIndex: 9999 }}\r\n >\r\n <BaseCombobox.Popup className={cn(popup(), 'min-w-0')}>\r\n {multiple && options.length > 0 && (\r\n <div className={actionsHeader()}>\r\n <button\r\n type=\"button\"\r\n aria-label={selectAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange(options.map((o) => o.value));\r\n }}\r\n className={actionButton()}\r\n >\r\n {selectAllText}\r\n </button>\r\n <div className=\"w-px h-3 bg-border\" />\r\n <button\r\n type=\"button\"\r\n aria-label={clearAllText}\r\n onClick={(e) => {\r\n e.preventDefault();\r\n handleValueChange([]);\r\n }}\r\n className={actionButton()}\r\n >\r\n {clearAllText}\r\n </button>\r\n </div>\r\n )}\r\n <BaseCombobox.List className=\"p-1 max-h-[300px] overflow-auto\">\r\n {filteredOptions.length === 0 ? (\r\n <div className=\"py-2 px-8 text-sm text-muted-foreground italic\">{emptyText}</div>\r\n ) : (\r\n filteredOptions.map((option) => (\r\n <BaseCombobox.Item\r\n key={option.value}\r\n value={option.value}\r\n className={item()}\r\n >\r\n <BaseCombobox.ItemIndicator className={indicator()}>\r\n <Check className=\"h-4 w-4\" />\r\n </BaseCombobox.ItemIndicator>\r\n {option.label}\r\n </BaseCombobox.Item>\r\n ))\r\n )}\r\n </BaseCombobox.List>\r\n </BaseCombobox.Popup>\r\n </BaseCombobox.Positioner>\r\n </BaseCombobox.Portal>\r\n </div>\r\n </div>\r\n </BaseCombobox.Root>\r\n );\r\n }\r\n);\r\n\r\nComboBox.displayName = 'ComboBox';\r\n\r\nexport { ComboBox };\r\n"
261
261
  }
262
262
  ]
263
263
  },
@@ -305,7 +305,7 @@
305
305
  "files": [
306
306
  {
307
307
  "path": "src/components/ui/datepicker/DatePicker.tsx",
308
- "content": "import * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { DayPicker, type DateRange } from 'react-day-picker';\r\nimport { format } from 'date-fns';\r\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\nimport { Button } from '../button/Button';\r\nimport { TimePicker, type TimePickerStyle } from './TimePicker';\r\nimport {\r\n type TimeFormat,\r\n type TimeParts,\r\n DEFAULT_TIME,\r\n parseTimeParts,\r\n buildTimeString,\r\n applyTimeToDate,\r\n dateToTimeParts,\r\n formatDateDisplay,\r\n} from './time-helpers';\r\n\r\n// ---------- types ----------\r\n\r\nexport type { TimeFormat, TimePickerStyle };\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\n\r\nexport interface DatePickerProps {\r\n mode?: DatePickerMode;\r\n date?: Date | DateRange;\r\n onDateChange?: (date: Date | DateRange | undefined) => void;\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n timeValue?: string;\r\n onTimeChange?: (time: string) => void;\r\n label?: string;\r\n placeholder?: string;\r\n disablePastDates?: boolean;\r\n showTime?: boolean;\r\n timeFormat?: TimeFormat;\r\n timePickerStyle?: TimePickerStyle;\r\n disabled?: boolean;\r\n className?: string;\r\n description?: string;\r\n error?: string;\r\n locale?: keyof typeof locales;\r\n}\r\n\r\n// ---------- styles ----------\r\n\r\nconst popoverContent = tv({\r\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n});\r\n\r\n// ---------- main component ----------\r\n\r\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\r\n mode = 'single',\r\n date,\r\n onDateChange,\r\n onChange,\r\n timeValue,\r\n onTimeChange,\r\n label,\r\n placeholder = 'Select date...',\r\n disablePastDates = false,\r\n showTime = false,\r\n timeFormat = 'HH:mm:ss',\r\n timePickerStyle = 'select',\r\n disabled = false,\r\n className,\r\n description,\r\n error,\r\n locale: localeKey = 'vi',\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n const resolvedLocale = locales[localeKey];\r\n\r\n const initParts = React.useMemo<TimeParts>(() => {\r\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\r\n if (date instanceof Date) return dateToTimeParts(date);\r\n return DEFAULT_TIME;\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n const [timeParts, setTimeParts] = React.useState<TimeParts>(initParts);\r\n\r\n React.useEffect(() => {\r\n if (mode === 'time-only' && timeValue) {\r\n setTimeParts(parseTimeParts(timeValue));\r\n } else if (date instanceof Date) {\r\n setTimeParts(dateToTimeParts(date));\r\n }\r\n }, [date, timeValue, mode]);\r\n\r\n const handlePartsChange = (newParts: TimeParts) => {\r\n setTimeParts(newParts);\r\n if (mode === 'time-only') {\r\n onTimeChange?.(buildTimeString(newParts, timeFormat));\r\n return;\r\n }\r\n if (date instanceof Date) {\r\n const newDate = applyTimeToDate(date, newParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n }\r\n };\r\n\r\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\r\n if (!selectedDate) {\r\n onDateChange?.(undefined);\r\n onChange?.(undefined);\r\n return;\r\n }\r\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\r\n const newDate = applyTimeToDate(selectedDate, timeParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n } else {\r\n onDateChange?.(selectedDate as DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n }\r\n };\r\n\r\n const triggerLabel = React.useMemo(() => {\r\n if (mode === 'time-only') {\r\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\r\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\r\n return <span className=\"text-muted-foreground\">{placeholder || 'Select time...'}</span>;\r\n return <span>{val}</span>;\r\n }\r\n\r\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n\r\n if (mode === 'single' && date instanceof Date) {\r\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\r\n }\r\n\r\n if (mode === 'range') {\r\n const range = date as DateRange;\r\n if (range.from && range.to) {\r\n return (\r\n <span>\r\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\r\n </span>\r\n );\r\n }\r\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\r\n }\r\n\r\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\r\n\r\n const isTimeMode = mode === 'time-only';\r\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\r\n\r\n return (\r\n <div ref={ref} className={`flex flex-col gap-1.5 ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground leading-none\">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n ref={triggerRef}\r\n type=\"button\"\r\n disabled={disabled}\r\n className={[\r\n 'flex h-10 w-full items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm',\r\n 'ring-offset-background transition-shadow',\r\n 'hover:border-primary focus:border-primary focus:outline-none',\r\n 'disabled:cursor-not-allowed disabled:opacity-50',\r\n error ? 'border-danger focus:border-danger' : 'border-border',\r\n 'group',\r\n ].join(' ')}\r\n >\r\n {isTimeMode ? (\r\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n ) : (\r\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n )}\r\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\r\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\r\n </button>\r\n }\r\n />\r\n\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} className=\"z-50\">\r\n <BasePopover.Popup className={popoverContent()}>\r\n {!isTimeMode && mode === 'single' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"single\"\r\n locale={resolvedLocale}\r\n selected={date as Date | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n {!isTimeMode && mode === 'range' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"range\"\r\n locale={resolvedLocale}\r\n selected={date as DateRange | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n\r\n {needsTimePicker && (\r\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\r\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\r\n <Clock className=\"w-3.5 h-3.5\" />\r\n <span>\r\n {timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second'}\r\n </span>\r\n </div>\r\n <TimePicker\r\n parts={timeParts}\r\n onChange={handlePartsChange}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n )}\r\n\r\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\r\n <button\r\n type=\"button\"\r\n onClick={() => {\r\n if (mode === 'time-only') {\r\n setTimeParts(DEFAULT_TIME);\r\n onTimeChange?.('');\r\n } else {\r\n onDateChange?.(undefined);\r\n }\r\n }}\r\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\r\n >\r\n Clear\r\n </button>\r\n <Button size=\"sm\" onClick={() => setOpen(false)}>\r\n Confirm\r\n </Button>\r\n </div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nDatePicker.displayName = \"DatePicker\";\r\n"
308
+ "content": "'use client';\r\nimport * as React from 'react';\r\nimport { Popover as BasePopover } from '@base-ui/react';\r\nimport { DayPicker, type DateRange } from 'react-day-picker';\r\nimport { format } from 'date-fns';\r\nimport { Calendar as CalendarIcon, ChevronDown, Clock } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport * as locales from 'react-day-picker/locale';\r\n\r\nimport 'react-day-picker/dist/style.css';\r\nimport { Button } from '../button/Button';\r\nimport { TimePicker, type TimePickerStyle } from './TimePicker';\r\nimport {\r\n type TimeFormat,\r\n type TimeParts,\r\n DEFAULT_TIME,\r\n parseTimeParts,\r\n buildTimeString,\r\n applyTimeToDate,\r\n dateToTimeParts,\r\n formatDateDisplay,\r\n} from './time-helpers';\r\n\r\n// ---------- types ----------\r\n\r\nexport type { TimeFormat, TimePickerStyle };\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\n\r\nexport interface DatePickerProps {\r\n mode?: DatePickerMode;\r\n date?: Date | DateRange;\r\n onDateChange?: (date: Date | DateRange | undefined) => void;\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n timeValue?: string;\r\n onTimeChange?: (time: string) => void;\r\n label?: string;\r\n placeholder?: string;\r\n disablePastDates?: boolean;\r\n showTime?: boolean;\r\n timeFormat?: TimeFormat;\r\n timePickerStyle?: TimePickerStyle;\r\n disabled?: boolean;\r\n className?: string;\r\n description?: string;\r\n error?: string;\r\n locale?: keyof typeof locales;\r\n}\r\n\r\n// ---------- styles ----------\r\n\r\nconst popoverContent = tv({\r\n base: 'z-50 rounded-xl border border-border bg-background text-foreground shadow-xl outline-none data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-side-bottom:slide-in-from-top-2 data-side-left:slide-in-from-right-2 data-side-right:slide-in-from-left-2 data-side-top:slide-in-from-bottom-2',\r\n});\r\n\r\n// ---------- main component ----------\r\n\r\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\r\n mode = 'single',\r\n date,\r\n onDateChange,\r\n onChange,\r\n timeValue,\r\n onTimeChange,\r\n label,\r\n placeholder = 'Select date...',\r\n disablePastDates = false,\r\n showTime = false,\r\n timeFormat = 'HH:mm:ss',\r\n timePickerStyle = 'select',\r\n disabled = false,\r\n className,\r\n description,\r\n error,\r\n locale: localeKey = 'vi',\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n const resolvedLocale = locales[localeKey];\r\n\r\n const initParts = React.useMemo<TimeParts>(() => {\r\n if (mode === 'time-only' && timeValue) return parseTimeParts(timeValue);\r\n if (date instanceof Date) return dateToTimeParts(date);\r\n return DEFAULT_TIME;\r\n // eslint-disable-next-line react-hooks/exhaustive-deps\r\n }, []);\r\n\r\n const [timeParts, setTimeParts] = React.useState<TimeParts>(initParts);\r\n\r\n React.useEffect(() => {\r\n if (mode === 'time-only' && timeValue) {\r\n setTimeParts(parseTimeParts(timeValue));\r\n } else if (date instanceof Date) {\r\n setTimeParts(dateToTimeParts(date));\r\n }\r\n }, [date, timeValue, mode]);\r\n\r\n const handlePartsChange = (newParts: TimeParts) => {\r\n setTimeParts(newParts);\r\n if (mode === 'time-only') {\r\n onTimeChange?.(buildTimeString(newParts, timeFormat));\r\n return;\r\n }\r\n if (date instanceof Date) {\r\n const newDate = applyTimeToDate(date, newParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n }\r\n };\r\n\r\n const handleDateSelect = (selectedDate: Date | DateRange | Date[] | undefined) => {\r\n if (!selectedDate) {\r\n onDateChange?.(undefined);\r\n onChange?.(undefined);\r\n return;\r\n }\r\n if (mode === 'single' && showTime && selectedDate instanceof Date) {\r\n const newDate = applyTimeToDate(selectedDate, timeParts);\r\n onDateChange?.(newDate);\r\n onChange?.(newDate);\r\n } else {\r\n onDateChange?.(selectedDate as DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n }\r\n };\r\n\r\n const triggerLabel = React.useMemo(() => {\r\n if (mode === 'time-only') {\r\n const val = timeValue ?? buildTimeString(timeParts, timeFormat);\r\n if (!val || val === '00' || val === '00:00' || val === '00:00:00')\r\n return <span className=\"text-muted-foreground\">{placeholder || 'Select time...'}</span>;\r\n return <span>{val}</span>;\r\n }\r\n\r\n if (!date) return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n\r\n if (mode === 'single' && date instanceof Date) {\r\n return <span>{formatDateDisplay(date, showTime, timeFormat)}</span>;\r\n }\r\n\r\n if (mode === 'range') {\r\n const range = date as DateRange;\r\n if (range.from && range.to) {\r\n return (\r\n <span>\r\n {format(range.from, 'dd/MM/yyyy')} – {format(range.to, 'dd/MM/yyyy')}\r\n </span>\r\n );\r\n }\r\n if (range.from) return <span>{format(range.from, 'dd/MM/yyyy')} –</span>;\r\n }\r\n\r\n return <span className=\"text-muted-foreground\">{placeholder}</span>;\r\n }, [date, mode, showTime, timeFormat, timeValue, timeParts, placeholder]);\r\n\r\n const isTimeMode = mode === 'time-only';\r\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\r\n\r\n return (\r\n <div ref={ref} className={`flex flex-col gap-1.5 ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground \">\r\n {label}\r\n </label>\r\n )}\r\n\r\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>\r\n <BasePopover.Trigger\r\n render={\r\n <button\r\n ref={triggerRef}\r\n type=\"button\"\r\n disabled={disabled}\r\n className={[\r\n 'flex h-10 w-full items-center gap-2 rounded-lg border bg-background px-3 py-2 text-sm',\r\n 'ring-offset-background transition-shadow',\r\n 'hover:border-primary focus:border-primary focus:outline-none',\r\n 'disabled:cursor-not-allowed disabled:opacity-50',\r\n error ? 'border-danger focus:border-danger' : 'border-border',\r\n 'group',\r\n ].join(' ')}\r\n >\r\n {isTimeMode ? (\r\n <Clock className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n ) : (\r\n <CalendarIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\r\n )}\r\n <div className=\"flex-1 truncate text-left\">{triggerLabel}</div>\r\n <ChevronDown className=\"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-open:rotate-180\" />\r\n </button>\r\n }\r\n />\r\n\r\n <BasePopover.Portal>\r\n <BasePopover.Positioner anchor={triggerRef} sideOffset={6} style={{ zIndex: 9999 }}>\r\n <BasePopover.Popup className={popoverContent()}>\r\n {!isTimeMode && mode === 'single' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"single\"\r\n locale={resolvedLocale}\r\n selected={date as Date | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n {!isTimeMode && mode === 'range' && (\r\n <div className=\"p-2 flex justify-center\">\r\n <DayPicker\r\n mode=\"range\"\r\n locale={resolvedLocale}\r\n selected={date as DateRange | undefined}\r\n onSelect={(d) => handleDateSelect(d)}\r\n disabled={disablePastDates ? [{ before: new Date() }] : undefined}\r\n className=\"rdp-custom\"\r\n />\r\n </div>\r\n )}\r\n\r\n {needsTimePicker && (\r\n <div className={`border-t border-border p-3 flex flex-col gap-2 ${isTimeMode ? 'border-t-0' : ''}`}>\r\n <div className=\"flex items-center gap-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wide\">\r\n <Clock className=\"w-3.5 h-3.5\" />\r\n <span>\r\n {timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second'}\r\n </span>\r\n </div>\r\n <TimePicker\r\n parts={timeParts}\r\n onChange={handlePartsChange}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n )}\r\n\r\n <div className=\"flex items-center justify-between gap-2 p-3 border-t border-border\">\r\n <button\r\n type=\"button\"\r\n onClick={() => {\r\n if (mode === 'time-only') {\r\n setTimeParts(DEFAULT_TIME);\r\n onTimeChange?.('');\r\n } else {\r\n onDateChange?.(undefined);\r\n }\r\n }}\r\n className=\"text-xs text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline\"\r\n >\r\n Clear\r\n </button>\r\n <Button size=\"sm\" onClick={() => setOpen(false)}>\r\n Confirm\r\n </Button>\r\n </div>\r\n </BasePopover.Popup>\r\n </BasePopover.Positioner>\r\n </BasePopover.Portal>\r\n </BasePopover.Root>\r\n {description && !error && (\r\n <p className=\"text-[0.8rem] text-muted-foreground\">{description}</p>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">{error}</p>\r\n )}\r\n </div>\r\n );\r\n});\r\n\r\nDatePicker.displayName = \"DatePicker\";\r\n"
309
309
  },
310
310
  {
311
311
  "path": "src/components/ui/datepicker/time-helpers.ts",
@@ -406,7 +406,8 @@
406
406
  "name": "input",
407
407
  "dependencies": [
408
408
  "@base-ui/react",
409
- "tailwind-variants"
409
+ "tailwind-variants",
410
+ "lucide-react"
410
411
  ],
411
412
  "internalDependencies": [
412
413
  "toggle"
@@ -414,7 +415,7 @@
414
415
  "files": [
415
416
  {
416
417
  "path": "src/components/ui/input/Input.tsx",
417
- "content": "import * as React from 'react';\r\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport * as Icon from \"@/components/ui/icons\";\r\nimport { Toggle } from '@/components/ui/toggle/Toggle';\r\n\r\nconst inputVariants = tv({\r\n base: 'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus:border-primary',\r\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Input component */\r\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\r\n /** Label text displayed above the input */\r\n label?: string;\r\n /** Error message displayed below the input; also applies danger styling */\r\n error?: string;\r\n /** Helper text displayed below the input (hidden when error is present) */\r\n description?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n icon?: React.ReactNode;\r\n /** Icon rendered at the end (right side) of the input; ignored for password type */\r\n endIcon?: React.ReactNode;\r\n placeholder?: string;\r\n className?: string;\r\n}\r\n\r\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\r\n ({ className, variant, label, error, description, icon, endIcon, id, type, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const inputId = id || defaultId;\r\n const [showPassword, setShowPassword] = React.useState(false);\r\n\r\n const isPassword = type === 'password';\r\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\r\n {label}\r\n </BaseField.Label>\r\n )}\r\n <div className=\"relative\">\r\n {icon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {icon}\r\n </div>\r\n )}\r\n <BaseField.Control render={<BaseInput\r\n ref={ref}\r\n id={inputId}\r\n type={inputType}\r\n className={cn(\r\n inputVariants({ variant }),\r\n icon && 'pl-9',\r\n (isPassword || endIcon) && 'pr-10',\r\n error && 'border-danger focus:border-danger',\r\n className\r\n )}\r\n {...props}\r\n />} />\r\n {isPassword ? (\r\n <Toggle\r\n type=\"button\"\r\n variant=\"ghost\"\r\n size=\"icon\"\r\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\r\n pressed={showPassword}\r\n onPressedChange={setShowPassword}\r\n aria-label={showPassword ? 'Hide password' : 'Show password'}\r\n >\r\n {showPassword ? <Icon.EyeOff className=\"h-4 w-4\" /> : <Icon.Eye className=\"h-4 w-4\" />}\r\n </Toggle>\r\n ) : endIcon ? (\r\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {endIcon}\r\n </div>\r\n ) : null}\r\n </div>\r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nInput.displayName = 'Input';\r\n\r\nexport { Input };\r\n"
418
+ "content": "import * as React from 'react';\r\nimport { Input as BaseInput, Field as BaseField } from '@base-ui/react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Toggle } from '@/components/ui/toggle/Toggle';\r\nimport { Eye, EyeOff } from 'lucide-react';\r\n\r\nconst inputVariants = tv({\r\n base: 'flex h-10 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus:border-primary focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-shadow',\r\n variants: {\r\n variant: {\r\n default: '',\r\n filled: 'bg-accent border-transparent focus:border-primary',\r\n flushed: 'border-b-2 border-transparent border-b-border rounded-none px-0 focus:outline-none focus:ring-0 focus:border-transparent focus:border-b-primary bg-transparent',\r\n }\r\n },\r\n defaultVariants: {\r\n variant: 'default'\r\n }\r\n});\r\n\r\n/** Props for the Input component */\r\nexport interface InputProps extends Omit<React.ComponentPropsWithoutRef<typeof BaseInput>, 'className'>, VariantProps<typeof inputVariants> {\r\n /** Label text displayed above the input */\r\n label?: string;\r\n /** Error message displayed below the input; also applies danger styling */\r\n error?: string;\r\n /** Helper text displayed below the input (hidden when error is present) */\r\n description?: string;\r\n /** Icon rendered at the start (left side) of the input */\r\n icon?: React.ReactNode;\r\n /** Icon rendered at the end (right side) of the input; ignored for password type */\r\n endIcon?: React.ReactNode;\r\n placeholder?: string;\r\n className?: string;\r\n}\r\n\r\nconst Input = React.forwardRef<React.ElementRef<typeof BaseInput>, InputProps>(\r\n ({ className, variant, label, error, description, icon, endIcon, id, type, ...props }, ref) => {\r\n const defaultId = React.useId();\r\n const inputId = id || defaultId;\r\n const [showPassword, setShowPassword] = React.useState(false);\r\n\r\n const isPassword = type === 'password';\r\n const inputType = isPassword ? (showPassword ? 'text' : 'password') : type;\r\n\r\n return (\r\n <BaseField.Root className=\"flex flex-col gap-1.5 w-full\">\r\n {label && (\r\n <BaseField.Label htmlFor={inputId} className=\"text-sm font-medium text-foreground \">\r\n {label}\r\n </BaseField.Label>\r\n )}\r\n <div className=\"relative\">\r\n {icon && (\r\n <div className=\"absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {icon}\r\n </div>\r\n )}\r\n <BaseInput\r\n ref={ref}\r\n id={inputId}\r\n type={inputType || 'text'}\r\n className={cn(\r\n inputVariants({ variant }),\r\n icon && 'pl-9',\r\n (isPassword || endIcon) && 'pr-10',\r\n error && 'border-danger focus:border-danger',\r\n className\r\n )}\r\n {...props}\r\n />\r\n {isPassword ? (\r\n <Toggle\r\n type=\"button\"\r\n variant=\"ghost\"\r\n size=\"icon\"\r\n className=\"absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8 text-muted-foreground hover:text-foreground\"\r\n pressed={showPassword}\r\n onPressedChange={setShowPassword}\r\n aria-label={showPassword ? 'Hide password' : 'Show password'}\r\n >\r\n {showPassword ? <EyeOff className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />}\r\n </Toggle>\r\n ) : endIcon ? (\r\n <div className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground\">\r\n {endIcon}\r\n </div>\r\n ) : null}\r\n </div>\r\n {description && !error && (\r\n <BaseField.Description className=\"text-[0.8rem] text-muted-foreground\">\r\n {description}\r\n </BaseField.Description>\r\n )}\r\n {error && (\r\n <p className=\"text-[0.8rem] font-medium text-danger\">\r\n {error}\r\n </p>\r\n )}\r\n </BaseField.Root>\r\n );\r\n }\r\n);\r\nInput.displayName = 'Input';\r\n\r\nexport { Input };\r\n"
418
419
  }
419
420
  ]
420
421
  },
@@ -1126,6 +1127,10 @@
1126
1127
  {
1127
1128
  "path": "src/components/ui/typography/Typography.tsx",
1128
1129
  "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\nimport { Copy, Check, ExternalLink } from 'lucide-react';\r\nimport { useCopy } from '@/hooks/useCopy';\r\n\r\n// ─── Shared text style variants ───────────────────────────────────────────────\r\n\r\nconst textVariants = tv({\r\n base: '',\r\n variants: {\r\n size: {\r\n xs: 'text-xs',\r\n sm: 'text-sm',\r\n md: 'text-base',\r\n lg: 'text-lg',\r\n xl: 'text-xl',\r\n '2xl': 'text-2xl',\r\n '3xl': 'text-3xl',\r\n },\r\n weight: {\r\n thin: 'font-thin',\r\n light: 'font-light',\r\n normal: 'font-normal',\r\n medium: 'font-medium',\r\n semibold: 'font-semibold',\r\n bold: 'font-bold',\r\n extrabold: 'font-extrabold',\r\n },\r\n color: {\r\n default: 'text-foreground',\r\n muted: 'text-muted-foreground',\r\n primary: 'text-primary',\r\n success: 'text-success',\r\n warning: 'text-warning',\r\n danger: 'text-danger',\r\n inherit: 'text-inherit',\r\n },\r\n align: {\r\n left: 'text-left',\r\n center: 'text-center',\r\n right: 'text-right',\r\n justify: 'text-justify',\r\n },\r\n leading: {\r\n none: 'leading-none',\r\n tight: 'leading-tight',\r\n normal: 'leading-normal',\r\n relaxed: 'leading-relaxed',\r\n loose: 'leading-loose',\r\n },\r\n tracking: {\r\n tighter: 'tracking-tighter',\r\n tight: 'tracking-tight',\r\n normal: 'tracking-normal',\r\n wide: 'tracking-wide',\r\n widest: 'tracking-widest',\r\n },\r\n },\r\n defaultVariants: {\r\n color: 'default',\r\n },\r\n});\r\n\r\n// ─── Text ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface TextProps\r\n extends Omit<React.HTMLAttributes<HTMLElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** HTML tag to render — default `span` */\r\n as?: React.ElementType;\r\n /** Bold */\r\n strong?: boolean;\r\n /** Italic */\r\n italic?: boolean;\r\n /** Underline */\r\n underline?: boolean;\r\n /** Strikethrough */\r\n strikethrough?: boolean;\r\n /** Gradient text (primary → indigo) */\r\n gradient?: boolean;\r\n /** Highlighted mark background */\r\n mark?: boolean;\r\n /** Single-line truncate with ellipsis */\r\n truncate?: boolean;\r\n /** Multi-line clamp (number of lines) */\r\n lines?: 1 | 2 | 3 | 4 | 5;\r\n /** Tabular numbers — fixed-width digits */\r\n numeric?: boolean;\r\n /** Inline code styling */\r\n code?: boolean;\r\n /** Show copy icon on hover, copies text content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst LINES_MAP: Record<number, string> = {\r\n 1: 'line-clamp-1',\r\n 2: 'line-clamp-2',\r\n 3: 'line-clamp-3',\r\n 4: 'line-clamp-4',\r\n 5: 'line-clamp-5',\r\n};\r\n\r\nconst Text = React.forwardRef<HTMLElement, TextProps>(\r\n (\r\n {\r\n as: Tag = 'span',\r\n size,\r\n weight,\r\n color,\r\n align,\r\n leading,\r\n tracking,\r\n strong,\r\n italic,\r\n underline,\r\n strikethrough,\r\n gradient,\r\n mark,\r\n truncate,\r\n lines,\r\n numeric,\r\n code,\r\n copyable,\r\n className,\r\n children,\r\n onClick,\r\n ...props\r\n },\r\n ref\r\n ) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n const handleClick = (e: React.MouseEvent<HTMLElement>) => {\r\n if (copyable && elRef.current) {\r\n copy(elRef.current.innerText);\r\n }\r\n onClick?.(e);\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading, tracking }),\r\n strong && 'font-bold',\r\n italic && 'italic',\r\n underline && 'underline underline-offset-2',\r\n strikethrough && 'line-through',\r\n gradient && 'bg-gradient-to-r from-primary to-indigo-500 bg-clip-text text-transparent',\r\n mark && 'bg-warning/20 text-warning-foreground rounded px-0.5',\r\n truncate && 'block max-w-full truncate',\r\n lines && cn('block', LINES_MAP[lines]),\r\n numeric && 'tabular-nums',\r\n code && 'font-mono text-[0.9em] bg-muted rounded px-1 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-1.5',\r\n className,\r\n )}\r\n onClick={handleClick}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-60 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-3.5 h-3.5 text-success\" />\r\n : <Copy className=\"w-3.5 h-3.5\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nText.displayName = 'Text';\r\n\r\n// ─── Heading ──────────────────────────────────────────────────────────────────\r\n\r\nconst HEADING_SIZE: Record<1 | 2 | 3 | 4 | 5 | 6, string> = {\r\n 1: 'text-4xl font-extrabold tracking-tight',\r\n 2: 'text-3xl font-bold tracking-tight',\r\n 3: 'text-2xl font-semibold tracking-tight',\r\n 4: 'text-xl font-semibold',\r\n 5: 'text-lg font-medium',\r\n 6: 'text-base font-medium',\r\n};\r\n\r\nexport interface HeadingProps\r\n extends Omit<React.HTMLAttributes<HTMLHeadingElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Heading level 1–6, also sets default size (default: 2) */\r\n level?: 1 | 2 | 3 | 4 | 5 | 6;\r\n /** Show copy icon on hover */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Heading = React.forwardRef<HTMLHeadingElement, HeadingProps>(\r\n ({ level = 2, size, weight, color = 'default', align, className, copyable, children, ...props }, ref) => {\r\n const Tag = `h${level}` as React.ElementType;\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLHeadingElement>(null);\r\n const mergedRef = (node: HTMLHeadingElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLHeadingElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <Tag\r\n ref={mergedRef}\r\n className={cn(\r\n HEADING_SIZE[level],\r\n textVariants({ size, weight, color, align }),\r\n copyable && 'cursor-pointer group/copy inline-flex items-center gap-2',\r\n className,\r\n )}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/copy:opacity-50 transition-opacity shrink-0\">\r\n {copied\r\n ? <Check className=\"w-4 h-4 text-success\" />\r\n : <Copy className=\"w-4 h-4\" />\r\n }\r\n </span>\r\n )}\r\n </Tag>\r\n );\r\n }\r\n);\r\nHeading.displayName = 'Heading';\r\n\r\n// ─── Paragraph ────────────────────────────────────────────────────────────────\r\n\r\nexport interface ParagraphProps\r\n extends Omit<React.HTMLAttributes<HTMLParagraphElement>, 'color'>,\r\n VariantProps<typeof textVariants> {\r\n /** Larger intro-text styling */\r\n lead?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Paragraph = React.forwardRef<HTMLParagraphElement, ParagraphProps>(\r\n ({ lead, size, weight, color = 'default', align, leading = 'relaxed', className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn(\r\n textVariants({ size, weight, color, align, leading }),\r\n lead && 'text-xl text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nParagraph.displayName = 'Paragraph';\r\n\r\n// ─── Lead ────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LeadProps extends React.HTMLAttributes<HTMLParagraphElement> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Lead = React.forwardRef<HTMLParagraphElement, LeadProps>(\r\n ({ className, children, ...props }, ref) => (\r\n <p\r\n ref={ref}\r\n className={cn('text-xl text-muted-foreground leading-relaxed', className)}\r\n {...props}\r\n >\r\n {children}\r\n </p>\r\n )\r\n);\r\nLead.displayName = 'Lead';\r\n\r\n// ─── Blockquote ───────────────────────────────────────────────────────────────\r\n\r\nexport interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement> {\r\n /** Citation source text shown below the quote */\r\n cite?: string;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Blockquote = React.forwardRef<HTMLQuoteElement, BlockquoteProps>(\r\n ({ cite, className, children, ...props }, ref) => (\r\n <figure className=\"my-1\">\r\n <blockquote\r\n ref={ref}\r\n className={cn(\r\n 'border-l-4 border-primary pl-4 py-1 italic text-muted-foreground leading-relaxed',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </blockquote>\r\n {cite && (\r\n <figcaption className=\"mt-2 pl-4 text-sm text-muted-foreground/70 not-italic\">\r\n — {cite}\r\n </figcaption>\r\n )}\r\n </figure>\r\n )\r\n);\r\nBlockquote.displayName = 'Blockquote';\r\n\r\n// ─── Code (inline) ────────────────────────────────────────────────────────────\r\n\r\nexport interface CodeProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Copy content on click */\r\n copyable?: boolean;\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Code = React.forwardRef<HTMLElement, CodeProps>(\r\n ({ copyable, className, children, ...props }, ref) => {\r\n const { copied, copy } = useCopy();\r\n const elRef = React.useRef<HTMLElement>(null);\r\n const mergedRef = (node: HTMLElement | null) => {\r\n (elRef as React.MutableRefObject<HTMLElement | null>).current = node;\r\n if (typeof ref === 'function') ref(node);\r\n else if (ref) (ref as React.MutableRefObject<HTMLElement | null>).current = node;\r\n };\r\n\r\n return (\r\n <code\r\n ref={mergedRef}\r\n onClick={copyable ? () => elRef.current && copy(elRef.current.innerText) : undefined}\r\n className={cn(\r\n 'font-mono text-[0.875em] bg-muted text-foreground rounded px-1.5 py-0.5 border border-border/50',\r\n copyable && 'cursor-pointer hover:bg-muted/70 transition-colors group/code inline-flex items-center gap-1',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {copyable && (\r\n <span className=\"opacity-0 group-hover/code:opacity-60 transition-opacity\">\r\n {copied\r\n ? <Check className=\"w-3 h-3 text-success inline\" />\r\n : <Copy className=\"w-3 h-3 inline\" />\r\n }\r\n </span>\r\n )}\r\n </code>\r\n );\r\n }\r\n);\r\nCode.displayName = 'Code';\r\n\r\n// ─── Kbd ──────────────────────────────────────────────────────────────────────\r\n\r\nexport interface KbdProps extends React.HTMLAttributes<HTMLElement> {\r\n /** Array of keys to display; joined with `+` separator */\r\n keys?: string[];\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst KbdKey = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & { className?: string }>(\r\n ({ className, children, ...props }, ref) => (\r\n <kbd\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={cn(\r\n 'inline-flex items-center justify-center h-6 min-w-[1.5rem] px-1.5',\r\n 'font-mono text-xs font-medium',\r\n 'bg-background border border-border rounded shadow-[0_2px_0_0_hsl(var(--border))]',\r\n 'text-muted-foreground',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n </kbd>\r\n )\r\n);\r\nKbdKey.displayName = 'KbdKey';\r\n\r\nconst Kbd = React.forwardRef<HTMLSpanElement, KbdProps>(\r\n ({ keys, className, children, ...props }, ref) => {\r\n const items = keys ?? (children ? [children] : []);\r\n\r\n return (\r\n <span ref={ref} className={cn('inline-flex items-center gap-0.5', className)} {...props}>\r\n {items.map((key, i) => (\r\n <React.Fragment key={i}>\r\n {i > 0 && <span className=\"text-[10px] text-muted-foreground/60 px-0.5\">+</span>}\r\n <KbdKey>{key}</KbdKey>\r\n </React.Fragment>\r\n ))}\r\n </span>\r\n );\r\n }\r\n);\r\nKbd.displayName = 'Kbd';\r\n\r\n// ─── Link ─────────────────────────────────────────────────────────────────────\r\n\r\nexport interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {\r\n /** Open in new tab with rel=\"noopener noreferrer\" */\r\n external?: boolean;\r\n /** Underline behaviour — default `hover` */\r\n underline?: 'always' | 'hover' | 'none';\r\n /** Color variant */\r\n color?: 'primary' | 'muted' | 'danger' | 'foreground';\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Link = React.forwardRef<HTMLAnchorElement, LinkProps>(\r\n ({ external, underline = 'hover', color = 'primary', className, children, ...props }, ref) => (\r\n <a\r\n ref={ref}\r\n target={external ? '_blank' : props.target}\r\n rel={external ? 'noopener noreferrer' : props.rel}\r\n className={cn(\r\n 'inline-flex items-center gap-0.5 transition-colors',\r\n color === 'primary' && 'text-primary',\r\n color === 'muted' && 'text-muted-foreground',\r\n color === 'danger' && 'text-danger',\r\n color === 'foreground' && 'text-foreground',\r\n underline === 'always' && 'underline underline-offset-2',\r\n underline === 'hover' && 'hover:underline underline-offset-2',\r\n underline === 'none' && 'no-underline',\r\n className,\r\n )}\r\n {...props}\r\n >\r\n {children}\r\n {external && <ExternalLink className=\"w-3 h-3 shrink-0 opacity-70\" />}\r\n </a>\r\n )\r\n);\r\nLink.displayName = 'Link';\r\n\r\n// ─── Mark ─────────────────────────────────────────────────────────────────────\r\n\r\nconst markVariants = tv({\r\n base: 'rounded px-0.5 py-px font-medium',\r\n variants: {\r\n variant: {\r\n default: 'bg-warning/25 text-warning-foreground',\r\n primary: 'bg-primary/15 text-primary',\r\n success: 'bg-success/15 text-success',\r\n warning: 'bg-warning/25 text-warning-foreground',\r\n danger: 'bg-danger/15 text-danger',\r\n },\r\n },\r\n defaultVariants: { variant: 'default' },\r\n});\r\n\r\nexport interface MarkProps\r\n extends React.HTMLAttributes<HTMLElement>,\r\n VariantProps<typeof markVariants> {\r\n className?: string;\r\n children?: React.ReactNode;\r\n}\r\n\r\nconst Mark = React.forwardRef<HTMLElement, MarkProps>(\r\n ({ variant, className, children, ...props }, ref) => (\r\n <mark\r\n ref={ref as React.Ref<HTMLElement>}\r\n className={markVariants({ variant, className })}\r\n {...props}\r\n >\r\n {children}\r\n </mark>\r\n )\r\n);\r\nMark.displayName = 'Mark';\r\n\r\n// ─── Exports ──────────────────────────────────────────────────────────────────\r\n\r\nexport {\r\n Text,\r\n Heading,\r\n Paragraph,\r\n Lead,\r\n Blockquote,\r\n Code,\r\n Kbd,\r\n KbdKey,\r\n Link,\r\n Mark,\r\n textVariants,\r\n markVariants,\r\n};\r\n"
1130
+ },
1131
+ {
1132
+ "path": "src/hooks/useCopy.ts",
1133
+ "content": "import { useState, useCallback } from 'react';\r\n\r\nexport function useCopy(timeout = 2000) {\r\n const [copied, setCopied] = useState(false);\r\n\r\n const copy = useCallback(\r\n async (text: string) => {\r\n try {\r\n await navigator.clipboard.writeText(text);\r\n setCopied(true);\r\n setTimeout(() => setCopied(false), timeout);\r\n } catch {\r\n /* noop */\r\n }\r\n },\r\n [timeout],\r\n );\r\n\r\n return { copied, copy };\r\n}\r\n"
1129
1134
  }
1130
1135
  ]
1131
1136
  }
@@ -1,217 +1,261 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
-
4
- const COMPONENTS_DIR = './src/components/ui';
5
- const OUTPUT_FILE = './registry.json';
6
-
7
- // Directories to exclude from registry (not reusable components)
8
- const EXCLUDE_DIRS = new Set(['icons', 'layout', 'vs-code', 'Showcase.tsx']);
9
-
10
- interface RegistryFile {
11
- path: string;
12
- content: string;
13
- }
14
-
15
- interface RegistryComponent {
16
- name: string;
17
- dependencies: string[];
18
- internalDependencies: string[];
19
- files: RegistryFile[];
20
- }
21
-
22
- interface Registry {
23
- core: {
24
- dependencies: string[];
25
- files: RegistryFile[];
26
- };
27
- components: Record<string, RegistryComponent>;
28
- }
29
-
30
- const getFiles = (dir: string): string[] => {
31
- const files: string[] = [];
32
- let entries: string[];
33
- try {
34
- entries = fs.readdirSync(dir);
35
- } catch (err) {
36
- console.warn(`Cannot read directory ${dir}: ${err}`);
37
- return files;
38
- }
39
- for (const file of entries) {
40
- const fullPath = path.join(dir, file);
41
- try {
42
- const stat = fs.statSync(fullPath);
43
- if (stat.isDirectory()) {
44
- files.push(...getFiles(fullPath));
45
- } else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
46
- if (!file.includes('.test.') && !file.includes('.stories.')) {
47
- files.push(fullPath);
48
- }
49
- }
50
- } catch (err) {
51
- console.warn(`Skipping ${fullPath}: ${err}`);
52
- }
53
- }
54
- return files;
55
- };
56
-
57
- const INTERNAL_ALIAS_PREFIXES = ['@/', '@lib/', '@components/', '@app/'];
58
-
59
- const parseDependencies = (content: string): string[] => {
60
- const dependencies = new Set<string>();
61
- const importRegex = /(?:from|import)\s+['"]([^'"]+)['"]/g;
62
- let match;
63
-
64
- while ((match = importRegex.exec(content)) !== null) {
65
- const pkg = match[1];
66
-
67
- // Skip relative imports
68
- if (pkg.startsWith('.')) continue;
69
- // Skip internal aliases
70
- if (INTERNAL_ALIAS_PREFIXES.some(prefix => pkg.startsWith(prefix))) continue;
71
-
72
- // Extract package name
73
- const parts = pkg.split('/');
74
- if (pkg.startsWith('@') && parts.length >= 2) {
75
- dependencies.add(`${parts[0]}/${parts[1]}`);
76
- } else {
77
- dependencies.add(parts[0]);
78
- }
79
- }
80
-
81
- // Remove React (always a peer dependency)
82
- dependencies.delete('react');
83
- dependencies.delete('react-dom');
84
-
85
- return [...dependencies];
86
- };
87
-
88
- const getInternalDeps = (content: string, currentDirName: string): string[] => {
89
- const internalDeps = new Set<string>();
90
-
91
- // 1. Alias imports: @/components/ui/xxx or @components/ui/xxx
92
- const aliasRegex = /from\s+['"](?:@\/?components\/ui)\/([^'"]+)['"]/g;
93
- let match;
94
- while ((match = aliasRegex.exec(content)) !== null) {
95
- const depPath = match[1].split('/')[0];
96
- if (depPath !== currentDirName && depPath !== 'icons') {
97
- internalDeps.add(depPath);
98
- }
99
- }
100
-
101
- // 2. Relative imports: ../spinner/Spinner
102
- const relativeRegex = /from\s+['"]\.\.\/([^'"]+)['"]/g;
103
- while ((match = relativeRegex.exec(content)) !== null) {
104
- const depPath = match[1].split('/')[0];
105
- if (depPath !== currentDirName) {
106
- internalDeps.add(depPath);
107
- }
108
- }
109
-
110
- return [...internalDeps];
111
- };
112
-
113
- const buildRegistry = () => {
114
- console.log('Building component registry...');
115
-
116
- const registry: Registry = {
117
- core: {
118
- dependencies: [
119
- '@base-ui/react',
120
- 'clsx',
121
- 'tailwind-merge',
122
- 'tailwind-variants',
123
- 'tailwindcss-animate',
124
- '@tailwindcss/vite',
125
- 'autoprefixer',
126
- 'tailwindcss',
127
- 'postcss',
128
- ],
129
- files: [
130
- {
131
- path: 'src/lib/utils/cn.ts',
132
- content: fs.readFileSync('./src/lib/utils/cn.ts', 'utf-8'),
133
- },
134
- {
135
- path: 'src/styles/index.css',
136
- content: fs.readFileSync('./src/styles/index.css', 'utf-8'),
137
- },
138
- {
139
- path: 'src/lib/theme/themes.ts',
140
- content: fs.readFileSync('./src/lib/theme/themes.ts', 'utf-8'),
141
- },
142
- {
143
- path: 'src/lib/theme/ThemeProvider.tsx',
144
- content: fs.readFileSync('./src/lib/theme/ThemeProvider.tsx', 'utf-8'),
145
- },
146
- ],
147
- },
148
- components: {},
149
- };
150
-
151
- const componentDirs = fs.readdirSync(COMPONENTS_DIR);
152
-
153
- for (const dirName of componentDirs) {
154
- if (EXCLUDE_DIRS.has(dirName)) continue;
155
-
156
- const dirPath = path.join(COMPONENTS_DIR, dirName);
157
- if (!fs.statSync(dirPath).isDirectory()) continue;
158
-
159
- const files = getFiles(dirPath);
160
- if (files.length === 0) continue;
161
-
162
- // Find main file: prefer <DirName>.tsx, then any .tsx
163
- const mainFile =
164
- files.find(
165
- (f) =>
166
- f.toLowerCase().includes(dirName.toLowerCase()) &&
167
- f.endsWith('.tsx')
168
- ) || files.find((f) => f.endsWith('.tsx'));
169
-
170
- if (!mainFile) continue;
171
-
172
- // Parse all files in the component directory for dependencies
173
- const allDependencies = new Set<string>();
174
- const allInternalDeps = new Set<string>();
175
-
176
- for (const file of files) {
177
- try {
178
- const content = fs.readFileSync(file, 'utf-8');
179
- for (const dep of parseDependencies(content)) allDependencies.add(dep);
180
- for (const dep of getInternalDeps(content, dirName)) allInternalDeps.add(dep);
181
- } catch (err) {
182
- console.warn(`Failed to read ${file}: ${err}`);
183
- }
184
- }
185
-
186
- registry.components[dirName] = {
187
- name: dirName,
188
- dependencies: [...allDependencies],
189
- internalDependencies: [...allInternalDeps],
190
- files: files.map((f) => {
191
- try {
192
- return {
193
- path: f.replace(/\\/g, '/').replace(/^\.\//, ''),
194
- content: fs.readFileSync(f, 'utf-8'),
195
- };
196
- } catch (err) {
197
- console.warn(`Failed to read ${f}: ${err}`);
198
- return { path: f.replace(/\\/g, '/').replace(/^\.\//, ''), content: '' };
199
- }
200
- }).filter(f => f.content !== ''),
201
- };
202
- }
203
-
204
- const componentCount = Object.keys(registry.components).length;
205
- if (componentCount === 0) {
206
- console.warn('Warning: No components found in registry');
207
- }
208
- try {
209
- fs.writeFileSync(OUTPUT_FILE, JSON.stringify(registry, null, 2));
210
- } catch (err) {
211
- console.error(`Failed to write registry: ${err}`);
212
- process.exit(1);
213
- }
214
- console.log(`Registry built: ${componentCount} components → ${OUTPUT_FILE}`);
215
- };
216
-
217
- buildRegistry();
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const COMPONENTS_DIR = './src/components/ui';
5
+ const HOOKS_DIR = './src/hooks';
6
+ const OUTPUT_FILE = './registry.json';
7
+
8
+ // Directories to exclude from registry (not reusable components)
9
+ const EXCLUDE_DIRS = new Set(['icons', 'layout', 'vs-code', 'Showcase.tsx']);
10
+
11
+ interface RegistryFile {
12
+ path: string;
13
+ content: string;
14
+ }
15
+
16
+ interface RegistryComponent {
17
+ name: string;
18
+ dependencies: string[];
19
+ internalDependencies: string[];
20
+ files: RegistryFile[];
21
+ }
22
+
23
+ interface Registry {
24
+ core: {
25
+ dependencies: string[];
26
+ files: RegistryFile[];
27
+ };
28
+ components: Record<string, RegistryComponent>;
29
+ }
30
+
31
+ const getFiles = (dir: string): string[] => {
32
+ const files: string[] = [];
33
+ let entries: string[];
34
+ try {
35
+ entries = fs.readdirSync(dir);
36
+ } catch (err) {
37
+ console.warn(`Cannot read directory ${dir}: ${err}`);
38
+ return files;
39
+ }
40
+ for (const file of entries) {
41
+ const fullPath = path.join(dir, file);
42
+ try {
43
+ const stat = fs.statSync(fullPath);
44
+ if (stat.isDirectory()) {
45
+ files.push(...getFiles(fullPath));
46
+ } else if (file.endsWith('.tsx') || file.endsWith('.ts')) {
47
+ if (!file.includes('.test.') && !file.includes('.stories.')) {
48
+ files.push(fullPath);
49
+ }
50
+ }
51
+ } catch (err) {
52
+ console.warn(`Skipping ${fullPath}: ${err}`);
53
+ }
54
+ }
55
+ return files;
56
+ };
57
+
58
+ const INTERNAL_ALIAS_PREFIXES = ['@/', '@lib/', '@components/', '@app/'];
59
+
60
+ const parseDependencies = (content: string): string[] => {
61
+ const dependencies = new Set<string>();
62
+ const importRegex = /(?:from|import)\s+['"]([^'"]+)['"]/g;
63
+ let match;
64
+
65
+ while ((match = importRegex.exec(content)) !== null) {
66
+ const pkg = match[1];
67
+
68
+ // Skip relative imports
69
+ if (pkg.startsWith('.')) continue;
70
+ // Skip internal aliases
71
+ if (INTERNAL_ALIAS_PREFIXES.some(prefix => pkg.startsWith(prefix))) continue;
72
+
73
+ // Extract package name
74
+ const parts = pkg.split('/');
75
+ if (pkg.startsWith('@') && parts.length >= 2) {
76
+ dependencies.add(`${parts[0]}/${parts[1]}`);
77
+ } else {
78
+ dependencies.add(parts[0]);
79
+ }
80
+ }
81
+
82
+ // Remove React (always a peer dependency)
83
+ dependencies.delete('react');
84
+ dependencies.delete('react-dom');
85
+
86
+ return [...dependencies];
87
+ };
88
+
89
+ const getInternalDeps = (content: string, currentDirName: string): string[] => {
90
+ const internalDeps = new Set<string>();
91
+
92
+ // 1. Alias imports: @/components/ui/xxx or @components/ui/xxx
93
+ const aliasRegex = /from\s+['"](?:@\/?components\/ui)\/([^'"]+)['"]/g;
94
+ let match;
95
+ while ((match = aliasRegex.exec(content)) !== null) {
96
+ const depPath = match[1].split('/')[0];
97
+ if (depPath !== currentDirName && depPath !== 'icons') {
98
+ internalDeps.add(depPath);
99
+ }
100
+ }
101
+
102
+ // 2. Relative imports: ../spinner/Spinner
103
+ const relativeRegex = /from\s+['"]\.\.\/([^'"]+)['"]/g;
104
+ while ((match = relativeRegex.exec(content)) !== null) {
105
+ const depPath = match[1].split('/')[0];
106
+ if (depPath !== currentDirName) {
107
+ internalDeps.add(depPath);
108
+ }
109
+ }
110
+
111
+ return [...internalDeps];
112
+ };
113
+
114
+ /** Collect hook files imported via `@/hooks/xxx` in any of the given source files */
115
+ const collectHookFiles = (sourceFiles: string[]): RegistryFile[] => {
116
+ const seen = new Set<string>();
117
+ const hookFiles: RegistryFile[] = [];
118
+
119
+ for (const file of sourceFiles) {
120
+ let content: string;
121
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { continue; }
122
+
123
+ const hookRegex = /from\s+['"]@\/hooks\/([^'"]+)['"]/g;
124
+ let match;
125
+ while ((match = hookRegex.exec(content)) !== null) {
126
+ // Normalise: strip extension if present, then try .ts and .tsx
127
+ const rawName = match[1].replace(/\.(ts|tsx)$/, '');
128
+ if (seen.has(rawName)) continue;
129
+
130
+ const candidates = [
131
+ path.join(HOOKS_DIR, `${rawName}.ts`),
132
+ path.join(HOOKS_DIR, `${rawName}.tsx`),
133
+ path.join(HOOKS_DIR, rawName, 'index.ts'),
134
+ ];
135
+
136
+ for (const candidate of candidates) {
137
+ if (fs.existsSync(candidate)) {
138
+ seen.add(rawName);
139
+ hookFiles.push({
140
+ path: candidate.replace(/\\/g, '/').replace(/^\.\//, ''),
141
+ content: fs.readFileSync(candidate, 'utf-8'),
142
+ });
143
+ break;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return hookFiles;
150
+ };
151
+
152
+ const buildRegistry = () => {
153
+ console.log('Building component registry...');
154
+
155
+ const registry: Registry = {
156
+ core: {
157
+ dependencies: [
158
+ '@base-ui/react',
159
+ 'clsx',
160
+ 'tailwind-merge',
161
+ 'tailwind-variants',
162
+ 'tailwindcss-animate',
163
+ '@tailwindcss/vite',
164
+ 'autoprefixer',
165
+ 'tailwindcss',
166
+ 'postcss',
167
+ ],
168
+ files: [
169
+ {
170
+ path: 'src/lib/utils/cn.ts',
171
+ content: fs.readFileSync('./src/lib/utils/cn.ts', 'utf-8'),
172
+ },
173
+ {
174
+ path: 'src/styles/index.css',
175
+ content: fs.readFileSync('./src/styles/index.css', 'utf-8'),
176
+ },
177
+ {
178
+ path: 'src/lib/theme/themes.ts',
179
+ content: fs.readFileSync('./src/lib/theme/themes.ts', 'utf-8'),
180
+ },
181
+ {
182
+ path: 'src/lib/theme/ThemeProvider.tsx',
183
+ content: fs.readFileSync('./src/lib/theme/ThemeProvider.tsx', 'utf-8'),
184
+ },
185
+ ],
186
+ },
187
+ components: {},
188
+ };
189
+
190
+ const componentDirs = fs.readdirSync(COMPONENTS_DIR);
191
+
192
+ for (const dirName of componentDirs) {
193
+ if (EXCLUDE_DIRS.has(dirName)) continue;
194
+
195
+ const dirPath = path.join(COMPONENTS_DIR, dirName);
196
+ if (!fs.statSync(dirPath).isDirectory()) continue;
197
+
198
+ const files = getFiles(dirPath);
199
+ if (files.length === 0) continue;
200
+
201
+ // Find main file: prefer <DirName>.tsx, then any .tsx
202
+ const mainFile =
203
+ files.find(
204
+ (f) =>
205
+ f.toLowerCase().includes(dirName.toLowerCase()) &&
206
+ f.endsWith('.tsx')
207
+ ) || files.find((f) => f.endsWith('.tsx'));
208
+
209
+ if (!mainFile) continue;
210
+
211
+ // Parse all files in the component directory for dependencies
212
+ const allDependencies = new Set<string>();
213
+ const allInternalDeps = new Set<string>();
214
+
215
+ for (const file of files) {
216
+ try {
217
+ const content = fs.readFileSync(file, 'utf-8');
218
+ for (const dep of parseDependencies(content)) allDependencies.add(dep);
219
+ for (const dep of getInternalDeps(content, dirName)) allInternalDeps.add(dep);
220
+ } catch (err) {
221
+ console.warn(`Failed to read ${file}: ${err}`);
222
+ }
223
+ }
224
+
225
+ // Collect any hook files this component imports from @/hooks/
226
+ const hookFiles = collectHookFiles(files);
227
+
228
+ const componentFiles: RegistryFile[] = files.map((f) => {
229
+ try {
230
+ return {
231
+ path: f.replace(/\\/g, '/').replace(/^\.\//, ''),
232
+ content: fs.readFileSync(f, 'utf-8'),
233
+ };
234
+ } catch (err) {
235
+ console.warn(`Failed to read ${f}: ${err}`);
236
+ return { path: f.replace(/\\/g, '/').replace(/^\.\//, ''), content: '' };
237
+ }
238
+ }).filter(f => f.content !== '');
239
+
240
+ registry.components[dirName] = {
241
+ name: dirName,
242
+ dependencies: [...allDependencies],
243
+ internalDependencies: [...allInternalDeps],
244
+ files: [...componentFiles, ...hookFiles],
245
+ };
246
+ }
247
+
248
+ const componentCount = Object.keys(registry.components).length;
249
+ if (componentCount === 0) {
250
+ console.warn('Warning: No components found in registry');
251
+ }
252
+ try {
253
+ fs.writeFileSync(OUTPUT_FILE, JSON.stringify(registry, null, 2));
254
+ } catch (err) {
255
+ console.error(`Failed to write registry: ${err}`);
256
+ process.exit(1);
257
+ }
258
+ console.log(`Registry built: ${componentCount} components → ${OUTPUT_FILE}`);
259
+ };
260
+
261
+ buildRegistry();
package/scripts/ui-cli.ts CHANGED
@@ -6,7 +6,7 @@ import readline from 'readline';
6
6
 
7
7
  // ─── Constants ────────────────────────────────────────────────────────────────
8
8
 
9
- const VERSION = '0.2.11';
9
+ const VERSION = '0.3.0';
10
10
  const REGISTRY_LOCAL = './registry.json';
11
11
  const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json';
12
12
 
@@ -685,6 +685,62 @@ const patchMainTsxComponent = (cwd: string, componentName: string) => {
685
685
  ok(`Added <${tagName}> to ${path.relative(cwd, mainPath)}.`);
686
686
  };
687
687
 
688
+ // ─── index.ts barrel update ───────────────────────────────────────────────────
689
+
690
+ const UI_INDEX_PATH = 'src/components/ui/index.ts';
691
+ const UI_INDEX_DIR = 'src/components/ui';
692
+
693
+ /** Pick the primary .tsx component file (skip tests, stories, hooks) */
694
+ const pickMainFile = (files: RegistryFile[]): RegistryFile | undefined =>
695
+ files.find(
696
+ (f) =>
697
+ f.path.startsWith(UI_INDEX_DIR + '/') &&
698
+ f.path.endsWith('.tsx') &&
699
+ !f.path.includes('.test.') &&
700
+ !f.path.includes('.stories.'),
701
+ );
702
+
703
+ /** Append `export * from './dir/File';` to index.ts if not already present */
704
+ const addToComponentIndex = (componentFiles: RegistryFile[], cwd: string) => {
705
+ const indexPath = path.join(cwd, UI_INDEX_PATH);
706
+ if (!fs.existsSync(indexPath)) return;
707
+
708
+ const mainFile = pickMainFile(componentFiles);
709
+ if (!mainFile) return;
710
+
711
+ const withoutExt = mainFile.path.replace(/\.tsx$/, '');
712
+ const relPath = './' + path.relative(UI_INDEX_DIR, withoutExt).replace(/\\/g, '/');
713
+ const exportLine = `export * from '${relPath}';`;
714
+
715
+ const content = fs.readFileSync(indexPath, 'utf-8');
716
+ if (content.includes(relPath)) return;
717
+
718
+ fs.writeFileSync(indexPath, content.trimEnd() + '\n' + exportLine + '\n');
719
+ ok(`Updated index.ts: added ${c.dim}${relPath}${c.reset}`);
720
+ };
721
+
722
+ /** Remove the export line from index.ts */
723
+ const removeFromComponentIndex = (componentFiles: RegistryFile[], cwd: string) => {
724
+ const indexPath = path.join(cwd, UI_INDEX_PATH);
725
+ if (!fs.existsSync(indexPath)) return;
726
+
727
+ const mainFile = pickMainFile(componentFiles);
728
+ if (!mainFile) return;
729
+
730
+ const withoutExt = mainFile.path.replace(/\.tsx$/, '');
731
+ const relPath = './' + path.relative(UI_INDEX_DIR, withoutExt).replace(/\\/g, '/');
732
+
733
+ const content = fs.readFileSync(indexPath, 'utf-8');
734
+ const filtered = content
735
+ .split('\n')
736
+ .filter((line) => !line.includes(relPath))
737
+ .join('\n');
738
+
739
+ if (filtered === content) return;
740
+ fs.writeFileSync(indexPath, filtered);
741
+ ok(`Updated index.ts: removed ${c.dim}${relPath}${c.reset}`);
742
+ };
743
+
688
744
  // ─── Component add/remove ─────────────────────────────────────────────────────
689
745
 
690
746
  const addComponent = (
@@ -738,6 +794,8 @@ const addComponent = (
738
794
  fs.writeFileSync(targetPath, content);
739
795
  ok(`Created: ${file.path}`);
740
796
  }
797
+
798
+ addToComponentIndex(component.files, cwd);
741
799
  };
742
800
 
743
801
  const removeComponent = (
@@ -772,6 +830,8 @@ const removeComponent = (
772
830
  warn(`Could not remove directory: ${err instanceof Error ? err.message : err}`);
773
831
  }
774
832
  }
833
+
834
+ removeFromComponentIndex(component.files as RegistryFile[], cwd);
775
835
  };
776
836
 
777
837
  // ─── Help texts ───────────────────────────────────────────────────────────────