From 0a1a270e3e7437c57307d108ef13ca2ab778976f Mon Sep 17 00:00:00 2001 From: herlambangajiprasetyo Date: Wed, 8 Jul 2026 14:16:05 +0700 Subject: [PATCH 1/7] chore: setup registry.json and folder structure --- .gitignore | 3 +- registry.json | 12 + registry/registry-components.json | 386 ++++++++++++++++++++++++++++++ registry/registry-form.json | 127 ++++++++++ registry/registry-hooks.json | 80 +++++++ registry/registry-lib.json | 78 ++++++ registry/registry-ui.json | 375 +++++++++++++++++++++++++++++ 7 files changed, 1060 insertions(+), 1 deletion(-) create mode 100644 registry.json create mode 100644 registry/registry-components.json create mode 100644 registry/registry-form.json create mode 100644 registry/registry-hooks.json create mode 100644 registry/registry-lib.json create mode 100644 registry/registry-ui.json diff --git a/.gitignore b/.gitignore index 2a5274f..5a452a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .kiro .vscode -.agents/ \ No newline at end of file +.agents/ +.gemini \ No newline at end of file diff --git a/registry.json b/registry.json new file mode 100644 index 0000000..0e34705 --- /dev/null +++ b/registry.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase", + "homepage": "https://kombase.komerce.id", + "include": [ + "registry/registry-ui.json", + "registry/registry-hooks.json", + "registry/registry-lib.json", + "registry/registry-components.json", + "registry/registry-form.json" + ] +} diff --git a/registry/registry-components.json b/registry/registry-components.json new file mode 100644 index 0000000..80c20c9 --- /dev/null +++ b/registry/registry-components.json @@ -0,0 +1,386 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase-components", + "items": [ + { + "name": "data-table-config", + "type": "registry:component", + "files": [ + { + "path": "registry/components/data-table/data-table-config.ts", + "type": "registry:component", + "target": "@components/data-table/data-table-config.ts" + } + ] + }, + { + "name": "data-table-types", + "type": "registry:component", + "registryDependencies": ["@kombase/data-table-config"], + "files": [ + { + "path": "registry/components/data-table/types.ts", + "type": "registry:component", + "target": "@components/data-table/types.ts" + } + ] + }, + { + "name": "data-table-pagination", + "type": "registry:component", + "dependencies": ["@tanstack/react-table"], + "registryDependencies": ["button", "select", "@kombase/lib-pagination", "@kombase/utils"], + "files": [ + { + "path": "registry/components/data-table/data-table-pagination.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-pagination.tsx" + } + ] + }, + { + "name": "data-table-range-filter", + "type": "registry:component", + "registryDependencies": ["debounced-input"], + "files": [ + { + "path": "registry/components/data-table/data-table-range-filter.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-range-filter.tsx" + } + ] + }, + { + "name": "data-table-skeleton", + "type": "registry:component", + "registryDependencies": ["skeleton", "table"], + "files": [ + { + "path": "registry/components/data-table/data-table-skeleton.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-skeleton.tsx" + } + ] + }, + { + "name": "data-table-date-filter", + "type": "registry:component", + "registryDependencies": ["button", "calendar", "popover", "separator", "@kombase/lib-date"], + "files": [ + { + "path": "registry/components/data-table/data-table-date-filter.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-date-filter.tsx" + } + ] + }, + { + "name": "data-table-faceted-filter", + "type": "registry:component", + "registryDependencies": ["badge", "button", "command", "popover", "separator"], + "files": [ + { + "path": "registry/components/data-table/data-table-faceted-filter.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-faceted-filter.tsx" + } + ] + }, + { + "name": "data-table-slider-filter", + "type": "registry:component", + "registryDependencies": ["button", "input", "label", "popover", "separator", "slider"], + "files": [ + { + "path": "registry/components/data-table/data-table-slider-filter.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-slider-filter.tsx" + } + ] + }, + { + "name": "data-table-view-options", + "type": "registry:component", + "registryDependencies": ["button", "command", "popover"], + "files": [ + { + "path": "registry/components/data-table/data-table-view-options.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-view-options.tsx" + } + ] + }, + { + "name": "data-table-toolbar", + "type": "registry:component", + "registryDependencies": [ + "button", + "debounced-input", + "@kombase/data-table-date-filter", + "@kombase/data-table-faceted-filter", + "@kombase/data-table-slider-filter", + "@kombase/data-table-view-options" + ], + "files": [ + { + "path": "registry/components/data-table/data-table-toolbar.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-toolbar.tsx" + } + ] + }, + { + "name": "data-table-bulk-action", + "type": "registry:component", + "registryDependencies": ["badge", "button", "separator", "tooltip"], + "files": [ + { + "path": "registry/components/data-table/data-table-bulk-action.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-bulk-action.tsx" + } + ] + }, + { + "name": "data-table-column-header", + "type": "registry:component", + "registryDependencies": ["dropdown-menu"], + "files": [ + { + "path": "registry/components/data-table/data-table-column-header.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-column-header.tsx" + } + ] + }, + { + "name": "data-table-advance-filter", + "type": "registry:component", + "registryDependencies": [ + "@kombase/lib-data-table", + "badge", + "button", + "calendar", + "command", + "popover", + "select", + "@kombase/data-table-config", + "@kombase/data-table-date-filter", + "@kombase/data-table-range-filter" + ], + "files": [ + { + "path": "registry/components/data-table/data-table-advance-filter.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table-advance-filter.tsx" + } + ] + }, + { + "name": "data-table", + "type": "registry:component", + "dependencies": ["@tanstack/react-table"], + "registryDependencies": [ + "table", + "@kombase/data-table-config", + "@kombase/data-table-types", + "@kombase/data-table-pagination", + "@kombase/data-table-toolbar", + "@kombase/data-table-advance-filter", + "@kombase/data-table-bulk-action", + "@kombase/data-table-column-header", + "@kombase/data-table-skeleton", + "@kombase/lib-data-table", + "@kombase/use-data-table" + ], + "files": [ + { + "path": "registry/components/data-table/data-table.tsx", + "type": "registry:component", + "target": "@components/data-table/data-table.tsx" + } + ] + }, + { + "name": "action-bar", + "type": "registry:component", + "registryDependencies": [ + "button", + "@kombase/component-refs", + "@kombase/use-as-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/action-bar.tsx", + "type": "registry:component", + "target": "@components/action-bar.tsx" + } + ] + }, + { + "name": "avatar-group", + "type": "registry:component", + "dependencies": ["@radix-ui/react-avatar"], + "files": [ + { + "path": "registry/components/avatar-group.tsx", + "type": "registry:component", + "target": "@components/avatar-group.tsx" + } + ] + }, + { + "name": "confirm-dialog", + "type": "registry:component", + "registryDependencies": ["alert-dialog", "button"], + "files": [ + { + "path": "registry/components/confirm-dialog.tsx", + "type": "registry:component", + "target": "@components/confirm-dialog.tsx" + } + ] + }, + { + "name": "long-text", + "type": "registry:component", + "registryDependencies": ["popover", "tooltip"], + "files": [ + { + "path": "registry/components/long-text.tsx", + "type": "registry:component", + "target": "@components/long-text.tsx" + } + ] + }, + { + "name": "phone-input", + "type": "registry:component", + "registryDependencies": [ + "command", + "input", + "popover", + "@kombase/component-refs", + "@kombase/visually-hidden-input", + "@kombase/use-as-ref", + "@kombase/use-callback-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/phone-input.tsx", + "type": "registry:component", + "target": "@components/phone-input.tsx" + } + ] + }, + { + "name": "rating", + "type": "registry:component", + "dependencies": ["@radix-ui/react-direction"], + "registryDependencies": [ + "@kombase/component-refs", + "@kombase/visually-hidden-input", + "@kombase/use-as-ref", + "@kombase/use-callback-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/rating.tsx", + "type": "registry:component", + "target": "@components/rating.tsx" + } + ] + }, + { + "name": "stepper", + "type": "registry:component", + "dependencies": ["@radix-ui/react-direction"], + "registryDependencies": [ + "@kombase/component-refs", + "@kombase/use-as-ref", + "@kombase/use-callback-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/stepper.tsx", + "type": "registry:component", + "target": "@components/stepper.tsx" + } + ] + }, + { + "name": "timeline", + "type": "registry:component", + "dependencies": ["@radix-ui/react-direction", "class-variance-authority"], + "registryDependencies": [ + "@kombase/component-refs", + "@kombase/use-as-ref", + "@kombase/use-callback-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/timeline.tsx", + "type": "registry:component", + "target": "@components/timeline.tsx" + } + ] + }, + { + "name": "tour", + "type": "registry:component", + "dependencies": ["@floating-ui/react-dom", "@radix-ui/react-direction"], + "registryDependencies": [ + "button", + "@kombase/component-refs", + "@kombase/use-as-ref", + "@kombase/use-callback-ref", + "@kombase/use-isomorphic-layout-effect" + ], + "files": [ + { + "path": "registry/components/tour.tsx", + "type": "registry:component", + "target": "@components/tour.tsx" + } + ] + }, + { + "name": "visually-hidden-input", + "type": "registry:component", + "files": [ + { + "path": "registry/components/visually-hidden-input.tsx", + "type": "registry:component", + "target": "@components/visually-hidden-input.tsx" + } + ] + }, + { + "name": "password-input", + "type": "registry:component", + "files": [ + { + "path": "registry/components/password-input.tsx", + "type": "registry:component", + "target": "@components/password-input.tsx" + } + ] + }, + { + "name": "select-dropdown", + "type": "registry:component", + "files": [ + { + "path": "registry/components/select-dropdown.tsx", + "type": "registry:component", + "target": "@components/select-dropdown.tsx" + } + ] + } + ] +} diff --git a/registry/registry-form.json b/registry/registry-form.json new file mode 100644 index 0000000..01583fb --- /dev/null +++ b/registry/registry-form.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase-form", + "items": [ + { + "name": "form-date-picker", + "type": "registry:component", + "dependencies": ["dayjs"], + "registryDependencies": ["calendar", "form", "label", "popover"], + "files": [ + { + "path": "registry/form/form-date-picker.tsx", + "type": "registry:component", + "target": "@components/form/form-date-picker.tsx" + } + ] + }, + { + "name": "form-input", + "type": "registry:component", + "registryDependencies": ["form", "input", "label"], + "files": [ + { + "path": "registry/form/form-input.tsx", + "type": "registry:component", + "target": "@components/form/form-input.tsx" + } + ] + }, + { + "name": "form-input-group", + "type": "registry:component", + "registryDependencies": ["form", "input-group", "label"], + "files": [ + { + "path": "registry/form/form-input-group.tsx", + "type": "registry:component", + "target": "@components/form/form-input-group.tsx" + } + ] + }, + { + "name": "form-password", + "type": "registry:component", + "registryDependencies": ["form", "label", "@kombase/password-input"], + "files": [ + { + "path": "registry/form/form-password.tsx", + "type": "registry:component", + "target": "@components/form/form-password.tsx" + } + ] + }, + { + "name": "form-phone-input", + "type": "registry:component", + "registryDependencies": ["form", "label", "@kombase/phone-input"], + "files": [ + { + "path": "registry/form/form-phone-input.tsx", + "type": "registry:component", + "target": "@components/form/form-phone-input.tsx" + } + ] + }, + { + "name": "form-pick", + "type": "registry:component", + "registryDependencies": ["form", "label", "radio-group"], + "files": [ + { + "path": "registry/form/form-pick.tsx", + "type": "registry:component", + "target": "@components/form/form-pick.tsx" + } + ] + }, + { + "name": "form-radio", + "type": "registry:component", + "registryDependencies": ["form", "label", "radio-group"], + "files": [ + { + "path": "registry/form/form-radio.tsx", + "type": "registry:component", + "target": "@components/form/form-radio.tsx" + } + ] + }, + { + "name": "form-search-select", + "type": "registry:component", + "registryDependencies": ["combobox", "form", "label"], + "files": [ + { + "path": "registry/form/form-search-select.tsx", + "type": "registry:component", + "target": "@components/form/form-search-select.tsx" + } + ] + }, + { + "name": "form-textarea", + "type": "registry:component", + "registryDependencies": ["form", "label", "textarea"], + "files": [ + { + "path": "registry/form/form-textarea.tsx", + "type": "registry:component", + "target": "@components/form/form-textarea.tsx" + } + ] + }, + { + "name": "form-upload", + "type": "registry:component", + "registryDependencies": ["form", "label", "@kombase/file-upload"], + "files": [ + { + "path": "registry/form/form-upload.tsx", + "type": "registry:component", + "target": "@components/form/form-upload.tsx" + } + ] + } + ] +} diff --git a/registry/registry-hooks.json b/registry/registry-hooks.json new file mode 100644 index 0000000..6be7d5d --- /dev/null +++ b/registry/registry-hooks.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase-hooks", + "items": [ + { + "name": "use-as-ref", + "type": "registry:hook", + "registryDependencies": ["@kombase/use-isomorphic-layout-effect"], + "files": [ + { + "path": "registry/hooks/use-as-ref.ts", + "type": "registry:hook", + "target": "@hooks/use-as-ref.ts" + } + ] + }, + { + "name": "use-callback-ref", + "type": "registry:hook", + "files": [ + { + "path": "registry/hooks/use-callback-ref.ts", + "type": "registry:hook", + "target": "@hooks/use-callback-ref.ts" + } + ] + }, + { + "name": "use-data-table", + "type": "registry:hook", + "dependencies": ["@tanstack/react-table"], + "registryDependencies": [ + "@kombase/data-table-config", + "@kombase/data-table-types", + "@kombase/lib-data-table" + ], + "files": [ + { + "path": "registry/hooks/use-data-table.ts", + "type": "registry:hook", + "target": "@hooks/use-data-table.ts" + } + ] + }, + { + "name": "use-debounced-callback", + "type": "registry:hook", + "registryDependencies": ["@kombase/use-callback-ref"], + "files": [ + { + "path": "registry/hooks/use-debounced-callback.ts", + "type": "registry:hook", + "target": "@hooks/use-debounced-callback.ts" + } + ] + }, + { + "name": "use-isomorphic-layout-effect", + "type": "registry:hook", + "files": [ + { + "path": "registry/hooks/use-isomorphic-layout-effect.ts", + "type": "registry:hook", + "target": "@hooks/use-isomorphic-layout-effect.ts" + } + ] + }, + { + "name": "use-lazy-ref", + "type": "registry:hook", + "files": [ + { + "path": "registry/hooks/use-lazy-ref.ts", + "type": "registry:hook", + "target": "@hooks/use-lazy-ref.ts" + } + ] + } + ] +} diff --git a/registry/registry-lib.json b/registry/registry-lib.json new file mode 100644 index 0000000..592bd75 --- /dev/null +++ b/registry/registry-lib.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase-lib", + "items": [ + { + "name": "utils", + "type": "registry:lib", + "dependencies": ["clsx", "tailwind-merge"], + "files": [ + { + "path": "registry/lib/utils.ts", + "type": "registry:lib", + "target": "@lib/utils.ts" + } + ] + }, + { + "name": "component-refs", + "type": "registry:lib", + "files": [ + { + "path": "registry/lib/component-refs.ts", + "type": "registry:lib", + "target": "@lib/component-refs.ts" + } + ] + }, + { + "name": "lib-data-table", + "type": "registry:lib", + "dependencies": ["@tanstack/react-table"], + "registryDependencies": ["@kombase/data-table-config", "@kombase/data-table-types"], + "files": [ + { + "path": "registry/lib/data-table.ts", + "type": "registry:lib", + "target": "@lib/data-table.ts" + } + ] + }, + { + "name": "lib-date", + "type": "registry:lib", + "dependencies": ["dayjs", "react-day-picker"], + "files": [ + { + "path": "registry/lib/date.ts", + "type": "registry:lib", + "target": "@lib/date.ts" + } + ] + }, + { + "name": "lib-pagination", + "type": "registry:lib", + "files": [ + { + "path": "registry/lib/pagination.ts", + "type": "registry:lib", + "target": "@lib/pagination.ts" + } + ] + }, + { + "name": "filter-helper", + "type": "registry:lib", + "dependencies": ["dayjs"], + "registryDependencies": ["@kombase/data-table-types"], + "files": [ + { + "path": "registry/lib/filter-helper.ts", + "type": "registry:lib", + "target": "@lib/filter-helper.ts" + } + ] + } + ] +} diff --git a/registry/registry-ui.json b/registry/registry-ui.json new file mode 100644 index 0000000..ed20b43 --- /dev/null +++ b/registry/registry-ui.json @@ -0,0 +1,375 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "name": "kombase-ui", + "items": [ + { + "name": "alert-dialog", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-alert-dialog"], + "registryDependencies": ["button"], + "files": [ + { + "path": "registry/ui/alert-dialog.tsx", + "type": "registry:ui", + "target": "@ui/alert-dialog.tsx" + } + ] + }, + { + "name": "alert", + "type": "registry:ui", + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/alert.tsx", + "type": "registry:ui", + "target": "@ui/alert.tsx" + } + ] + }, + { + "name": "avatar", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-avatar"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/avatar.tsx", + "type": "registry:ui", + "target": "@ui/avatar.tsx" + } + ] + }, + { + "name": "badge", + "type": "registry:ui", + "dependencies": ["class-variance-authority"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/badge.tsx", + "type": "registry:ui", + "target": "@ui/badge.tsx" + } + ] + }, + { + "name": "button", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-slot", "class-variance-authority"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/button.tsx", + "type": "registry:ui", + "target": "@ui/button.tsx" + } + ] + }, + { + "name": "calendar", + "type": "registry:ui", + "dependencies": ["react-day-picker", "dayjs"], + "registryDependencies": ["button", "@kombase/utils"], + "files": [ + { + "path": "registry/ui/calendar.tsx", + "type": "registry:ui", + "target": "@ui/calendar.tsx" + } + ] + }, + { + "name": "checkbox", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-checkbox"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/checkbox.tsx", + "type": "registry:ui", + "target": "@ui/checkbox.tsx" + } + ] + }, + { + "name": "collapsible", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-collapsible"], + "files": [ + { + "path": "registry/ui/collapsible.tsx", + "type": "registry:ui", + "target": "@ui/collapsible.tsx" + } + ] + }, + { + "name": "combobox", + "type": "registry:ui", + "dependencies": ["cmdk"], + "registryDependencies": ["popover", "@kombase/utils"], + "files": [ + { + "path": "registry/ui/combobox.tsx", + "type": "registry:ui", + "target": "@ui/combobox.tsx" + } + ] + }, + { + "name": "command", + "type": "registry:ui", + "dependencies": ["cmdk"], + "registryDependencies": ["dialog", "@kombase/utils"], + "files": [ + { + "path": "registry/ui/command.tsx", + "type": "registry:ui", + "target": "@ui/command.tsx" + } + ] + }, + { + "name": "debounced-input", + "type": "registry:ui", + "registryDependencies": ["input"], + "files": [ + { + "path": "registry/ui/debounced-input.tsx", + "type": "registry:ui", + "target": "@ui/debounced-input.tsx" + } + ] + }, + { + "name": "dialog", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-dialog"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/dialog.tsx", + "type": "registry:ui", + "target": "@ui/dialog.tsx" + } + ] + }, + { + "name": "dropdown-menu", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-dropdown-menu"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/dropdown-menu.tsx", + "type": "registry:ui", + "target": "@ui/dropdown-menu.tsx" + } + ] + }, + { + "name": "file-upload", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-slot", "lucide-react"], + "registryDependencies": ["@kombase/utils", "@kombase/use-as-ref", "@kombase/use-lazy-ref"], + "files": [ + { + "path": "registry/ui/file-upload.tsx", + "type": "registry:ui", + "target": "@ui/file-upload.tsx" + } + ] + }, + { + "name": "form", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-form", "@radix-ui/react-label", "react-hook-form"], + "registryDependencies": ["label", "@kombase/utils"], + "files": [ + { + "path": "registry/ui/form.tsx", + "type": "registry:ui", + "target": "@ui/form.tsx" + } + ] + }, + { + "name": "input-group", + "type": "registry:ui", + "registryDependencies": ["button", "input", "textarea", "@kombase/utils"], + "files": [ + { + "path": "registry/ui/input-group.tsx", + "type": "registry:ui", + "target": "@ui/input-group.tsx" + } + ] + }, + { + "name": "input", + "type": "registry:ui", + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/input.tsx", + "type": "registry:ui", + "target": "@ui/input.tsx" + } + ] + }, + { + "name": "label", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-label"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/label.tsx", + "type": "registry:ui", + "target": "@ui/label.tsx" + } + ] + }, + { + "name": "popover", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-popover"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/popover.tsx", + "type": "registry:ui", + "target": "@ui/popover.tsx" + } + ] + }, + { + "name": "radio-group", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-radio-group"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/radio-group.tsx", + "type": "registry:ui", + "target": "@ui/radio-group.tsx" + } + ] + }, + { + "name": "select", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-select"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/select.tsx", + "type": "registry:ui", + "target": "@ui/select.tsx" + } + ] + }, + { + "name": "separator", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-separator"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/separator.tsx", + "type": "registry:ui", + "target": "@ui/separator.tsx" + } + ] + }, + { + "name": "skeleton", + "type": "registry:ui", + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/skeleton.tsx", + "type": "registry:ui", + "target": "@ui/skeleton.tsx" + } + ] + }, + { + "name": "slider", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-slider"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/slider.tsx", + "type": "registry:ui", + "target": "@ui/slider.tsx" + } + ] + }, + { + "name": "switch", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-switch"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/switch.tsx", + "type": "registry:ui", + "target": "@ui/switch.tsx" + } + ] + }, + { + "name": "table", + "type": "registry:ui", + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/table.tsx", + "type": "registry:ui", + "target": "@ui/table.tsx" + } + ] + }, + { + "name": "tabs", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-tabs"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/tabs.tsx", + "type": "registry:ui", + "target": "@ui/tabs.tsx" + } + ] + }, + { + "name": "textarea", + "type": "registry:ui", + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/textarea.tsx", + "type": "registry:ui", + "target": "@ui/textarea.tsx" + } + ] + }, + { + "name": "tooltip", + "type": "registry:ui", + "dependencies": ["@radix-ui/react-tooltip"], + "registryDependencies": ["@kombase/utils"], + "files": [ + { + "path": "registry/ui/tooltip.tsx", + "type": "registry:ui", + "target": "@ui/tooltip.tsx" + } + ] + } + ] +} From 83257cfbc80f0ee9cb29fd17ff1240198b53d136 Mon Sep 17 00:00:00 2001 From: herlambangajiprasetyo Date: Wed, 8 Jul 2026 14:35:30 +0700 Subject: [PATCH 2/7] refactor: normalize all imports to use @/ alias --- package.json | 1 + .../data-table/data-table-advance-filter.tsx | 18 +++++++++--------- .../data-table/data-table-range-filter.tsx | 4 ++-- packages/src/components/dynamic/action-bar.tsx | 4 ++-- .../src/components/dynamic/phone-input.tsx | 8 ++++---- packages/src/components/dynamic/rating.tsx | 8 ++++---- packages/src/components/form/form-password.tsx | 2 +- ...seach-select.tsx => form-search-select.tsx} | 0 packages/src/components/hooks/use-as-ref.ts | 2 +- .../components/hooks/use-debounced-callback.ts | 2 +- packages/src/components/ui/alert-dialog.tsx | 2 +- packages/src/components/ui/command.tsx | 2 +- packages/src/components/ui/debounced-input.tsx | 2 +- packages/src/components/ui/file-upload.tsx | 4 ++-- packages/src/components/ui/input-group.tsx | 6 +++--- packages/src/lib/data-table.ts | 4 ++-- packages/src/lib/filter-helper.ts | 2 +- 17 files changed, 36 insertions(+), 35 deletions(-) rename packages/src/components/form/{form-seach-select.tsx => form-search-select.tsx} (100%) diff --git a/package.json b/package.json index a865f77..caa5fb9 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:docs": "pnpm -F docs dev", "build": "pnpm -F docs build && pnpm -F kombase build", "build:docs": "pnpm -F docs build", + "build:registry": "npx shadcn@latest build -o docs/public/r", "list-packages": "pnpm m ls --json --depth -1", "docs": "pnpm -F kombase docs", "validate": "pnpm format && pnpm lint:check && pnpm build:kombase && pnpm build:docs", diff --git a/packages/src/components/data-table/data-table-advance-filter.tsx b/packages/src/components/data-table/data-table-advance-filter.tsx index bb11837..c80e831 100644 --- a/packages/src/components/data-table/data-table-advance-filter.tsx +++ b/packages/src/components/data-table/data-table-advance-filter.tsx @@ -7,11 +7,11 @@ import { getDefaultFilterOperator, getFilterOperators, getValidFilters, -} from '../../lib/data-table'; -import { cn } from '../../lib/utils'; -import { Badge } from '../ui/badge'; -import { Button } from '../ui/button'; -import { Calendar } from '../ui/calendar'; +} from '@/lib/data-table'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; import { Command, CommandEmpty, @@ -19,9 +19,9 @@ import { CommandInput, CommandItem, CommandList, -} from '../ui/command'; -import { DebouncedInput } from '../ui/debounced-input'; -import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +} from '@/components/ui/command'; +import { DebouncedInput } from '@/components/ui/debounced-input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, @@ -29,7 +29,7 @@ import { SelectItem, SelectTrigger, SelectValue, -} from '../ui/select'; +} from '@/components/ui/select'; import { dataTableConfig } from './data-table-config'; import { getIsDateRange, parseAsDate, parseColumnFilterValue } from './data-table-date-filter'; import { DataTableRangeFilter } from './data-table-range-filter'; diff --git a/packages/src/components/data-table/data-table-range-filter.tsx b/packages/src/components/data-table/data-table-range-filter.tsx index 0652570..2981e26 100644 --- a/packages/src/components/data-table/data-table-range-filter.tsx +++ b/packages/src/components/data-table/data-table-range-filter.tsx @@ -1,7 +1,7 @@ import type { Column } from '@tanstack/react-table'; import * as React from 'react'; -import { cn } from '../../lib/utils'; -import { DebouncedInput } from '../ui/debounced-input'; +import { cn } from '@/lib/utils'; +import { DebouncedInput } from '@/components/ui/debounced-input'; import type { ExtendedColumnFilter } from './types'; interface DataTableRangeFilterProps extends React.ComponentProps<'div'> { diff --git a/packages/src/components/dynamic/action-bar.tsx b/packages/src/components/dynamic/action-bar.tsx index 2476305..e5eb552 100644 --- a/packages/src/components/dynamic/action-bar.tsx +++ b/packages/src/components/dynamic/action-bar.tsx @@ -7,8 +7,8 @@ import * as ReactDOM from 'react-dom'; import { Button } from '@/components/ui/button'; import { useComposedRefs } from '@/lib/component-refs'; import { cn } from '@/lib/utils'; -import { useAsRef } from '../hooks/use-as-ref'; -import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; const ROOT_NAME = 'ActionBar'; const GROUP_NAME = 'ActionBarGroup'; diff --git a/packages/src/components/dynamic/phone-input.tsx b/packages/src/components/dynamic/phone-input.tsx index ba11734..0fae1c7 100644 --- a/packages/src/components/dynamic/phone-input.tsx +++ b/packages/src/components/dynamic/phone-input.tsx @@ -3,9 +3,9 @@ import * as SlotPrimitive from '@radix-ui/react-slot'; import { Check, ChevronDown } from 'lucide-react'; import * as React from 'react'; -import { useAsRef } from '@/components/hooks/use-as-ref'; -import { useIsomorphicLayoutEffect } from '@/components/hooks/use-isomorphic-layout-effect'; -import { useLazyRef } from '@/components/hooks/use-lazy-ref'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; import { Command, CommandEmpty, @@ -18,7 +18,7 @@ import { Input } from '@/components/ui/input'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { useComposedRefs } from '@/lib/component-refs'; import { cn } from '@/lib/utils'; -import { VisuallyHiddenInput } from '../visually-hidden-input'; +import { VisuallyHiddenInput } from '@/components/visually-hidden-input'; const ROOT_NAME = 'PhoneInput'; const COUNTRY_SELECT_NAME = 'PhoneInputCountrySelect'; diff --git a/packages/src/components/dynamic/rating.tsx b/packages/src/components/dynamic/rating.tsx index 6c8b995..eb83767 100644 --- a/packages/src/components/dynamic/rating.tsx +++ b/packages/src/components/dynamic/rating.tsx @@ -4,12 +4,12 @@ import * as DirectionPrimitive from '@radix-ui/react-direction'; import * as SlotPrimitive from '@radix-ui/react-slot'; import { Star } from 'lucide-react'; import * as React from 'react'; -import { useAsRef } from '@/components/hooks/use-as-ref'; -import { useIsomorphicLayoutEffect } from '@/components/hooks/use-isomorphic-layout-effect'; -import { useLazyRef } from '@/components/hooks/use-lazy-ref'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; import { useComposedRefs } from '@/lib/component-refs'; import { cn } from '@/lib/utils'; -import { VisuallyHiddenInput } from '../visually-hidden-input'; +import { VisuallyHiddenInput } from '@/components/visually-hidden-input'; type Direction = 'ltr' | 'rtl'; type Orientation = 'horizontal' | 'vertical'; diff --git a/packages/src/components/form/form-password.tsx b/packages/src/components/form/form-password.tsx index 66662c6..4151252 100644 --- a/packages/src/components/form/form-password.tsx +++ b/packages/src/components/form/form-password.tsx @@ -4,7 +4,7 @@ import { FormControl, FormField, FormItem, FormMessage } from '@/components/ui/f import type { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { cn } from '@/lib/utils'; -import { PasswordInput } from '../password-input'; +import { PasswordInput } from '@/components/password-input'; type FormInputPasswordProps = { control: Control; diff --git a/packages/src/components/form/form-seach-select.tsx b/packages/src/components/form/form-search-select.tsx similarity index 100% rename from packages/src/components/form/form-seach-select.tsx rename to packages/src/components/form/form-search-select.tsx diff --git a/packages/src/components/hooks/use-as-ref.ts b/packages/src/components/hooks/use-as-ref.ts index 6a8c52d..9c8d508 100644 --- a/packages/src/components/hooks/use-as-ref.ts +++ b/packages/src/components/hooks/use-as-ref.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; function useAsRef(props: T) { const ref = React.useRef(props); diff --git a/packages/src/components/hooks/use-debounced-callback.ts b/packages/src/components/hooks/use-debounced-callback.ts index ef79b78..4a6f0bd 100644 --- a/packages/src/components/hooks/use-debounced-callback.ts +++ b/packages/src/components/hooks/use-debounced-callback.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -import { useCallbackRef } from './use-callback-ref'; +import { useCallbackRef } from '@/hooks/use-callback-ref'; export function useDebouncedCallback unknown>( callback: T, diff --git a/packages/src/components/ui/alert-dialog.tsx b/packages/src/components/ui/alert-dialog.tsx index 57d2a4d..2b90c5f 100644 --- a/packages/src/components/ui/alert-dialog.tsx +++ b/packages/src/components/ui/alert-dialog.tsx @@ -3,7 +3,7 @@ import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import React from 'react'; import { cn } from '@/lib/utils'; -import { buttonVariants } from './button'; +import { buttonVariants } from '@/components/ui/button'; const AlertDialog = ({ ...props }: React.ComponentProps) => ( diff --git a/packages/src/components/ui/command.tsx b/packages/src/components/ui/command.tsx index 91c7277..663d51d 100644 --- a/packages/src/components/ui/command.tsx +++ b/packages/src/components/ui/command.tsx @@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from 'cmdk'; import { SearchIcon } from 'lucide-react'; import * as React from 'react'; import { cn } from '@/lib/utils'; -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; const Command = React.forwardRef< React.ComponentRef, diff --git a/packages/src/components/ui/debounced-input.tsx b/packages/src/components/ui/debounced-input.tsx index 56b45d0..3d93630 100644 --- a/packages/src/components/ui/debounced-input.tsx +++ b/packages/src/components/ui/debounced-input.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Input } from './input'; +import { Input } from '@/components/ui/input'; export interface DebouncedInputProps extends Omit, 'onChange' | 'value'> { diff --git a/packages/src/components/ui/file-upload.tsx b/packages/src/components/ui/file-upload.tsx index b7354ca..5bdc342 100644 --- a/packages/src/components/ui/file-upload.tsx +++ b/packages/src/components/ui/file-upload.tsx @@ -12,8 +12,8 @@ import { import { Direction as DirectionPrimitive, Slot as SlotPrimitive } from 'radix-ui'; import * as React from 'react'; import { cn } from '@/lib/utils'; -import { useAsRef } from '../hooks/use-as-ref'; -import { useLazyRef } from '../hooks/use-lazy-ref'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; const ROOT_NAME = 'FileUpload'; const DROPZONE_NAME = 'FileUploadDropzone'; diff --git a/packages/src/components/ui/input-group.tsx b/packages/src/components/ui/input-group.tsx index 0661498..3933acb 100644 --- a/packages/src/components/ui/input-group.tsx +++ b/packages/src/components/ui/input-group.tsx @@ -3,9 +3,9 @@ import { cva, type VariantProps } from 'class-variance-authority'; import * as React from 'react'; import { cn } from '@/lib/utils'; -import { Button } from './button'; -import { Input } from './input'; -import { Textarea } from './textarea'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; const InputGroup = React.forwardRef>( ({ className, ...props }, ref) => { diff --git a/packages/src/lib/data-table.ts b/packages/src/lib/data-table.ts index a1de71a..243f65d 100644 --- a/packages/src/lib/data-table.ts +++ b/packages/src/lib/data-table.ts @@ -1,10 +1,10 @@ import type { Column } from '@tanstack/react-table'; -import { dataTableConfig } from '../components/data-table/data-table-config'; +import { dataTableConfig } from '@/components/data-table/data-table-config'; import type { ExtendedColumnFilter, FilterOperator, FilterVariant, -} from '../components/data-table/types'; +} from '@/components/data-table/types'; export function getCommonPinningStyles({ column, diff --git a/packages/src/lib/filter-helper.ts b/packages/src/lib/filter-helper.ts index 501e444..c1e1e49 100644 --- a/packages/src/lib/filter-helper.ts +++ b/packages/src/lib/filter-helper.ts @@ -3,7 +3,7 @@ import type { ExtendedColumnFilter, FilterOperator, JoinOperator, -} from '../components/data-table/types'; +} from '@/components/data-table/types'; const DEFAULT_OPERATORS: Record = { eq: 'eq', From 2097037c2c24fb342bb901fbc8312bdff4167fbb Mon Sep 17 00:00:00 2001 From: herlambangajiprasetyo Date: Wed, 8 Jul 2026 14:38:39 +0700 Subject: [PATCH 3/7] refactor: migrate source from packages/ to registry/ --- packages/README.md | 22 +- packages/kombase.css | 15 - packages/src/index.ts | 31 - packages/tsup.config.ts | 17 - registry/components/action-bar.tsx | 635 +++++++ registry/components/avatar-group.tsx | 215 +++ registry/components/confirm-dialog.tsx | 107 ++ .../data-table/data-table-advance-filter.tsx | 895 +++++++++ .../data-table/data-table-bulk-action.tsx | 192 ++ .../data-table/data-table-column-header.tsx | 105 ++ .../data-table/data-table-config.ts | 113 ++ .../data-table/data-table-date-filter.tsx | 300 +++ .../data-table/data-table-faceted-filter.tsx | 178 ++ .../data-table/data-table-pagination.tsx | 131 ++ .../data-table/data-table-range-filter.tsx | 114 ++ .../data-table/data-table-skeleton.tsx | 110 ++ .../data-table/data-table-slider-filter.tsx | 232 +++ .../data-table/data-table-toolbar.tsx | 188 ++ .../data-table/data-table-view-options.tsx | 75 + registry/components/data-table/data-table.tsx | 160 ++ registry/components/data-table/types.ts | 152 ++ registry/components/long-text.tsx | 73 + registry/components/password-input.tsx | 36 + registry/components/phone-input.tsx | 765 ++++++++ registry/components/rating.tsx | 866 +++++++++ registry/components/select-dropdown.tsx | 62 + registry/components/stepper.tsx | 1207 +++++++++++++ registry/components/timeline.tsx | 692 +++++++ registry/components/tour.tsx | 1605 +++++++++++++++++ registry/components/visually-hidden-input.tsx | 141 ++ registry/form/form-date-picker.tsx | 125 ++ registry/form/form-input-group.tsx | 71 + registry/form/form-input.tsx | 77 + registry/form/form-password.tsx | 59 + registry/form/form-phone-input.tsx | 74 + registry/form/form-pick.tsx | 130 ++ registry/form/form-radio.tsx | 117 ++ registry/form/form-search-select.tsx | 77 + registry/form/form-textarea.tsx | 86 + registry/form/form-upload.tsx | 71 + registry/hooks/use-as-ref.ts | 15 + registry/hooks/use-callback-ref.ts | 22 + registry/hooks/use-data-table.ts | 385 ++++ registry/hooks/use-debounced-callback.ts | 22 + .../hooks/use-isomorphic-layout-effect.ts | 6 + registry/hooks/use-lazy-ref.ts | 13 + registry/lib/component-refs.ts | 65 + registry/lib/data-table.ts | 86 + registry/lib/date.ts | 57 + registry/lib/filter-helper.ts | 469 +++++ registry/lib/pagination.ts | 49 + registry/lib/utils.ts | 6 + registry/ui/alert-dialog.tsx | 145 ++ registry/ui/alert.tsx | 80 + registry/ui/avatar.tsx | 104 ++ registry/ui/badge.tsx | 49 + registry/ui/button.tsx | 64 + registry/ui/calendar.tsx | 188 ++ registry/ui/checkbox.tsx | 33 + registry/ui/collapsible.tsx | 41 + registry/ui/combobox.tsx | 340 ++++ registry/ui/command.tsx | 178 ++ registry/ui/debounced-input.tsx | 37 + registry/ui/dialog.tsx | 139 ++ registry/ui/dropdown-menu.tsx | 232 +++ registry/ui/file-upload.tsx | 1338 ++++++++++++++ registry/ui/form.tsx | 171 ++ registry/ui/input-group.tsx | 184 ++ registry/ui/input.tsx | 26 + registry/ui/label.tsx | 24 + registry/ui/popover.tsx | 33 + registry/ui/radio-group.tsx | 49 + registry/ui/select.tsx | 177 ++ registry/ui/separator.tsx | 25 + registry/ui/skeleton.tsx | 20 + registry/ui/slider.tsx | 55 + registry/ui/switch.tsx | 36 + registry/ui/table.tsx | 99 + registry/ui/tabs.tsx | 85 + registry/ui/textarea.tsx | 23 + registry/ui/tooltip.tsx | 53 + 81 files changed, 15479 insertions(+), 65 deletions(-) delete mode 100644 packages/kombase.css delete mode 100644 packages/src/index.ts delete mode 100644 packages/tsup.config.ts create mode 100644 registry/components/action-bar.tsx create mode 100644 registry/components/avatar-group.tsx create mode 100644 registry/components/confirm-dialog.tsx create mode 100644 registry/components/data-table/data-table-advance-filter.tsx create mode 100644 registry/components/data-table/data-table-bulk-action.tsx create mode 100644 registry/components/data-table/data-table-column-header.tsx create mode 100644 registry/components/data-table/data-table-config.ts create mode 100644 registry/components/data-table/data-table-date-filter.tsx create mode 100644 registry/components/data-table/data-table-faceted-filter.tsx create mode 100644 registry/components/data-table/data-table-pagination.tsx create mode 100644 registry/components/data-table/data-table-range-filter.tsx create mode 100644 registry/components/data-table/data-table-skeleton.tsx create mode 100644 registry/components/data-table/data-table-slider-filter.tsx create mode 100644 registry/components/data-table/data-table-toolbar.tsx create mode 100644 registry/components/data-table/data-table-view-options.tsx create mode 100644 registry/components/data-table/data-table.tsx create mode 100644 registry/components/data-table/types.ts create mode 100644 registry/components/long-text.tsx create mode 100644 registry/components/password-input.tsx create mode 100644 registry/components/phone-input.tsx create mode 100644 registry/components/rating.tsx create mode 100644 registry/components/select-dropdown.tsx create mode 100644 registry/components/stepper.tsx create mode 100644 registry/components/timeline.tsx create mode 100644 registry/components/tour.tsx create mode 100644 registry/components/visually-hidden-input.tsx create mode 100644 registry/form/form-date-picker.tsx create mode 100644 registry/form/form-input-group.tsx create mode 100644 registry/form/form-input.tsx create mode 100644 registry/form/form-password.tsx create mode 100644 registry/form/form-phone-input.tsx create mode 100644 registry/form/form-pick.tsx create mode 100644 registry/form/form-radio.tsx create mode 100644 registry/form/form-search-select.tsx create mode 100644 registry/form/form-textarea.tsx create mode 100644 registry/form/form-upload.tsx create mode 100644 registry/hooks/use-as-ref.ts create mode 100644 registry/hooks/use-callback-ref.ts create mode 100644 registry/hooks/use-data-table.ts create mode 100644 registry/hooks/use-debounced-callback.ts create mode 100644 registry/hooks/use-isomorphic-layout-effect.ts create mode 100644 registry/hooks/use-lazy-ref.ts create mode 100644 registry/lib/component-refs.ts create mode 100644 registry/lib/data-table.ts create mode 100644 registry/lib/date.ts create mode 100644 registry/lib/filter-helper.ts create mode 100644 registry/lib/pagination.ts create mode 100644 registry/lib/utils.ts create mode 100644 registry/ui/alert-dialog.tsx create mode 100644 registry/ui/alert.tsx create mode 100644 registry/ui/avatar.tsx create mode 100644 registry/ui/badge.tsx create mode 100644 registry/ui/button.tsx create mode 100644 registry/ui/calendar.tsx create mode 100644 registry/ui/checkbox.tsx create mode 100644 registry/ui/collapsible.tsx create mode 100644 registry/ui/combobox.tsx create mode 100644 registry/ui/command.tsx create mode 100644 registry/ui/debounced-input.tsx create mode 100644 registry/ui/dialog.tsx create mode 100644 registry/ui/dropdown-menu.tsx create mode 100644 registry/ui/file-upload.tsx create mode 100644 registry/ui/form.tsx create mode 100644 registry/ui/input-group.tsx create mode 100644 registry/ui/input.tsx create mode 100644 registry/ui/label.tsx create mode 100644 registry/ui/popover.tsx create mode 100644 registry/ui/radio-group.tsx create mode 100644 registry/ui/select.tsx create mode 100644 registry/ui/separator.tsx create mode 100644 registry/ui/skeleton.tsx create mode 100644 registry/ui/slider.tsx create mode 100644 registry/ui/switch.tsx create mode 100644 registry/ui/table.tsx create mode 100644 registry/ui/tabs.tsx create mode 100644 registry/ui/textarea.tsx create mode 100644 registry/ui/tooltip.tsx diff --git a/packages/README.md b/packages/README.md index 087a77c..af31e9f 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,6 +1,24 @@ -# šŸš€ Kombase UI +# šŸš€ Kombase UI (DEPRECATED) + +> [!WARNING] +> **PENTING**: Package npm `kombase` ini telah didepresiasi (deprecated). Distribusi komponen Kombase kini menggunakan **shadcn Registry**. Silakan gunakan `@kombase` registry namespace untuk menambahkan komponen ke proyek Anda secara langsung. +> +> Panduan instalasi baru: +> 1. Tambahkan registry Kombase di `components.json` Anda: +> ```json +> { +> "registries": { +> "@kombase": "https://kombase.komerce.id/r/{name}.json" +> } +> } +> ``` +> 2. Jalankan perintah: +> ```bash +> bunx shadcn@latest add @kombase/ +> ``` +> +> Untuk informasi lebih lengkap mengenai migrasi, silakan merujuk ke **[kombase.komerce.id](https://kombase.komerce.id)**. -Welcome to **Kombase UI**! šŸ‘‹ This is the core UI component library of the Kombase ecosystem. Built with React, Tailwind CSS, and a component-driven design philosophy, it's crafted with love, sweat, and sleepless nights to make your developer life a little more peaceful and drama-free. šŸ§˜ā€ā™‚ļøāœØ ## šŸ“¦ Installation diff --git a/packages/kombase.css b/packages/kombase.css deleted file mode 100644 index a1290ef..0000000 --- a/packages/kombase.css +++ /dev/null @@ -1,15 +0,0 @@ -/** - * kombase - Tailwind CSS v4 integration - * - * Import this file once in your project's main CSS to enable - * Tailwind utility class generation for all kombase components. - * - * @example - * In your global CSS file: - * @import "kombase/styles"; - * - * This @source directive tells Tailwind CSS v4 to scan the - * kombase compiled bundle for class names, so all component - * styles are generated using your project's own Tailwind theme. - */ -@source "./dist"; diff --git a/packages/src/index.ts b/packages/src/index.ts deleted file mode 100644 index 0d26839..0000000 --- a/packages/src/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -export * from './components/data-table/data-table'; -export * from './components/data-table/data-table-advance-filter'; -export * from './components/data-table/data-table-bulk-action'; -export * from './components/data-table/data-table-column-header'; -export * from './components/data-table/data-table-config'; -export * from './components/data-table/data-table-pagination'; -export * from './components/data-table/data-table-skeleton'; -export * from './components/data-table/data-table-toolbar'; -export * from './components/data-table/types'; -export * from './components/dynamic/action-bar'; -export * from './components/dynamic/avatar-group'; -export * from './components/dynamic/confirm-dialog'; -export * from './components/dynamic/long-text'; -export * from './components/dynamic/phone-input'; -export * from './components/dynamic/rating'; -export * from './components/dynamic/stepper'; -export * from './components/dynamic/timeline'; -export * from './components/dynamic/tour'; -export * from './components/form/form-date-picker'; -export * from './components/form/form-input'; -export * from './components/form/form-input-group'; -export * from './components/form/form-password'; -export * from './components/form/form-phone-input'; -export * from './components/form/form-pick'; -export * from './components/form/form-radio'; -export * from './components/form/form-seach-select'; -export * from './components/form/form-textarea'; -export * from './components/form/form-upload'; -export * from './components/hooks/use-data-table'; -export * from './components/ui/file-upload'; -export * from './lib/filter-helper'; diff --git a/packages/tsup.config.ts b/packages/tsup.config.ts deleted file mode 100644 index a956255..0000000 --- a/packages/tsup.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { defineConfig } from 'tsup'; -import pkg from './package.json'; - -export default defineConfig({ - clean: true, - dts: true, - entry: ['src/index.ts'], - external: [ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.peerDependencies || {}), - 'fsevents', - ], - format: ['cjs', 'esm'], - minify: true, - splitting: true, - treeshake: true, -}); diff --git a/registry/components/action-bar.tsx b/registry/components/action-bar.tsx new file mode 100644 index 0000000..e5eb552 --- /dev/null +++ b/registry/components/action-bar.tsx @@ -0,0 +1,635 @@ +'use client'; + +import * as DirectionPrimitive from '@radix-ui/react-direction'; +import * as SlotPrimitive from '@radix-ui/react-slot'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Button } from '@/components/ui/button'; +import { useComposedRefs } from '@/lib/component-refs'; +import { cn } from '@/lib/utils'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; + +const ROOT_NAME = 'ActionBar'; +const GROUP_NAME = 'ActionBarGroup'; +const ITEM_NAME = 'ActionBarItem'; +const CLOSE_NAME = 'ActionBarClose'; +const SEPARATOR_NAME = 'ActionBarSeparator'; +const ITEM_SELECT = 'actionbar.itemSelect'; +const ENTRY_FOCUS = 'actionbarFocusGroup.onEntryFocus'; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +type Direction = 'ltr' | 'rtl'; +type Orientation = 'horizontal' | 'vertical'; + +interface DivProps extends React.ComponentProps<'div'> { + asChild?: boolean; +} + +type RootElement = React.ComponentRef; +type ItemElement = React.ComponentRef; +type CloseElement = React.ComponentRef; + +function focusFirst(candidates: React.RefObject[], preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length] as T); +} + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; +} + +interface ItemData { + id: string; + ref: React.RefObject; + disabled: boolean; +} + +interface ActionBarContextValue { + onOpenChange?: (open: boolean) => void; + dir: Direction; + orientation: Orientation; + loop: boolean; +} + +const ActionBarContext = React.createContext(null); + +function useActionBarContext(consumerName: string) { + const context = React.useContext(ActionBarContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`FocusProvider\``); + } + return context; +} + +interface ActionBarProps extends DivProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + align?: 'start' | 'center' | 'end'; + alignOffset?: number; + side?: 'top' | 'bottom'; + sideOffset?: number; + portalContainer?: Element | DocumentFragment | null; + dir?: Direction; + orientation?: Orientation; + loop?: boolean; +} + +function ActionBar(props: ActionBarProps) { + const { + open = false, + onOpenChange, + onEscapeKeyDown, + side = 'bottom', + alignOffset = 0, + align = 'center', + sideOffset = 16, + portalContainer: portalContainerProp, + dir: dirProp, + orientation = 'horizontal', + loop = true, + className, + style, + ref, + asChild, + ...rootProps + } = props; + + const [mounted, setMounted] = React.useState(false); + + const rootRef = React.useRef(null); + const composedRef = useComposedRefs(ref, rootRef); + + const propsRef = useAsRef({ + onEscapeKeyDown, + onOpenChange, + }); + + const dir = DirectionPrimitive.useDirection(dirProp); + + React.useLayoutEffect(() => { + setMounted(true); + }, []); + + React.useEffect(() => { + if (!open) return; + + const ownerDocument = rootRef.current?.ownerDocument ?? document; + + function onKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + propsRef.current.onEscapeKeyDown?.(event); + if (!event.defaultPrevented) { + propsRef.current.onOpenChange?.(false); + } + } + } + + ownerDocument.addEventListener('keydown', onKeyDown); + return () => ownerDocument.removeEventListener('keydown', onKeyDown); + }, [open, propsRef]); + + const contextValue = React.useMemo( + () => ({ + dir, + loop, + onOpenChange, + orientation, + }), + [onOpenChange, dir, orientation, loop], + ); + + const portalContainer = portalContainerProp ?? (mounted ? globalThis.document?.body : null); + + if (!portalContainer || !open) return null; + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + {ReactDOM.createPortal( + , + portalContainer, + )} + + ); +} + +function ActionBarSelection(props: DivProps) { + const { className, asChild, ...selectionProps } = props; + + const SelectionPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + ); +} + +function ActionBarGroup(props: DivProps) { + const { + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + className, + asChild, + ref, + ...groupProps + } = props; + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + + const groupRef = React.useRef(null); + const composedRef = useComposedRefs(ref, groupRef); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + + const { dir, orientation } = useActionBarContext(GROUP_NAME); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + onBlurProp?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [onBlurProp], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusProp?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter((item) => !item.disabled); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [currentItem, ...items].filter(Boolean) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [onFocusProp, isTabbingBackOut, tabStopId], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + onMouseDownProp?.(event); + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [onMouseDownProp], + ); + + const focusContextValue = React.useMemo( + () => ({ + getItems, + onFocusableItemAdd, + onFocusableItemRemove, + onItemFocus, + onItemRegister, + onItemShiftTab, + onItemUnregister, + tabStopId, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ], + ); + + const GroupPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + ); +} + +interface ActionBarItemProps extends Omit, 'onSelect'> { + onSelect?: (event: Event) => void; +} + +function ActionBarItem(props: ActionBarItemProps) { + const { + onSelect, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + className, + disabled, + ref, + ...itemProps + } = props; + + const itemRef = React.useRef(null); + const composedRef = useComposedRefs(ref, itemRef); + const isMouseClickRef = React.useRef(false); + + const { onOpenChange, dir, orientation, loop } = useActionBarContext(ITEM_NAME); + const focusContext = useFocusContext(ITEM_NAME); + + const itemId = React.useId(); + const isTabStop = focusContext.tabStopId === itemId; + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + disabled: !!disabled, + id: itemId, + ref: itemRef, + }); + + if (!disabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(itemId); + if (!disabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, itemId, disabled]); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + if (event.defaultPrevented) return; + + const item = itemRef.current; + if (!item) return; + + const itemSelectEvent = new CustomEvent(ITEM_SELECT, { + bubbles: true, + cancelable: true, + }); + + item.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), { + once: true, + }); + + item.dispatchEvent(itemSelectEvent); + + if (!itemSelectEvent.defaultPrevented) { + onOpenChange?.(false); + } + }, + [onClickProp, onOpenChange, onSelect], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + onFocusProp?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(itemId); + isMouseClickRef.current = false; + }, + [onFocusProp, focusContext, itemId], + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDownProp?.(event); + if (event.defaultPrevented) return; + + if (event.key === 'Tab' && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const key = getDirectionAwareKey(event.key, dir); + let focusIntent: 'first' | 'last' | 'prev' | 'next' | undefined; + + if (orientation === 'horizontal') { + if (key === 'ArrowLeft') focusIntent = 'prev'; + else if (key === 'ArrowRight') focusIntent = 'next'; + else if (key === 'Home') focusIntent = 'first'; + else if (key === 'End') focusIntent = 'last'; + } else { + if (key === 'ArrowUp') focusIntent = 'prev'; + else if (key === 'ArrowDown') focusIntent = 'next'; + else if (key === 'Home') focusIntent = 'first'; + else if (key === 'End') focusIntent = 'last'; + } + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === 'last') { + candidateRefs.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex( + (ref) => ref.current === event.currentTarget, + ); + candidateRefs = loop + ? wrapArray(candidateRefs, currentIndex + 1) + : candidateRefs.slice(currentIndex + 1); + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [onKeyDownProp, focusContext, dir, orientation, loop], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + onMouseDownProp?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (disabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(itemId); + } + }, + [onMouseDownProp, focusContext, itemId, disabled], + ); + + return ( + + + )} + + )} + + + ); +} diff --git a/registry/components/data-table/data-table-advance-filter.tsx b/registry/components/data-table/data-table-advance-filter.tsx new file mode 100644 index 0000000..c80e831 --- /dev/null +++ b/registry/components/data-table/data-table-advance-filter.tsx @@ -0,0 +1,895 @@ +import type { Column, ColumnMeta, Table } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import { Calendar as CalendarIcon, Check, ChevronsUpDown, ListFilter, Trash2 } from 'lucide-react'; +import * as React from 'react'; +import type { DateRange } from 'react-day-picker'; +import { + getDefaultFilterOperator, + getFilterOperators, + getValidFilters, +} from '@/lib/data-table'; +import { cn } from '@/lib/utils'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { DebouncedInput } from '@/components/ui/debounced-input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { dataTableConfig } from './data-table-config'; +import { getIsDateRange, parseAsDate, parseColumnFilterValue } from './data-table-date-filter'; +import { DataTableRangeFilter } from './data-table-range-filter'; +import type { + DataTableTranslations, + DatePreset, + ExtendedColumnFilter, + FilterOperator, + JoinOperator, + Option, +} from './types'; + +const DEBOUNCE_MS = 300; + +const DEFAULT_TRANSLATIONS = { + addFilter: 'Add filter', + and: 'and', + enterValue: 'Enter a value...', + filters: 'Filters', + noFiltersApplied: 'No filters applied', + operators: { + eq: 'is', + gt: 'is greater than', + gte: 'is greater than or equal to', + iLike: 'contains', + inArray: 'has any of', + isBetween: 'is between', + isEmpty: 'is empty', + isNotEmpty: 'is not empty', + isRelativeToToday: 'is relative to today', + lt: 'is less than', + lte: 'is less than or equal to', + ne: 'is not', + notILike: 'does not contain', + notInArray: 'has none of', + }, + or: 'or', + pickADate: 'Pick a date', + resetFilters: 'Reset filters', + searchFields: 'Search fields...', + selected: 'selected', + selectField: 'Select field', + where: 'Where', +}; + +const generateId = () => Math.random().toString(36).substring(2, 10); + +export interface DataTableAdvanceFilterProps + extends React.ComponentPropsWithoutRef { + table: Table; + translations?: DataTableTranslations; + shallow?: boolean; + debounceMs?: number; + throttleMs?: number; + disabled?: boolean; +} + +export function DataTableAdvanceFilter({ + table, + translations: customTranslations, + debounceMs = DEBOUNCE_MS, + shallow, + throttleMs, + disabled, + align = 'center', + ...props +}: DataTableAdvanceFilterProps) { + const labelId = React.useId(); + const descriptionId = React.useId(); + const [open, setOpen] = React.useState(false); + const addButtonRef = React.useRef(null); + + const translations = React.useMemo(() => { + return { + ...DEFAULT_TRANSLATIONS, + ...customTranslations, + operators: { + ...DEFAULT_TRANSLATIONS.operators, + ...customTranslations?.operators, + }, + }; + }, [customTranslations]); + + const columns = React.useMemo(() => { + return table + .getAllColumns() + .filter( + (column) => + column.columnDef.enableColumnFilter !== false && + column.id !== 'select' && + column.id !== 'actions', + ); + }, [table, table.options.columns]); + + // Read/write state from table meta + const metaFilters = (table.options.meta as any)?.filters as + | ExtendedColumnFilter[] + | undefined; + const metaSetFilters = (table.options.meta as any)?.setFilters as + | ((val: any) => void) + | undefined; + const metaJoinOperator = (table.options.meta as any)?.joinOperator as JoinOperator | undefined; + const metaSetJoinOperator = (table.options.meta as any)?.setJoinOperator as + | ((val: JoinOperator) => void) + | undefined; + const metaDebounceMs = (table.options.meta as any)?.debounceMs as number | undefined; + + const [localFilters, setLocalFilters] = React.useState[]>([]); + const [localJoinOperator, setLocalJoinOperator] = React.useState('and'); + + // Sync local filters with controlled filters when popover is closed + React.useEffect(() => { + if (metaFilters !== undefined && !open) { + setLocalFilters(metaFilters); + } + }, [open, metaFilters]); + + // Sync local joinOperator with controlled joinOperator when popover is closed + React.useEffect(() => { + if (metaJoinOperator !== undefined && !open) { + setLocalJoinOperator(metaJoinOperator); + } + }, [open, metaJoinOperator]); + + const filters = localFilters; + const setFilters = React.useCallback( + (value: any) => { + if (metaSetFilters) { + setLocalFilters((prev) => { + const next = typeof value === 'function' ? value(prev) : value; + metaSetFilters(getValidFilters(next)); + return next; + }); + } else { + setLocalFilters(value); + } + }, + [metaSetFilters], + ); + + const joinOperator = localJoinOperator; + const setJoinOperator = React.useCallback( + (value: any) => { + if (metaSetJoinOperator) { + setLocalJoinOperator((prev) => { + const next = typeof value === 'function' ? value(prev) : value; + metaSetJoinOperator(next); + return next; + }); + } else { + setLocalJoinOperator(value); + } + }, + [metaSetJoinOperator], + ); + + const activeDebounceMs = metaDebounceMs !== undefined ? metaDebounceMs : debounceMs; + + const onFilterAdd = React.useCallback(() => { + const column = columns[0]; + if (!column) return; + + setFilters([ + ...filters, + { + filterId: generateId(), + id: column.id as Extract, + operator: getDefaultFilterOperator(column.columnDef.meta?.variant ?? 'text'), + value: '', + variant: column.columnDef.meta?.variant ?? 'text', + }, + ]); + }, [columns, filters, setFilters]); + + const onFilterUpdate = React.useCallback( + (filterId: string, updates: Partial, 'filterId'>>) => { + setFilters((prevFilters: ExtendedColumnFilter[]) => { + return prevFilters.map((filter) => { + if (filter.filterId === filterId) { + return { ...filter, ...updates } as ExtendedColumnFilter; + } + return filter; + }); + }); + }, + [setFilters], + ); + + const onFilterRemove = React.useCallback( + (filterId: string) => { + const updatedFilters = filters.filter((filter) => filter.filterId !== filterId); + setFilters(updatedFilters); + requestAnimationFrame(() => { + addButtonRef.current?.focus(); + }); + }, + [filters, setFilters], + ); + + const onFiltersReset = React.useCallback(() => { + setFilters([]); + setJoinOperator('and'); + }, [setFilters, setJoinOperator]); + + return ( + + + + + +
+

