basuicn 0.3.10 → 0.3.12

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.3.9";
30
+ var VERSION = "0.3.12";
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "basuicn",
3
3
  "private": false,
4
- "version": "0.3.10",
4
+ "version": "0.3.12",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -106,6 +106,7 @@
106
106
  "dependencies": {
107
107
  "@recharts/devtools": "^0.0.11",
108
108
  "keen-slider": "^6.8.6",
109
+ "qrcode.react": "^4.2.0",
109
110
  "recharts": "^3.8.1",
110
111
  "zustand": "^5.0.12"
111
112
  }
package/registry.json CHANGED
@@ -305,7 +305,7 @@
305
305
  "files": [
306
306
  {
307
307
  "path": "src/components/ui/datepicker/DatePicker.tsx",
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\n\r\n// ---------- types ----------\r\n\r\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\nexport type TimePickerStyle = 'input' | 'select';\r\n\r\ninterface TimeParts {\r\n h: string;\r\n m: string;\r\n s: string;\r\n}\r\n\r\n/** Props for the DatePicker component */\r\nexport interface DatePickerProps {\r\n /** Picker mode: single date, date range, or time-only */\r\n mode?: DatePickerMode;\r\n /** Selected date (Date for single, DateRange for range) */\r\n date?: Date | DateRange;\r\n /** Callback fired when the date changes */\r\n onDateChange?: (date: Date | DateRange | undefined) => void;\r\n /** Alternative callback (alias for onDateChange) */\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n /** Current time string, only used when mode is 'time-only' */\r\n timeValue?: string;\r\n /** Callback fired when the time value changes (time-only mode) */\r\n onTimeChange?: (time: string) => void;\r\n /** Label text displayed above the picker */\r\n label?: string;\r\n /** Placeholder text when no date is selected */\r\n placeholder?: string;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Show time picker alongside the calendar */\r\n showTime?: boolean;\r\n /** Time format: hours only, hours:minutes, or hours:minutes:seconds */\r\n timeFormat?: TimeFormat;\r\n /** Time picker UI style: native input or dropdown selects */\r\n timePickerStyle?: TimePickerStyle;\r\n /** Disable the entire picker */\r\n disabled?: boolean;\r\n className?: string;\r\n /** Helper text displayed below the picker */\r\n description?: string;\r\n /** Error message displayed below the picker (replaces description) */\r\n error?: string;\r\n required?: boolean;\r\n}\r\n\r\n// ---------- helpers ----------\r\n\r\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\r\n\r\nfunction parseTimeParts(timeStr: string): TimeParts {\r\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\r\n return {\r\n h: h.padStart(2, '0'),\r\n m: m.padStart(2, '0'),\r\n s: s.padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\r\n if (fmt === 'HH') return parts.h;\r\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\r\n return `${parts.h}:${parts.m}:${parts.s}`;\r\n}\r\n\r\nfunction applyTimeToDate(base: Date, parts: TimeParts): Date {\r\n const d = new Date(base);\r\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\r\n return d;\r\n}\r\n\r\nfunction dateToTimeParts(d: Date): TimeParts {\r\n return {\r\n h: d.getHours().toString().padStart(2, '0'),\r\n m: d.getMinutes().toString().padStart(2, '0'),\r\n s: d.getSeconds().toString().padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\r\n const datePart = format(d, 'dd/MM/yyyy');\r\n if (!showTime) return datePart;\r\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\r\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\r\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\r\n}\r\n\r\nfunction padOptions(count: number) {\r\n return Array.from({ length: count }, (_, i) => ({\r\n label: i.toString().padStart(2, '0'),\r\n value: i.toString().padStart(2, '0'),\r\n }));\r\n}\r\n\r\nconst hoursOptions = padOptions(24);\r\nconst minutesOptions = padOptions(60);\r\nconst secondsOptions = padOptions(60);\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// ---------- sub-components ----------\r\n\r\ninterface NativeSelectProps {\r\n value: string;\r\n options: { label: string; value: string }[];\r\n onChange: (val: string) => void;\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\r\n <select\r\n aria-label={ariaLabel}\r\n value={value}\r\n onChange={(e) => onChange(e.target.value)}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n >\r\n {options.map((o) => (\r\n <option key={o.value} value={o.value}>{o.label}</option>\r\n ))}\r\n </select>\r\n);\r\n\r\ninterface TimePickerProps {\r\n parts: TimeParts;\r\n onChange: (parts: TimeParts) => void;\r\n timeFormat: TimeFormat;\r\n timePickerStyle: TimePickerStyle;\r\n}\r\n\r\nconst TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\r\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\r\n const showSeconds = timeFormat === 'HH:mm:ss';\r\n\r\n if (timePickerStyle === 'input') {\r\n const step = showSeconds ? 1 : 60;\r\n const rawValue = showSeconds\r\n ? `${parts.h}:${parts.m}:${parts.s}`\r\n : `${parts.h}:${parts.m}`;\r\n\r\n return (\r\n <input\r\n type=\"time\"\r\n value={rawValue}\r\n step={step}\r\n onChange={(e) => {\r\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\r\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\r\n }}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n />\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"flex items-center gap-1.5\">\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Hours\"\r\n value={parts.h}\r\n options={hoursOptions}\r\n onChange={(val) => onChange({ ...parts, h: val })}\r\n />\r\n </div>\r\n {showMinutes && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Minutes\"\r\n value={parts.m}\r\n options={minutesOptions}\r\n onChange={(val) => onChange({ ...parts, m: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n {showSeconds && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Seconds\"\r\n value={parts.s}\r\n options={secondsOptions}\r\n onChange={(val) => onChange({ ...parts, s: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\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 required,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n const timeParts = 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 }, [date, timeValue, mode]);\r\n\r\n const rangeTimeParts = React.useMemo<{ from: TimeParts; to: TimeParts }>(() => {\r\n if (mode !== 'range') return { from: DEFAULT_TIME, to: DEFAULT_TIME };\r\n const range = date as DateRange | undefined;\r\n return {\r\n from: range?.from ? dateToTimeParts(range.from) : DEFAULT_TIME,\r\n to: range?.to ? dateToTimeParts(range.to) : DEFAULT_TIME,\r\n };\r\n }, [date, mode]);\r\n\r\n const emitRange = (newRange: DateRange | undefined) => {\r\n onDateChange?.(newRange);\r\n onChange?.(newRange);\r\n };\r\n\r\n const handlePartsChange = (newParts: TimeParts) => {\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 handleRangePartsChange = (newParts: TimeParts, which: 'from' | 'to') => {\r\n const range = date as DateRange | undefined;\r\n const target = range?.[which];\r\n if (!range || !target) return;\r\n emitRange({ ...range, [which]: applyTimeToDate(target, newParts) });\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 return;\r\n }\r\n if (mode === 'range' && showTime) {\r\n const newRange = selectedDate as DateRange;\r\n const preserved: DateRange = {\r\n from: newRange.from ? applyTimeToDate(newRange.from, rangeTimeParts.from) : undefined,\r\n to: newRange.to ? applyTimeToDate(newRange.to, rangeTimeParts.to) : undefined,\r\n };\r\n emitRange(preserved);\r\n return;\r\n }\r\n // Because of our mode checking, we can be confident here\r\n onDateChange?.(selectedDate as DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n };\r\n\r\n // ---------- render trigger label ----------\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 const fmtOne = (d: Date) => (showTime ? formatDateDisplay(d, true, timeFormat) : format(d, 'dd/MM/yyyy'));\r\n if (range.from && range.to) {\r\n return <span>{fmtOne(range.from)} – {fmtOne(range.to)}</span>;\r\n }\r\n if (range.from) return <span>{fmtOne(range.from)} –</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 isRangeWithTime = mode === 'range' && showTime;\r\n const needsTimePicker = isTimeMode || (mode === 'single' && showTime);\r\n const timeLegend = timeFormat === 'HH' ? 'Select hour' : timeFormat === 'HH:mm' ? 'Hour : Minute' : 'Hour : Minute : Second';\r\n\r\n return (\r\n <div ref={ref} className={`flex flex-col gap-1.5 w-full ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\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={locales.vi}\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={locales.vi}\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 {/* Time picker — single / time-only */}\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>{timeLegend}</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 {/* Time picker — range (2 bộ: Từ / Đến) */}\r\n {isRangeWithTime && (() => {\r\n const range = date as DateRange | undefined;\r\n const hasFrom = !!range?.from;\r\n const hasTo = !!range?.to;\r\n return (\r\n <div className=\"border-t border-border p-3 flex flex-col gap-3\">\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>{timeLegend}</span>\r\n </div>\r\n <div className=\"grid grid-cols-2 gap-3\">\r\n <div className=\"flex flex-col gap-1.5\">\r\n <span className=\"text-[11px] font-medium text-muted-foreground\">Từ</span>\r\n <div className={!hasFrom ? 'pointer-events-none opacity-50' : ''}>\r\n <TimePicker\r\n parts={rangeTimeParts.from}\r\n onChange={(p) => handleRangePartsChange(p, 'from')}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n </div>\r\n <div className=\"flex flex-col gap-1.5\">\r\n <span className=\"text-[11px] font-medium text-muted-foreground\">Đến</span>\r\n <div className={!hasTo ? 'pointer-events-none opacity-50' : ''}>\r\n <TimePicker\r\n parts={rangeTimeParts.to}\r\n onChange={(p) => handleRangePartsChange(p, 'to')}\r\n timeFormat={timeFormat}\r\n timePickerStyle={timePickerStyle}\r\n />\r\n </div>\r\n </div>\r\n </div>\r\n </div>\r\n );\r\n })()}\r\n\r\n {/* Footer actions */}\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 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": "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\n\r\n// ---------- types ----------\r\n\r\nexport type TimeFormat = 'HH' | 'HH:mm' | 'HH:mm:ss';\r\nexport type DatePickerMode = 'single' | 'range' | 'time-only';\r\nexport type TimePickerStyle = 'input' | 'select';\r\n\r\ninterface TimeParts {\r\n h: string;\r\n m: string;\r\n s: string;\r\n}\r\n\r\n/** Props for the DatePicker component */\r\nexport interface DatePickerProps {\r\n /** Picker mode: single date, date range, or time-only */\r\n mode?: DatePickerMode;\r\n /** Selected date (Date for single, DateRange for range) */\r\n value?: Date | DateRange | string;\r\n /** Callback fired when the date changes */\r\n onChange?: (date: Date | DateRange | undefined) => void;\r\n /** Current time string, only used when mode is 'time-only' */\r\n timeValue?: string;\r\n /** Callback fired when the time value changes (time-only mode) */\r\n onTimeChange?: (time: string) => void;\r\n /** Label text displayed above the picker */\r\n label?: string;\r\n /** Placeholder text when no date is selected */\r\n placeholder?: string;\r\n /** Disable all dates before today */\r\n disablePastDates?: boolean;\r\n /** Show time picker alongside the calendar */\r\n showTime?: boolean;\r\n /** Time format: hours only, hours:minutes, or hours:minutes:seconds */\r\n timeFormat?: TimeFormat;\r\n /** Time picker UI style: native input or dropdown selects */\r\n timePickerStyle?: TimePickerStyle;\r\n /** Disable the entire picker */\r\n disabled?: boolean;\r\n className?: string;\r\n /** Helper text displayed below the picker */\r\n description?: string;\r\n /** Error message displayed below the picker (replaces description) */\r\n error?: string;\r\n required?: boolean;\r\n captionLayout?: \"label\" | \"dropdown\" | \"dropdown-months\" | \"dropdown-years\" | undefined;\r\n}\r\n\r\n// ---------- helpers ----------\r\n\r\nconst DEFAULT_TIME: TimeParts = { h: '00', m: '00', s: '00' };\r\n\r\nfunction parseTimeParts(timeStr: string): TimeParts {\r\n const [h = '00', m = '00', s = '00'] = timeStr.split(':');\r\n return {\r\n h: h.padStart(2, '0'),\r\n m: m.padStart(2, '0'),\r\n s: s.padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction buildTimeString(parts: TimeParts, fmt: TimeFormat): string {\r\n if (fmt === 'HH') return parts.h;\r\n if (fmt === 'HH:mm') return `${parts.h}:${parts.m}`;\r\n return `${parts.h}:${parts.m}:${parts.s}`;\r\n}\r\n\r\nfunction applyTimeToDate(base: Date, parts: TimeParts): Date {\r\n const d = new Date(base);\r\n d.setHours(Number(parts.h), Number(parts.m), Number(parts.s), 0);\r\n return d;\r\n}\r\n\r\nfunction dateToTimeParts(d: Date): TimeParts {\r\n return {\r\n h: d.getHours().toString().padStart(2, '0'),\r\n m: d.getMinutes().toString().padStart(2, '0'),\r\n s: d.getSeconds().toString().padStart(2, '0'),\r\n };\r\n}\r\n\r\nfunction formatDateDisplay(d: Date, showTime: boolean, fmt: TimeFormat): string {\r\n const datePart = format(d, 'dd/MM/yyyy');\r\n if (!showTime) return datePart;\r\n if (fmt === 'HH') return `${datePart} ${format(d, 'HH')}h`;\r\n if (fmt === 'HH:mm') return `${datePart} ${format(d, 'HH:mm')}`;\r\n return `${datePart} ${format(d, 'HH:mm:ss')}`;\r\n}\r\n\r\nfunction padOptions(count: number) {\r\n return Array.from({ length: count }, (_, i) => ({\r\n label: i.toString().padStart(2, '0'),\r\n value: i.toString().padStart(2, '0'),\r\n }));\r\n}\r\n\r\nconst hoursOptions = padOptions(24);\r\nconst minutesOptions = padOptions(60);\r\nconst secondsOptions = padOptions(60);\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// ---------- sub-components ----------\r\n\r\ninterface NativeSelectProps {\r\n value: string;\r\n options: { label: string; value: string }[];\r\n onChange: (val: string) => void;\r\n 'aria-label'?: string;\r\n}\r\n\r\nconst NativeScrollSelect: React.FC<NativeSelectProps> = ({ value, options, onChange, 'aria-label': ariaLabel }) => (\r\n <select\r\n aria-label={ariaLabel}\r\n value={value}\r\n onChange={(e) => onChange(e.target.value)}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n >\r\n {options.map((o) => (\r\n <option key={o.value} value={o.value}>{o.label}</option>\r\n ))}\r\n </select>\r\n);\r\n\r\ninterface TimePickerProps {\r\n parts: TimeParts;\r\n onChange: (parts: TimeParts) => void;\r\n timeFormat: TimeFormat;\r\n timePickerStyle: TimePickerStyle;\r\n}\r\n\r\nconst TimePicker: React.FC<TimePickerProps> = ({ parts, onChange, timeFormat, timePickerStyle }) => {\r\n const showMinutes = timeFormat === 'HH:mm' || timeFormat === 'HH:mm:ss';\r\n const showSeconds = timeFormat === 'HH:mm:ss';\r\n\r\n if (timePickerStyle === 'input') {\r\n const step = showSeconds ? 1 : 60;\r\n const rawValue = showSeconds\r\n ? `${parts.h}:${parts.m}:${parts.s}`\r\n : `${parts.h}:${parts.m}`;\r\n\r\n return (\r\n <input\r\n type=\"time\"\r\n value={rawValue}\r\n step={step}\r\n onChange={(e) => {\r\n const [h = '00', m = '00', s = '00'] = e.target.value.split(':');\r\n onChange({ h: h.padStart(2, '0'), m: m.padStart(2, '0'), s: s.padStart(2, '0') });\r\n }}\r\n className=\"h-9 w-full rounded-md border border-border bg-background px-3 text-sm text-foreground focus:border-primary focus:outline-none\"\r\n />\r\n );\r\n }\r\n\r\n return (\r\n <div className=\"flex items-center gap-1.5\">\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Hours\"\r\n value={parts.h}\r\n options={hoursOptions}\r\n onChange={(val) => onChange({ ...parts, h: val })}\r\n />\r\n </div>\r\n {showMinutes && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Minutes\"\r\n value={parts.m}\r\n options={minutesOptions}\r\n onChange={(val) => onChange({ ...parts, m: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n {showSeconds && (\r\n <>\r\n <span className=\"text-sm font-bold text-muted-foreground\">:</span>\r\n <div className=\"flex-1\">\r\n <NativeScrollSelect\r\n aria-label=\"Seconds\"\r\n value={parts.s}\r\n options={secondsOptions}\r\n onChange={(val) => onChange({ ...parts, s: val })}\r\n />\r\n </div>\r\n </>\r\n )}\r\n </div>\r\n );\r\n};\r\n\r\n// ---------- main component ----------\r\n\r\nexport const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(({\r\n mode = 'single',\r\n value,\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 required,\r\n captionLayout = undefined,\r\n}, ref) => {\r\n const [open, setOpen] = React.useState(false);\r\n const triggerRef = React.useRef<HTMLButtonElement>(null);\r\n\r\n // Controlled nếu có onChange — value prop được trust kể cả khi undefined\r\n const isControlled = onChange !== undefined;\r\n const [internalDate, setInternalDate] = React.useState<Date | DateRange | undefined>(undefined);\r\n const date = isControlled ? value : internalDate;\r\n\r\n const [calendarMonth, setCalendarMonth] = React.useState<Date>(new Date());\r\n const handleOpenChange = (newOpen: boolean) => {\r\n if (newOpen) {\r\n const selectedDate = date instanceof Date ? date : (date as DateRange)?.from;\r\n setCalendarMonth(selectedDate ?? new Date());\r\n }\r\n setOpen(newOpen);\r\n };\r\n\r\n const timeParts = 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 }, [date, timeValue, mode]);\r\n\r\n const handlePartsChange = (newParts: TimeParts) => {\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 if (!isControlled) setInternalDate(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 if (!isControlled) setInternalDate(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 if (!isControlled) setInternalDate(newDate);\r\n onChange?.(newDate);\r\n } else {\r\n if (!isControlled) setInternalDate(selectedDate as Date | DateRange);\r\n onChange?.(selectedDate as DateRange);\r\n if (mode === 'single' && !showTime) setOpen(false);\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 w-full ${className || ''}`}>\r\n {label && (\r\n <label className=\"text-sm font-medium text-foreground\">\r\n {label}\r\n {required && <span className=\"ml-0.5 text-destructive\">*</span>}\r\n </label>\r\n )}\r\n\r\n <BasePopover.Root open={open} onOpenChange={disabled ? undefined : handleOpenChange}>\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={locales.vi}\r\n selected={date as Date | undefined}\r\n month={calendarMonth}\r\n onMonthChange={setCalendarMonth}\r\n onSelect={(d) => handleDateSelect(d)}\r\n captionLayout={captionLayout}\r\n startMonth={new Date(1900, 0)}\r\n endMonth={new Date(2100, 11)}\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={locales.vi}\r\n selected={date as DateRange | undefined}\r\n month={calendarMonth}\r\n onMonthChange={setCalendarMonth}\r\n onSelect={(d) => handleDateSelect(d)}\r\n captionLayout={captionLayout}\r\n startMonth={new Date(1900, 0)}\r\n endMonth={new Date(2100, 11)}\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 onTimeChange?.('');\r\n } else {\r\n if (!isControlled) setInternalDate(undefined);\r\n onChange?.(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/index.ts",
@@ -339,7 +339,7 @@
339
339
  "files": [
340
340
  {
341
341
  "path": "src/components/ui/drawer/Drawer.tsx",
342
- "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay: [\r\n 'fixed inset-0 z-50 bg-black/40',\r\n 'transition-opacity duration-200 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n 'transition duration-300 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel: [\r\n 'inset-y-0 left-0 h-full',\r\n 'data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full',\r\n ],\r\n },\r\n right: {\r\n panel: [\r\n 'inset-y-0 right-0 h-full',\r\n 'data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full',\r\n ],\r\n },\r\n top: {\r\n panel: [\r\n 'inset-x-0 top-0 w-full',\r\n 'data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full',\r\n ],\r\n },\r\n bottom: {\r\n panel: [\r\n 'inset-x-0 bottom-0 w-full',\r\n 'data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full',\r\n ],\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'right',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
342
+ "content": "import * as React from 'react';\r\nimport { tv, type VariantProps } from 'tailwind-variants';\r\nimport { Dialog as BaseDialog } from '@base-ui/react';\r\nimport { X } from 'lucide-react';\r\n\r\nconst drawerVariants = tv({\r\n slots: {\r\n overlay: [\r\n 'fixed inset-0 z-50 bg-black/40',\r\n 'transition-opacity duration-200 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n panel: [\r\n 'fixed z-50 bg-background shadow-2xl flex flex-col',\r\n 'outline-none overflow-hidden m-0 p-0 max-w-full max-h-full border-none',\r\n 'transition duration-300 ease-out',\r\n 'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',\r\n ],\r\n header:\r\n 'flex items-center justify-between px-6 py-4 border-b border-border/50 shrink-0',\r\n title: 'text-base font-semibold text-foreground',\r\n description: 'text-sm text-muted-foreground mt-0.5',\r\n body: 'flex-1 overflow-y-auto px-6 py-4',\r\n footer: 'px-6 py-4 border-t border-border/50 shrink-0',\r\n close:\r\n 'rounded-sm opacity-70 hover:opacity-100 transition-opacity ring-offset-background focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2',\r\n },\r\n variants: {\r\n direction: {\r\n left: {\r\n panel: [\r\n 'inset-y-0 left-0 h-full',\r\n 'data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full',\r\n ],\r\n },\r\n right: {\r\n panel: [\r\n 'inset-y-0 right-0 h-full',\r\n 'data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full',\r\n ],\r\n },\r\n top: {\r\n panel: [\r\n 'inset-x-0 top-0 w-full',\r\n 'data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full',\r\n ],\r\n },\r\n bottom: {\r\n panel: [\r\n 'inset-x-0 bottom-0 w-full',\r\n 'data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full',\r\n ],\r\n },\r\n },\r\n size: {\r\n sm: {},\r\n md: {},\r\n lg: {},\r\n full: {},\r\n },\r\n backdropBlur: {\r\n true: { overlay: 'backdrop-blur-sm' },\r\n false: { overlay: '' },\r\n },\r\n },\r\n compoundVariants: [\r\n { direction: 'left', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'left', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'left', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'left', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'right', size: 'sm', class: { panel: 'w-64' } },\r\n { direction: 'right', size: 'md', class: { panel: 'w-80' } },\r\n { direction: 'right', size: 'lg', class: { panel: 'w-[500px]' } },\r\n { direction: 'right', size: 'full', class: { panel: 'w-full' } },\r\n { direction: 'top', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'top', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'top', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'top', size: 'full', class: { panel: 'h-full' } },\r\n { direction: 'bottom', size: 'sm', class: { panel: 'h-48' } },\r\n { direction: 'bottom', size: 'md', class: { panel: 'h-64' } },\r\n { direction: 'bottom', size: 'lg', class: { panel: 'h-[500px]' } },\r\n { direction: 'bottom', size: 'full', class: { panel: 'h-full' } },\r\n ],\r\n defaultVariants: {\r\n direction: 'top',\r\n size: 'md',\r\n backdropBlur: true,\r\n },\r\n});\r\n\r\n/* ─── Root ─── */\r\nconst Drawer = BaseDialog.Root;\r\n\r\n/* ─── Trigger ─── */\r\nconst DrawerTrigger = BaseDialog.Trigger;\r\n\r\n/* ─── Close (re-export for custom close buttons) ─── */\r\nconst DrawerClose = BaseDialog.Close;\r\n\r\n/* ─── Content (Portal + Backdrop + Popup) ─── */\r\ninterface DrawerContentProps\r\n extends Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Popup>, 'className'>,\r\n VariantProps<typeof drawerVariants> {\r\n className?: string;\r\n keepMounted?: boolean;\r\n}\r\n\r\nconst DrawerContent = React.forwardRef<HTMLDivElement, DrawerContentProps>(\r\n ({ className, children, direction, size, backdropBlur, keepMounted, ...props }, ref) => {\r\n const slots = drawerVariants({ direction, size, backdropBlur });\r\n return (\r\n <BaseDialog.Portal keepMounted={keepMounted}>\r\n <BaseDialog.Backdrop className={slots.overlay()} />\r\n <BaseDialog.Popup ref={ref} className={slots.panel({ className })} {...props}>\r\n {children}\r\n </BaseDialog.Popup>\r\n </BaseDialog.Portal>\r\n );\r\n },\r\n);\r\nDrawerContent.displayName = 'DrawerContent';\r\n\r\n/* ─── Header (includes close button by default) ─── */\r\ninterface DrawerHeaderProps extends React.HTMLAttributes<HTMLDivElement> {\r\n hideClose?: boolean;\r\n}\r\n\r\nconst DrawerHeader = React.forwardRef<HTMLDivElement, DrawerHeaderProps>(\r\n ({ className, children, hideClose, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <div ref={ref} className={slots.header({ className })} {...props}>\r\n <div>{children}</div>\r\n {!hideClose && (\r\n <BaseDialog.Close className={slots.close()} aria-label=\"Close\">\r\n <X className=\"h-4 w-4\" />\r\n </BaseDialog.Close>\r\n )}\r\n </div>\r\n );\r\n },\r\n);\r\nDrawerHeader.displayName = 'DrawerHeader';\r\n\r\n/* ─── Title ─── */\r\nconst DrawerTitle = React.forwardRef<\r\n HTMLHeadingElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Title>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <BaseDialog.Title ref={ref} className={slots.title({ className })} {...props} />;\r\n});\r\nDrawerTitle.displayName = 'DrawerTitle';\r\n\r\n/* ─── Description ─── */\r\nconst DrawerDescription = React.forwardRef<\r\n HTMLParagraphElement,\r\n Omit<React.ComponentPropsWithoutRef<typeof BaseDialog.Description>, 'className'> & {\r\n className?: string;\r\n }\r\n>(({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return (\r\n <BaseDialog.Description ref={ref} className={slots.description({ className })} {...props} />\r\n );\r\n});\r\nDrawerDescription.displayName = 'DrawerDescription';\r\n\r\n/* ─── Body (scrollable content area) ─── */\r\nconst DrawerBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.body({ className })} {...props} />;\r\n },\r\n);\r\nDrawerBody.displayName = 'DrawerBody';\r\n\r\n/* ─── Footer ─── */\r\nconst DrawerFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(\r\n ({ className, ...props }, ref) => {\r\n const slots = drawerVariants();\r\n return <div ref={ref} className={slots.footer({ className })} {...props} />;\r\n },\r\n);\r\nDrawerFooter.displayName = 'DrawerFooter';\r\n\r\nexport {\r\n Drawer,\r\n DrawerTrigger,\r\n DrawerContent,\r\n DrawerHeader,\r\n DrawerTitle,\r\n DrawerDescription,\r\n DrawerBody,\r\n DrawerFooter,\r\n DrawerClose,\r\n};\r\nexport type { DrawerContentProps, DrawerHeaderProps };\r\n"
343
343
  }
344
344
  ]
345
345
  },
@@ -773,6 +773,26 @@
773
773
  }
