basuicn 0.1.8 → 0.2.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 +1 -1
- package/package.json +1 -1
- package/registry.json +3 -3
- package/scripts/ui-cli.ts +1 -1
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.
|
|
30
|
+
var VERSION = "0.2.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 = {
|
package/package.json
CHANGED
package/registry.json
CHANGED
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"files": [
|
|
98
98
|
{
|
|
99
99
|
"path": "src/components/ui/autocomplete/Autocomplete.tsx",
|
|
100
|
-
"content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-md 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 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-md border border-border bg-background text-popover-foreground shadow-md 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-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 },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(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 | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n
|
|
100
|
+
"content": "import * as React from 'react';\r\nimport { Combobox as BaseCombobox } from '@base-ui/react';\r\nimport { Check, X, Loader2 } from 'lucide-react';\r\nimport { tv } from 'tailwind-variants';\r\nimport { cn } from '@/lib/utils/cn';\r\n\r\nconst autocompleteVariants = tv({\r\n slots: {\r\n root: 'flex flex-col gap-1.5 w-full',\r\n inputContainer: 'flex items-center min-h-10 w-full rounded-md 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 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-md border border-border bg-background text-popover-foreground shadow-md 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-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 },\r\n});\r\n\r\nexport interface AutocompleteOption {\r\n label: string;\r\n value: string;\r\n}\r\n\r\nexport interface AutocompleteProps {\r\n options: AutocompleteOption[];\r\n label?: string;\r\n placeholder?: string;\r\n value?: string;\r\n defaultValue?: string;\r\n onValueChange?: (value: string) => void;\r\n isLoading?: boolean;\r\n className?: string;\r\n emptyText?: string;\r\n leftIcon?: React.ReactNode;\r\n}\r\n\r\nconst Autocomplete = React.forwardRef<HTMLInputElement, AutocompleteProps>(\r\n ({\r\n options,\r\n label,\r\n placeholder,\r\n value,\r\n defaultValue,\r\n onValueChange,\r\n isLoading,\r\n className,\r\n emptyText = 'No results found.',\r\n leftIcon,\r\n }, ref) => {\r\n const [inputValue, setInputValue] = React.useState('');\r\n const [open, setOpen] = React.useState(false);\r\n const [internalValue, setInternalValue] = React.useState<string | null>(defaultValue ?? null);\r\n const inputGroupRef = React.useRef<HTMLDivElement>(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 | null) => {\r\n isSelectingRef.current = true;\r\n if (value === undefined) setInternalValue(newVal);\r\n if (newVal !== null) onValueChange?.(newVal);\r\n };\r\n\r\n const handleInputValueChange = (val: string) => {\r\n // Khi base-ui cập nhật input sau khi chọn item, bỏ qua để 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 // Chỉ mở popup khi người dùng đang gõ\r\n setOpen(val.length > 0);\r\n };\r\n\r\n // Block mọi lần mở từ focus/click — chỉ cho phép đóng từ bên ngoài (click-outside, select)\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (!newOpen) setOpen(false);\r\n };\r\n\r\n const handleClear = (e: React.MouseEvent) => {\r\n e.preventDefault();\r\n e.stopPropagation();\r\n handleValueChange(null);\r\n setInputValue('');\r\n setOpen(false);\r\n };\r\n\r\n const filteredOptions = React.useMemo(() => {\r\n if (!inputValue) return options;\r\n if (activeValue) {\r\n const selected = options.find(o => o.value === activeValue);\r\n if (selected && inputValue === selected.label) return options;\r\n }\r\n return options.filter(o =>\r\n o.label.toLowerCase().includes(inputValue.toLowerCase())\r\n );\r\n }, [options, inputValue, activeValue]);\r\n\r\n const { root, inputContainer, input, popup, item, indicator } = autocompleteVariants();\r\n\r\n return (\r\n <BaseCombobox.Root\r\n value={activeValue}\r\n onValueChange={handleValueChange}\r\n onInputValueChange={handleInputValueChange}\r\n open={open}\r\n onOpenChange={handleOpenChange}\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\r\n {isLoading ? (\r\n <Loader2 className=\"absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground\" />\r\n ) : activeValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Input\r\n ref={ref}\r\n placeholder={placeholder}\r\n className={cn(input(), (isLoading || activeValue) && 'pr-8')}\r\n />\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 <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\nAutocomplete.displayName = 'Autocomplete';\r\n\r\nexport { Autocomplete };\r\n"
|
|
101
101
|
}
|
|
102
102
|
]
|
|
103
103
|
},
|
|
@@ -240,7 +240,7 @@
|
|
|
240
240
|
"files": [
|
|
241
241
|
{
|
|
242
242
|
"path": "src/components/ui/combobox/ComboBox.tsx",
|
|
243
|
-
"content": "import * 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-md 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-md border border-border bg-background text-popover-foreground shadow-md 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\r\n const activeValue = value !== undefined ? value : internalValue;\r\n\r\n const handleValueChange = (newVal: string | string[] | null) => {\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 handleClear = (e: React.MouseEvent) => {\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={setInputValue}\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 {hasValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors mr-1\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Trigger className=\"text-muted-foreground transition-transform group-data-open:rotate-180 ml-auto\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\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"
|
|
243
|
+
"content": "import * 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-md 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-md border border-border bg-background text-popover-foreground shadow-md 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.MouseEvent) => {\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 {hasValue && (\r\n <button\r\n type=\"button\"\r\n aria-label=\"Clear selection\"\r\n onClick={handleClear}\r\n className=\"p-1 hover:bg-muted rounded-full text-muted-foreground transition-colors mr-1\"\r\n >\r\n <X className=\"h-3.5 w-3.5\" />\r\n </button>\r\n )}\r\n\r\n <BaseCombobox.Trigger className=\"text-muted-foreground transition-transform group-data-open:rotate-180 ml-auto\">\r\n {isLoading ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : <ChevronDown className=\"h-4 w-4\" />}\r\n </BaseCombobox.Trigger>\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"
|
|
244
244
|
}
|
|
245
245
|
]
|
|
246
246
|
},
|
|
@@ -363,7 +363,7 @@
|
|
|
363
363
|
"files": [
|
|
364
364
|
{
|
|
365
365
|
"path": "src/components/ui/menu-bar/MenuBar.tsx",
|
|
366
|
-
"content": "import * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { useNavigate, useMatch } from 'react-router-dom';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, ExternalLink } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n/* ─── Types ─────────────────────────────────────────────────────────────── */\n\n/** How the menu item behaves when clicked */\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\n\n/** Config for a single item inside a menu */\nexport interface MenuBarItemConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** @default 'button' */\n type?: MenuBarItemType;\n /** Route path for type='link', full URL for type='external' */\n href?: string;\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\n onClick?: () => void;\n shortcut?: string;\n disabled?: boolean;\n /** Renders a separator line before this item */\n separator?: boolean;\n /** Nested items — renders as a flyout submenu (unlimited depth) */\n children?: MenuBarItemConfig[];\n}\n\n/** Config for one top-level menu (trigger + its dropdown) */\nexport interface MenuBarMenuConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n items: MenuBarItemConfig[];\n disabled?: boolean;\n}\n\n/* ─── Variants ──────────────────────────────────────────────────────────── */\n\nconst menuBarVariants = tv({\n slots: {\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\n trigger:\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\n content:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n item:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n itemActive: 'bg-accent/50 font-medium',\n subTrigger:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n separator: '-mx-1 my-1 h-px bg-border',\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n },\n});\n\nconst styles = menuBarVariants();\n\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\n\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\n));\nMenuBar.displayName = 'MenuBar';\n\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\n\nconst MenuBarMenu = BaseMenu.Root;\n\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\n\nexport interface MenuBarTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\n ({ className, ...props }, ref) => (\n <BaseMenu.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n className={styles.trigger({ className })}\n {...props}\n />\n )\n);\nMenuBarTrigger.displayName = 'MenuBarTrigger';\n\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\n\nexport interface MenuBarContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n side?: 'top' | 'right' | 'bottom' | 'left';\n align?: 'start' | 'center' | 'end';\n sideOffset?: number;\n}\n\nconst MenuBarContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarContent.displayName = 'MenuBarContent';\n\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\n\nexport interface MenuBarItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n className?: string;\n /** Applies active/highlighted styling (e.g. current route) */\n active?: boolean;\n}\n\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\n ({ className, active, children, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n >\n {children}\n </BaseMenu.Item>\n )\n);\nMenuBarItem.displayName = 'MenuBarItem';\n\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\n\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nMenuBarSeparator.displayName = 'MenuBarSeparator';\n\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\n\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nMenuBarLabel.displayName = 'MenuBarLabel';\n\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\n\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nMenuBarShortcut.displayName = 'MenuBarShortcut';\n\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\n\nconst MenuBarSub = BaseMenu.SubmenuRoot;\n\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n MenuBarSubTriggerProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\n\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarSubContent.displayName = 'MenuBarSubContent';\n\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\n\nconst MenuBarGroup = BaseMenu.Group;\n\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\n\n/**\n * Internal recursive renderer for MenuBarItemConfig.\n * Handles all 4 item types, separators, and unlimited submenu depth.\n */\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\n const navigate = useNavigate();\n const isLinkType = item.type === 'link' && !!item.href;\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\n const isActive = isLinkType && !!match;\n\n const handleClick = React.useCallback(() => {\n if (item.type === 'link' && item.href) {\n navigate(item.href);\n } else if (item.type === 'external' && item.href) {\n window.open(item.href, '_blank', 'noopener,noreferrer');\n } else {\n item.onClick?.();\n }\n }, [item, navigate]);\n\n if (item.children && item.children.length > 0) {\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarSub>\n <MenuBarSubTrigger disabled={item.disabled}>\n {item.icon}\n {item.label}\n </MenuBarSubTrigger>\n <MenuBarSubContent>\n {item.children.map((child) => (\n <MenuBarItemRenderer key={child.id} item={child} />\n ))}\n </MenuBarSubContent>\n </MenuBarSub>\n </>\n );\n }\n\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\n {item.icon}\n {item.label}\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\n </MenuBarItem>\n </>\n );\n};\n\n/** Props for the config-driven MenuBarNav component */\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\n /** Array of top-level menus, each with nested items supporting unlimited depth */\n menus: MenuBarMenuConfig[];\n}\n\n/**\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\n * triggers, dropdowns, submenus, separators, active link states.\n *\n * @example\n * ```tsx\n * <MenuBarNav menus={[\n * {\n * id: 'file', label: 'File',\n * items: [\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\n * { id: 'sep', label: '---', separator: true, ... },\n * ],\n * },\n * ]} />\n * ```\n */\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\n ({ menus, className, ...props }, ref) => (\n <MenuBar ref={ref} className={className} {...props}>\n {menus.map((menu) => (\n <MenuBarMenu key={menu.id}>\n <MenuBarTrigger disabled={menu.disabled}>\n {menu.icon}\n {menu.label}\n </MenuBarTrigger>\n <MenuBarContent>\n {menu.items.map((item) => (\n <MenuBarItemRenderer key={item.id} item={item} />\n ))}\n </MenuBarContent>\n </MenuBarMenu>\n ))}\n </MenuBar>\n )\n);\nMenuBarNav.displayName = 'MenuBarNav';\n\n/* ─── Exports ───────────────────────────────────────────────────────────── */\n\nexport {\n menuBarVariants,\n // Primitive API\n MenuBar,\n MenuBarMenu,\n MenuBarTrigger,\n MenuBarContent,\n MenuBarItem,\n MenuBarSeparator,\n MenuBarLabel,\n MenuBarShortcut,\n MenuBarSub,\n MenuBarSubTrigger,\n MenuBarSubContent,\n MenuBarGroup,\n // Config-driven API\n MenuBarNav,\n};\n"
|
|
366
|
+
"content": "\"use client\"\nimport * as React from 'react';\nimport { Menu as BaseMenu } from '@base-ui/react';\nimport { useNavigate, useMatch } from 'react-router-dom';\nimport { tv } from 'tailwind-variants';\nimport { ChevronRight, ExternalLink } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\n\n/* ─── Types ─────────────────────────────────────────────────────────────── */\n\n/** How the menu item behaves when clicked */\nexport type MenuBarItemType = 'link' | 'button' | 'modal' | 'external';\n\n/** Config for a single item inside a menu */\nexport interface MenuBarItemConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** @default 'button' */\n type?: MenuBarItemType;\n /** Route path for type='link', full URL for type='external' */\n href?: string;\n /** Called on click for type='button' | 'modal', and as fallback for 'link' | 'external' */\n onClick?: () => void;\n shortcut?: string;\n disabled?: boolean;\n /** Renders a separator line before this item */\n separator?: boolean;\n /** Nested items — renders as a flyout submenu (unlimited depth) */\n children?: MenuBarItemConfig[];\n}\n\n/** Config for one top-level menu entry.\n *\n * - Có `items` → dropdown menu bình thường\n * - Không có `items` → click thẳng vào entry (dùng `type` + `href` / `onClick`)\n */\nexport interface MenuBarMenuConfig {\n id: string;\n label: React.ReactNode;\n icon?: React.ReactNode;\n /** Nếu có items → render dropdown. Nếu bỏ qua → render trực tiếp như button/link */\n items?: MenuBarItemConfig[];\n disabled?: boolean;\n /** Chỉ dùng khi không có items. @default 'button' */\n type?: MenuBarItemType;\n /** Route path (type='link') hoặc URL (type='external') */\n href?: string;\n /** Callback khi click (type='button' | 'modal') */\n onClick?: () => void;\n}\n\n/* ─── Variants ──────────────────────────────────────────────────────────── */\n\nconst menuBarVariants = tv({\n slots: {\n root: 'flex items-center gap-0.5 rounded-md border border-border bg-background p-1',\n trigger:\n 'inline-flex items-center gap-1.5 rounded-sm px-3 py-1.5 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 cursor-pointer select-none',\n content:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n item:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n itemActive: 'bg-accent/50 font-medium',\n subTrigger:\n 'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n subContent:\n 'z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-background p-1 text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',\n separator: '-mx-1 my-1 h-px bg-border',\n label: 'px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider',\n shortcut: 'ml-auto text-xs tracking-widest opacity-60',\n },\n});\n\nconst styles = menuBarVariants();\n\n/* ─── MenuBar ───────────────────────────────────────────────────────────── */\n\nexport interface MenuBarProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBar = React.forwardRef<HTMLDivElement, MenuBarProps>(({ className, ...props }, ref) => (\n <div ref={ref} role=\"menubar\" className={styles.root({ className })} {...props} />\n));\nMenuBar.displayName = 'MenuBar';\n\n/* ─── MenuBarMenu ───────────────────────────────────────────────────────── */\n\nconst MenuBarMenu = BaseMenu.Root;\n\n/* ─── MenuBarTrigger ────────────────────────────────────────────────────── */\n\nexport interface MenuBarTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Trigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarTrigger = React.forwardRef<HTMLButtonElement, MenuBarTriggerProps>(\n ({ className, ...props }, ref) => (\n <BaseMenu.Trigger\n ref={ref as React.Ref<HTMLButtonElement>}\n className={styles.trigger({ className })}\n {...props}\n />\n )\n);\nMenuBarTrigger.displayName = 'MenuBarTrigger';\n\n/* ─── MenuBarButton (top-level direct item, no dropdown) ───────────────── */\n\nexport interface MenuBarButtonProps extends React.ComponentPropsWithoutRef<'button'> {\n /** Highlights the button (e.g. active route) */\n active?: boolean;\n}\n\nconst MenuBarButton = React.forwardRef<HTMLButtonElement, MenuBarButtonProps>(\n ({ className, active, ...props }, ref) => (\n <button\n ref={ref}\n className={styles.trigger({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n />\n )\n);\nMenuBarButton.displayName = 'MenuBarButton';\n\n/* ─── MenuBarContent ────────────────────────────────────────────────────── */\n\nexport interface MenuBarContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n side?: 'top' | 'right' | 'bottom' | 'left';\n align?: 'start' | 'center' | 'end';\n sideOffset?: number;\n}\n\nconst MenuBarContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarContentProps\n>(({ className, side = 'bottom', align = 'start', sideOffset = 4, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner side={side} align={align} sideOffset={sideOffset}>\n <BaseMenu.Popup ref={ref} className={styles.content({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarContent.displayName = 'MenuBarContent';\n\n/* ─── MenuBarItem ───────────────────────────────────────────────────────── */\n\nexport interface MenuBarItemProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Item>, 'className'> {\n className?: string;\n /** Applies active/highlighted styling (e.g. current route) */\n active?: boolean;\n}\n\nconst MenuBarItem = React.forwardRef<React.ComponentRef<typeof BaseMenu.Item>, MenuBarItemProps>(\n ({ className, active, children, ...props }, ref) => (\n <BaseMenu.Item\n ref={ref}\n className={styles.item({ className: cn(active && styles.itemActive(), className) })}\n {...props}\n >\n {children}\n </BaseMenu.Item>\n )\n);\nMenuBarItem.displayName = 'MenuBarItem';\n\n/* ─── MenuBarSeparator ──────────────────────────────────────────────────── */\n\nexport interface MenuBarSeparatorProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarSeparator = React.forwardRef<HTMLDivElement, MenuBarSeparatorProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.separator({ className })} {...props} />\n )\n);\nMenuBarSeparator.displayName = 'MenuBarSeparator';\n\n/* ─── MenuBarLabel ──────────────────────────────────────────────────────── */\n\nexport interface MenuBarLabelProps extends React.ComponentPropsWithoutRef<'div'> {}\n\nconst MenuBarLabel = React.forwardRef<HTMLDivElement, MenuBarLabelProps>(\n ({ className, ...props }, ref) => (\n <div ref={ref} className={styles.label({ className })} {...props} />\n )\n);\nMenuBarLabel.displayName = 'MenuBarLabel';\n\n/* ─── MenuBarShortcut ───────────────────────────────────────────────────── */\n\nconst MenuBarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (\n <span className={styles.shortcut({ className })} {...props} />\n);\nMenuBarShortcut.displayName = 'MenuBarShortcut';\n\n/* ─── MenuBarSub ────────────────────────────────────────────────────────── */\n\nconst MenuBarSub = BaseMenu.SubmenuRoot;\n\n/* ─── MenuBarSubTrigger ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubTriggerProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.SubmenuTrigger>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubTrigger = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.SubmenuTrigger>,\n MenuBarSubTriggerProps\n>(({ className, children, ...props }, ref) => (\n <BaseMenu.SubmenuTrigger ref={ref} className={styles.subTrigger({ className })} {...props}>\n {children}\n <ChevronRight className=\"ml-auto\" />\n </BaseMenu.SubmenuTrigger>\n));\nMenuBarSubTrigger.displayName = 'MenuBarSubTrigger';\n\n/* ─── MenuBarSubContent ─────────────────────────────────────────────────── */\n\nexport interface MenuBarSubContentProps\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseMenu.Popup>, 'className'> {\n className?: string;\n}\n\nconst MenuBarSubContent = React.forwardRef<\n React.ComponentRef<typeof BaseMenu.Popup>,\n MenuBarSubContentProps\n>(({ className, ...props }, ref) => (\n <BaseMenu.Portal>\n <BaseMenu.Positioner sideOffset={-4}>\n <BaseMenu.Popup ref={ref} className={styles.subContent({ className })} {...props} />\n </BaseMenu.Positioner>\n </BaseMenu.Portal>\n));\nMenuBarSubContent.displayName = 'MenuBarSubContent';\n\n/* ─── MenuBarGroup ──────────────────────────────────────────────────────── */\n\nconst MenuBarGroup = BaseMenu.Group;\n\n/* ─── Config-driven layer ───────────────────────────────────────────────── */\n\n/**\n * Internal recursive renderer for MenuBarItemConfig.\n * Handles all 4 item types, separators, and unlimited submenu depth.\n */\nconst MenuBarItemRenderer = ({ item }: { item: MenuBarItemConfig }) => {\n const navigate = useNavigate();\n const isLinkType = item.type === 'link' && !!item.href;\n const match = useMatch(isLinkType ? item.href! : '__NO_MATCH__');\n const isActive = isLinkType && !!match;\n\n const handleClick = React.useCallback(() => {\n if (item.type === 'link' && item.href) {\n navigate(item.href);\n } else if (item.type === 'external' && item.href) {\n window.open(item.href, '_blank', 'noopener,noreferrer');\n } else {\n item.onClick?.();\n }\n }, [item, navigate]);\n\n if (item.children && item.children.length > 0) {\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarSub>\n <MenuBarSubTrigger disabled={item.disabled}>\n {item.icon}\n {item.label}\n </MenuBarSubTrigger>\n <MenuBarSubContent>\n {item.children.map((child) => (\n <MenuBarItemRenderer key={child.id} item={child} />\n ))}\n </MenuBarSubContent>\n </MenuBarSub>\n </>\n );\n }\n\n return (\n <>\n {item.separator && <MenuBarSeparator />}\n <MenuBarItem active={isActive} onClick={handleClick} disabled={item.disabled}>\n {item.icon}\n {item.label}\n {item.shortcut && <MenuBarShortcut>{item.shortcut}</MenuBarShortcut>}\n {item.type === 'external' && <ExternalLink className=\"ml-auto !size-3 opacity-50\" />}\n </MenuBarItem>\n </>\n );\n};\n\n/** Props for the config-driven MenuBarNav component */\nexport interface MenuBarNavProps extends Omit<MenuBarProps, 'children'> {\n /** Array of top-level menus, each with nested items supporting unlimited depth */\n menus: MenuBarMenuConfig[];\n}\n\n/**\n * Config-driven menu bar. Pass a `menus` array and it renders everything —\n * triggers, dropdowns, submenus, separators, active link states.\n *\n * @example\n * ```tsx\n * <MenuBarNav menus={[\n * {\n * id: 'file', label: 'File',\n * items: [\n * { id: 'new', label: 'New', type: 'button', onClick: handleNew, shortcut: '⌘N' },\n * { id: 'open', label: 'Open', type: 'link', href: '/open' },\n * { id: 'sep', label: '---', separator: true, ... },\n * ],\n * },\n * ]} />\n * ```\n */\n/** Internal: renders a direct (no-dropdown) top-level entry */\nconst MenuBarDirectRenderer = ({ menu }: { menu: MenuBarMenuConfig }) => {\n const navigate = useNavigate();\n const isLink = menu.type === 'link' && !!menu.href;\n const match = useMatch(isLink ? menu.href! : '__NO_MATCH__');\n\n const handleClick = React.useCallback(() => {\n if (menu.type === 'link' && menu.href) navigate(menu.href);\n else if (menu.type === 'external' && menu.href) window.open(menu.href, '_blank', 'noopener,noreferrer');\n else menu.onClick?.();\n }, [menu, navigate]);\n\n return (\n <MenuBarButton active={isLink && !!match} disabled={menu.disabled} onClick={handleClick}>\n {menu.icon}\n {menu.label}\n {menu.type === 'external' && <ExternalLink className=\"!size-3 opacity-50\" />}\n </MenuBarButton>\n );\n};\n\nconst MenuBarNav = React.forwardRef<HTMLDivElement, MenuBarNavProps>(\n ({ menus, className, ...props }, ref) => (\n <MenuBar ref={ref} className={className} {...props}>\n {menus.map((menu) =>\n !menu.items || menu.items.length === 0 ? (\n // Direct item — không có dropdown\n <MenuBarDirectRenderer key={menu.id} menu={menu} />\n ) : (\n // Dropdown menu bình thường\n <MenuBarMenu key={menu.id}>\n <MenuBarTrigger disabled={menu.disabled}>\n {menu.icon}\n {menu.label}\n </MenuBarTrigger>\n <MenuBarContent>\n {menu.items.map((item) => (\n <MenuBarItemRenderer key={item.id} item={item} />\n ))}\n </MenuBarContent>\n </MenuBarMenu>\n )\n )}\n </MenuBar>\n )\n);\nMenuBarNav.displayName = 'MenuBarNav';\n\n/* ─── Exports ───────────────────────────────────────────────────────────── */\n\nexport {\n menuBarVariants,\n // Primitive API\n MenuBar,\n MenuBarMenu,\n MenuBarTrigger,\n MenuBarButton,\n MenuBarContent,\n MenuBarItem,\n MenuBarSeparator,\n MenuBarLabel,\n MenuBarShortcut,\n MenuBarSub,\n MenuBarSubTrigger,\n MenuBarSubContent,\n MenuBarGroup,\n // Config-driven API\n MenuBarNav,\n};\n"
|
|
367
367
|
}
|
|
368
368
|
]
|
|
369
369
|
},
|
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.
|
|
9
|
+
const VERSION = '0.2.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
|
|