+ {filters.length > 0 ? translations.filters : translations.noFiltersApplied} +

+
+ {filters.length > 0 ? ( +
+ {filters.map((filter, index) => ( + + columns={columns} + debounceMs={activeDebounceMs} + filter={filter} + filterItemId={`filter-${filter.filterId}`} + index={index} + joinOperator={joinOperator} + key={filter.filterId} + onFilterRemove={onFilterRemove} + onFilterUpdate={onFilterUpdate} + setJoinOperator={setJoinOperator} + translations={translations} + /> + ))} +
+ ) : null} +
+ + {filters.length > 0 ? ( + + ) : null} +
+
+
+ ); +} + +interface DataTableFilterItemProps { + filter: ExtendedColumnFilter; + index: number; + filterItemId: string; + joinOperator: JoinOperator; + setJoinOperator: (value: JoinOperator) => void; + columns: Column[]; + onFilterUpdate: ( + filterId: string, + updates: Partial, 'filterId'>>, + ) => void; + onFilterRemove: (filterId: string) => void; + translations: any; + debounceMs: number; +} + +function DataTableFilterItem({ + filter, + index, + filterItemId, + joinOperator, + setJoinOperator, + columns, + onFilterUpdate, + onFilterRemove, + translations, + debounceMs, +}: DataTableFilterItemProps) { + const [showFieldSelector, setShowFieldSelector] = React.useState(false); + + const column = columns.find((col) => col.id === filter.id); + const columnMeta = column?.columnDef.meta; + const filterOperators = React.useMemo(() => { + return getFilterOperators(filter.variant); + }, [filter.variant]); + + if (!column) return null; + + return ( +
+
+ {index === 0 ? ( + {translations.where} + ) : index === 1 ? ( + + ) : ( + + {joinOperator === 'and' ? translations.and : translations.or} + + )} +
+ + + + + + + + + {translations.noResults ?? 'No fields found.'} + + {columns.map((col) => ( + { + onFilterUpdate(filter.filterId, { + id: value as Extract, + operator: getDefaultFilterOperator(col.columnDef.meta?.variant ?? 'text'), + value: '', + variant: col.columnDef.meta?.variant ?? 'text', + }); + setShowFieldSelector(false); + }} + value={col.id} + > + {col.columnDef.meta?.label ?? col.id} + + + ))} + + + + + + +
+ {onFilterInputRender({ + column, + columnMeta, + debounceMs, + filter, + inputId: `${filterItemId}-input`, + onFilterUpdate, + translations, + })} +
+ +
+ ); +} + +function onFilterInputRender({ + filter, + inputId, + column, + columnMeta, + onFilterUpdate, + translations, + debounceMs, +}: { + filter: ExtendedColumnFilter; + inputId: string; + column: Column; + columnMeta?: ColumnMeta; + onFilterUpdate: ( + filterId: string, + updates: Partial, 'filterId'>>, + ) => void; + translations: any; + debounceMs: number; +}) { + if (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty') { + return ( +
+ ); + } + + switch (filter.variant) { + case 'text': + case 'number': + case 'range': { + if (filter.operator === 'isBetween') { + return ( + + ); + } + + const isNumber = filter.variant === 'number' || filter.variant === 'range'; + + return ( + + onFilterUpdate(filter.filterId, { + value: String(val), + }) + } + placeholder={columnMeta?.placeholder ?? translations.enterValue} + type={isNumber ? 'number' : filter.variant} + value={ + typeof filter.value === 'string' || typeof filter.value === 'number' ? filter.value : '' + } + /> + ); + } + + case 'boolean': { + const options = columnMeta?.options ?? [ + { label: 'True', value: 'true' }, + { label: 'False', value: 'false' }, + ]; + const selectedOption = options.find((opt) => String(opt.value) === String(filter.value)); + const displayLabel = selectedOption?.label ?? (filter.value === 'true' ? 'True' : 'False'); + + return ( + + ); + } + + case 'select': + case 'multiSelect': { + const multiple = filter.variant === 'multiSelect'; + return ( + { + onFilterUpdate(filter.filterId, { + value: val, + }); + }} + options={columnMeta?.options ?? []} + placeholder={columnMeta?.placeholder} + translations={translations} + value={filter.value} + /> + ); + } + + case 'date': + case 'dateRange': { + return ( + { + onFilterUpdate(filter.filterId, { + value: val, + ...(Array.isArray(val) ? { operator: 'isBetween' } : {}), + }); + }} + operator={filter.operator} + placeholder={columnMeta?.placeholder} + presets={columnMeta?.presets ?? (columnMeta as any)?.preset} + translations={translations} + value={filter.value} + /> + ); + } + + default: + return null; + } +} + +function FacetedFilter({ + options, + value, + onChange, + multiple, + placeholder, + translations, +}: { + options: Option[]; + value: string | string[]; + onChange: (value: string | string[]) => void; + multiple: boolean; + placeholder?: string; + translations: any; +}) { + const [open, setOpen] = React.useState(false); + const selectedValues = React.useMemo(() => { + if (multiple) { + return new Set(Array.isArray(value) ? value : []); + } + return new Set(typeof value === 'string' && value ? [value] : []); + }, [value, multiple]); + + const handleSelect = (optionValue: string) => { + if (multiple) { + const next = new Set(selectedValues); + if (next.has(optionValue)) { + next.delete(optionValue); + } else { + next.add(optionValue); + } + onChange(Array.from(next)); + } else { + onChange(optionValue); + setOpen(false); + } + }; + + const getLabel = (val: string) => { + return options.find((o) => o.value === val)?.label ?? val; + }; + + return ( + + + + + + + + + {translations.noResults ?? 'No options found.'} + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + return ( + handleSelect(option.value)} + value={option.value} + > + + + + {option.icon && } + {option.label} + {option.count && ( + + {option.count} + + )} + + ); + })} + + + + + + ); +} + +function DateFilterInput({ + value, + onChange, + operator, + placeholder, + translations, + disabled, + presets, + label, +}: { + value: any; + onChange: (value: any) => void; + operator: string; + placeholder?: string; + translations: any; + disabled?: import('react-day-picker').Matcher | import('react-day-picker').Matcher[]; + presets?: boolean | DatePreset[]; + label?: string; +}) { + const selectedDates = React.useMemo(() => { + if (!value) { + return operator === 'isBetween' ? { from: undefined, to: undefined } : []; + } + + if (operator === 'isBetween') { + const timestamps = parseColumnFilterValue(value); + return { + from: parseAsDate(timestamps[0]), + to: parseAsDate(timestamps[1]), + }; + } + + const timestamps = parseColumnFilterValue(value); + const date = parseAsDate(timestamps[0]); + return date ? [date] : []; + }, [value, operator]); + + const startDate = getIsDateRange(selectedDates) ? selectedDates.from : selectedDates[0]; + const endDate = getIsDateRange(selectedDates) ? selectedDates.to : undefined; + + const displayValue = React.useMemo(() => { + if (operator === 'isBetween' && startDate && endDate) { + return `${dayjs(startDate).format('MMM D, YYYY')} - ${dayjs(endDate).format('MMM D, YYYY')}`; + } + return startDate + ? dayjs(startDate).format('MMM D, YYYY') + : (placeholder ?? translations.pickADate); + }, [startDate, endDate, operator, placeholder, translations]); + + const hasPresets = Array.isArray(presets) && presets.length > 0; + const resolvedPresets = React.useMemo(() => { + return hasPresets ? (presets as DatePreset[]) : []; + }, [hasPresets, presets]); + + const isPresetActive = React.useCallback( + (presetValue: DateRange | [Date, Date] | (() => DateRange | [Date, Date])) => { + let resolvedValue: DateRange; + const val = typeof presetValue === 'function' ? presetValue() : presetValue; + if (Array.isArray(val)) { + resolvedValue = { from: val[0], to: val[1] }; + } else { + resolvedValue = val; + } + + if (!resolvedValue.from && !startDate && !resolvedValue.to && !endDate) return true; + if (!resolvedValue.from || !startDate) return false; + + const sameFrom = dayjs(resolvedValue.from).isSame(startDate, 'day'); + const sameTo = + resolvedValue.to && endDate + ? dayjs(resolvedValue.to).isSame(endDate, 'day') + : !resolvedValue.to && !endDate; + + return sameFrom && sameTo; + }, + [startDate, endDate], + ); + + const handlePresetClick = React.useCallback( + (presetValue: DateRange | [Date, Date] | (() => DateRange | [Date, Date])) => { + let resolvedValue: DateRange; + const val = typeof presetValue === 'function' ? presetValue() : presetValue; + if (Array.isArray(val)) { + resolvedValue = { from: val[0], to: val[1] }; + } else { + resolvedValue = val; + } + + const from = resolvedValue.from?.getTime(); + const to = resolvedValue.to?.getTime(); + onChange(from || to ? [from ? from.toString() : '', to ? to.toString() : ''] : undefined); + }, + [onChange], + ); + + return ( + + + + + + {hasPresets && ( +
+ {resolvedPresets.map((preset) => ( + + ))} +
+ )} +
+ {operator === 'isBetween' ? ( + { + if (range?.from || range?.to) { + const from = range.from?.getTime(); + const to = range.to?.getTime(); + onChange( + from || to ? [from ? from.toString() : '', to ? to.toString() : ''] : undefined, + ); + } + }} + selected={startDate ? { from: startDate, to: endDate } : undefined} + /> + ) : ( + { + if (date) { + onChange(date.getTime().toString()); + } + }} + selected={startDate} + /> + )} +
+
+
+ ); +} diff --git a/registry/components/data-table/data-table-bulk-action.tsx b/registry/components/data-table/data-table-bulk-action.tsx new file mode 100644 index 0000000..b68abba --- /dev/null +++ b/registry/components/data-table/data-table-bulk-action.tsx @@ -0,0 +1,192 @@ +import type { Table } from '@tanstack/react-table'; +import { X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +type DataTableBulkActionsProps = { + table: Table; + entityName?: string; + children: React.ReactNode; + className?: string; +}; + +/** + * A modular toolbar for displaying bulk actions when table rows are selected. + * + * @template TData The type of data in the table. + * @param {object} props The component props. + * @param {Table} props.table The react-table instance. + * @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user"). + * @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar. + * @param {string} [props.className] Optional custom CSS class for positioning and styling. + * @returns {React.ReactNode | null} The rendered component or null if no rows are selected. + */ +export function DataTableBulkActions({ + table, + entityName, + children, + className, +}: DataTableBulkActionsProps): React.ReactNode | null { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const selectedCount = selectedRows.length; + const toolbarRef = useRef(null); + const [announcement, setAnnouncement] = useState(''); + + // Announce selection changes to screen readers + useEffect(() => { + if (selectedCount > 0) { + const message = `${selectedCount} ${entityName || 'item'}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`; + + // Use queueMicrotask to defer state update and avoid cascading renders + queueMicrotask(() => { + setAnnouncement(message); + }); + + // Clear announcement after a delay + const timer = setTimeout(() => setAnnouncement(''), 3000); + return () => clearTimeout(timer); + } + }, [selectedCount, entityName]); + + const handleClearSelection = () => { + table.resetRowSelection(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + const buttons = toolbarRef.current?.querySelectorAll('button'); + if (!buttons) return; + + const activeElement = document.activeElement as HTMLButtonElement | null; + const currentIndex = activeElement ? Array.from(buttons).indexOf(activeElement) : -1; + + switch (event.key) { + case 'ArrowRight': { + event.preventDefault(); + const nextIndex = (currentIndex + 1) % buttons.length; + buttons[nextIndex]?.focus(); + break; + } + case 'ArrowLeft': { + event.preventDefault(); + const prevIndex = currentIndex === 0 ? buttons.length - 1 : currentIndex - 1; + buttons[prevIndex]?.focus(); + break; + } + case 'Home': + event.preventDefault(); + buttons[0]?.focus(); + break; + case 'End': + event.preventDefault(); + buttons[buttons.length - 1]?.focus(); + break; + case 'Escape': { + // Check if the Escape key came from a dropdown trigger or content + // We can't check dropdown state because Radix UI closes it before our handler runs + const target = event.target as HTMLElement; + const activeElement = document.activeElement as HTMLElement; + + // Check if the event target or currently focused element is a dropdown trigger + const isFromDropdownTrigger = + target?.getAttribute('data-slot') === 'dropdown-menu-trigger' || + activeElement?.getAttribute('data-slot') === 'dropdown-menu-trigger' || + target?.closest('[data-slot="dropdown-menu-trigger"]') || + activeElement?.closest('[data-slot="dropdown-menu-trigger"]'); + + // Check if the focused element is inside dropdown content (which is portaled) + const isFromDropdownContent = + activeElement?.closest('[data-slot="dropdown-menu-content"]') || + target?.closest('[data-slot="dropdown-menu-content"]'); + + if (isFromDropdownTrigger || isFromDropdownContent) { + // Escape was meant for the dropdown - don't clear selection + return; + } + + // Escape was meant for the toolbar - clear selection + event.preventDefault(); + handleClearSelection(); + break; + } + } + }; + + if (selectedCount === 0) { + return null; + } + + return ( + <> + {/* Live region for screen reader announcements */} +
+ {announcement} +
+ +
1 ? 's' : ''}`} + className={cn( + 'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl w-max', + 'transition-all delay-100 duration-300 ease-out hover:scale-105', + 'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none', + className, + )} + onKeyDown={handleKeyDown} + ref={toolbarRef} + role="toolbar" + tabIndex={-1} + > +
+ + + + + +

Clear selection (Escape)

+
+
+ +
+
+ + ); +} diff --git a/registry/components/data-table/data-table-column-header.tsx b/registry/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000..367ccbd --- /dev/null +++ b/registry/components/data-table/data-table-column-header.tsx @@ -0,0 +1,105 @@ +import type { Column } from '@tanstack/react-table'; +import { ChevronDown, ChevronsUpDown, ChevronUp, EyeOff, X } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { cn } from '@/lib/utils'; + +interface DataTableColumnHeaderProps + extends React.ComponentProps { + column: Column; + label: React.ReactNode; +} + +export function DataTableColumnHeader({ + column, + label, + className, + ...props +}: DataTableColumnHeaderProps) { + const align = column.columnDef.meta?.align; + + if (!column.getCanSort() && !column.getCanHide()) { + return ( +
+ {label} +
+ ); + } + + return ( + + + {label} + {column.getCanSort() && + (column.getIsSorted() === 'desc' ? ( + + ) : column.getIsSorted() === 'asc' ? ( + + ) : ( + + ))} + + + {column.getCanSort() && ( + <> + column.toggleSorting(false)} + > + + Asc + + column.toggleSorting(true)} + > + + Desc + + {column.getIsSorted() && ( + column.clearSorting()} + > + + Reset + + )} + + )} + {column.getCanHide() && ( + column.toggleVisibility(false)} + > + + Hide + + )} + + + ); +} diff --git a/registry/components/data-table/data-table-config.ts b/registry/components/data-table/data-table-config.ts new file mode 100644 index 0000000..fa1ff26 --- /dev/null +++ b/registry/components/data-table/data-table-config.ts @@ -0,0 +1,113 @@ +export type DataTableConfig = typeof dataTableConfig; + +export const dataTableConfig = { + booleanOperators: [ + { label: 'Is', value: 'eq' as const }, + { label: 'Is not', value: 'ne' as const }, + ], + dateOperators: [ + { label: 'Is', value: 'eq' as const }, + { label: 'Is not', value: 'ne' as const }, + { label: 'Is before', value: 'lt' as const }, + { label: 'Is after', value: 'gt' as const }, + { label: 'Is on or before', value: 'lte' as const }, + { label: 'Is on or after', value: 'gte' as const }, + { label: 'Is between', value: 'isBetween' as const }, + { label: 'Is relative to today', value: 'isRelativeToToday' as const }, + { label: 'Is empty', value: 'isEmpty' as const }, + { label: 'Is not empty', value: 'isNotEmpty' as const }, + ], + filterVariants: [ + 'text', + 'number', + 'range', + 'date', + 'dateRange', + 'boolean', + 'select', + 'multiSelect', + ] as const, + joinOperators: ['and', 'or'] as const, + multiSelectOperators: [ + { label: 'Has any of', value: 'inArray' as const }, + { label: 'Has none of', value: 'notInArray' as const }, + { label: 'Is empty', value: 'isEmpty' as const }, + { label: 'Is not empty', value: 'isNotEmpty' as const }, + ], + numericOperators: [ + { label: 'Is', value: 'eq' as const }, + { label: 'Is not', value: 'ne' as const }, + { label: 'Is less than', value: 'lt' as const }, + { label: 'Is less than or equal to', value: 'lte' as const }, + { label: 'Is greater than', value: 'gt' as const }, + { label: 'Is greater than or equal to', value: 'gte' as const }, + { label: 'Is between', value: 'isBetween' as const }, + { label: 'Is empty', value: 'isEmpty' as const }, + { label: 'Is not empty', value: 'isNotEmpty' as const }, + ], + operators: [ + 'iLike', + 'notILike', + 'eq', + 'ne', + 'inArray', + 'notInArray', + 'isEmpty', + 'isNotEmpty', + 'lt', + 'lte', + 'gt', + 'gte', + 'isBetween', + 'isRelativeToToday', + ] as const, + selectOperators: [ + { label: 'Is', value: 'eq' as const }, + { label: 'Is not', value: 'ne' as const }, + { label: 'Is empty', value: 'isEmpty' as const }, + { label: 'Is not empty', value: 'isNotEmpty' as const }, + ], + sortOrders: [ + { label: 'Asc', value: 'asc' as const }, + { label: 'Desc', value: 'desc' as const }, + ], + textOperators: [ + { label: 'Contains', value: 'iLike' as const }, + { label: 'Does not contain', value: 'notILike' as const }, + { label: 'Is', value: 'eq' as const }, + { label: 'Is not', value: 'ne' as const }, + { label: 'Is empty', value: 'isEmpty' as const }, + { label: 'Is not empty', value: 'isNotEmpty' as const }, + ], +}; + +export const idIDTranslations = { + addFilter: 'Tambah filter', + and: 'dan', + enterValue: 'Masukkan nilai...', + filters: 'Filter', + noFiltersApplied: 'Tidak ada filter yang diterapkan', + operators: { + eq: 'adalah', + gt: 'lebih dari', + gte: 'lebih dari atau sama dengan', + iLike: 'mengandung', + inArray: 'salah satu dari', + isBetween: 'di antara', + isEmpty: 'kosong', + isNotEmpty: 'tidak kosong', + isRelativeToToday: 'relatif terhadap hari ini', + lt: 'kurang dari', + lte: 'kurang dari atau sama dengan', + ne: 'bukan', + notILike: 'tidak mengandung', + notInArray: 'bukan salah satu dari', + }, + or: 'atau', + pickADate: 'Pilih tanggal', + resetFilters: 'Hapus filter', + searchFields: 'Cari kolom...', + selected: 'terpilih', + selectField: 'Pilih kolom', + where: 'Di mana', +}; diff --git a/registry/components/data-table/data-table-date-filter.tsx b/registry/components/data-table/data-table-date-filter.tsx new file mode 100644 index 0000000..597cf3f --- /dev/null +++ b/registry/components/data-table/data-table-date-filter.tsx @@ -0,0 +1,300 @@ +import type { Column } from '@tanstack/react-table'; +import dayjs from 'dayjs'; +import { CalendarDays, XCircle } from 'lucide-react'; +import * as React from 'react'; +import type { DateRange } from 'react-day-picker'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { formatDateFilterTable } from '@/lib/date'; +import type { DatePreset } from './types'; + +type DateSelection = Date[] | DateRange; + +export function getIsDateRange(value: DateSelection): value is DateRange { + return value && typeof value === 'object' && !Array.isArray(value); +} + +export function parseAsDate(timestamp: number | string | undefined): Date | undefined { + if (!timestamp) return undefined; + const numericTimestamp = typeof timestamp === 'string' ? Number(timestamp) : timestamp; + const date = new Date(numericTimestamp); + return !Number.isNaN(date.getTime()) ? date : undefined; +} + +export function parseColumnFilterValue(value: unknown) { + if (value === null || value === undefined) { + return []; + } + + if (Array.isArray(value)) { + return value.map((item) => { + if (typeof item === 'number' || typeof item === 'string') { + return item; + } + return undefined; + }); + } + + if (typeof value === 'string' || typeof value === 'number') { + return [value]; + } + + return []; +} + +interface DataTableDateFilterProps { + column: Column; + title?: string; + multiple?: boolean; + presets?: boolean | DatePreset[]; +} + +export function DataTableDateFilter({ + column, + title, + multiple, + presets, +}: DataTableDateFilterProps) { + const [open, setOpen] = React.useState(false); + const columnFilterValue = column.getFilterValue(); + + const serializedFilterValue = React.useMemo(() => { + if (columnFilterValue === undefined || columnFilterValue === null) { + return ''; + } + if (Array.isArray(columnFilterValue)) { + return columnFilterValue.join(','); + } + return String(columnFilterValue); + }, [columnFilterValue]); + + const selectedDates = React.useMemo(() => { + if (!columnFilterValue) { + return multiple ? { from: undefined, to: undefined } : []; + } + + if (multiple) { + const timestamps = parseColumnFilterValue(columnFilterValue); + return { + from: parseAsDate(timestamps[0]), + to: parseAsDate(timestamps[1]), + }; + } + + const timestamps = parseColumnFilterValue(columnFilterValue); + const date = parseAsDate(timestamps[0]); + return date ? [date] : []; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serializedFilterValue, multiple]); + + const onSelect = React.useCallback( + (date: Date | DateRange | undefined) => { + if (!date) { + column.setFilterValue(undefined); + return; + } + + if (multiple && !('getTime' in date)) { + const from = date.from?.getTime(); + const to = date.to?.getTime(); + column.setFilterValue(from || to ? [from, to] : undefined); + } else if (!multiple && 'getTime' in date) { + column.setFilterValue(date.getTime()); + setOpen(false); + } + }, + [column, multiple], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + column.setFilterValue(undefined); + setOpen(false); + }, + [column], + ); + + const hasValue = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return false; + return selectedDates.from || selectedDates.to; + } + if (!Array.isArray(selectedDates)) return false; + return selectedDates.length > 0; + }, [multiple, selectedDates]); + + const formatDateRange = React.useCallback((range: DateRange) => { + if (!range.from && !range.to) return ''; + if (range.from && range.to) { + return `${formatDateFilterTable(range.from)} - ${formatDateFilterTable(range.to)}`; + } + return formatDateFilterTable(range.from ?? range.to); + }, []); + + const label = React.useMemo(() => { + if (multiple) { + if (!getIsDateRange(selectedDates)) return null; + + const hasSelectedDates = selectedDates.from || selectedDates.to; + const dateText = hasSelectedDates ? formatDateRange(selectedDates) : 'Select date range'; + + return ( + + {title} + {hasSelectedDates && ( + <> + + {dateText} + + )} + + ); + } + + if (getIsDateRange(selectedDates)) return null; + + const hasSelectedDate = selectedDates.length > 0; + const dateText = hasSelectedDate ? formatDateFilterTable(selectedDates[0]) : 'Select date'; + + return ( + + {title} + {hasSelectedDate && ( + <> + + {dateText} + + )} + + ); + }, [selectedDates, multiple, formatDateRange, title]); + + const disabled = column.columnDef.meta?.disabled; + + const resolvedPresetsOption = + presets ?? column.columnDef.meta?.presets ?? (column.columnDef.meta as any)?.preset; + const hasPresets = + multiple && Array.isArray(resolvedPresetsOption) && resolvedPresetsOption.length > 0; + const resolvedPresets = React.useMemo(() => { + return hasPresets ? (resolvedPresetsOption as DatePreset[]) : []; + }, [hasPresets, resolvedPresetsOption]); + + const isPresetActive = React.useCallback( + (presetValue: DateRange | [Date, Date] | (() => DateRange | [Date, Date])) => { + let resolvedValue: DateRange; + const val = typeof presetValue === 'function' ? presetValue() : presetValue; + if (Array.isArray(val)) { + resolvedValue = { from: val[0], to: val[1] }; + } else { + resolvedValue = val; + } + + const current = selectedDates; + if (!getIsDateRange(current)) return false; + + if (!resolvedValue.from && !current.from && !resolvedValue.to && !current.to) return true; + if (!resolvedValue.from || !current.from) return false; + + const sameFrom = dayjs(resolvedValue.from).isSame(current.from, 'day'); + const sameTo = + resolvedValue.to && current.to + ? dayjs(resolvedValue.to).isSame(current.to, 'day') + : !resolvedValue.to && !current.to; + + return sameFrom && sameTo; + }, + [selectedDates], + ); + + const handlePresetClick = React.useCallback( + (presetValue: DateRange | [Date, Date] | (() => DateRange | [Date, Date])) => { + let resolvedValue: DateRange; + const val = typeof presetValue === 'function' ? presetValue() : presetValue; + if (Array.isArray(val)) { + resolvedValue = { from: val[0], to: val[1] }; + } else { + resolvedValue = val; + } + + const from = resolvedValue.from?.getTime(); + const to = resolvedValue.to?.getTime(); + column.setFilterValue(from || to ? [from, to] : undefined); + setOpen(false); + }, + [column], + ); + + return ( + + + + + + {hasPresets && ( +
+ {resolvedPresets.map((preset) => ( + + ))} +
+ )} +
+ {multiple ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/registry/components/data-table/data-table-faceted-filter.tsx b/registry/components/data-table/data-table-faceted-filter.tsx new file mode 100644 index 0000000..263e186 --- /dev/null +++ b/registry/components/data-table/data-table-faceted-filter.tsx @@ -0,0 +1,178 @@ +import type { Column } from '@tanstack/react-table'; +import { Check, Loader, PlusCircle, XCircle } from 'lucide-react'; +import * as React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; +import type { Option } from './types'; + +interface DataTableFacetedFilterProps { + column?: Column; + title?: string; + options: Option[]; + multiple?: boolean; + loading?: boolean; +} + +export function DataTableFacetedFilter({ + column, + title, + options, + multiple, + loading = false, +}: DataTableFacetedFilterProps) { + const [open, setOpen] = React.useState(false); + + const columnFilterValue = column?.getFilterValue(); + const selectedValues = new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []); + + const onItemSelect = React.useCallback( + (option: Option, isSelected: boolean) => { + if (!column) return; + + if (multiple) { + const newSelectedValues = new Set(selectedValues); + if (isSelected) { + newSelectedValues.delete(option.value); + } else { + newSelectedValues.add(option.value); + } + const filterValues = Array.from(newSelectedValues); + column.setFilterValue(filterValues.length ? filterValues : undefined); + } else { + column.setFilterValue(isSelected ? undefined : [option.value]); + setOpen(false); + } + }, + [column, multiple, selectedValues], + ); + + const onReset = React.useCallback( + (event?: React.MouseEvent) => { + event?.stopPropagation(); + column?.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + + + + + {loading ? ( +
+ + Loading... +
+ ) : ( + <> + No results found. + + {options.map((option) => { + const isSelected = selectedValues.has(option.value); + + return ( + onItemSelect(option, isSelected)} + > +
+ +
+ {option.icon && } + {option.label} + {option.count && ( + {option.count} + )} +
+ ); + })} +
+ {selectedValues.size > 0 && ( + <> + + + onReset()} + > + Clear filters + + + + )} + + )} +
+
+
+
+ ); +} diff --git a/registry/components/data-table/data-table-pagination.tsx b/registry/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000..27f5240 --- /dev/null +++ b/registry/components/data-table/data-table-pagination.tsx @@ -0,0 +1,131 @@ +import type { Table } from '@tanstack/react-table'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { getPageNumbers } from '@/lib/pagination'; +import { cn } from '@/lib/utils'; + +interface DataTablePaginationProps extends React.ComponentProps<'div'> { + table: Table; + pageSizeOptions?: number[]; + showPageNumbers?: boolean; + rowsPerPageText?: React.ReactNode | null; + pageText?: ((page: number, total: number) => React.ReactNode) | null; +} + +export function DataTablePagination({ + table, + pageSizeOptions = [10, 20, 30, 40, 50], + className, + showPageNumbers = true, + rowsPerPageText = 'Rows per page', + pageText, + ...props +}: DataTablePaginationProps) { + const currentPage = table.getState().pagination.pageIndex + 1; + const totalPages = table.getPageCount(); + const pageNumbers = getPageNumbers(currentPage, totalPages); + + return ( +
+
+ + {showPageNumbers && rowsPerPageText !== null && ( +

{rowsPerPageText}

+ )} +
+
+ {showPageNumbers && pageText !== null && ( +
+ {pageText ? pageText(currentPage, totalPages) : `Page ${currentPage} of ${totalPages}`} +
+ )} +
+ + + + {/* Page number buttons */} + {pageNumbers.map((pageNumber, index) => ( +
+ {pageNumber === '...' ? ( + ... + ) : ( + + )} +
+ ))} + + + +
+
+
+ ); +} diff --git a/registry/components/data-table/data-table-range-filter.tsx b/registry/components/data-table/data-table-range-filter.tsx new file mode 100644 index 0000000..2981e26 --- /dev/null +++ b/registry/components/data-table/data-table-range-filter.tsx @@ -0,0 +1,114 @@ +import type { Column } from '@tanstack/react-table'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { DebouncedInput } from '@/components/ui/debounced-input'; +import type { ExtendedColumnFilter } from './types'; + +interface DataTableRangeFilterProps extends React.ComponentProps<'div'> { + filter: ExtendedColumnFilter; + column: Column; + inputId: string; + onFilterUpdate: ( + filterId: string, + updates: Partial, 'filterId'>>, + ) => void; + debounceMs?: number; +} + +export function DataTableRangeFilter({ + filter, + column, + inputId, + onFilterUpdate, + className, + debounceMs = 300, + ...props +}: DataTableRangeFilterProps) { + const meta = column.columnDef.meta; + + const [min, max] = React.useMemo(() => { + const range = column.columnDef.meta?.range; + if (range) return range; + + const values = column.getFacetedMinMaxValues(); + if (!values) return [0, 100]; + + return [values[0], values[1]]; + }, [column]); + + const formatValue = React.useCallback((value: string | number | undefined) => { + if (value === undefined || value === '') return ''; + const numValue = Number(value); + return Number.isNaN(numValue) + ? '' + : numValue.toLocaleString(undefined, { + maximumFractionDigits: 0, + }); + }, []); + + const value = React.useMemo((): [string, string] => { + if (Array.isArray(filter.value)) { + return [formatValue(filter.value[0]), formatValue(filter.value[1])]; + } + return [formatValue(filter.value), '']; + }, [filter.value, formatValue]); + + const onRangeValueChange = React.useCallback( + (value: string, isMin?: boolean) => { + const numValue = Number(value); + const currentValues = Array.isArray(filter.value) ? filter.value : ['', '']; + const otherValue = isMin ? (currentValues[1] ?? '') : (currentValues[0] ?? ''); + + if ( + value === '' || + (!Number.isNaN(numValue) && + (isMin + ? numValue >= min && numValue <= (Number(otherValue) || max) + : numValue <= max && numValue >= (Number(otherValue) || min))) + ) { + onFilterUpdate(filter.filterId, { + value: isMin ? [value, otherValue] : [otherValue, value], + }); + } + }, + [filter.filterId, filter.value, min, max, onFilterUpdate], + ); + + return ( +
+ onRangeValueChange(String(val), true)} + placeholder={min.toString()} + type="number" + value={value[0]} + /> + to + onRangeValueChange(String(val))} + placeholder={max.toString()} + type="number" + value={value[1]} + /> +
+ ); +} diff --git a/registry/components/data-table/data-table-skeleton.tsx b/registry/components/data-table/data-table-skeleton.tsx new file mode 100644 index 0000000..f8b94fe --- /dev/null +++ b/registry/components/data-table/data-table-skeleton.tsx @@ -0,0 +1,110 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { cn } from '@/lib/utils'; + +interface DataTableSkeletonProps extends React.ComponentProps<'div'> { + columnCount: number; + rowCount?: number; + filterCount?: number; + cellWidths?: string[]; + withViewOptions?: boolean; + withPagination?: boolean; + shrinkZero?: boolean; +} + +export function DataTableSkeleton({ + columnCount, + rowCount = 10, + filterCount = 0, + cellWidths = ['auto'], + withViewOptions = true, + withPagination = true, + shrinkZero = false, + className, + ...props +}: DataTableSkeletonProps) { + const cozyCellWidths = Array.from( + { length: columnCount }, + (_, index) => cellWidths[index % cellWidths.length] ?? 'auto', + ); + + return ( +
+
+
+ {filterCount > 0 + ? Array.from({ length: filterCount }).map((_, i) => ( + + )) + : null} +
+ {withViewOptions ? : null} +
+
+ + + {Array.from({ length: 1 }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + + + {Array.from({ length: rowCount }).map((_, i) => ( + + {Array.from({ length: columnCount }).map((_, j) => ( + + + + ))} + + ))} + +
+
+ {withPagination ? ( +
+ +
+
+ + +
+
+ +
+
+ + + + +
+
+
+ ) : null} +
+ ); +} diff --git a/registry/components/data-table/data-table-slider-filter.tsx b/registry/components/data-table/data-table-slider-filter.tsx new file mode 100644 index 0000000..4c52d59 --- /dev/null +++ b/registry/components/data-table/data-table-slider-filter.tsx @@ -0,0 +1,232 @@ +import type { Column } from '@tanstack/react-table'; +import { PlusCircle, XCircle } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { Slider } from '@/components/ui/slider'; +import { cn } from '@/lib/utils'; + +interface Range { + min: number; + max: number; +} + +type RangeValue = [number, number]; + +function getIsValidRange(value: unknown): value is RangeValue { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' + ); +} + +function parseValuesAsNumbers(value: unknown): RangeValue | undefined { + if ( + Array.isArray(value) && + value.length === 2 && + value.every((v) => (typeof v === 'string' || typeof v === 'number') && !Number.isNaN(v)) + ) { + return [Number(value[0]), Number(value[1])]; + } + + return undefined; +} + +interface DataTableSliderFilterProps { + column: Column; + title?: string; +} + +export function DataTableSliderFilter({ column, title }: DataTableSliderFilterProps) { + const id = React.useId(); + + const columnFilterValue = parseValuesAsNumbers(column.getFilterValue()); + + const defaultRange = column.columnDef.meta?.range; + const unit = column.columnDef.meta?.unit; + + const { min, max, step } = React.useMemo(() => { + let minValue = 0; + let maxValue = 100; + + if (defaultRange && getIsValidRange(defaultRange)) { + [minValue, maxValue] = defaultRange; + } else { + const values = column.getFacetedMinMaxValues(); + if (values && Array.isArray(values) && values.length === 2) { + const [facetMinValue, facetMaxValue] = values; + if (typeof facetMinValue === 'number' && typeof facetMaxValue === 'number') { + minValue = facetMinValue; + maxValue = facetMaxValue; + } + } + } + + const rangeSize = maxValue - minValue; + const step = + rangeSize <= 20 + ? 1 + : rangeSize <= 100 + ? Math.ceil(rangeSize / 20) + : Math.ceil(rangeSize / 50); + + return { max: maxValue, min: minValue, step }; + }, [column, defaultRange]); + + const range = React.useMemo((): RangeValue => { + return columnFilterValue ?? [min, max]; + }, [columnFilterValue, min, max]); + + const formatValue = React.useCallback((value: number) => { + return value.toLocaleString(undefined, { maximumFractionDigits: 0 }); + }, []); + + const onFromInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) { + column.setFilterValue([numValue, range[1]]); + } + }, + [column, min, range], + ); + + const onToInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const numValue = Number(event.target.value); + if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) { + column.setFilterValue([range[0], numValue]); + } + }, + [column, max, range], + ); + + const onSliderValueChange = React.useCallback( + (value: RangeValue) => { + if (Array.isArray(value) && value.length === 2) { + column.setFilterValue(value); + } + }, + [column], + ); + + const onReset = React.useCallback( + (event: React.MouseEvent) => { + if (event.target instanceof HTMLDivElement) { + event.stopPropagation(); + } + column.setFilterValue(undefined); + }, + [column], + ); + + return ( + + + + + +
+

+ {title} +

+
+ +
+ + {unit && ( + + {unit} + + )} +
+ +
+ + {unit && ( + + {unit} + + )} +
+
+ + +
+ +
+
+ ); +} diff --git a/registry/components/data-table/data-table-toolbar.tsx b/registry/components/data-table/data-table-toolbar.tsx new file mode 100644 index 0000000..1e2c852 --- /dev/null +++ b/registry/components/data-table/data-table-toolbar.tsx @@ -0,0 +1,188 @@ +import type { Column, Table } from '@tanstack/react-table'; +import { X } from 'lucide-react'; +import * as React from 'react'; + +import { DataTableDateFilter } from '@/components/data-table/data-table-date-filter'; +import { DataTableFacetedFilter } from '@/components/data-table/data-table-faceted-filter'; +import { DataTableSliderFilter } from '@/components/data-table/data-table-slider-filter'; +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options'; +import { Button } from '@/components/ui/button'; +import { DebouncedInput } from '@/components/ui/debounced-input'; +import { cn } from '@/lib/utils'; + +interface DataTableToolbarProps extends React.ComponentProps<'div'> { + table: Table; + viewOptions?: boolean; + hideFilter?: boolean; + actions?: React.ReactNode; + align?: 'start' | 'center' | 'end'; +} + +export function DataTableToolbar({ + table, + children, + actions, + className, + viewOptions = true, + hideFilter = false, + align = 'start', + ...props +}: DataTableToolbarProps) { + const advancedFilters = (table.options.meta as any)?.filters as any[] | undefined; + const setAdvancedFilters = (table.options.meta as any)?.setFilters as + | ((val: any) => void) + | undefined; + const isAdvancedActive = (table.options.meta as any)?.isAdvanceFilter ?? false; + + const isFiltered = isAdvancedActive + ? advancedFilters && advancedFilters.length > 0 + : table.getState().columnFilters.length > 0; + + const columns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanFilter()), + [table, table.options.columns], + ); + + const onReset = React.useCallback(() => { + if (isAdvancedActive && setAdvancedFilters) { + setAdvancedFilters([]); + } else { + table.resetColumnFilters(); + } + }, [table, isAdvancedActive, setAdvancedFilters]); + + return ( +
+
+ {!hideFilter && + columns.map((column) => ( + + ))} + {children} + {isFiltered && !isAdvancedActive && ( + + )} +
+
+ {actions} + {viewOptions && } +
+
+ ); +} +interface DataTableToolbarFilterProps { + column: Column; + table: Table; +} + +function DataTableToolbarFilter({ column, table }: DataTableToolbarFilterProps) { + { + const columnMeta = column.columnDef.meta; + const debounceMs = (table.options.meta as any)?.debounceMs ?? 300; + + const onFilterRender = React.useCallback(() => { + if (!columnMeta?.variant) return null; + + switch (columnMeta.variant) { + case 'text': + return ( + column.setFilterValue(val)} + placeholder={columnMeta.placeholder ?? columnMeta.label} + value={(column.getFilterValue() as string) ?? ''} + /> + ); + + case 'number': + return ( +
+ column.setFilterValue(val)} + placeholder={columnMeta.placeholder ?? columnMeta.label} + type="number" + value={(column.getFilterValue() as string) ?? ''} + /> + {columnMeta.unit && ( + + {columnMeta.unit} + + )} +
+ ); + + case 'range': + return ; + + case 'date': + case 'dateRange': + return ( + + ); + + case 'boolean': + return ( + + ); + + case 'select': + case 'multiSelect': + return ( + + ); + + default: + return null; + } + }, [column, columnMeta]); + + return onFilterRender(); + } +} diff --git a/registry/components/data-table/data-table-view-options.tsx b/registry/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000..7a86ba2 --- /dev/null +++ b/registry/components/data-table/data-table-view-options.tsx @@ -0,0 +1,75 @@ +import type { Table } from '@tanstack/react-table'; +import { Check, Settings2 } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +interface DataTableViewOptionsProps extends React.ComponentProps { + table: Table; + disabled?: boolean; +} + +export function DataTableViewOptions({ + table, + disabled, + ...props +}: DataTableViewOptionsProps) { + const columns = React.useMemo( + () => + table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined' && column.getCanHide()), + [table, table.options.columns], + ); + + return ( + + + + + + + + + No columns found. + + {columns.map((column) => ( + column.toggleVisibility(!column.getIsVisible())} + > + {column.columnDef.meta?.label ?? column.id} + + + ))} + + + + + + ); +} diff --git a/registry/components/data-table/data-table.tsx b/registry/components/data-table/data-table.tsx new file mode 100644 index 0000000..f9e8b5d --- /dev/null +++ b/registry/components/data-table/data-table.tsx @@ -0,0 +1,160 @@ +import { flexRender, type Table as TanstackTable } from '@tanstack/react-table'; +import { Loader } from 'lucide-react'; +import * as React from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { getCommonPinningStyles } from '@/lib/data-table'; +import { cn } from '@/lib/utils'; + +interface DataTableProps extends React.ComponentProps<'div'> { + table: TanstackTable; + actionBar?: React.ReactNode; + wrapperClassname?: string; + emptyState?: React.ReactNode; + stickyHeader?: boolean; + onEndReached?: () => void; + isLoadingMore?: boolean; + loading?: boolean | React.ReactNode; + endReachedThreshold?: number; +} + +export function DataTable({ + table, + actionBar, + children, + className, + wrapperClassname, + emptyState, + stickyHeader, + onEndReached, + isLoadingMore, + loading, + endReachedThreshold = 100, + ...props +}: DataTableProps) { + const tableRef = React.useRef(null); + + const isFetchingMore = isLoadingMore; + + React.useEffect(() => { + if (!onEndReached) return; + + const scrollContainer = tableRef.current?.parentElement; + if (!scrollContainer) return; + + let isFetchingLocal = false; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + if (scrollHeight - scrollTop - clientHeight < endReachedThreshold) { + if (!isFetchingLocal && !isFetchingMore && !loading) { + isFetchingLocal = true; + onEndReached(); + } + } else { + isFetchingLocal = false; + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [onEndReached, endReachedThreshold, isFetchingMore, loading]); + + return ( +
+ {children} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {loading ? ( + + +
+ {typeof loading === 'boolean' ? ( + + ) : ( + loading + )} +
+
+
+ ) : table.getRowModel()?.rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {emptyState ?? 'No results.'} + + + )} +
+
+
+
+ ); +} diff --git a/registry/components/data-table/types.ts b/registry/components/data-table/types.ts new file mode 100644 index 0000000..68ee29f --- /dev/null +++ b/registry/components/data-table/types.ts @@ -0,0 +1,152 @@ +import type { + ColumnSort, + Row, + RowData, + ColumnDef as TanStackColumnDef, +} from '@tanstack/react-table'; +import type { DateRange } from 'react-day-picker'; +import type { DataTableConfig } from './data-table-config'; + +export interface DatePreset { + label: string; + value: DateRange | [Date, Date] | (() => DateRange | [Date, Date]); +} + +export interface BaseColumnMeta { + label?: string; + icon?: React.FC>; + align?: 'left' | 'center' | 'right'; + thClass?: string; + cellClass?: string; +} + +export type ColumnFilterMeta = + | { + variant?: 'text'; + placeholder?: string; + } + | { + variant: 'number'; + placeholder?: string; + unit?: string; + } + | { + variant: 'range'; + range?: [number, number]; + unit?: string; + } + | { + variant: 'date'; + disabled?: import('react-day-picker').Matcher | import('react-day-picker').Matcher[]; + } + | { + variant: 'dateRange'; + presets?: boolean | DatePreset[]; + disabled?: import('react-day-picker').Matcher | import('react-day-picker').Matcher[]; + } + | { + variant: 'boolean'; + options?: Option[]; + } + | { + variant: 'select'; + options?: Option[]; + loading?: boolean; + } + | { + variant: 'multiSelect'; + options?: Option[]; + loading?: boolean; + }; + +export type ColumnMetaUnion = BaseColumnMeta & ColumnFilterMeta; + +type DistributiveOmit = T extends any ? Omit : never; + +export type ColumnDef = DistributiveOmit< + TanStackColumnDef, + 'meta' +> & { + meta?: ColumnMetaUnion; +}; + +declare module '@tanstack/react-table' { + interface ColumnMeta { + label?: string; + placeholder?: string; + variant?: FilterVariant; + options?: Option[]; + range?: [number, number]; + unit?: string; + icon?: React.FC>; + loading?: boolean; + disabled?: import('react-day-picker').Matcher | import('react-day-picker').Matcher[]; + presets?: boolean | DatePreset[]; + align?: 'left' | 'center' | 'right'; + thClass?: string; + cellClass?: string; + } +} + +export type FilterOperator = DataTableConfig['operators'][number]; +export type FilterVariant = DataTableConfig['filterVariants'][number]; +export type JoinOperator = DataTableConfig['joinOperators'][number]; + +export interface FilterItemSchema { + filterId: string; + id: string; + operator: FilterOperator; + value: any; + variant: FilterVariant; +} + +export interface Option { + label: string; + value: string; + count?: number; + icon?: React.FC>; +} + +export interface ExtendedColumnSort extends Omit { + id: Extract; +} + +export interface ExtendedColumnFilter extends FilterItemSchema { + id: Extract; +} + +export interface DataTableRowAction { + row: Row; + variant: 'update' | 'delete'; +} + +export interface DataTableTranslations { + where?: string; + and?: string; + or?: string; + addFilter?: string; + resetFilters?: string; + filters?: string; + noFiltersApplied?: string; + selectField?: string; + searchFields?: string; + pickADate?: string; + enterValue?: string; + selected?: string; + operators?: { + eq?: string; + ne?: string; + iLike?: string; + notILike?: string; + lt?: string; + lte?: string; + gt?: string; + gte?: string; + isEmpty?: string; + isNotEmpty?: string; + isBetween?: string; + inArray?: string; + notInArray?: string; + isRelativeToToday?: string; + }; +} diff --git a/registry/components/long-text.tsx b/registry/components/long-text.tsx new file mode 100644 index 0000000..317631b --- /dev/null +++ b/registry/components/long-text.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +type LongTextProps = { + children: React.ReactNode; + className?: string; + contentClassName?: string; +}; + +export function LongText({ children, className = '', contentClassName = '' }: LongTextProps) { + const ref = useRef(null); + const [isOverflown, setIsOverflown] = useState(false); + + // Use ref callback to check overflow when element is mounted + const refCallback = (node: HTMLDivElement | null) => { + ref.current = node; + if (node && checkOverflow(node)) { + queueMicrotask(() => setIsOverflown(true)); + } + }; + + if (!isOverflown) + return ( +
+ {children} +
+ ); + + return ( + <> +
+ + + +
+ {children} +
+
+ +

{children}

+
+
+
+
+
+ + +
+ {children} +
+
+ +

{children}

+
+
+
+ + ); +} + +const checkOverflow = (textContainer: HTMLDivElement | null) => { + if (textContainer) { + return ( + textContainer.offsetHeight < textContainer.scrollHeight || + textContainer.offsetWidth < textContainer.scrollWidth + ); + } + return false; +}; diff --git a/registry/components/password-input.tsx b/registry/components/password-input.tsx new file mode 100644 index 0000000..ed4eb3c --- /dev/null +++ b/registry/components/password-input.tsx @@ -0,0 +1,36 @@ +import { Eye, EyeOff } from 'lucide-react'; +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from './ui/button'; + +type PasswordInputProps = Omit, 'type'>; + +export const PasswordInput = React.forwardRef( + ({ className, disabled, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ + +
+ ); + }, +); +PasswordInput.displayName = 'PasswordInput'; diff --git a/registry/components/phone-input.tsx b/registry/components/phone-input.tsx new file mode 100644 index 0000000..0fae1c7 --- /dev/null +++ b/registry/components/phone-input.tsx @@ -0,0 +1,765 @@ +'use client'; + +import * as SlotPrimitive from '@radix-ui/react-slot'; +import { Check, ChevronDown } from 'lucide-react'; +import * as React from 'react'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useComposedRefs } from '@/lib/component-refs'; +import { cn } from '@/lib/utils'; +import { VisuallyHiddenInput } from '@/components/visually-hidden-input'; + +const ROOT_NAME = 'PhoneInput'; +const COUNTRY_SELECT_NAME = 'PhoneInputCountrySelect'; +const FIELD_NAME = 'PhoneInputField'; + +/** + * @see https://github.com/mukeshsoni/country-telephone-data/blob/master/country_telephone_data.js + * @format [iso2, dialCode] + */ +const COUNTRY_DATA: [string, string][] = [ + ['af', '93'], + ['ax', '358'], + ['al', '355'], + ['dz', '213'], + ['as', '1684'], + ['ad', '376'], + ['ao', '244'], + ['ai', '1264'], + ['ag', '1268'], + ['ar', '54'], + ['am', '374'], + ['aw', '297'], + ['au', '61'], + ['at', '43'], + ['az', '994'], + ['bs', '1242'], + ['bh', '973'], + ['bd', '880'], + ['bb', '1246'], + ['by', '375'], + ['be', '32'], + ['bz', '501'], + ['bj', '229'], + ['bm', '1441'], + ['bt', '975'], + ['bo', '591'], + ['ba', '387'], + ['bw', '267'], + ['br', '55'], + ['io', '246'], + ['vg', '1284'], + ['bn', '673'], + ['bg', '359'], + ['bf', '226'], + ['bi', '257'], + ['kh', '855'], + ['cm', '237'], + ['ca', '1'], + ['cv', '238'], + ['bq', '599'], + ['ky', '1345'], + ['cf', '236'], + ['td', '235'], + ['cl', '56'], + ['cn', '86'], + ['co', '57'], + ['km', '269'], + ['cd', '243'], + ['cg', '242'], + ['ck', '682'], + ['cr', '506'], + ['ci', '225'], + ['hr', '385'], + ['cu', '53'], + ['cw', '599'], + ['cy', '357'], + ['cz', '420'], + ['dk', '45'], + ['dj', '253'], + ['dm', '1767'], + ['do', '1'], + ['ec', '593'], + ['eg', '20'], + ['sv', '503'], + ['gq', '240'], + ['er', '291'], + ['ee', '372'], + ['et', '251'], + ['fk', '500'], + ['fo', '298'], + ['fj', '679'], + ['fi', '358'], + ['fr', '33'], + ['gf', '594'], + ['pf', '689'], + ['ga', '241'], + ['gm', '220'], + ['ge', '995'], + ['de', '49'], + ['gh', '233'], + ['gi', '350'], + ['gr', '30'], + ['gl', '299'], + ['gd', '1473'], + ['gp', '590'], + ['gu', '1671'], + ['gt', '502'], + ['gg', '44'], + ['gn', '224'], + ['gw', '245'], + ['gy', '592'], + ['ht', '509'], + ['hn', '504'], + ['hk', '852'], + ['hu', '36'], + ['is', '354'], + ['in', '91'], + ['id', '62'], + ['ir', '98'], + ['iq', '964'], + ['ie', '353'], + ['im', '44'], + ['il', '972'], + ['it', '39'], + ['jm', '1876'], + ['jp', '81'], + ['je', '44'], + ['jo', '962'], + ['kz', '7'], + ['ke', '254'], + ['ki', '686'], + ['xk', '383'], + ['kw', '965'], + ['kg', '996'], + ['la', '856'], + ['lv', '371'], + ['lb', '961'], + ['ls', '266'], + ['lr', '231'], + ['ly', '218'], + ['li', '423'], + ['lt', '370'], + ['lu', '352'], + ['mo', '853'], + ['mk', '389'], + ['mg', '261'], + ['mw', '265'], + ['my', '60'], + ['mv', '960'], + ['ml', '223'], + ['mt', '356'], + ['mh', '692'], + ['mq', '596'], + ['mr', '222'], + ['mu', '230'], + ['mx', '52'], + ['fm', '691'], + ['md', '373'], + ['mc', '377'], + ['mn', '976'], + ['me', '382'], + ['ms', '1664'], + ['ma', '212'], + ['mz', '258'], + ['mm', '95'], + ['na', '264'], + ['nr', '674'], + ['np', '977'], + ['nl', '31'], + ['nc', '687'], + ['nz', '64'], + ['ni', '505'], + ['ne', '227'], + ['ng', '234'], + ['nu', '683'], + ['nf', '672'], + ['kp', '850'], + ['mp', '1670'], + ['no', '47'], + ['om', '968'], + ['pk', '92'], + ['pw', '680'], + ['ps', '970'], + ['pa', '507'], + ['pg', '675'], + ['py', '595'], + ['pe', '51'], + ['ph', '63'], + ['pl', '48'], + ['pt', '351'], + ['pr', '1'], + ['qa', '974'], + ['re', '262'], + ['ro', '40'], + ['ru', '7'], + ['rw', '250'], + ['bl', '590'], + ['sh', '290'], + ['kn', '1869'], + ['lc', '1758'], + ['mf', '590'], + ['pm', '508'], + ['vc', '1784'], + ['ws', '685'], + ['sm', '378'], + ['st', '239'], + ['sa', '966'], + ['sn', '221'], + ['rs', '381'], + ['sc', '248'], + ['sl', '232'], + ['sg', '65'], + ['sx', '1721'], + ['sk', '421'], + ['si', '386'], + ['sb', '677'], + ['so', '252'], + ['za', '27'], + ['kr', '82'], + ['ss', '211'], + ['es', '34'], + ['lk', '94'], + ['sd', '249'], + ['sr', '597'], + ['sz', '268'], + ['se', '46'], + ['ch', '41'], + ['sy', '963'], + ['tw', '886'], + ['tj', '992'], + ['tz', '255'], + ['th', '66'], + ['tl', '670'], + ['tg', '228'], + ['tk', '690'], + ['to', '676'], + ['tt', '1868'], + ['tn', '216'], + ['tr', '90'], + ['tm', '993'], + ['tc', '1649'], + ['tv', '688'], + ['vi', '1340'], + ['ug', '256'], + ['ua', '380'], + ['ae', '971'], + ['gb', '44'], + ['us', '1'], + ['uy', '598'], + ['uz', '998'], + ['vu', '678'], + ['va', '39'], + ['ve', '58'], + ['vn', '84'], + ['wf', '681'], + ['eh', '212'], + ['ye', '967'], + ['zm', '260'], + ['zw', '263'], +]; + +interface Country { + code: string; + name: string; + dialCode: string; + flag?: string; +} + +function getCountryName(countryCode: string, locale = 'en'): string { + try { + const regionNames = new Intl.DisplayNames([locale], { type: 'region' }); + return regionNames.of(countryCode) ?? countryCode; + } catch { + return countryCode; + } +} + +function getFlagEmoji(countryCode: string): string { + const codePoints = countryCode + .toUpperCase() + .split('') + .map((char) => 127397 + char.charCodeAt(0)); + return String.fromCodePoint(...codePoints); +} + +function getCountries(): Country[] { + return COUNTRY_DATA.map(([iso2, dialCode]): Country => { + const code = iso2.toUpperCase(); + return { + code, + dialCode: `+${dialCode}`, + flag: getFlagEmoji(code), + name: getCountryName(code), + }; + }).sort((a, b) => a.name.localeCompare(b.name)); +} + +function detectCountryFromNumber(value: string, countries: Country[]): Country | undefined { + if (!value?.startsWith('+')) return undefined; + + const digits = value.slice(1).replace(/\D/g, ''); + if (!digits) return undefined; + + const sorted = [...countries].sort((a, b) => b.dialCode.length - a.dialCode.length); + + const matches: Country[] = []; + for (const country of sorted) { + const dialCode = country.dialCode.slice(1); + if (digits.startsWith(dialCode)) { + matches.push(country); + } + } + + if (matches.length === 0) return undefined; + + if (matches.length > 1 && matches[0]?.dialCode === '+1') { + const usCountry = matches.find((c) => c.code === 'US'); + if (usCountry) return usCountry; + } + + return matches[0]; +} + +function formatPhoneNumber(value: string, countries: Country[]): string { + if (!value) return ''; + + const normalized = value.startsWith('+') ? value : `+${value}`; + + const digits = normalized.slice(1).replace(/\D/g, ''); + if (!digits) return '+'; + + const detected = detectCountryFromNumber(`+${digits}`, countries); + const dialCodeLength = detected ? detected.dialCode.slice(1).length : Math.min(digits.length, 3); + + const countryCode = digits.slice(0, dialCodeLength); + const rest = digits.slice(dialCodeLength); + + let formatted = `+${countryCode}`; + + if (rest) { + formatted += ' '; + for (let i = 0; i < rest.length; i++) { + if (i > 0 && i % 3 === 0) { + formatted += ' '; + } + formatted += rest[i]; + } + } + + return formatted; +} + +type RootElement = React.ComponentRef; + +interface StoreState { + value: string; + country: string; + open: boolean; + startsWithPlus: boolean; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + notify: () => void; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T, ogStore?: Store | null): T { + const contextStore = React.useContext(StoreContext); + + const store = ogStore ?? contextStore; + + if (!store) { + throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``); + } + + const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface PhoneInputContextValue { + rootId: string; + countries: Country[]; + placeholder: string; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + invalid?: boolean; + showFlag: boolean; + inputRef: React.RefObject; +} + +const PhoneInputContext = React.createContext(null); + +function usePhoneInputContext(consumerName: string) { + const context = React.useContext(PhoneInputContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface PhoneInputProps extends React.ComponentProps<'div'> { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + defaultCountry?: string; + country?: string; + onCountryChange?: (country: string) => void; + countries?: Country[]; + name?: string; + placeholder?: string; + asChild?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + invalid?: boolean; + showFlag?: boolean; +} + +function PhoneInput(props: PhoneInputProps) { + const { + value: valueProp, + defaultValue, + defaultCountry, + country: countryProp, + onValueChange, + onCountryChange, + countries = getCountries(), + name, + placeholder = 'Enter phone number', + asChild, + disabled, + required, + readOnly, + invalid, + showFlag = true, + className, + id, + ref, + ...rootProps + } = props; + + const instanceId = React.useId(); + const rootId = id ?? instanceId; + + const inputRef = React.useRef(null); + + const [formTrigger, setFormTrigger] = React.useState(null); + const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node)); + const isFormControl = formTrigger ? !!formTrigger.closest('form') : true; + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => { + const initialValue = valueProp ?? defaultValue ?? ''; + const initialCountry = countryProp ?? defaultCountry ?? ''; + + return { + country: initialCountry, + open: false, + startsWithPlus: initialValue.startsWith('+'), + value: initialValue, + }; + }); + + const propsRef = useAsRef({ + onCountryChange, + onValueChange, + }); + + const store = React.useMemo(() => { + return { + getState: () => stateRef.current, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + setState: (key, value) => { + if (Object.is(stateRef.current[key], value)) return; + + if (key === 'value' && typeof value === 'string') { + stateRef.current.value = value; + propsRef.current.onValueChange?.(value); + } else if (key === 'country' && typeof value === 'string') { + stateRef.current.country = value; + propsRef.current.onCountryChange?.(value); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + }; + }, [listenersRef, stateRef, propsRef]); + + const value = useStore((state) => state.value, store); + const country = useStore((state) => state.country, store); + + useIsomorphicLayoutEffect(() => { + if (valueProp !== undefined) { + store.setState('value', valueProp); + } + }, [valueProp]); + + useIsomorphicLayoutEffect(() => { + if (countryProp !== undefined) { + store.setState('country', countryProp); + } + }, [countryProp]); + + const startsWithPlus = useStore((state) => state.startsWithPlus, store); + + React.useEffect(() => { + if (!value) return; + + const digits = value.slice(1).replace(/\D/g, ''); + const shouldDetect = startsWithPlus || digits.length >= 10; + + if (!shouldDetect) return; + + const detected = detectCountryFromNumber(value, countries); + if (detected && detected.code !== country) { + store.setState('country', detected.code); + } + }, [value, countries, country, store, startsWithPlus]); + + const contextValue = React.useMemo( + () => ({ + countries, + disabled, + inputRef, + invalid, + placeholder, + readOnly, + required, + rootId, + showFlag, + }), + [rootId, countries, placeholder, disabled, required, readOnly, invalid, showFlag], + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + {isFormControl && ( + + )} + + + ); +} + +interface PhoneInputCountrySelectProps + extends React.ComponentProps, + Pick, 'disabled' | 'className'> {} + +function PhoneInputCountrySelect(props: PhoneInputCountrySelectProps) { + const { + disabled: disabledProp, + className, + children, + onOpenChange: onOpenChangeProp, + ...popoverProps + } = props; + + const { countries, inputRef, disabled, showFlag } = usePhoneInputContext(COUNTRY_SELECT_NAME); + const store = useStoreContext(COUNTRY_SELECT_NAME); + const country = useStore((state) => state.country); + const open = useStore((state) => state.open); + const onOpenChangeRef = useAsRef(onOpenChangeProp); + + const isDisabled = disabledProp || disabled; + + const countryContext = countries.find((c) => c.code === country); + + const onOpenChange = React.useCallback( + (open: boolean) => { + store.setState('open', open); + onOpenChangeRef.current?.(open); + }, + [store, onOpenChangeRef], + ); + + return ( + + + {!countryContext ? ( +
+ ) : ( + showFlag && + countryContext.flag && ( +
{countryContext.flag}
+ ) + )} + + + + + + + No country found. + + {countries.map((c) => ( + { + store.setState('country', c.code); + store.setState('open', false); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }} + value={`${c.name} ${c.dialCode} ${c.code}`} + > + {showFlag && c.flag && {c.flag}} + {c.name} + {c.dialCode} + + + ))} + + + + + + ); +} + +function PhoneInputField(props: React.ComponentProps<'input'>) { + const { + onChange: onChangeProp, + className, + disabled: disabledProp, + readOnly: readOnlyProp, + required: requiredProp, + ref, + ...inputProps + } = props; + + const { inputRef, disabled, invalid, readOnly, required, placeholder, countries } = + usePhoneInputContext(FIELD_NAME); + const store = useStoreContext(FIELD_NAME); + const value = useStore((state) => state.value); + + const composedRef = useComposedRefs(ref, inputRef); + + const onChangeRef = useAsRef(onChangeProp); + + const isDisabled = disabledProp || disabled; + const isReadOnly = readOnlyProp || readOnly; + const isRequired = requiredProp || required; + + const onChange = React.useCallback( + (event: React.ChangeEvent) => { + if (isDisabled || isReadOnly) return; + + onChangeRef.current?.(event); + if (event.defaultPrevented) return; + + const inputValue = event.target.value; + + const startsWithPlus = inputValue.startsWith('+'); + const digits = inputValue.replace(/\D/g, ''); + const newValue = digits ? `+${digits}` : startsWithPlus ? '+' : ''; + store.setState('startsWithPlus', startsWithPlus); + store.setState('value', newValue); + }, + [store, onChangeRef, isDisabled, isReadOnly], + ); + + const displayValue = React.useMemo(() => { + return formatPhoneNumber(value, countries); + }, [value, countries]); + + return ( + + ); +} + +export { + PhoneInput, + PhoneInputCountrySelect, + PhoneInputField, + type PhoneInputProps, + useStore as usePhoneInput, +}; diff --git a/registry/components/rating.tsx b/registry/components/rating.tsx new file mode 100644 index 0000000..eb83767 --- /dev/null +++ b/registry/components/rating.tsx @@ -0,0 +1,866 @@ +'use client'; + +import * as DirectionPrimitive from '@radix-ui/react-direction'; +import * as SlotPrimitive from '@radix-ui/react-slot'; +import { Star } from 'lucide-react'; +import * as React from 'react'; +import { useAsRef } from '@/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/hooks/use-lazy-ref'; +import { useComposedRefs } from '@/lib/component-refs'; +import { cn } from '@/lib/utils'; +import { VisuallyHiddenInput } from '@/components/visually-hidden-input'; + +type Direction = 'ltr' | 'rtl'; +type Orientation = 'horizontal' | 'vertical'; +type ActivationMode = 'automatic' | 'manual'; +type Size = 'default' | 'sm' | 'lg'; +type Step = 0.5 | 1; +type DataState = 'full' | 'partial' | 'empty'; +type FocusIntent = 'first' | 'last' | 'prev' | 'next'; + +type RootElement = React.ComponentRef; +type ItemElement = React.ComponentRef; + +const ROOT_NAME = 'Rating'; +const ITEM_NAME = 'RatingItem'; + +const ENTRY_FOCUS = 'ratingFocusGroup.onEntryFocus'; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; + +function getItemId(id: string, value: number) { + return `${id}-item-${value}`; +} + +function getPartialFillGradientId(id: string, step: Step) { + return `partial-fill-gradient-${id}-${step}`; +} + +const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowDown: 'next', + ArrowLeft: 'prev', + ArrowRight: 'next', + ArrowUp: 'prev', + End: 'last', + Home: 'first', +}; + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; +} + +function getFocusIntent( + event: React.KeyboardEvent, + dir?: Direction, + orientation?: Orientation, +) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined; + if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined; + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +function focusFirst(candidates: React.RefObject[], preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +interface StoreState { + value: number; + hoveredValue: number | null; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + notify: () => void; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T, ogStore?: Store | null): T { + const contextStore = React.useContext(StoreContext); + + const store = ogStore ?? contextStore; + + if (!store) { + throw new Error(`\`useStore\` must be used within \`${ROOT_NAME}\``); + } + + const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface ItemData { + id: string; + ref: React.RefObject; + value: number; + disabled: boolean; +} + +interface RatingContextValue { + rootId: string; + dir: Direction; + orientation: Orientation; + activationMode: ActivationMode; + size: Size; + max: number; + step: Step; + clearable: boolean; + disabled: boolean; + readOnly: boolean; + getAutoIndex: (instanceId: string) => number; +} + +const RatingContext = React.createContext(null); + +function useRatingContext(consumerName: string) { + const context = React.useContext(RatingContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`FocusProvider\``); + } + return context; +} + +interface RatingProps extends React.ComponentProps<'div'> { + value?: number; + defaultValue?: number; + onValueChange?: (value: number) => void; + onHover?: (value: number | null) => void; + max?: number; + activationMode?: ActivationMode; + dir?: Direction; + orientation?: Orientation; + size?: Size; + asChild?: boolean; + step?: Step; + clearable?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + name?: string; +} + +function Rating(props: RatingProps) { + const { + value: valueProp, + defaultValue = 0, + onValueChange, + onHover, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + dir: dirProp, + orientation = 'horizontal', + activationMode = 'automatic', + size = 'default', + max = 5, + step = 1, + clearable = false, + asChild, + disabled = false, + readOnly = false, + required = false, + className, + id, + name, + ref, + ...rootProps + } = props; + + const dir = DirectionPrimitive.useDirection(dirProp); + const instanceId = React.useId(); + const rootId = id ?? instanceId; + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + hoveredValue: null, + value: valueProp ?? defaultValue, + })); + + const propsRef = useAsRef({ + onFocus: onFocusProp, + onHover, + onMouseDown: onMouseDownProp, + onValueChange, + step, + }); + + const store = React.useMemo(() => { + return { + getState: () => stateRef.current, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + setState: (key, value) => { + if (Object.is(stateRef.current[key], value)) return; + + if (key === 'value' && typeof value === 'number') { + stateRef.current.value = value; + propsRef.current.onValueChange?.(value); + } else if (key === 'hoveredValue') { + stateRef.current.hoveredValue = value as number | null; + propsRef.current.onHover?.(value as number | null); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + }; + }, [listenersRef, stateRef, propsRef]); + + useIsomorphicLayoutEffect(() => { + if (valueProp !== undefined) { + store.setState('value', valueProp); + } + }, [valueProp]); + + const value = useStore((state) => state.value, store); + + const [formTrigger, setFormTrigger] = React.useState(null); + const composedRef = useComposedRefs(ref, (node) => setFormTrigger(node)); + const isFormControl = formTrigger ? !!formTrigger.closest('form') : true; + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + + const autoIndexMapRef = React.useRef(new Map()); + const nextAutoIndexRef = React.useRef(0); + + const getAutoIndex = React.useCallback((instanceId: string) => { + const existingIndex = autoIndexMapRef.current.get(instanceId); + if (existingIndex !== undefined) { + return existingIndex; + } + + const newIndex = nextAutoIndexRef.current++; + autoIndexMapRef.current.set(instanceId, newIndex); + return newIndex; + }, []); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + rootProps.onBlur?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [rootProps.onBlur], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter((item) => !item.disabled); + // For half-step ratings, find the item that represents the selected value + // by looking for the ceiling value (e.g., 3.5 → find item with value 4) + const selectedItem = + propsRef.current.step < 1 + ? items.find((item) => item.value === Math.ceil(value)) + : items.find((item) => item.value === value); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [selectedItem, currentItem, ...items].filter( + Boolean, + ) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [propsRef, isTabbingBackOut, value, tabStopId], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [propsRef], + ); + + const contextValue = React.useMemo( + () => ({ + activationMode, + clearable, + dir, + disabled, + getAutoIndex, + max, + orientation, + readOnly, + rootId, + size, + step, + }), + [ + rootId, + dir, + orientation, + activationMode, + disabled, + readOnly, + size, + max, + step, + clearable, + getAutoIndex, + ], + ); + + const focusContextValue = React.useMemo( + () => ({ + getItems, + onFocusableItemAdd, + onFocusableItemRemove, + onItemFocus, + onItemRegister, + onItemShiftTab, + onItemUnregister, + tabStopId, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ], + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + + + + + {dir === 'rtl' ? ( + <> + + + + ) : ( + <> + + + + )} + + + + {isFormControl && ( + + )} + + + + ); +} + +interface RatingItemProps extends Omit, 'children'> { + index?: number; + asChild?: boolean; + children?: React.ReactNode | ((dataState: DataState) => React.ReactNode); +} + +function RatingItem(props: RatingItemProps) { + const { + index, + asChild, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + onMouseEnter: onMouseEnterProp, + onMouseMove: onMouseMoveProp, + onMouseLeave: onMouseLeaveProp, + disabled, + className, + children, + ref, + ...itemProps + } = props; + + const itemRef = React.useRef(null); + const composedRef = useComposedRefs(ref, itemRef); + + const context = useRatingContext(ITEM_NAME); + + const instanceId = React.useId(); + + const actualIndex = React.useMemo(() => { + if (index !== undefined) { + return index; + } + + return context.getAutoIndex(instanceId); + }, [index, context, instanceId]); + + const itemValue = actualIndex + 1; + const store = useStoreContext(ITEM_NAME); + const focusContext = useFocusContext(ITEM_NAME); + const value = useStore((state) => state.value); + const hoveredValue = useStore((state) => state.hoveredValue); + const clearable = context.clearable; + const step = context.step; + const activationMode = context.activationMode; + + const itemId = getItemId(context.rootId, itemValue); + const isDisabled = context.disabled || disabled; + const isReadOnly = context.readOnly; + const isTabStop = focusContext.tabStopId === itemId; + + const displayValue = hoveredValue ?? value; + const isFilled = displayValue >= itemValue; + const isPartiallyFilled = + step < 1 && displayValue >= itemValue - step && displayValue < itemValue; + const isHovered = hoveredValue !== null && hoveredValue < itemValue; + + const isMouseClickRef = React.useRef(false); + + const propsRef = useAsRef({ + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + onMouseEnter: onMouseEnterProp, + onMouseLeave: onMouseLeaveProp, + onMouseMove: onMouseMoveProp, + }); + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + disabled: !!isDisabled, + id: itemId, + ref: itemRef, + value: itemValue, + }); + + if (!isDisabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(itemId); + if (!isDisabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, itemId, itemValue, isDisabled]); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !isReadOnly) { + let newValue = itemValue; + + if (step < 1) { + const rect = event.currentTarget.getBoundingClientRect(); + const clickX = event.clientX - rect.left; + const isLeftHalf = clickX < rect.width / 2; + + if (context.dir === 'rtl') { + if (!isLeftHalf) { + newValue = itemValue - step; + } + } else { + if (isLeftHalf) { + newValue = itemValue - step; + } + } + } + + if (clearable && value === newValue) { + newValue = 0; + } + + store.setState('value', newValue); + } + }, + [isDisabled, isReadOnly, clearable, step, value, itemValue, store, context.dir, propsRef], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(itemId); + + const isKeyboardFocus = !isMouseClickRef.current; + + if (!isDisabled && !isReadOnly && activationMode !== 'manual' && isKeyboardFocus) { + // For half-step mode, check if the current value is a half-step that belongs to this item + // e.g., if value is 3.5 and itemValue is 4, don't change it + const isHalfStepValue = step < 1 && value === itemValue - step; + + if (!isHalfStepValue) { + const newValue = clearable && value === itemValue ? 0 : itemValue; + store.setState('value', newValue); + } + } + + isMouseClickRef.current = false; + }, + [ + focusContext, + itemId, + activationMode, + isDisabled, + isReadOnly, + clearable, + value, + itemValue, + step, + store, + propsRef, + ], + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + propsRef.current.onKeyDown?.(event); + if (event.defaultPrevented) return; + + if ((event.key === 'Enter' || event.key === ' ') && activationMode === 'manual') { + event.preventDefault(); + if (!isDisabled && !isReadOnly && itemRef.current) { + itemRef.current.click(); + } + return; + } + + if (event.key === 'Tab' && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const focusIntent = getFocusIntent(event, context.dir, context.orientation); + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + + // For half-step mode, increment/decrement by step value instead of jumping to next item + if (step < 1 && (focusIntent === 'prev' || focusIntent === 'next')) { + if (!isDisabled && !isReadOnly) { + let newValue = value; + + if (focusIntent === 'next') { + newValue = Math.min(value + step, context.max); + } else { + newValue = Math.max(value - step, 0); + } + + store.setState('value', newValue); + + // Find and focus the item that represents this value + const items = focusContext.getItems().filter((item) => !item.disabled); + const targetItem = items.find((item) => item.value === Math.ceil(newValue)); + if (targetItem?.ref.current) { + queueMicrotask(() => targetItem.ref.current?.focus()); + } + } + return; + } + + // For full-step mode or Home/End keys, use the original navigation + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === 'last') { + candidateRefs.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex( + (ref) => ref.current === event.currentTarget, + ); + candidateRefs = candidateRefs.slice(currentIndex + 1); + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [ + focusContext, + context.dir, + context.orientation, + activationMode, + isDisabled, + isReadOnly, + step, + value, + context.max, + store, + propsRef, + ], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (isDisabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(itemId); + } + }, + [focusContext, itemId, isDisabled, propsRef], + ); + + const onMouseEnter = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseEnter?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !isReadOnly) { + let hoverValue = itemValue; + + if (step < 1) { + const rect = event.currentTarget.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const isLeftHalf = mouseX < rect.width / 2; + + if (context.dir === 'rtl') { + if (!isLeftHalf) { + hoverValue = itemValue - step; + } + } else { + if (isLeftHalf) { + hoverValue = itemValue - step; + } + } + } + + store.setState('hoveredValue', hoverValue); + } + }, + [isDisabled, isReadOnly, step, itemValue, store, context.dir, propsRef], + ); + + const onMouseLeave = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseLeave?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !isReadOnly) { + store.setState('hoveredValue', null); + } + }, + [isDisabled, isReadOnly, store, propsRef], + ); + + const onMouseMove = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseMove?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !isReadOnly && step < 1) { + const rect = event.currentTarget.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const isLeftHalf = mouseX < rect.width / 2; + + let hoverValue = itemValue; + if (context.dir === 'rtl') { + hoverValue = !isLeftHalf ? itemValue - step : itemValue; + } else { + hoverValue = isLeftHalf ? itemValue - step : itemValue; + } + + store.setState('hoveredValue', hoverValue); + } + }, + [isDisabled, isReadOnly, step, itemValue, store, context.dir, propsRef], + ); + + const dataState: DataState = isFilled ? 'full' : isPartiallyFilled ? 'partial' : 'empty'; + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + {typeof children === 'function' ? children(dataState) : (children ?? )} + + ); +} + +export { Rating, RatingItem, useStore as useRating }; diff --git a/registry/components/select-dropdown.tsx b/registry/components/select-dropdown.tsx new file mode 100644 index 0000000..38af25d --- /dev/null +++ b/registry/components/select-dropdown.tsx @@ -0,0 +1,62 @@ +import { Loader } from 'lucide-react'; +import { FormControl } from '@/components/ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +type SelectDropdownProps = { + onValueChange?: (value: string) => void; + defaultValue: string | undefined; + placeholder?: string; + isPending?: boolean; + items: { label: string; value: string }[] | undefined; + disabled?: boolean; + className?: string; + isControlled?: boolean; +}; + +export function SelectDropdown({ + defaultValue, + onValueChange, + isPending, + items, + placeholder, + disabled, + className = '', + isControlled = false, +}: SelectDropdownProps) { + const defaultState = isControlled + ? { onValueChange, value: defaultValue } + : { defaultValue, onValueChange }; + return ( + + ); +} diff --git a/registry/components/stepper.tsx b/registry/components/stepper.tsx new file mode 100644 index 0000000..85d2214 --- /dev/null +++ b/registry/components/stepper.tsx @@ -0,0 +1,1207 @@ +'use client'; + +import * as DirectionPrimitive from '@radix-ui/react-direction'; +import * as SlotPrimitive from '@radix-ui/react-slot'; +import { Check } from 'lucide-react'; +import * as React from 'react'; +import { useAsRef } from '@/components/hooks/use-as-ref'; +import { useIsomorphicLayoutEffect } from '@/components/hooks/use-isomorphic-layout-effect'; +import { useLazyRef } from '@/components/hooks/use-lazy-ref'; +import { useComposedRefs } from '@/lib/component-refs'; +import { cn } from '@/lib/utils'; + +const ROOT_NAME = 'Stepper'; +const LIST_NAME = 'StepperList'; +const ITEM_NAME = 'StepperItem'; +const TRIGGER_NAME = 'StepperTrigger'; +const INDICATOR_NAME = 'StepperIndicator'; +const SEPARATOR_NAME = 'StepperSeparator'; +const TITLE_NAME = 'StepperTitle'; +const DESCRIPTION_NAME = 'StepperDescription'; +const CONTENT_NAME = 'StepperContent'; +const PREV_NAME = 'StepperPrev'; +const NEXT_NAME = 'StepperNext'; + +const ENTRY_FOCUS = 'stepperFocusGroup.onEntryFocus'; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; +const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + +type Direction = 'ltr' | 'rtl'; +type Orientation = 'horizontal' | 'vertical'; +type NavigationDirection = 'next' | 'prev'; +type ActivationMode = 'automatic' | 'manual'; +type DataState = 'inactive' | 'active' | 'completed'; + +interface DivProps extends React.ComponentProps<'div'> { + asChild?: boolean; +} +interface ButtonProps extends React.ComponentProps<'button'> { + asChild?: boolean; +} + +type ListElement = React.ComponentRef; +type TriggerElement = React.ComponentRef; + +function getId( + id: string, + variant: 'trigger' | 'content' | 'title' | 'description', + value: string, +) { + return `${id}-${variant}-${value}`; +} + +type FocusIntent = 'first' | 'last' | 'prev' | 'next'; + +const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowDown: 'next', + ArrowLeft: 'prev', + ArrowRight: 'next', + ArrowUp: 'prev', + End: 'last', + Home: 'first', + PageDown: 'last', + PageUp: 'first', +}; + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== 'rtl') return key; + return key === 'ArrowLeft' ? 'ArrowRight' : key === 'ArrowRight' ? 'ArrowLeft' : key; +} + +function getFocusIntent( + event: React.KeyboardEvent, + dir?: Direction, + orientation?: Orientation, +) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === 'horizontal' && ['ArrowUp', 'ArrowDown'].includes(key)) return undefined; + if (orientation === 'vertical' && ['ArrowLeft', 'ArrowRight'].includes(key)) return undefined; + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +function focusFirst(candidates: React.RefObject[], preventScroll = false) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +function wrapArray(array: T[], startIndex: number) { + return array.map((_, index) => array[(startIndex + index) % array.length] as T); +} + +function getDataState( + value: string | undefined, + itemValue: string, + stepState: StepState | undefined, + steps: Map, + variant: 'item' | 'separator' = 'item', +): DataState { + const stepKeys = Array.from(steps.keys()); + const currentIndex = stepKeys.indexOf(itemValue); + + if (stepState?.completed) return 'completed'; + + if (value === itemValue) { + return variant === 'separator' ? 'inactive' : 'active'; + } + + if (value) { + const activeIndex = stepKeys.indexOf(value); + + if (activeIndex > currentIndex) return 'completed'; + } + + return 'inactive'; +} + +interface StepState { + value: string; + completed: boolean; + disabled: boolean; +} + +interface StoreState { + steps: Map; + value: string; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + setStateWithValidation: (value: string, direction: NavigationDirection) => Promise; + hasValidation: () => boolean; + notify: () => void; + addStep: (value: string, completed: boolean, disabled: boolean) => void; + removeStep: (value: string) => void; + setStep: (value: string, completed: boolean, disabled: boolean) => void; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T): T { + const store = useStoreContext('useStore'); + + const getSnapshot = React.useCallback(() => selector(store.getState()), [store, selector]); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface ItemData { + id: string; + ref: React.RefObject; + value: string; + active: boolean; + disabled: boolean; +} + +interface StepperContextValue { + rootId: string; + dir: Direction; + orientation: Orientation; + activationMode: ActivationMode; + disabled: boolean; + nonInteractive: boolean; + loop: boolean; +} + +const StepperContext = React.createContext(null); + +function useStepperContext(consumerName: string) { + const context = React.useContext(StepperContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface StepperProps extends DivProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + onValueComplete?: (value: string, completed: boolean) => void; + onValueAdd?: (value: string) => void; + onValueRemove?: (value: string) => void; + onValidate?: (value: string, direction: NavigationDirection) => boolean | Promise; + activationMode?: ActivationMode; + dir?: Direction; + orientation?: Orientation; + disabled?: boolean; + loop?: boolean; + nonInteractive?: boolean; +} + +function Stepper(props: StepperProps) { + const { + value, + defaultValue, + onValueChange, + onValueComplete, + onValueAdd, + onValueRemove, + onValidate, + dir: dirProp, + orientation = 'horizontal', + activationMode = 'automatic', + asChild, + disabled = false, + nonInteractive = false, + loop = false, + className, + id, + ...rootProps + } = props; + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + steps: new Map(), + value: value ?? defaultValue ?? '', + })); + + const propsRef = useAsRef({ + onValidate, + onValueAdd, + onValueChange, + onValueComplete, + onValueRemove, + }); + + const store = React.useMemo(() => { + return { + addStep: (value, completed, disabled) => { + const newStep: StepState = { completed, disabled, value }; + stateRef.current.steps.set(value, newStep); + propsRef.current.onValueAdd?.(value); + store.notify(); + }, + getState: () => stateRef.current, + hasValidation: () => !!propsRef.current.onValidate, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + removeStep: (value) => { + stateRef.current.steps.delete(value); + propsRef.current.onValueRemove?.(value); + store.notify(); + }, + setState: (key, value) => { + if (Object.is(stateRef.current[key], value)) return; + + if (key === 'value' && typeof value === 'string') { + stateRef.current.value = value; + propsRef.current.onValueChange?.(value); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + setStateWithValidation: async (value, direction) => { + if (!propsRef.current.onValidate) { + store.setState('value', value); + return true; + } + + try { + const isValid = await propsRef.current.onValidate(value, direction); + if (isValid) { + store.setState('value', value); + } + return isValid; + } catch { + return false; + } + }, + setStep: (value, completed, disabled) => { + const step = stateRef.current.steps.get(value); + if (step) { + const updatedStep: StepState = { ...step, completed, disabled }; + stateRef.current.steps.set(value, updatedStep); + + if (completed !== step.completed) { + propsRef.current.onValueComplete?.(value, completed); + } + + store.notify(); + } + }, + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + }; + }, [listenersRef, stateRef, propsRef]); + + useIsomorphicLayoutEffect(() => { + if (value !== undefined) { + store.setState('value', value); + } + }, [value]); + + const dir = DirectionPrimitive.useDirection(dirProp); + + const instanceId = React.useId(); + const rootId = id ?? instanceId; + + const contextValue = React.useMemo( + () => ({ + activationMode, + dir, + disabled, + loop, + nonInteractive, + orientation, + rootId, + }), + [rootId, dir, orientation, activationMode, disabled, nonInteractive, loop], + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + + + + ); +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`FocusProvider\``); + } + return context; +} + +function StepperList(props: DivProps) { + const { + asChild, + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + className, + children, + ref, + ...listProps + } = props; + + const context = useStepperContext(LIST_NAME); + const orientation = context.orientation; + const currentValue = useStore((state) => state.value); + + const propsRef = useAsRef({ + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + }); + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + const listRef = React.useRef(null); + const composedRef = useComposedRefs(ref, listRef); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onBlur?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [propsRef], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if (event.target === event.currentTarget && isKeyboardFocus && !isTabbingBackOut) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter((item) => !item.disabled); + const selectedItem = currentValue + ? items.find((item) => item.value === currentValue) + : undefined; + const activeItem = items.find((item) => item.active); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [selectedItem, activeItem, currentItem, ...items].filter( + Boolean, + ) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [propsRef, isTabbingBackOut, currentValue, tabStopId], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [propsRef], + ); + + const focusContextValue = React.useMemo( + () => ({ + getItems, + onFocusableItemAdd, + onFocusableItemRemove, + onItemFocus, + onItemRegister, + onItemShiftTab, + onItemUnregister, + tabStopId, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ], + ); + + const ListPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + {children} + + + ); +} + +interface StepperItemContextValue { + value: string; + stepState: StepState | undefined; +} + +const StepperItemContext = React.createContext(null); + +function useStepperItemContext(consumerName: string) { + const context = React.useContext(StepperItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +interface StepperItemProps extends DivProps { + value: string; + completed?: boolean; + disabled?: boolean; +} + +function StepperItem(props: StepperItemProps) { + const { + value: itemValue, + completed = false, + disabled = false, + asChild, + className, + children, + ref, + ...itemProps + } = props; + + const context = useStepperContext(ITEM_NAME); + const store = useStoreContext(ITEM_NAME); + const orientation = context.orientation; + const value = useStore((state) => state.value); + + useIsomorphicLayoutEffect(() => { + store.addStep(itemValue, completed, disabled); + + return () => { + store.removeStep(itemValue); + }; + }, [itemValue, completed, disabled]); + + useIsomorphicLayoutEffect(() => { + store.setStep(itemValue, completed, disabled); + }, [itemValue, completed, disabled]); + + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + const dataState = getDataState(value, itemValue, stepState, steps); + + const itemContextValue = React.useMemo( + () => ({ + stepState, + value: itemValue, + }), + [itemValue, stepState], + ); + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + + {children} + + + ); +} + +function StepperTrigger(props: ButtonProps) { + const { + asChild, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + disabled, + className, + ref, + ...triggerProps + } = props; + + const context = useStepperContext(TRIGGER_NAME); + const itemContext = useStepperItemContext(TRIGGER_NAME); + const itemValue = itemContext.value; + + const store = useStoreContext(TRIGGER_NAME); + const focusContext = useFocusContext(TRIGGER_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + const stepState = useStore((state) => state.steps.get(itemValue)); + + const propsRef = useAsRef({ + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + }); + + const activationMode = context.activationMode; + const orientation = context.orientation; + const loop = context.loop; + + const stepIndex = Array.from(steps.keys()).indexOf(itemValue); + + const stepPosition = stepIndex + 1; + const stepCount = steps.size; + + const triggerId = getId(context.rootId, 'trigger', itemValue); + const contentId = getId(context.rootId, 'content', itemValue); + const titleId = getId(context.rootId, 'title', itemValue); + const descriptionId = getId(context.rootId, 'description', itemValue); + + const isDisabled = disabled || stepState?.disabled || context.disabled; + const isActive = value === itemValue; + const isTabStop = focusContext.tabStopId === triggerId; + const dataState = getDataState(value, itemValue, stepState, steps); + + const triggerRef = React.useRef(null); + const composedRef = useComposedRefs(ref, triggerRef); + const isArrowKeyPressedRef = React.useRef(false); + const isMouseClickRef = React.useRef(false); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (ARROW_KEYS.includes(event.key)) { + isArrowKeyPressedRef.current = true; + } + } + function onKeyUp() { + isArrowKeyPressedRef.current = false; + } + document.addEventListener('keydown', onKeyDown); + document.addEventListener('keyup', onKeyUp); + return () => { + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp); + }; + }, []); + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + active: isTabStop, + disabled: !!isDisabled, + id: triggerId, + ref: triggerRef, + value: itemValue, + }); + + if (!isDisabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(triggerId); + if (!isDisabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, triggerId, itemValue, isTabStop, isDisabled]); + + const onClick = React.useCallback( + async (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !context.nonInteractive) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value ?? ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + await store.setStateWithValidation(itemValue, direction); + } + }, + [isDisabled, context.nonInteractive, store, itemValue, value, steps, propsRef], + ); + + const onFocus = React.useCallback( + async (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(triggerId); + + const isKeyboardFocus = !isMouseClickRef.current; + + if ( + !isActive && + !isDisabled && + activationMode !== 'manual' && + !context.nonInteractive && + isKeyboardFocus + ) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value || ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + await store.setStateWithValidation(itemValue, direction); + } + + isMouseClickRef.current = false; + }, + [ + focusContext, + triggerId, + activationMode, + isActive, + isDisabled, + context.nonInteractive, + store, + itemValue, + value, + steps, + propsRef, + ], + ); + + const onKeyDown = React.useCallback( + async (event: React.KeyboardEvent) => { + propsRef.current.onKeyDown?.(event); + if (event.defaultPrevented) return; + + if (event.key === 'Enter' && context.nonInteractive) { + event.preventDefault(); + return; + } + + if ( + (event.key === 'Enter' || event.key === ' ') && + activationMode === 'manual' && + !context.nonInteractive + ) { + event.preventDefault(); + if (!isDisabled && triggerRef.current) { + triggerRef.current.click(); + } + return; + } + + if (event.key === 'Tab' && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const focusIntent = getFocusIntent(event, context.dir, orientation); + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + event.preventDefault(); + + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === 'last') { + candidateRefs.reverse(); + } else if (focusIntent === 'prev' || focusIntent === 'next') { + if (focusIntent === 'prev') candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex( + (ref) => ref.current === event.currentTarget, + ); + candidateRefs = loop + ? wrapArray(candidateRefs, currentIndex + 1) + : candidateRefs.slice(currentIndex + 1); + } + + if (store.hasValidation() && candidateRefs.length > 0) { + const nextRef = candidateRefs[0]; + const nextElement = nextRef?.current; + const nextItem = items.find((item) => item.ref.current === nextElement); + + if (nextItem && nextItem.value !== itemValue) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value || ''); + const targetStepIndex = Array.from(steps.keys()).indexOf(nextItem.value); + const direction: NavigationDirection = + targetStepIndex > currentStepIndex ? 'next' : 'prev'; + + if (direction === 'next') { + const isValid = await store.setStateWithValidation(nextItem.value, direction); + if (!isValid) return; + } else { + store.setState('value', nextItem.value); + } + + queueMicrotask(() => nextElement?.focus()); + return; + } + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [ + focusContext, + context.nonInteractive, + context.dir, + activationMode, + orientation, + loop, + isDisabled, + store, + propsRef, + itemValue, + value, + steps, + ], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (isDisabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(triggerId); + } + }, + [focusContext, triggerId, isDisabled, propsRef], + ); + + const TriggerPrimitive = asChild ? SlotPrimitive.Slot : 'button'; + + return ( + + ); +} + +interface StepperIndicatorProps extends Omit { + children?: React.ReactNode | ((dataState: DataState) => React.ReactNode); +} + +function StepperIndicator(props: StepperIndicatorProps) { + const { className, children, asChild, ref, ...indicatorProps } = props; + + const context = useStepperContext(INDICATOR_NAME); + const itemContext = useStepperItemContext(INDICATOR_NAME); + + const value = useStore((state) => state.value); + const itemValue = itemContext.value; + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + + const stepPosition = Array.from(steps.keys()).indexOf(itemValue) + 1; + + const dataState = getDataState(value, itemValue, stepState, steps); + + const IndicatorPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( + + {typeof children === 'function' ? ( + children(dataState) + ) : children ? ( + children + ) : dataState === 'completed' ? ( + + ) : ( + stepPosition + )} + + ); +} + +interface StepperSeparatorProps extends DivProps { + forceMount?: boolean; +} + +function StepperSeparator(props: StepperSeparatorProps) { + const { className, asChild, forceMount = false, ref, ...separatorProps } = props; + + const context = useStepperContext(SEPARATOR_NAME); + const itemContext = useStepperItemContext(SEPARATOR_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + + const orientation = context.orientation; + + const stepIndex = Array.from(steps.keys()).indexOf(itemContext.value); + + const isLastStep = stepIndex === steps.size - 1; + + if (isLastStep && !forceMount) return null; + + const dataState = getDataState( + value, + itemContext.value, + itemContext.stepState, + steps, + 'separator', + ); + + const SeparatorPrimitive = asChild ? SlotPrimitive.Slot : 'div'; + + return ( +