774
774
  ]
775
775
  },
776
+ "qrcode": {
777
+ "name": "qrcode",
778
+ "dependencies": [
779
+ "qrcode.react",
780
+ "lucide-react"
781
+ ],
782
+ "internalDependencies": [
783
+ "button"
784
+ ],
785
+ "files": [
786
+ {
787
+ "path": "src/components/ui/qrcode/index.ts",
788
+ "content": "export { QRCode } from './QRCode';\nexport type { QRCodeProps, QRCodeLevel, QRCodeRenderer, QRCodeImageSettings } from './QRCode';\n"
789
+ },
790
+ {
791
+ "path": "src/components/ui/qrcode/QRCode.tsx",
792
+ "content": "import * as React from 'react';\nimport { QRCodeSVG, QRCodeCanvas } from 'qrcode.react';\nimport { Download } from 'lucide-react';\nimport { cn } from '@/lib/utils/cn';\nimport { Button } from '../button/Button';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\nexport type QRCodeLevel = 'L' | 'M' | 'Q' | 'H';\nexport type QRCodeRenderer = 'svg' | 'canvas';\n\nexport interface QRCodeImageSettings {\n src: string;\n width: number;\n height: number;\n excavate?: boolean;\n x?: number;\n y?: number;\n opacity?: number;\n crossOrigin?: 'anonymous' | 'use-credentials' | '';\n}\n\nconst PIXEL_SIZE = { sm: 96, md: 128, lg: 192, xl: 256 } as const;\n\n// ─── Props ────────────────────────────────────────────────────────────────────\nexport interface QRCodeProps {\n /** Value to encode — URL, text, etc. */\n value: string;\n /** Preset size: sm=96 md=128 lg=192 xl=256 */\n size?: keyof typeof PIXEL_SIZE;\n /** Override pixel dimension, takes precedence over size */\n pixelSize?: number;\n /** Error correction level — higher = more redundancy but denser pattern */\n level?: QRCodeLevel;\n /** Background color (hex/rgb string) */\n bgColor?: string;\n /** Foreground / module color (hex/rgb string) */\n fgColor?: string;\n /** Quiet zone modules around the QR code */\n marginSize?: number;\n /** Embed an image or logo in the center (use level H for best results) */\n imageSettings?: QRCodeImageSettings;\n /** SVG (crisp at any scale) or Canvas (downloadable as PNG) */\n renderer?: QRCodeRenderer;\n /** Accessible title for screen readers */\n title?: string;\n /** Label displayed above the QR code */\n label?: string;\n /** Caption displayed below the QR code */\n description?: string;\n /** Show a download button */\n downloadable?: boolean;\n /** Filename without extension used when downloading */\n downloadFilename?: string;\n className?: string;\n style?: React.CSSProperties;\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\nexport const QRCode = React.forwardRef<HTMLDivElement, QRCodeProps>(({\n value,\n size = 'md',\n pixelSize,\n level = 'L',\n bgColor = '#ffffff',\n fgColor = '#000000',\n marginSize = 2,\n imageSettings,\n renderer = 'svg',\n title,\n label,\n description,\n downloadable = false,\n downloadFilename = 'qrcode',\n className,\n style,\n}, ref) => {\n const qrWrapperRef = React.useRef<HTMLDivElement>(null);\n const resolvedSize = pixelSize ?? PIXEL_SIZE[size];\n\n const handleDownload = React.useCallback(() => {\n const el = qrWrapperRef.current;\n if (!el) return;\n\n if (renderer === 'canvas') {\n const canvas = el.querySelector('canvas');\n if (!canvas) return;\n const link = document.createElement('a');\n link.download = `${downloadFilename}.png`;\n link.href = canvas.toDataURL('image/png');\n link.click();\n return;\n }\n\n const svg = el.querySelector('svg');\n if (!svg) return;\n const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });\n const url = URL.createObjectURL(blob);\n const link = document.createElement('a');\n link.download = `${downloadFilename}.svg`;\n link.href = url;\n link.click();\n URL.revokeObjectURL(url);\n }, [renderer, downloadFilename]);\n\n const qrProps = {\n value,\n size: resolvedSize,\n level,\n bgColor,\n fgColor,\n marginSize,\n imageSettings: imageSettings\n ? { excavate: false, ...imageSettings }\n : undefined,\n title: title ?? `QR Code: ${value}`,\n };\n\n return (\n <div ref={ref} className={cn('inline-flex flex-col items-center gap-2', className)} style={style}>\n {label && (\n <p className=\"text-sm font-medium text-foreground\">{label}</p>\n )}\n <div ref={qrWrapperRef} className=\"rounded-xl overflow-hidden border border-border shadow-sm\">\n {renderer === 'svg' ? <QRCodeSVG {...qrProps} /> : <QRCodeCanvas {...qrProps} />}\n </div>\n {description && (\n <p className=\"text-xs text-muted-foreground text-center max-w-[200px]\">{description}</p>\n )}\n {downloadable && (\n <Button\n size=\"sm\"\n variant=\"outline\"\n leftIcon={<Download className=\"w-3.5 h-3.5\" />}\n onClick={handleDownload}\n >\n Download\n </Button>\n )}\n </div>\n );\n});\n\nQRCode.displayName = 'QRCode';\n"
793
+ }
794
+ ]
795
+ },
776
796
  "radio": {
777
797
  "name": "radio",
778
798
  "dependencies": [
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.3.10';
9
+ const VERSION = '0.3.12';
10
10
  const REGISTRY_LOCAL = './registry.json';
11
11
  const REGISTRY_REMOTE = 'https://raw.githubusercontent.com/Basuicn/basuicn-core/main/registry.json';
12
12