From d20a47c2da92c12169adbeac04fab4433dfd5e14 Mon Sep 17 00:00:00 2001 From: Yahya Date: Tue, 3 Mar 2026 20:40:45 +0700 Subject: [PATCH 01/12] feat: datum-ui v0.2 monorepo scaffold with component library - Initialize v0.2 monorepo with turborepo, pnpm workspaces - Add @repo/config (eslint, tsconfig presets), @repo/shadcn primitives - Scaffold @datum-cloud/datum-ui with tsdown multi-entry build - Add DatumProvider with theme system and design tokens - Migrate all branded components from cloud-portal shadcn module - Extract form primitives (input, select, checkbox, switch, textarea, radio-group, autocomplete) to top-level component directories - Add compound Form library with stepper, validation, and Conform.js - Add data-table, task-queue, time-range-picker, and 20+ components - Set up Storybook with Rsbuild and Fumadocs documentation site - Add Vitest test infrastructure with component tests - Configure changesets for versioning --- .changeset/config.json | 9 +- .github/workflows/publish-datum-ui.yml | 21 - .github/workflows/release.yml | 42 - .gitignore | 24 +- .npmrc | 4 +- .nvmrc | 1 - .vscode/settings.json | 11 - README.md | 208 - apps/docs/.eslintrc.cjs | 4 - apps/docs/.storybook/main.js | 37 - apps/docs/.storybook/preview.tsx | 27 - apps/docs/app/docs/[[...slug]]/page.tsx | 40 + apps/docs/app/docs/layout.tsx | 11 + apps/docs/app/layout.tsx | 18 + apps/docs/app/page.tsx | 11 + apps/docs/content/docs/index.mdx | 37 + apps/docs/content/docs/meta.json | 4 + apps/docs/lib/source.ts | 7 + apps/docs/next.config.ts | 9 + apps/docs/package.json | 42 +- apps/docs/source.config.ts | 5 + apps/docs/tsconfig.json | 14 +- apps/docs/vite.config.ts | 6 - apps/storybook/.storybook/main.ts | 27 + apps/storybook/.storybook/preview.tsx | 17 + apps/storybook/eslint.config.ts | 3 + apps/storybook/package.json | 28 + .../stories/add-filter-dropdown.stories.tsx | 0 .../stories/alert.stories.tsx | 0 .../stories/badge.stories.tsx | 0 .../stories/button.stories.tsx | 0 .../stories/card.stories.tsx | 0 .../stories/checkbox.stories.tsx | 0 .../stories/combobox.stories.tsx | 0 .../stories/dialog.stories.tsx | 0 .../stories/filter-chip.stories.tsx | 0 .../stories/input.stories.tsx | 0 .../stories/label.stories.tsx | 0 .../stories/multi-combobox.stories.tsx | 0 .../stories/select.stories.tsx | 0 .../stories/separator.stories.tsx | 0 .../stories/skeleton.stories.tsx | 0 apps/{docs => storybook}/stories/styles.css | 15 +- .../stories/tabs.stories.tsx | 0 .../stories/textarea.stories.tsx | 0 .../stories/time-range-dropdown.stories.tsx | 0 .../stories/tooltip.stories.tsx | 0 apps/storybook/tsconfig.json | 4 + eslint.config.ts | 3 + package.json | 20 +- packages/config/eslint/index.ts | 16 + packages/config/package.json | 21 + .../tsconfig}/base.json | 26 +- packages/config/tsconfig/react-app.json | 10 + .../tsconfig}/react-library.json | 6 +- packages/datum-ui/.eslintrc.js | 4 - packages/datum-ui/.prettierrc | 3 - packages/datum-ui/LICENSE | 201 - packages/datum-ui/README.md | 34 - packages/datum-ui/eslint.config.ts | 3 + packages/datum-ui/package.json | 188 +- .../add-filter-dropdown.tsx | 111 - .../src/add-filter-dropdown/index.tsx | 2 - packages/datum-ui/src/alert/index.tsx | 2 - packages/datum-ui/src/badge/badge.tsx | 38 - packages/datum-ui/src/badge/index.tsx | 2 - packages/datum-ui/src/button/index.tsx | 4 - packages/datum-ui/src/button/link-button.tsx | 49 - packages/datum-ui/src/card/card.tsx | 71 - packages/datum-ui/src/card/index.tsx | 16 - packages/datum-ui/src/checkbox/checkbox.tsx | 21 - packages/datum-ui/src/checkbox/index.tsx | 2 - packages/datum-ui/src/combobox/combobox.tsx | 155 - packages/datum-ui/src/combobox/index.tsx | 2 - .../datum-ui/src/components/alert/README.md | 443 + .../src/{ => components}/alert/alert.tsx | 72 +- .../datum-ui/src/components/alert/index.ts | 1 + .../components/autocomplete/autocomplete.tsx | 459 + .../autocomplete/autocomplete.types.ts | 102 + .../src/components/autocomplete/index.ts | 7 + .../components/avatar-stack/avatar-stack.tsx | 95 + .../src/components/avatar-stack/index.ts | 1 + .../datum-ui/src/components/badge/README.md | 317 + .../datum-ui/src/components/badge/badge.tsx | 229 + .../datum-ui/src/components/badge/index.ts | 1 + .../src/{ => components}/button/README.md | 2 +- .../src/{ => components}/button/button.tsx | 145 +- .../datum-ui/src/components/button/index.tsx | 4 + .../src/components/button/link-button.tsx | 60 + .../calendar-date-picker.tsx | 889 ++ .../components/calendar-date-picker/index.ts | 1 + .../src/components/calendar/calendar.tsx | 183 + .../datum-ui/src/components/calendar/index.ts | 1 + .../datum-ui/src/components/card/card.tsx | 38 + .../datum-ui/src/components/card/index.ts | 1 + .../src/components/checkbox/checkbox.tsx | 11 + .../datum-ui/src/components/checkbox/index.ts | 1 + .../src/components/data-table/MIGRATION.md | 665 + .../src/components/data-table/README.md | 1502 ++ .../components/data-table/TOOLBAR_GUIDE.md | 429 + .../data-table/core/data-table-card-view.tsx | 94 + .../data-table/core/data-table-loading.tsx | 10 + .../data-table/core/data-table-view.tsx | 125 + .../data-table/core/data-table.context.tsx | 793 + .../components/data-table/core/data-table.tsx | 647 + .../data-table/core/data-table.types.ts | 416 + .../actions/data-table-inline-actions.tsx | 86 + .../actions/data-table-row-actions.tsx | 105 + .../columns/data-table-column-header.tsx | 130 + .../columns/data-table-column-meta.ts | 42 + .../columns/data-table-column.types.ts | 57 + .../data-table/features/filter/README.md | 776 + .../filter/components/checkbox-popover.tsx | 148 + .../features/filter/components/checkbox.tsx | 150 + .../features/filter/components/datepicker.tsx | 205 + .../filter/components/global-search.tsx | 158 + .../filter/components/radio-popover.tsx | 126 + .../features/filter/components/radio.tsx | 84 + .../features/filter/components/search.tsx | 59 + .../features/filter/components/select.tsx | 286 + .../filter/components/shared/search-input.tsx | 77 + .../components/shared/use-search-state.ts | 55 + .../features/filter/components/tag.tsx | 94 + .../features/filter/components/time-range.tsx | 86 + .../features/filter/data-table-filter.tsx | 210 + .../data-table/features/filter/index.ts | 42 + .../data-table-inline-content.tsx | 107 + .../pagination/data-table-pagination.tsx | 127 + .../data-table-toolbar-filter-dropdown.tsx | 70 + .../data-table-toolbar-multi-actions.tsx | 123 + .../toolbar/data-table-toolbar-row-count.tsx | 61 + .../toolbar/data-table-toolbar-search.tsx | 66 + .../features/toolbar/data-table-toolbar.tsx | 377 + .../data-table/hooks/useFilterQueryState.ts | 176 + .../data-table/hooks/useInlineContent.ts | 67 + .../src/components/data-table/index.ts | 176 + .../data-table/utils/date-serialization.ts | 104 + .../data-table/utils/global-search.helpers.ts | 291 + .../data-table/utils/sort-labels.ts | 46 + .../data-table/utils/sorting.helpers.ts | 228 + .../utils/time-range-serialization.ts | 70 + .../datum-ui/src/components/dialog/README.md | 180 + .../datum-ui/src/components/dialog/dialog.tsx | 182 + .../datum-ui/src/components/dialog/index.ts | 9 + .../src/components/dropdown/dropdown.tsx | 225 + .../datum-ui/src/components/dropdown/index.ts | 1 + .../src/components/dropzone/dropzone.tsx | 218 + .../datum-ui/src/components/dropzone/index.ts | 2 + .../empty-content/empty-content.tsx | 242 + .../src/components/empty-content/index.ts | 2 + .../file-input-button/file-input-button.tsx | 139 + .../src/components/file-input-button/index.ts | 1 + .../datum-ui/src/components/form/README.md | 574 + .../form/components/form-autocomplete.tsx | 73 + .../form/components/form-button.tsx | 52 + .../form/components/form-checkbox.tsx | 63 + .../form/components/form-copy-box.tsx | 124 + .../form/components/form-custom.tsx | 40 + .../form/components/form-description.tsx | 30 + .../form/components/form-dialog.tsx | 201 + .../components/form/components/form-error.tsx | 62 + .../form/components/form-field-array.tsx | 135 + .../components/form/components/form-field.tsx | 310 + .../form/components/form-input-group.tsx | 55 + .../components/form/components/form-input.tsx | 41 + .../form/components/form-radio-group.tsx | 90 + .../components/form/components/form-root.tsx | 218 + .../form/components/form-select.tsx | 78 + .../form/components/form-submit.tsx | 27 + .../form/components/form-switch.tsx | 63 + .../form/components/form-textarea.tsx | 41 + .../components/form/components/form-when.tsx | 66 + .../src/components/form/components/index.ts | 28 + .../form/components/stepper/form-step.tsx | 32 + .../form/components/stepper/form-stepper.tsx | 504 + .../form/components/stepper/index.ts | 4 + .../components/stepper/stepper-controls.tsx | 106 + .../components/stepper/stepper-navigation.tsx | 181 + .../components/form/context/field-context.tsx | 36 + .../components/form/context/form-context.tsx | 44 + .../src/components/form/context/index.ts | 17 + .../src/components/form/hooks/index.ts | 5 + .../form/hooks/use-field-context.ts | 27 + .../src/components/form/hooks/use-field.ts | 114 + .../components/form/hooks/use-form-context.ts | 24 + .../src/components/form/hooks/use-stepper.ts | 73 + .../src/components/form/hooks/use-watch.ts | 127 + .../datum-ui/src/components/form/index.ts | 263 + .../src/components/form/types/index.ts | 716 + .../datum-ui/src/components/grid/README.md | 230 + .../src/components/grid/components/col.tsx | 85 + .../src/components/grid/components/index.tsx | 8 + .../src/components/grid/components/row.tsx | 99 + .../grid/constants/grid.constants.ts | 26 + .../datum-ui/src/components/grid/index.ts | 24 + .../datum-ui/src/components/grid/style.css | 1088 ++ .../src/components/grid/types/grid.types.ts | 48 + .../src/components/grid/utils/responsive.ts | 75 + .../src/{ => components}/icons/close-icon.tsx | 17 +- .../{ => components}/icons/icon-wrapper.tsx | 8 +- .../datum-ui/src/components/icons/index.ts | 3 + .../{ => components}/icons/spinner-icon.tsx | 32 +- packages/datum-ui/src/components/index.ts | 66 + .../src/components/input-number/index.ts | 1 + .../components/input-number/input-number.tsx | 133 + .../src/components/input-with-addons/index.ts | 1 + .../input-with-addons/input-with-addons.tsx | 57 + .../datum-ui/src/components/input/index.ts | 1 + .../datum-ui/src/components/input/input.tsx | 28 + .../datum-ui/src/components/label/index.ts | 17 + .../datum-ui/src/components/label/label.tsx | 37 + .../src/components/loader-overlay/index.ts | 1 + .../loader-overlay/loader-overlay.tsx | 28 + .../src/components/more-actions/index.ts | 2 + .../components/more-actions/more-actions.tsx | 100 + .../src/components/nprogress/index.ts | 32 + .../src/components/nprogress/nprogress.css | 24 + .../src/components/page-title/index.ts | 2 + .../src/components/page-title/page-title.tsx | 53 + .../src/components/radio-group/index.ts | 1 + .../components/radio-group/radio-group.tsx | 20 + .../index.tsx => components/select/index.ts} | 13 +- .../datum-ui/src/components/select/select.tsx | 96 + .../datum-ui/src/components/sheet/index.ts | 11 + .../datum-ui/src/components/sheet/sheet.tsx | 109 + .../src/components/sidebar/app-sidebar.tsx | 63 + .../datum-ui/src/components/sidebar/index.ts | 3 + .../src/components/sidebar/nav-main.tsx | 669 + .../src/components/sidebar/sidebar.tsx | 946 ++ .../datum-ui/src/components/stepper/index.ts | 1 + .../src/components/stepper/stepper.tsx | 506 + .../datum-ui/src/components/switch/index.ts | 1 + .../datum-ui/src/components/switch/switch.tsx | 11 + .../datum-ui/src/components/tabs/index.ts | 1 + .../datum-ui/src/components/tabs/tabs.tsx | 76 + .../src/components/tag-input/index.ts | 1 + .../src/components/tag-input/tag-input.tsx | 481 + .../task-queue/FUTURE_ENHANCEMENTS.md | 234 + .../src/components/task-queue/README.md | 518 + .../src/components/task-queue/constants.ts | 8 + .../src/components/task-queue/core/index.ts | 9 + .../task-queue/core/task-panel-actions.tsx | 91 + .../task-queue/core/task-panel-counter.tsx | 56 + .../task-queue/core/task-panel-header.tsx | 7 + .../task-queue/core/task-panel-item.tsx | 170 + .../components/task-queue/core/task-panel.tsx | 38 + .../task-queue/core/task-queue-dropdown.tsx | 108 + .../task-queue/core/task-queue-trigger.tsx | 87 + .../task-queue/core/task-summary-dialog.tsx | 172 + .../components/task-queue/engine/executor.ts | 155 + .../src/components/task-queue/engine/index.ts | 4 + .../src/components/task-queue/engine/queue.ts | 573 + .../engine/storage/detect-storage.ts | 41 + .../task-queue/engine/storage/index.ts | 5 + .../engine/storage/local-storage.ts | 81 + .../engine/storage/memory-storage.ts | 33 + .../engine/storage/redis-storage.ts | 106 + .../task-queue/engine/storage/storage.ts | 1 + .../src/components/task-queue/hooks/index.ts | 9 + .../task-queue/hooks/use-task-queue.ts | 45 + .../task-queue/hooks/use-task-scope.ts | 152 + .../src/components/task-queue/index.ts | 53 + .../components/task-queue/provider/index.ts | 1 + .../provider/task-queue-provider.tsx | 65 + .../src/components/task-queue/types.ts | 268 + .../src/components/task-queue/utils/index.ts | 89 + .../datum-ui/src/components/textarea/index.ts | 1 + .../src/components/textarea/textarea.tsx | 28 + .../src/components/themes/client-only.tsx | 20 + .../datum-ui/src/components/themes/index.ts | 9 + .../datum-ui/src/components/themes/script.ts | 50 + .../src/components/themes/theme-script.tsx | 50 + .../src/components/themes/theme.provider.tsx | 236 + .../datum-ui/src/components/themes/types.ts | 34 + .../components/absolute-range-panel.tsx | 326 + .../time-range-picker/components/index.ts | 5 + .../components/quick-ranges-panel.tsx | 54 + .../components/timezone-selector.tsx | 40 + .../src/components/time-range-picker/index.ts | 49 + .../components/time-range-picker/presets.ts | 160 + .../time-range-picker/time-range-picker.tsx | 366 + .../src/components/time-range-picker/types.ts | 67 + .../time-range-picker/utils/format-display.ts | 89 + .../time-range-picker/utils/index.ts | 23 + .../time-range-picker/utils/timezone.ts | 167 + .../time-range-picker/utils/to-api-format.ts | 51 + .../src/components/toast/headless-toast.tsx | 54 + .../datum-ui/src/components/toast/index.ts | 3 + .../datum-ui/src/components/toast/toast.ts | 53 + .../datum-ui/src/components/toast/toaster.tsx | 13 + .../datum-ui/src/components/toast/types.ts | 6 + .../src/components/toast/use-toast.ts | 20 + .../datum-ui/src/components/tooltip/README.md | 370 + .../datum-ui/src/components/tooltip/index.ts | 1 + .../src/{ => components}/tooltip/tooltip.tsx | 62 +- packages/datum-ui/src/dialog/dialog.tsx | 153 - packages/datum-ui/src/dialog/index.tsx | 20 - .../datum-ui/src/filter-chip/filter-chip.tsx | 206 - packages/datum-ui/src/filter-chip/index.tsx | 2 - packages/datum-ui/src/hooks/index.ts | 3 + .../src/hooks/use-copy-to-clipboard.ts | 49 + packages/datum-ui/src/hooks/use-debounce.ts | 23 + packages/datum-ui/src/hooks/use-theme.ts | 1 + packages/datum-ui/src/icons/index.ts | 3 - packages/datum-ui/src/index.ts | 29 +- packages/datum-ui/src/input/index.tsx | 2 - packages/datum-ui/src/input/input.tsx | 32 - packages/datum-ui/src/label/index.tsx | 2 - packages/datum-ui/src/label/label.tsx | 21 - packages/datum-ui/src/lib/index.ts | 1 - .../datum-ui/src/multi-combobox/index.tsx | 2 - .../src/multi-combobox/multi-combobox.tsx | 157 - .../__tests__/datum-provider.test.tsx | 69 + .../datum-ui/src/providers/datum.provider.tsx | 54 + packages/datum-ui/src/providers/index.ts | 2 + packages/datum-ui/src/select/select.tsx | 64 - packages/datum-ui/src/separator/index.tsx | 2 - packages/datum-ui/src/separator/separator.tsx | 23 - packages/datum-ui/src/skeleton/index.tsx | 2 - packages/datum-ui/src/skeleton/skeleton.tsx | 18 - packages/datum-ui/src/styles/custom.css | 27 - packages/datum-ui/src/styles/fonts.css | 12 +- .../src/styles/fonts/AllianceNo1-Medium.ttf | Bin 0 -> 83136 bytes .../src/styles/fonts/AllianceNo1-Regular.ttf | Bin 0 -> 83128 bytes .../src/styles/fonts/AllianceNo1-SemiBold.ttf | Bin 0 -> 83284 bytes .../styles/fonts/FTRegolaNeue-Medium.woff2 | Bin 0 -> 63344 bytes .../styles/fonts/FTRegolaNeue-Regular.woff2 | Bin 0 -> 58176 bytes .../styles/fonts/FTRegolaNeue-Semibold.woff2 | Bin 0 -> 63696 bytes packages/datum-ui/src/styles/root.css | 6 +- packages/datum-ui/src/styles/theme.css | 3 - packages/datum-ui/src/styles/themes/alpha.css | 4 +- .../src/styles/tokens/brand-tokens.css | 57 - packages/datum-ui/src/tabs/index.tsx | 7 - packages/datum-ui/src/tabs/tabs.tsx | 49 - packages/datum-ui/src/textarea/index.tsx | 2 - packages/datum-ui/src/textarea/textarea.tsx | 30 - .../src/time-range-dropdown/index.tsx | 2 - .../time-range-dropdown.tsx | 226 - packages/datum-ui/src/tooltip/index.tsx | 2 - packages/datum-ui/src/utils/index.ts | 2 + packages/datum-ui/src/utils/timezone.ts | 35 + packages/datum-ui/test/setup.ts | 16 + packages/datum-ui/tsconfig.json | 9 +- packages/datum-ui/tsdown.config.ts | 112 + packages/datum-ui/tsup.config.ts | 9 - packages/datum-ui/vitest.config.ts | 21 + packages/eslint-config/README.md | 3 - packages/eslint-config/library.js | 35 - packages/eslint-config/package.json | 17 - packages/eslint-config/react.js | 39 - packages/eslint-config/storybook.js | 45 - packages/shadcn/.eslintrc.js | 5 - packages/shadcn/.prettierrc | 3 - packages/shadcn/README.md | 332 - packages/shadcn/components.json | 7 +- packages/shadcn/hooks/use-theme.ts | 37 + packages/shadcn/package.json | 145 +- packages/shadcn/postcss.config.js | 1 - packages/shadcn/style.css | 2 - packages/shadcn/styles/shadcn.css | 55 + packages/shadcn/styles/style.css | 2 + packages/shadcn/tsconfig.json | 9 +- packages/shadcn/ui/alert.tsx | 3 +- packages/shadcn/ui/avatar.tsx | 2 +- packages/shadcn/ui/badge.tsx | 2 +- packages/shadcn/ui/breadcrumb.tsx | 2 +- packages/shadcn/ui/button-group.tsx | 77 + packages/shadcn/ui/button.tsx | 4 +- packages/shadcn/ui/card.tsx | 2 +- packages/shadcn/ui/chart.tsx | 2 +- packages/shadcn/ui/checkbox.tsx | 40 +- packages/shadcn/ui/command.tsx | 4 +- packages/shadcn/ui/dialog.tsx | 2 +- packages/shadcn/ui/dropdown-menu.tsx | 2 +- packages/shadcn/ui/hover-card.tsx | 2 +- packages/shadcn/ui/input-group.tsx | 157 + packages/shadcn/ui/input.tsx | 2 +- packages/shadcn/ui/label.tsx | 30 +- packages/shadcn/ui/map.tsx | 1412 ++ packages/shadcn/ui/place-autocomplete.tsx | 351 + packages/shadcn/ui/popover.tsx | 2 +- packages/shadcn/ui/radio-group.tsx | 2 +- packages/shadcn/ui/select.tsx | 2 +- packages/shadcn/ui/separator.tsx | 39 +- packages/shadcn/ui/sheet.tsx | 6 +- packages/shadcn/ui/skeleton.tsx | 2 +- packages/shadcn/ui/spinner.tsx | 15 + packages/shadcn/ui/switch.tsx | 2 +- packages/shadcn/ui/table.tsx | 2 +- packages/shadcn/ui/tabs.tsx | 2 +- packages/shadcn/ui/textarea.tsx | 2 +- packages/shadcn/ui/tooltip.tsx | 2 +- packages/tailwind-config/main.css | 7 - packages/tailwind-config/package.json | 14 - packages/tailwind-config/postcss.config.js | 6 - packages/typescript-config/package.json | 9 - packages/typescript-config/react-app.json | 16 - pnpm-lock.yaml | 12143 ++++++++++------ turbo.json | 19 +- 399 files changed, 40002 insertions(+), 7965 deletions(-) delete mode 100644 .github/workflows/publish-datum-ui.yml delete mode 100644 .github/workflows/release.yml delete mode 100644 .nvmrc delete mode 100644 .vscode/settings.json delete mode 100644 README.md delete mode 100644 apps/docs/.eslintrc.cjs delete mode 100644 apps/docs/.storybook/main.js delete mode 100644 apps/docs/.storybook/preview.tsx create mode 100644 apps/docs/app/docs/[[...slug]]/page.tsx create mode 100644 apps/docs/app/docs/layout.tsx create mode 100644 apps/docs/app/layout.tsx create mode 100644 apps/docs/app/page.tsx create mode 100644 apps/docs/content/docs/index.mdx create mode 100644 apps/docs/content/docs/meta.json create mode 100644 apps/docs/lib/source.ts create mode 100644 apps/docs/next.config.ts create mode 100644 apps/docs/source.config.ts delete mode 100644 apps/docs/vite.config.ts create mode 100644 apps/storybook/.storybook/main.ts create mode 100644 apps/storybook/.storybook/preview.tsx create mode 100644 apps/storybook/eslint.config.ts create mode 100644 apps/storybook/package.json rename apps/{docs => storybook}/stories/add-filter-dropdown.stories.tsx (100%) rename apps/{docs => storybook}/stories/alert.stories.tsx (100%) rename apps/{docs => storybook}/stories/badge.stories.tsx (100%) rename apps/{docs => storybook}/stories/button.stories.tsx (100%) rename apps/{docs => storybook}/stories/card.stories.tsx (100%) rename apps/{docs => storybook}/stories/checkbox.stories.tsx (100%) rename apps/{docs => storybook}/stories/combobox.stories.tsx (100%) rename apps/{docs => storybook}/stories/dialog.stories.tsx (100%) rename apps/{docs => storybook}/stories/filter-chip.stories.tsx (100%) rename apps/{docs => storybook}/stories/input.stories.tsx (100%) rename apps/{docs => storybook}/stories/label.stories.tsx (100%) rename apps/{docs => storybook}/stories/multi-combobox.stories.tsx (100%) rename apps/{docs => storybook}/stories/select.stories.tsx (100%) rename apps/{docs => storybook}/stories/separator.stories.tsx (100%) rename apps/{docs => storybook}/stories/skeleton.stories.tsx (100%) rename apps/{docs => storybook}/stories/styles.css (58%) rename apps/{docs => storybook}/stories/tabs.stories.tsx (100%) rename apps/{docs => storybook}/stories/textarea.stories.tsx (100%) rename apps/{docs => storybook}/stories/time-range-dropdown.stories.tsx (100%) rename apps/{docs => storybook}/stories/tooltip.stories.tsx (100%) create mode 100644 apps/storybook/tsconfig.json create mode 100644 eslint.config.ts create mode 100644 packages/config/eslint/index.ts create mode 100644 packages/config/package.json rename packages/{typescript-config => config/tsconfig}/base.json (50%) create mode 100644 packages/config/tsconfig/react-app.json rename packages/{typescript-config => config/tsconfig}/react-library.json (53%) delete mode 100644 packages/datum-ui/.eslintrc.js delete mode 100644 packages/datum-ui/.prettierrc delete mode 100644 packages/datum-ui/LICENSE delete mode 100644 packages/datum-ui/README.md create mode 100644 packages/datum-ui/eslint.config.ts delete mode 100644 packages/datum-ui/src/add-filter-dropdown/add-filter-dropdown.tsx delete mode 100644 packages/datum-ui/src/add-filter-dropdown/index.tsx delete mode 100644 packages/datum-ui/src/alert/index.tsx delete mode 100644 packages/datum-ui/src/badge/badge.tsx delete mode 100644 packages/datum-ui/src/badge/index.tsx delete mode 100644 packages/datum-ui/src/button/index.tsx delete mode 100644 packages/datum-ui/src/button/link-button.tsx delete mode 100644 packages/datum-ui/src/card/card.tsx delete mode 100644 packages/datum-ui/src/card/index.tsx delete mode 100644 packages/datum-ui/src/checkbox/checkbox.tsx delete mode 100644 packages/datum-ui/src/checkbox/index.tsx delete mode 100644 packages/datum-ui/src/combobox/combobox.tsx delete mode 100644 packages/datum-ui/src/combobox/index.tsx create mode 100644 packages/datum-ui/src/components/alert/README.md rename packages/datum-ui/src/{ => components}/alert/alert.tsx (73%) create mode 100644 packages/datum-ui/src/components/alert/index.ts create mode 100644 packages/datum-ui/src/components/autocomplete/autocomplete.tsx create mode 100644 packages/datum-ui/src/components/autocomplete/autocomplete.types.ts create mode 100644 packages/datum-ui/src/components/autocomplete/index.ts create mode 100644 packages/datum-ui/src/components/avatar-stack/avatar-stack.tsx create mode 100644 packages/datum-ui/src/components/avatar-stack/index.ts create mode 100644 packages/datum-ui/src/components/badge/README.md create mode 100644 packages/datum-ui/src/components/badge/badge.tsx create mode 100644 packages/datum-ui/src/components/badge/index.ts rename packages/datum-ui/src/{ => components}/button/README.md (99%) rename packages/datum-ui/src/{ => components}/button/button.tsx (79%) create mode 100644 packages/datum-ui/src/components/button/index.tsx create mode 100644 packages/datum-ui/src/components/button/link-button.tsx create mode 100644 packages/datum-ui/src/components/calendar-date-picker/calendar-date-picker.tsx create mode 100644 packages/datum-ui/src/components/calendar-date-picker/index.ts create mode 100644 packages/datum-ui/src/components/calendar/calendar.tsx create mode 100644 packages/datum-ui/src/components/calendar/index.ts create mode 100644 packages/datum-ui/src/components/card/card.tsx create mode 100644 packages/datum-ui/src/components/card/index.ts create mode 100644 packages/datum-ui/src/components/checkbox/checkbox.tsx create mode 100644 packages/datum-ui/src/components/checkbox/index.ts create mode 100644 packages/datum-ui/src/components/data-table/MIGRATION.md create mode 100644 packages/datum-ui/src/components/data-table/README.md create mode 100644 packages/datum-ui/src/components/data-table/TOOLBAR_GUIDE.md create mode 100644 packages/datum-ui/src/components/data-table/core/data-table-card-view.tsx create mode 100644 packages/datum-ui/src/components/data-table/core/data-table-loading.tsx create mode 100644 packages/datum-ui/src/components/data-table/core/data-table-view.tsx create mode 100644 packages/datum-ui/src/components/data-table/core/data-table.context.tsx create mode 100644 packages/datum-ui/src/components/data-table/core/data-table.tsx create mode 100644 packages/datum-ui/src/components/data-table/core/data-table.types.ts create mode 100644 packages/datum-ui/src/components/data-table/features/actions/data-table-inline-actions.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/actions/data-table-row-actions.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/columns/data-table-column-header.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/columns/data-table-column-meta.ts create mode 100644 packages/datum-ui/src/components/data-table/features/columns/data-table-column.types.ts create mode 100644 packages/datum-ui/src/components/data-table/features/filter/README.md create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/checkbox-popover.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/checkbox.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/datepicker.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/global-search.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/radio-popover.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/radio.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/search.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/select.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/shared/search-input.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/shared/use-search-state.ts create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/tag.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/components/time-range.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/data-table-filter.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/filter/index.ts create mode 100644 packages/datum-ui/src/components/data-table/features/inline-content/data-table-inline-content.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/pagination/data-table-pagination.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-filter-dropdown.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-multi-actions.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-row-count.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-search.tsx create mode 100644 packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar.tsx create mode 100644 packages/datum-ui/src/components/data-table/hooks/useFilterQueryState.ts create mode 100644 packages/datum-ui/src/components/data-table/hooks/useInlineContent.ts create mode 100644 packages/datum-ui/src/components/data-table/index.ts create mode 100644 packages/datum-ui/src/components/data-table/utils/date-serialization.ts create mode 100644 packages/datum-ui/src/components/data-table/utils/global-search.helpers.ts create mode 100644 packages/datum-ui/src/components/data-table/utils/sort-labels.ts create mode 100644 packages/datum-ui/src/components/data-table/utils/sorting.helpers.ts create mode 100644 packages/datum-ui/src/components/data-table/utils/time-range-serialization.ts create mode 100644 packages/datum-ui/src/components/dialog/README.md create mode 100644 packages/datum-ui/src/components/dialog/dialog.tsx create mode 100644 packages/datum-ui/src/components/dialog/index.ts create mode 100644 packages/datum-ui/src/components/dropdown/dropdown.tsx create mode 100644 packages/datum-ui/src/components/dropdown/index.ts create mode 100644 packages/datum-ui/src/components/dropzone/dropzone.tsx create mode 100644 packages/datum-ui/src/components/dropzone/index.ts create mode 100644 packages/datum-ui/src/components/empty-content/empty-content.tsx create mode 100644 packages/datum-ui/src/components/empty-content/index.ts create mode 100644 packages/datum-ui/src/components/file-input-button/file-input-button.tsx create mode 100644 packages/datum-ui/src/components/file-input-button/index.ts create mode 100644 packages/datum-ui/src/components/form/README.md create mode 100644 packages/datum-ui/src/components/form/components/form-autocomplete.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-button.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-checkbox.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-copy-box.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-custom.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-description.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-dialog.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-error.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-field-array.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-field.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-input-group.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-input.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-radio-group.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-root.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-select.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-submit.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-switch.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-textarea.tsx create mode 100644 packages/datum-ui/src/components/form/components/form-when.tsx create mode 100644 packages/datum-ui/src/components/form/components/index.ts create mode 100644 packages/datum-ui/src/components/form/components/stepper/form-step.tsx create mode 100644 packages/datum-ui/src/components/form/components/stepper/form-stepper.tsx create mode 100644 packages/datum-ui/src/components/form/components/stepper/index.ts create mode 100644 packages/datum-ui/src/components/form/components/stepper/stepper-controls.tsx create mode 100644 packages/datum-ui/src/components/form/components/stepper/stepper-navigation.tsx create mode 100644 packages/datum-ui/src/components/form/context/field-context.tsx create mode 100644 packages/datum-ui/src/components/form/context/form-context.tsx create mode 100644 packages/datum-ui/src/components/form/context/index.ts create mode 100644 packages/datum-ui/src/components/form/hooks/index.ts create mode 100644 packages/datum-ui/src/components/form/hooks/use-field-context.ts create mode 100644 packages/datum-ui/src/components/form/hooks/use-field.ts create mode 100644 packages/datum-ui/src/components/form/hooks/use-form-context.ts create mode 100644 packages/datum-ui/src/components/form/hooks/use-stepper.ts create mode 100644 packages/datum-ui/src/components/form/hooks/use-watch.ts create mode 100644 packages/datum-ui/src/components/form/index.ts create mode 100644 packages/datum-ui/src/components/form/types/index.ts create mode 100644 packages/datum-ui/src/components/grid/README.md create mode 100644 packages/datum-ui/src/components/grid/components/col.tsx create mode 100644 packages/datum-ui/src/components/grid/components/index.tsx create mode 100644 packages/datum-ui/src/components/grid/components/row.tsx create mode 100644 packages/datum-ui/src/components/grid/constants/grid.constants.ts create mode 100644 packages/datum-ui/src/components/grid/index.ts create mode 100644 packages/datum-ui/src/components/grid/style.css create mode 100644 packages/datum-ui/src/components/grid/types/grid.types.ts create mode 100644 packages/datum-ui/src/components/grid/utils/responsive.ts rename packages/datum-ui/src/{ => components}/icons/close-icon.tsx (84%) rename packages/datum-ui/src/{ => components}/icons/icon-wrapper.tsx (78%) create mode 100644 packages/datum-ui/src/components/icons/index.ts rename packages/datum-ui/src/{ => components}/icons/spinner-icon.tsx (84%) create mode 100644 packages/datum-ui/src/components/index.ts create mode 100644 packages/datum-ui/src/components/input-number/index.ts create mode 100644 packages/datum-ui/src/components/input-number/input-number.tsx create mode 100644 packages/datum-ui/src/components/input-with-addons/index.ts create mode 100644 packages/datum-ui/src/components/input-with-addons/input-with-addons.tsx create mode 100644 packages/datum-ui/src/components/input/index.ts create mode 100644 packages/datum-ui/src/components/input/input.tsx create mode 100644 packages/datum-ui/src/components/label/index.ts create mode 100644 packages/datum-ui/src/components/label/label.tsx create mode 100644 packages/datum-ui/src/components/loader-overlay/index.ts create mode 100644 packages/datum-ui/src/components/loader-overlay/loader-overlay.tsx create mode 100644 packages/datum-ui/src/components/more-actions/index.ts create mode 100644 packages/datum-ui/src/components/more-actions/more-actions.tsx create mode 100644 packages/datum-ui/src/components/nprogress/index.ts create mode 100644 packages/datum-ui/src/components/nprogress/nprogress.css create mode 100644 packages/datum-ui/src/components/page-title/index.ts create mode 100644 packages/datum-ui/src/components/page-title/page-title.tsx create mode 100644 packages/datum-ui/src/components/radio-group/index.ts create mode 100644 packages/datum-ui/src/components/radio-group/radio-group.tsx rename packages/datum-ui/src/{select/index.tsx => components/select/index.ts} (71%) create mode 100644 packages/datum-ui/src/components/select/select.tsx create mode 100644 packages/datum-ui/src/components/sheet/index.ts create mode 100644 packages/datum-ui/src/components/sheet/sheet.tsx create mode 100644 packages/datum-ui/src/components/sidebar/app-sidebar.tsx create mode 100644 packages/datum-ui/src/components/sidebar/index.ts create mode 100644 packages/datum-ui/src/components/sidebar/nav-main.tsx create mode 100644 packages/datum-ui/src/components/sidebar/sidebar.tsx create mode 100644 packages/datum-ui/src/components/stepper/index.ts create mode 100644 packages/datum-ui/src/components/stepper/stepper.tsx create mode 100644 packages/datum-ui/src/components/switch/index.ts create mode 100644 packages/datum-ui/src/components/switch/switch.tsx create mode 100644 packages/datum-ui/src/components/tabs/index.ts create mode 100644 packages/datum-ui/src/components/tabs/tabs.tsx create mode 100644 packages/datum-ui/src/components/tag-input/index.ts create mode 100644 packages/datum-ui/src/components/tag-input/tag-input.tsx create mode 100644 packages/datum-ui/src/components/task-queue/FUTURE_ENHANCEMENTS.md create mode 100644 packages/datum-ui/src/components/task-queue/README.md create mode 100644 packages/datum-ui/src/components/task-queue/constants.ts create mode 100644 packages/datum-ui/src/components/task-queue/core/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/core/task-panel-actions.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-panel-counter.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-panel-header.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-panel-item.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-panel.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-queue-dropdown.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-queue-trigger.tsx create mode 100644 packages/datum-ui/src/components/task-queue/core/task-summary-dialog.tsx create mode 100644 packages/datum-ui/src/components/task-queue/engine/executor.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/queue.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/detect-storage.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/local-storage.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/memory-storage.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/redis-storage.ts create mode 100644 packages/datum-ui/src/components/task-queue/engine/storage/storage.ts create mode 100644 packages/datum-ui/src/components/task-queue/hooks/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/hooks/use-task-queue.ts create mode 100644 packages/datum-ui/src/components/task-queue/hooks/use-task-scope.ts create mode 100644 packages/datum-ui/src/components/task-queue/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/provider/index.ts create mode 100644 packages/datum-ui/src/components/task-queue/provider/task-queue-provider.tsx create mode 100644 packages/datum-ui/src/components/task-queue/types.ts create mode 100644 packages/datum-ui/src/components/task-queue/utils/index.ts create mode 100644 packages/datum-ui/src/components/textarea/index.ts create mode 100644 packages/datum-ui/src/components/textarea/textarea.tsx create mode 100644 packages/datum-ui/src/components/themes/client-only.tsx create mode 100644 packages/datum-ui/src/components/themes/index.ts create mode 100644 packages/datum-ui/src/components/themes/script.ts create mode 100644 packages/datum-ui/src/components/themes/theme-script.tsx create mode 100644 packages/datum-ui/src/components/themes/theme.provider.tsx create mode 100644 packages/datum-ui/src/components/themes/types.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/components/absolute-range-panel.tsx create mode 100644 packages/datum-ui/src/components/time-range-picker/components/index.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/components/quick-ranges-panel.tsx create mode 100644 packages/datum-ui/src/components/time-range-picker/components/timezone-selector.tsx create mode 100644 packages/datum-ui/src/components/time-range-picker/index.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/presets.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/time-range-picker.tsx create mode 100644 packages/datum-ui/src/components/time-range-picker/types.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/utils/format-display.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/utils/index.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/utils/timezone.ts create mode 100644 packages/datum-ui/src/components/time-range-picker/utils/to-api-format.ts create mode 100644 packages/datum-ui/src/components/toast/headless-toast.tsx create mode 100644 packages/datum-ui/src/components/toast/index.ts create mode 100644 packages/datum-ui/src/components/toast/toast.ts create mode 100644 packages/datum-ui/src/components/toast/toaster.tsx create mode 100644 packages/datum-ui/src/components/toast/types.ts create mode 100644 packages/datum-ui/src/components/toast/use-toast.ts create mode 100644 packages/datum-ui/src/components/tooltip/README.md create mode 100644 packages/datum-ui/src/components/tooltip/index.ts rename packages/datum-ui/src/{ => components}/tooltip/tooltip.tsx (70%) delete mode 100644 packages/datum-ui/src/dialog/dialog.tsx delete mode 100644 packages/datum-ui/src/dialog/index.tsx delete mode 100644 packages/datum-ui/src/filter-chip/filter-chip.tsx delete mode 100644 packages/datum-ui/src/filter-chip/index.tsx create mode 100644 packages/datum-ui/src/hooks/index.ts create mode 100644 packages/datum-ui/src/hooks/use-copy-to-clipboard.ts create mode 100644 packages/datum-ui/src/hooks/use-debounce.ts create mode 100644 packages/datum-ui/src/hooks/use-theme.ts delete mode 100644 packages/datum-ui/src/icons/index.ts delete mode 100644 packages/datum-ui/src/input/index.tsx delete mode 100644 packages/datum-ui/src/input/input.tsx delete mode 100644 packages/datum-ui/src/label/index.tsx delete mode 100644 packages/datum-ui/src/label/label.tsx delete mode 100644 packages/datum-ui/src/lib/index.ts delete mode 100644 packages/datum-ui/src/multi-combobox/index.tsx delete mode 100644 packages/datum-ui/src/multi-combobox/multi-combobox.tsx create mode 100644 packages/datum-ui/src/providers/__tests__/datum-provider.test.tsx create mode 100644 packages/datum-ui/src/providers/datum.provider.tsx create mode 100644 packages/datum-ui/src/providers/index.ts delete mode 100644 packages/datum-ui/src/select/select.tsx delete mode 100644 packages/datum-ui/src/separator/index.tsx delete mode 100644 packages/datum-ui/src/separator/separator.tsx delete mode 100644 packages/datum-ui/src/skeleton/index.tsx delete mode 100644 packages/datum-ui/src/skeleton/skeleton.tsx delete mode 100644 packages/datum-ui/src/styles/custom.css create mode 100755 packages/datum-ui/src/styles/fonts/AllianceNo1-Medium.ttf create mode 100755 packages/datum-ui/src/styles/fonts/AllianceNo1-Regular.ttf create mode 100755 packages/datum-ui/src/styles/fonts/AllianceNo1-SemiBold.ttf create mode 100644 packages/datum-ui/src/styles/fonts/FTRegolaNeue-Medium.woff2 create mode 100644 packages/datum-ui/src/styles/fonts/FTRegolaNeue-Regular.woff2 create mode 100644 packages/datum-ui/src/styles/fonts/FTRegolaNeue-Semibold.woff2 delete mode 100644 packages/datum-ui/src/styles/theme.css delete mode 100644 packages/datum-ui/src/styles/tokens/brand-tokens.css delete mode 100644 packages/datum-ui/src/tabs/index.tsx delete mode 100644 packages/datum-ui/src/tabs/tabs.tsx delete mode 100644 packages/datum-ui/src/textarea/index.tsx delete mode 100644 packages/datum-ui/src/textarea/textarea.tsx delete mode 100644 packages/datum-ui/src/time-range-dropdown/index.tsx delete mode 100644 packages/datum-ui/src/time-range-dropdown/time-range-dropdown.tsx delete mode 100644 packages/datum-ui/src/tooltip/index.tsx create mode 100644 packages/datum-ui/src/utils/index.ts create mode 100644 packages/datum-ui/src/utils/timezone.ts create mode 100644 packages/datum-ui/test/setup.ts create mode 100644 packages/datum-ui/tsdown.config.ts delete mode 100644 packages/datum-ui/tsup.config.ts create mode 100644 packages/datum-ui/vitest.config.ts delete mode 100644 packages/eslint-config/README.md delete mode 100644 packages/eslint-config/library.js delete mode 100644 packages/eslint-config/package.json delete mode 100644 packages/eslint-config/react.js delete mode 100644 packages/eslint-config/storybook.js delete mode 100644 packages/shadcn/.eslintrc.js delete mode 100644 packages/shadcn/.prettierrc delete mode 100644 packages/shadcn/README.md create mode 100644 packages/shadcn/hooks/use-theme.ts delete mode 100644 packages/shadcn/postcss.config.js delete mode 100644 packages/shadcn/style.css create mode 100644 packages/shadcn/styles/style.css create mode 100644 packages/shadcn/ui/button-group.tsx create mode 100644 packages/shadcn/ui/input-group.tsx create mode 100644 packages/shadcn/ui/map.tsx create mode 100644 packages/shadcn/ui/place-autocomplete.tsx create mode 100644 packages/shadcn/ui/spinner.tsx delete mode 100644 packages/tailwind-config/main.css delete mode 100644 packages/tailwind-config/package.json delete mode 100644 packages/tailwind-config/postcss.config.js delete mode 100644 packages/typescript-config/package.json delete mode 100644 packages/typescript-config/react-app.json diff --git a/.changeset/config.json b/.changeset/config.json index a91a85d..5eb625e 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,12 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.0.0/schema.json", + "$schema": "https://unpkg.com/@changesets/config@3/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", + "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [ - "@repo/docs" - ] -} \ No newline at end of file + "ignore": ["storybook", "docs"] +} diff --git a/.github/workflows/publish-datum-ui.yml b/.github/workflows/publish-datum-ui.yml deleted file mode 100644 index a1946b1..0000000 --- a/.github/workflows/publish-datum-ui.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Publish datum-ui - -on: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - publish-datum-ui: - uses: datum-cloud/actions/.github/workflows/publish-npm-package.yaml@main - permissions: - contents: write # needed to push the version bump commit and tag - id-token: write # needed for npm trusted publishing (OIDC) - - with: - package-name: "@datum-cloud/datum-ui" - package-path: packages/datum-ui diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7c8bbb4..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,42 +0,0 @@ -name: Release - -on: - push: - branches: - - main - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - release: - name: Release - runs-on: ubuntu-latest - steps: - - name: Checkout Repo - uses: actions/checkout@v4 - - - name: Setup Node.js 24.x - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: pnpm - - - name: Install Dependencies - run: | - corepack enable - pnpm install --frozen-lockfile - - - name: Create Release Pull Request or Publish to npm - id: changesets - uses: changesets/action@v1 - with: - # This expects you to have a script called release which does a build for your packages and calls changeset publish - publish: pnpm release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Send a Slack notification if a publish happens - if: steps.changesets.outputs.published == 'true' - # You can do something when a publish happens. - run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!" diff --git a/.gitignore b/.gitignore index 69b9263..56175bc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ -.DS_Store -node_modules -.turbo -*.log -.next -dist -dist-ssr -*.local -.env -.cache -server/dist -public/dist +node_modules/ +dist/ storybook-static/ +.next/ +.turbo/ +coverage/ +*.tsbuildinfo +.env* +.source/ + +.claude/ +CLAUDE.MD +/docs/ \ No newline at end of file diff --git a/.npmrc b/.npmrc index 15e41aa..3e775ef 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1 @@ -auto-install-peers = true -public-hoist-pattern[]=*storybook* -engine-strict = true +auto-install-peers=true diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 18c92ea..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v24 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 61a83af..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "eslint.workingDirectories": [ - { - "mode": "auto" - } - ], - "typescript.tsdk": "node_modules/typescript/lib", - "css.lint.unknownAtRules": "ignore", - "scss.lint.unknownAtRules": "ignore", - "less.lint.unknownAtRules": "ignore" -} diff --git a/README.md b/README.md deleted file mode 100644 index a97345a..0000000 --- a/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# Turborepo Design System Starter - -This is a community-maintained example. If you experience a problem, please submit a pull request with a fix. GitHub Issues will be closed. - -This guide explains how to use a React design system starter powered by: - -- 🏎 [Turborepo](https://turborepo.dev) — High-performance build system for Monorepos -- 🚀 [React](https://reactjs.org/) — JavaScript library for user interfaces -- 🛠 [Tsup](https://github.com/egoist/tsup) — TypeScript bundler powered by esbuild -- 📖 [Storybook](https://storybook.js.org/) — UI component environment powered by Vite - -As well as a few others tools preconfigured: - -- [TypeScript](https://www.typescriptlang.org/) for static type checking -- [ESLint](https://eslint.org/) for code linting -- [Prettier](https://prettier.io) for code formatting -- [Changesets](https://github.com/changesets/changesets) for managing versioning and changelogs -- [GitHub Actions](https://github.com/changesets/action) for fully automated package publishing - -## Using this example - -Run the following command: - -```sh -npx create-turbo@latest -e design-system -``` - -### Useful Commands - -- `pnpm build` - Build all packages, including the Storybook site -- `pnpm dev` - Run all packages locally and preview with Storybook -- `pnpm lint` - Lint all packages -- `pnpm changeset` - Generate a changeset -- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) - -## Turborepo - -[Turborepo](https://turborepo.dev) is a high-performance build system for JavaScript and TypeScript codebases. It was designed after the workflows used by massive software engineering organizations to ship code at scale. Turborepo abstracts the complex configuration needed for monorepos and provides fast, incremental builds with zero-configuration remote caching. - -Using Turborepo simplifies managing your design system monorepo, as you can have a single lint, build, test, and release process for all packages. [Learn more](https://vercel.com/blog/monorepos-are-changing-how-teams-build-software) about how monorepos improve your development workflow. - -## Apps & Packages - -This Turborepo includes the following packages and applications: - -- `apps/docs`: Component documentation site with Storybook -- `packages/ui`: Core React components -- `packages/typescript-config`: Shared `tsconfig.json`s used throughout the Turborepo -- `packages/eslint-config`: ESLint preset - -Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). Workspaces enables us to "hoist" dependencies that are shared between packages to the root `package.json`. This means smaller `node_modules` folders and a better local dev experience. To install a dependency for the entire monorepo, use the `-w` workspaces flag with `pnpm add`. - -This example sets up your `.gitignore` to exclude all generated files, other folders like `node_modules` used to store your dependencies. - -### Compilation - -To make the ui library code work across all browsers, we need to compile the raw TypeScript and React code to plain JavaScript. We can accomplish this with `tsup`, which uses `esbuild` to greatly improve performance. - -Running `pnpm build` from the root of the Turborepo will run the `build` command defined in each package's `package.json` file. Turborepo runs each `build` in parallel and caches & hashes the output to speed up future builds. - -For `@acme/ui`, the `build` command is equivalent to the following: - -```bash -tsup src/*.tsx --format esm,cjs --dts --external react -``` - -`tsup` compiles all of the components in the design system individually, into both ES Modules and CommonJS formats as well as their TypeScript types. The `package.json` for `@acme/ui` then instructs the consumer to select the correct format: - -```json:ui/package.json -{ - "name": "@acme/ui", - "version": "0.0.0", - "sideEffects": false, - "exports":{ - "./button": { - "types": "./src/button.tsx", - "import": "./dist/button.mjs", - "require": "./dist/button.js" - } - } -} -``` - -Run `pnpm build` to confirm compilation is working correctly. You should see a folder `ui/dist` which contains the compiled output. - -```bash -ui -└── dist - ├── button.d.ts <-- Types - ├── button.js <-- CommonJS version - ├── button.mjs <-- ES Modules version - └── button.d.mts <-- ES Modules version with Types -``` - -## Components - -Each file inside of `ui/src` is a component inside our design system. For example: - -```tsx:ui/src/Button.tsx -import * as React from 'react'; - -export interface ButtonProps { - children: React.ReactNode; -} - -export function Button(props: ButtonProps) { - return ; -} - -Button.displayName = 'Button'; -``` - -When adding a new file, ensure that its specifier is defined in `package.json` file: - -```json:ui/package.json -{ - "name": "@acme/ui", - "version": "0.0.0", - "sideEffects": false, - "exports":{ - "./button": { - "types": "./src/button.tsx", - "import": "./dist/button.mjs", - "require": "./dist/button.js" - } - // Add new component exports here - } -} -``` - -## Storybook - -Storybook provides us with an interactive UI playground for our components. This allows us to preview our components in the browser and instantly see changes when developing locally. This example preconfigures Storybook to: - -- Use Vite to bundle stories instantly (in milliseconds) -- Automatically find any stories inside the `stories/` folder -- Support using module path aliases like `@acme/ui` for imports -- Write MDX for component documentation pages - -For example, here's the included Story for our `Button` component: - -```js:apps/docs/stories/button.stories.mdx -import { Button } from '@acme/ui/button'; -import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; - - - -# Button - -Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec euismod, nisl eget consectetur tempor, nisl nunc egestas nisi, euismod aliquam nisl nunc euismod. - -## Props - - - -## Examples - - - - - - -``` - -This example includes a few helpful Storybook scripts: - -- `pnpm dev`: Starts Storybook in dev mode with hot reloading at `localhost:6006` -- `pnpm build`: Builds the Storybook UI and generates the static HTML files -- `pnpm preview-storybook`: Starts a local server to view the generated Storybook UI - -## Versioning & Publishing Packages - -This example uses [Changesets](https://github.com/changesets/changesets) to manage versions, create changelogs, and publish to npm. It's preconfigured so you can start publishing packages immediately. - -You'll need to create an `NPM_TOKEN` and `GITHUB_TOKEN` and add it to your GitHub repository settings to enable access to npm. It's also worth installing the [Changesets bot](https://github.com/apps/changeset-bot) on your repository. - -### Generating the Changelog - -To generate your changelog, run `pnpm changeset` locally: - -1. **Which packages would you like to include?** – This shows which packages and changed and which have remained the same. By default, no packages are included. Press `space` to select the packages you want to include in the `changeset`. -1. **Which packages should have a major bump?** – Press `space` to select the packages you want to bump versions for. -1. If doing the first major version, confirm you want to release. -1. Write a summary for the changes. -1. Confirm the changeset looks as expected. -1. A new Markdown file will be created in the `changeset` folder with the summary and a list of the packages included. - -### Releasing - -When you push your code to GitHub, the [GitHub Action](https://github.com/changesets/action) will run the `release` script defined in the root `package.json`: - -```bash -turbo run build --filter=docs^... && changeset publish -``` - -Turborepo runs the `build` script for all publishable packages (excluding docs) and publishes the packages to npm. By default, this example includes `acme` as the npm organization. To change this, do the following: - -- Rename folders in `packages/*` to replace `acme` with your desired scope -- Search and replace `acme` with your desired scope -- Re-run `pnpm install` - -To publish packages to a private npm organization scope, **remove** the following from each of the `package.json`'s - -```diff -- "publishConfig": { -- "access": "public" -- }, -``` diff --git a/apps/docs/.eslintrc.cjs b/apps/docs/.eslintrc.cjs deleted file mode 100644 index a38cd22..0000000 --- a/apps/docs/.eslintrc.cjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - extends: ["@repo/eslint-config/storybook.js"], -}; diff --git a/apps/docs/.storybook/main.js b/apps/docs/.storybook/main.js deleted file mode 100644 index 5b157eb..0000000 --- a/apps/docs/.storybook/main.js +++ /dev/null @@ -1,37 +0,0 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import tailwindcss from "@tailwindcss/vite"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); - -const config = { - stories: ["../stories/*.stories.tsx", "../stories/**/*.stories.tsx"], - framework: { - name: "@storybook/react-vite", - options: {}, - }, - - async viteFinal(config) { - config.plugins = [...(config.plugins || []), tailwindcss()]; - return { - ...config, - resolve: { - ...config.resolve, - alias: [ - ...(Array.isArray(config.resolve?.alias) ? config.resolve.alias : []), - { - find: /^@datum-cloud\/datum-ui$/, - replacement: path.resolve(__dirname, "../../../packages/datum-ui/src/index.ts"), - }, - ], - }, - define: { "process.env": {} }, - }; - }, - - docs: { - autodocs: true, - }, -}; - -export default config; diff --git a/apps/docs/.storybook/preview.tsx b/apps/docs/.storybook/preview.tsx deleted file mode 100644 index ed9432a..0000000 --- a/apps/docs/.storybook/preview.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { Preview } from "@storybook/react"; -import { useEffect } from "react"; -import "../stories/styles.css"; - -const preview: Preview = { - decorators: [ - (Story) => { - useEffect(() => { - document.documentElement.classList.add("theme-alpha"); - return () => { - document.documentElement.classList.remove("theme-alpha"); - }; - }, []); - return ; - }, - ], - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - }, -}; - -export default preview; diff --git a/apps/docs/app/docs/[[...slug]]/page.tsx b/apps/docs/app/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000..7e377da --- /dev/null +++ b/apps/docs/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,40 @@ +import { source } from '@/lib/source' +import { DocsPage, DocsBody } from 'fumadocs-ui/page' +import { notFound } from 'next/navigation' +import defaultMdxComponents from 'fumadocs-ui/mdx' + +interface PageProps { + params: Promise<{ slug?: string[] }> +} + +export default async function Page({ params }: PageProps) { + const { slug } = await params + const page = source.getPage(slug) + + if (!page) notFound() + + const MDX = page.data.body + + return ( + + + + + + ) +} + +export function generateStaticParams(): { slug: string[] }[] { + return source.generateParams() +} + +export async function generateMetadata({ params }: PageProps) { + const { slug } = await params + const page = source.getPage(slug) + if (!page) notFound() + + return { + title: page.data.title, + description: page.data.description, + } +} diff --git a/apps/docs/app/docs/layout.tsx b/apps/docs/app/docs/layout.tsx new file mode 100644 index 0000000..9b96041 --- /dev/null +++ b/apps/docs/app/docs/layout.tsx @@ -0,0 +1,11 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs' +import { source } from '@/lib/source' +import type { ReactNode } from 'react' + +export default function Layout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx new file mode 100644 index 0000000..24465e7 --- /dev/null +++ b/apps/docs/app/layout.tsx @@ -0,0 +1,18 @@ +import { RootProvider } from 'fumadocs-ui/provider/next' +import 'fumadocs-ui/style.css' +import type { ReactNode } from 'react' + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} + +export const metadata = { + title: 'datum-ui', + description: 'Datum Cloud Design System Documentation', +} diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx new file mode 100644 index 0000000..9e15b11 --- /dev/null +++ b/apps/docs/app/page.tsx @@ -0,0 +1,11 @@ +import Link from 'next/link' + +export default function HomePage() { + return ( +
+

datum-ui

+

Datum Cloud Design System

+ View Documentation +
+ ) +} diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx new file mode 100644 index 0000000..b57cd18 --- /dev/null +++ b/apps/docs/content/docs/index.mdx @@ -0,0 +1,37 @@ +--- +title: Getting Started +description: Install and set up datum-ui +--- + +## Installation + +```package-install +@datum-cloud/datum-ui +``` + +## Setup + +Wrap your app with `DatumProvider`: + +```tsx +import { DatumProvider } from '@datum-cloud/datum-ui' + +export default function App({ children }) { + return {children} +} +``` + +## Theme Control + +```tsx +import { useTheme } from '@datum-cloud/datum-ui' + +function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + return ( + + ) +} +``` diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json new file mode 100644 index 0000000..9081a62 --- /dev/null +++ b/apps/docs/content/docs/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Documentation", + "pages": ["---Getting Started---", "index"] +} diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts new file mode 100644 index 0000000..b655351 --- /dev/null +++ b/apps/docs/lib/source.ts @@ -0,0 +1,7 @@ +import { docs } from '@/.source/server' +import { loader } from 'fumadocs-core/source' + +export const source = loader({ + baseUrl: '/docs', + source: docs.toFumadocsSource(), +}) diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts new file mode 100644 index 0000000..725b4f6 --- /dev/null +++ b/apps/docs/next.config.ts @@ -0,0 +1,9 @@ +import { createMDX } from 'fumadocs-mdx/next' + +const withMDX = createMDX() + +const config = { + reactStrictMode: true, +} + +export default withMDX(config) diff --git a/apps/docs/package.json b/apps/docs/package.json index 05157cf..aba6f3a 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,38 +1,26 @@ { "name": "docs", "version": "0.0.0", - "type": "module", "private": true, "scripts": { - "dev": "storybook dev -p 6006", - "build": "storybook build --docs", - "preview-storybook": "serve storybook-static", - "clean": "rm -rf .turbo node_modules", - "lint": "eslint ./stories/*.stories.tsx --max-warnings 0" + "dev": "next dev --turbopack", + "build": "next build", + "clean": "rm -rf .next .turbo node_modules" }, "dependencies": { "@datum-cloud/datum-ui": "workspace:*", - "@radix-ui/react-tooltip": "^1.2.8", - "@repo/shadcn": "workspace:*", - "lucide-react": "^0.556.0", - "react": "^19.2.1", - "react-dom": "^19.2.1" + "fumadocs-core": "^16", + "fumadocs-mdx": "^14", + "fumadocs-ui": "^16", + "next": "^15", + "react": "^19", + "react-dom": "^19" }, "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@types/react": "^19.1.8", - "@storybook/react": "^10.2.0", - "@storybook/react-vite": "^10.2.0", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.1.17", - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^8.57.1", - "serve": "^14.2.5", - "storybook": "^10.2.0", - "tailwindcss": "^4.1.17", - "tw-animate-css": "^1.4.0", - "typescript": "5.9.3", - "vite": "^7.3.1" + "@repo/config": "workspace:*", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5.9", + "zod": "^4.3.6" } -} \ No newline at end of file +} diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts new file mode 100644 index 0000000..64d4340 --- /dev/null +++ b/apps/docs/source.config.ts @@ -0,0 +1,5 @@ +import { defineDocs } from 'fumadocs-mdx/config' + +export const docs = defineDocs({ + dir: 'content/docs', +}) diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index b31464b..3bf1977 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -1,5 +1,13 @@ { - "extends": "@repo/typescript-config/react-app.json", - "include": ["."], - "exclude": ["dist", "build", "node_modules"] + "extends": "@repo/config/tsconfig/react-app", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "paths": { + "@/*": ["./*"] + }, + "plugins": [{ "name": "next" }] + }, + "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".source/**/*.ts"], + "exclude": ["node_modules"] } diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts deleted file mode 100644 index 081c8d9..0000000 --- a/apps/docs/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react"; - -export default defineConfig({ - plugins: [react()], -}); diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts new file mode 100644 index 0000000..2485d21 --- /dev/null +++ b/apps/storybook/.storybook/main.ts @@ -0,0 +1,27 @@ +import type { StorybookConfig } from 'storybook-react-rsbuild' +import { pluginReact } from '@rsbuild/plugin-react' +import path from 'node:path' + +const config: StorybookConfig = { + framework: 'storybook-react-rsbuild', + stories: ['../stories/**/*.stories.tsx'], + docs: { + autodocs: true, + }, + rsbuildFinal: (config) => { + config.plugins = [...(config.plugins || []), pluginReact()] + config.resolve = { + ...config.resolve, + alias: { + ...config.resolve?.alias, + '@datum-cloud/datum-ui': path.resolve( + import.meta.dirname, + '../../../packages/datum-ui/src', + ), + }, + } + return config + }, +} + +export default config diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx new file mode 100644 index 0000000..87b6d22 --- /dev/null +++ b/apps/storybook/.storybook/preview.tsx @@ -0,0 +1,17 @@ +import type { Preview } from 'storybook-react-rsbuild' +import { useEffect } from 'react' +import '../stories/styles.css' + +const preview: Preview = { + decorators: [ + (Story) => { + useEffect(() => { + document.documentElement.classList.add('theme-alpha') + }, []) + return + }, + ], + tags: ['autodocs'], +} + +export default preview diff --git a/apps/storybook/eslint.config.ts b/apps/storybook/eslint.config.ts new file mode 100644 index 0000000..9563263 --- /dev/null +++ b/apps/storybook/eslint.config.ts @@ -0,0 +1,3 @@ +import { createConfig } from '@repo/config/eslint' + +export default createConfig({ react: true }) diff --git a/apps/storybook/package.json b/apps/storybook/package.json new file mode 100644 index 0000000..42d9f92 --- /dev/null +++ b/apps/storybook/package.json @@ -0,0 +1,28 @@ +{ + "name": "storybook", + "version": "0.0.0", + "type": "module", + "private": true, + "scripts": { + "dev": "storybook dev -p 6006", + "build": "storybook build --docs", + "clean": "rm -rf .turbo node_modules storybook-static" + }, + "dependencies": { + "@datum-cloud/datum-ui": "workspace:*", + "lucide-react": "^0.556.0", + "react": "^19", + "react-dom": "^19" + }, + "devDependencies": { + "@repo/config": "workspace:*", + "@repo/shadcn": "workspace:*", + "@rsbuild/core": "^1", + "@rsbuild/plugin-react": "^1", + "@types/react": "^19", + "storybook": "^10", + "storybook-react-rsbuild": "^2", + "tailwindcss": "^4", + "typescript": "^5.9" + } +} diff --git a/apps/docs/stories/add-filter-dropdown.stories.tsx b/apps/storybook/stories/add-filter-dropdown.stories.tsx similarity index 100% rename from apps/docs/stories/add-filter-dropdown.stories.tsx rename to apps/storybook/stories/add-filter-dropdown.stories.tsx diff --git a/apps/docs/stories/alert.stories.tsx b/apps/storybook/stories/alert.stories.tsx similarity index 100% rename from apps/docs/stories/alert.stories.tsx rename to apps/storybook/stories/alert.stories.tsx diff --git a/apps/docs/stories/badge.stories.tsx b/apps/storybook/stories/badge.stories.tsx similarity index 100% rename from apps/docs/stories/badge.stories.tsx rename to apps/storybook/stories/badge.stories.tsx diff --git a/apps/docs/stories/button.stories.tsx b/apps/storybook/stories/button.stories.tsx similarity index 100% rename from apps/docs/stories/button.stories.tsx rename to apps/storybook/stories/button.stories.tsx diff --git a/apps/docs/stories/card.stories.tsx b/apps/storybook/stories/card.stories.tsx similarity index 100% rename from apps/docs/stories/card.stories.tsx rename to apps/storybook/stories/card.stories.tsx diff --git a/apps/docs/stories/checkbox.stories.tsx b/apps/storybook/stories/checkbox.stories.tsx similarity index 100% rename from apps/docs/stories/checkbox.stories.tsx rename to apps/storybook/stories/checkbox.stories.tsx diff --git a/apps/docs/stories/combobox.stories.tsx b/apps/storybook/stories/combobox.stories.tsx similarity index 100% rename from apps/docs/stories/combobox.stories.tsx rename to apps/storybook/stories/combobox.stories.tsx diff --git a/apps/docs/stories/dialog.stories.tsx b/apps/storybook/stories/dialog.stories.tsx similarity index 100% rename from apps/docs/stories/dialog.stories.tsx rename to apps/storybook/stories/dialog.stories.tsx diff --git a/apps/docs/stories/filter-chip.stories.tsx b/apps/storybook/stories/filter-chip.stories.tsx similarity index 100% rename from apps/docs/stories/filter-chip.stories.tsx rename to apps/storybook/stories/filter-chip.stories.tsx diff --git a/apps/docs/stories/input.stories.tsx b/apps/storybook/stories/input.stories.tsx similarity index 100% rename from apps/docs/stories/input.stories.tsx rename to apps/storybook/stories/input.stories.tsx diff --git a/apps/docs/stories/label.stories.tsx b/apps/storybook/stories/label.stories.tsx similarity index 100% rename from apps/docs/stories/label.stories.tsx rename to apps/storybook/stories/label.stories.tsx diff --git a/apps/docs/stories/multi-combobox.stories.tsx b/apps/storybook/stories/multi-combobox.stories.tsx similarity index 100% rename from apps/docs/stories/multi-combobox.stories.tsx rename to apps/storybook/stories/multi-combobox.stories.tsx diff --git a/apps/docs/stories/select.stories.tsx b/apps/storybook/stories/select.stories.tsx similarity index 100% rename from apps/docs/stories/select.stories.tsx rename to apps/storybook/stories/select.stories.tsx diff --git a/apps/docs/stories/separator.stories.tsx b/apps/storybook/stories/separator.stories.tsx similarity index 100% rename from apps/docs/stories/separator.stories.tsx rename to apps/storybook/stories/separator.stories.tsx diff --git a/apps/docs/stories/skeleton.stories.tsx b/apps/storybook/stories/skeleton.stories.tsx similarity index 100% rename from apps/docs/stories/skeleton.stories.tsx rename to apps/storybook/stories/skeleton.stories.tsx diff --git a/apps/docs/stories/styles.css b/apps/storybook/stories/styles.css similarity index 58% rename from apps/docs/stories/styles.css rename to apps/storybook/stories/styles.css index 76fc7e4..75d0175 100644 --- a/apps/docs/stories/styles.css +++ b/apps/storybook/stories/styles.css @@ -1,22 +1,9 @@ @import "tailwindcss"; -@import "tw-animate-css"; - @source "../../../packages/datum-ui/src"; @source "../../../packages/shadcn"; -@custom-variant dark (&:is(.dark *)); - -/* Import fonts */ @import "@datum-cloud/datum-ui/styles/fonts.css"; - -/* Import Figma design tokens */ @import "@datum-cloud/datum-ui/styles/tokens/figma-tokens.css"; - -/* Import shadcn base styles */ -@import "@repo/shadcn/style.css"; - -/* Import theme */ +@import "@repo/shadcn/styles/shadcn.css"; @import "@datum-cloud/datum-ui/styles/themes/alpha.css"; - -/* Import custom styles */ @import "@datum-cloud/datum-ui/styles/custom.css"; diff --git a/apps/docs/stories/tabs.stories.tsx b/apps/storybook/stories/tabs.stories.tsx similarity index 100% rename from apps/docs/stories/tabs.stories.tsx rename to apps/storybook/stories/tabs.stories.tsx diff --git a/apps/docs/stories/textarea.stories.tsx b/apps/storybook/stories/textarea.stories.tsx similarity index 100% rename from apps/docs/stories/textarea.stories.tsx rename to apps/storybook/stories/textarea.stories.tsx diff --git a/apps/docs/stories/time-range-dropdown.stories.tsx b/apps/storybook/stories/time-range-dropdown.stories.tsx similarity index 100% rename from apps/docs/stories/time-range-dropdown.stories.tsx rename to apps/storybook/stories/time-range-dropdown.stories.tsx diff --git a/apps/docs/stories/tooltip.stories.tsx b/apps/storybook/stories/tooltip.stories.tsx similarity index 100% rename from apps/docs/stories/tooltip.stories.tsx rename to apps/storybook/stories/tooltip.stories.tsx diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json new file mode 100644 index 0000000..150f39a --- /dev/null +++ b/apps/storybook/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@repo/config/tsconfig/react-app", + "include": ["stories", ".storybook"] +} diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..6e23e5d --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,3 @@ +import { createConfig } from '@repo/config/eslint' + +export default createConfig() diff --git a/package.json b/package.json index 8866d2e..e3d205a 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,23 @@ { + "name": "design-system", "private": true, "scripts": { - "preinstall": "node -e \"const ua=process.env.npm_config_user_agent||''; if(!ua.includes('pnpm')){console.error('This repo uses pnpm. Install with: corepack enable && pnpm install'); process.exit(1)}\"", - "shadcn:add": "pnpm --filter @repo/shadcn ui:add", "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", + "test": "turbo run test", "clean": "turbo run clean && rm -rf node_modules", - "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "format": "turbo run format", "changeset": "changeset", "version-packages": "changeset version", - "release": "turbo run build --filter=docs^... && changeset publish", - "preview-storybook": "turbo run preview-storybook" + "release": "turbo run build --filter=@datum-cloud/datum-ui && changeset publish" }, "engines": { - "node": ">=24 <25" + "node": ">=22" }, + "packageManager": "pnpm@10.28.1", "devDependencies": { "@changesets/cli": "^2.29.8", - "prettier": "^3.8.1", - "turbo": "^2.7.5", - "typescript": "5.9.3" - }, - "packageManager": "pnpm@10.28.1", - "name": "design-system" + "turbo": "^2.8.12" + } } diff --git a/packages/config/eslint/index.ts b/packages/config/eslint/index.ts new file mode 100644 index 0000000..5bbe0e8 --- /dev/null +++ b/packages/config/eslint/index.ts @@ -0,0 +1,16 @@ +import antfu from '@antfu/eslint-config' +import type { Linter } from 'eslint' + +export function createConfig(options?: { react?: boolean, markdown?: boolean }): Linter.Config[] { + return antfu({ + react: options?.react ?? true, + typescript: true, + formatters: true, + markdown: options?.markdown ?? true, + stylistic: { + quotes: 'single', + semi: false, + }, + ignores: ['dist/**', 'storybook-static/**', '.next/**', 'coverage/**'], + }) as unknown as Linter.Config[] +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..537aa53 --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,21 @@ +{ + "name": "@repo/config", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./tsconfig/base": "./tsconfig/base.json", + "./tsconfig/react-library": "./tsconfig/react-library.json", + "./tsconfig/react-app": "./tsconfig/react-app.json", + "./eslint": "./eslint/index.ts" + }, + "devDependencies": { + "@antfu/eslint-config": "^4", + "@eslint-react/eslint-plugin": "^1.53.1", + "eslint": "^9.39.3", + "eslint-plugin-format": "^2.0.1", + "eslint-plugin-react-hooks": "^5", + "eslint-plugin-react-refresh": "^0.4", + "typescript": "^5.9" + } +} diff --git a/packages/typescript-config/base.json b/packages/config/tsconfig/base.json similarity index 50% rename from packages/typescript-config/base.json rename to packages/config/tsconfig/base.json index ad34ef1..6739faf 100644 --- a/packages/typescript-config/base.json +++ b/packages/config/tsconfig/base.json @@ -1,20 +1,22 @@ { "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "composite": false, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "inlineSources": false, + "strict": true, + "isolatedDeclarations": true, "isolatedModules": true, + "moduleDetection": "force", + "moduleResolution": "bundler", "module": "ESNext", - "moduleResolution": "Bundler", - "noUnusedLocals": false, - "noUnusedParameters": false, - "preserveWatchOutput": true, + "target": "ES2022", + "lib": ["ES2022"], + "jsx": "react-jsx", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "strict": true + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true }, - "exclude": ["node_modules"] + "exclude": ["node_modules", "dist"] } diff --git a/packages/config/tsconfig/react-app.json b/packages/config/tsconfig/react-app.json new file mode 100644 index 0000000..c4a2704 --- /dev/null +++ b/packages/config/tsconfig/react-app.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "resolveJsonModule": true, + "isolatedDeclarations": false + } +} diff --git a/packages/typescript-config/react-library.json b/packages/config/tsconfig/react-library.json similarity index 53% rename from packages/typescript-config/react-library.json rename to packages/config/tsconfig/react-library.json index 2c1f84e..da64fc4 100644 --- a/packages/typescript-config/react-library.json +++ b/packages/config/tsconfig/react-library.json @@ -2,9 +2,7 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "./base.json", "compilerOptions": { - "jsx": "react-jsx", - "lib": ["dom", "ES2017"], - "module": "ESNext", - "target": "es6" + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "./dist" } } diff --git a/packages/datum-ui/.eslintrc.js b/packages/datum-ui/.eslintrc.js deleted file mode 100644 index f075bfa..0000000 --- a/packages/datum-ui/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - extends: ["@repo/eslint-config/react.js"], -}; diff --git a/packages/datum-ui/.prettierrc b/packages/datum-ui/.prettierrc deleted file mode 100644 index 8762552..0000000 --- a/packages/datum-ui/.prettierrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-organize-imports"] - } \ No newline at end of file diff --git a/packages/datum-ui/LICENSE b/packages/datum-ui/LICENSE deleted file mode 100644 index 9825760..0000000 --- a/packages/datum-ui/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [2026] [Datum Technology, Inc.] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file diff --git a/packages/datum-ui/README.md b/packages/datum-ui/README.md deleted file mode 100644 index 9a401b2..0000000 --- a/packages/datum-ui/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# @datum-cloud/datum-ui - -A React component library by Datum. - -## Installation - -```bash -npm install @datum-cloud/datum-ui -``` - -## Usage - -Import components and the base styles in your app entry point: - -```tsx -// Import styles once at the root of your app -import "@datum-cloud/datum-ui/styles"; - -// Import components -import { Button } from "@datum-cloud/datum-ui"; - -export default function App() { - return ; -} -``` - -## Peer Dependencies - -- **React 18+** (required) -- **react-router ^7.0.0** (optional — only needed if you use routing-aware components) - -## License - -MIT diff --git a/packages/datum-ui/eslint.config.ts b/packages/datum-ui/eslint.config.ts new file mode 100644 index 0000000..36e7233 --- /dev/null +++ b/packages/datum-ui/eslint.config.ts @@ -0,0 +1,3 @@ +import { createConfig } from '@repo/config/eslint' + +export default createConfig({ react: true, markdown: false }) diff --git a/packages/datum-ui/package.json b/packages/datum-ui/package.json index 1d3fa69..2046d55 100644 --- a/packages/datum-ui/package.json +++ b/packages/datum-ui/package.json @@ -1,81 +1,165 @@ { "name": "@datum-cloud/datum-ui", - "version": "0.1.0", - "sideEffects": [ - "**/*.css" - ], + "type": "module", + "version": "0.2.0", "license": "MIT", - "main": "./dist/index.js", - "module": "./dist/index.mjs", "repository": { "url": "https://github.com/datum-cloud/datum-ui" }, - "types": "./dist/index.d.ts", + "sideEffects": [ + "**/*.css" + ], "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" }, - "./styles": { - "default": "./src/styles/root.css" + "./components": { + "types": "./dist/components/index.d.mts", + "default": "./dist/components/index.mjs" }, - "./styles/*": { - "default": "./src/styles/*" + "./providers": { + "types": "./dist/providers/index.d.mts", + "default": "./dist/providers/index.mjs" }, - "./styles/*/*": { - "default": "./src/styles/*/*" - } + "./hooks": { + "types": "./dist/hooks/index.d.mts", + "default": "./dist/hooks/index.mjs" + }, + "./icons": { + "types": "./dist/icons/index.d.mts", + "default": "./dist/icons/index.mjs" + }, + "./utils": { + "types": "./dist/utils/index.d.mts", + "default": "./dist/utils/index.mjs" + }, + "./styles": "./dist/style.css" }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", "files": [ - "dist", - "src/styles", + "LICENSE", "README.md", - "LICENSE" + "dist" ], "scripts": { - "build": "tsup", - "dev": "tsup --watch", - "lint": "eslint . --max-warnings 0", - "clean": "rm -rf .turbo node_modules dist" + "build": "tsdown && cp -r src/styles/fonts dist/fonts", + "dev": "tsdown --watch", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "clean": "rm -rf dist .turbo node_modules coverage" }, - "devDependencies": { - "@repo/eslint-config": "workspace:*", - "@repo/shadcn": "workspace:*", - "@repo/tailwind-config": "workspace:*", - "@repo/typescript-config": "workspace:*", - "@types/react": "^19.2.3", - "@types/react-dom": "^19.2.3", - "eslint": "^8.57.0", - "prettier": "^3.8.1", - "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.7.2", - "@types/nprogress": "^0.2.3", - "react": "^19.2.3", - "tsup": "^8.5.1", - "typescript": "5.9.3" + "peerDependencies": { + "@conform-to/react": ">=1", + "@conform-to/zod": ">=1", + "@stepperize/react": ">=4", + "@tanstack/react-table": ">=8", + "@tanstack/react-virtual": ">=3", + "date-fns": ">=4", + "date-fns-tz": ">=3", + "motion": ">=11", + "nprogress": ">=0.2", + "nuqs": ">=2", + "react": ">=19", + "react-day-picker": ">=9", + "react-dom": ">=19", + "react-dropzone": ">=14", + "react-number-format": ">=5", + "zod": ">=3" + }, + "peerDependenciesMeta": { + "@conform-to/react": { + "optional": true + }, + "@conform-to/zod": { + "optional": true + }, + "@stepperize/react": { + "optional": true + }, + "@tanstack/react-table": { + "optional": true + }, + "@tanstack/react-virtual": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-tz": { + "optional": true + }, + "motion": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "nuqs": { + "optional": true + }, + "react-day-picker": { + "optional": true + }, + "react-dropzone": { + "optional": true + }, + "react-number-format": { + "optional": true + }, + "zod": { + "optional": true + } }, "dependencies": { + "@repo/shadcn": "workspace:*", + "class-variance-authority": "^0.7", + "lucide-react": "^0.556", + "tailwind-scrollbar-hide": "^4.0.0", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@bosh-code/tsdown-plugin-tailwindcss": "^1.0.1", + "@conform-to/react": "^1.17.1", + "@conform-to/zod": "^1.17.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", - "@stepperize/react": "^3.0.0", - "class-variance-authority": "^0.7.1", - "lucide-react": "^0.556.0", + "@repo/config": "workspace:*", + "@stepperize/react": "^6.1.0", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.19", + "@tanstack/table-core": "^8.21.3", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^16", + "@testing-library/user-event": "^14", + "@types/nprogress": "^0.2.3", + "@types/react": "^19", + "@types/react-dom": "^19", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "eslint": "^9.39.3", + "jsdom": "^26", + "motion": "^12.34.4", "nprogress": "^0.2.0", + "nuqs": "^2.8.9", + "react": "^19", + "react-day-picker": "^9.14.0", + "react-dom": "^19", + "react-dropzone": "^15.0.0", + "react-number-format": "^5.4.4", "sonner": "^2.0.7", - "tailwind-scrollbar-hide": "^2.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-router": "^7.0.0" - }, - "peerDependenciesMeta": { - "react-router": { - "optional": true - } + "tailwindcss": "^4.2.1", + "tsdown": "^0.20.3", + "typescript": "^5.9", + "vitest": "^3", + "zod": "^4.3.6" }, "publishConfig": { "access": "public" diff --git a/packages/datum-ui/src/add-filter-dropdown/add-filter-dropdown.tsx b/packages/datum-ui/src/add-filter-dropdown/add-filter-dropdown.tsx deleted file mode 100644 index 84c28c5..0000000 --- a/packages/datum-ui/src/add-filter-dropdown/add-filter-dropdown.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { - Popover, - PopoverTrigger, - PopoverContent, -} from '@repo/shadcn/ui/popover'; -import { ListFilterIcon, PlusIcon } from 'lucide-react'; - -export interface FilterOption { - id: string; - label: string; - icon?: React.ReactNode; -} - -export interface AddFilterDropdownProps { - availableFilters: FilterOption[]; - activeFilterIds: string[]; - onAddFilter: (filterId: string) => void; - hasActiveFilters?: boolean; - disabled?: boolean; - className?: string; -} - -const AddFilterDropdown = ({ - availableFilters, - activeFilterIds, - onAddFilter, - hasActiveFilters = false, - disabled = false, - className, -}: AddFilterDropdownProps) => { - const [open, setOpen] = React.useState(false); - - const handleSelect = (filterId: string) => { - const isActive = activeFilterIds.includes(filterId); - if (!isActive) { - onAddFilter(filterId); - setOpen(false); - } - }; - - return ( - - - - - - -
- {availableFilters.length === 0 ? ( -

No filters available

- ) : ( - availableFilters.map((filter) => { - const isActive = activeFilterIds.includes(filter.id); - return ( - - ); - }) - )} -
-
-
- ); -}; - -AddFilterDropdown.displayName = 'AddFilterDropdown'; - -export { AddFilterDropdown }; diff --git a/packages/datum-ui/src/add-filter-dropdown/index.tsx b/packages/datum-ui/src/add-filter-dropdown/index.tsx deleted file mode 100644 index d43b024..0000000 --- a/packages/datum-ui/src/add-filter-dropdown/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { AddFilterDropdown } from './add-filter-dropdown'; -export type { AddFilterDropdownProps, FilterOption } from './add-filter-dropdown'; diff --git a/packages/datum-ui/src/alert/index.tsx b/packages/datum-ui/src/alert/index.tsx deleted file mode 100644 index dfa9087..0000000 --- a/packages/datum-ui/src/alert/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Alert, AlertDescription, AlertTitle, alertVariants } from './alert'; -export type { AlertProps } from './alert'; diff --git a/packages/datum-ui/src/badge/badge.tsx b/packages/datum-ui/src/badge/badge.tsx deleted file mode 100644 index b7281a0..0000000 --- a/packages/datum-ui/src/badge/badge.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { cva, type VariantProps } from 'class-variance-authority'; - -const badgeVariants = cva( - 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', - { - variants: { - variant: { - default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', - secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', - destructive: - 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', - outline: 'text-foreground', - }, - }, - defaultVariants: { - variant: 'default', - }, - } -); - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -const Badge = React.forwardRef( - ({ className, variant, ...props }, ref) => { - return ( -
- ); - } -); - -Badge.displayName = 'Badge'; - -export { Badge, badgeVariants }; diff --git a/packages/datum-ui/src/badge/index.tsx b/packages/datum-ui/src/badge/index.tsx deleted file mode 100644 index 6558368..0000000 --- a/packages/datum-ui/src/badge/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Badge, badgeVariants } from './badge'; -export type { BadgeProps } from './badge'; diff --git a/packages/datum-ui/src/button/index.tsx b/packages/datum-ui/src/button/index.tsx deleted file mode 100644 index a6378ab..0000000 --- a/packages/datum-ui/src/button/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export { Button, buttonVariants } from './button'; -export type { ButtonProps } from './button'; -export { LinkButton } from './link-button'; -export type { LinkButtonProps } from './link-button'; diff --git a/packages/datum-ui/src/button/link-button.tsx b/packages/datum-ui/src/button/link-button.tsx deleted file mode 100644 index b5ceacf..0000000 --- a/packages/datum-ui/src/button/link-button.tsx +++ /dev/null @@ -1,49 +0,0 @@ - -import { cn } from '@repo/shadcn/lib/utils'; -import { type VariantProps } from 'class-variance-authority'; -import * as React from 'react'; -import { Link, type LinkProps } from 'react-router'; -import { buttonVariants } from './button'; - -export interface LinkButtonProps - extends Omit, VariantProps { - icon?: React.ReactNode; - iconPosition?: 'left' | 'right'; -} - -const LinkButton = React.forwardRef( - ( - { className, type, theme, size, block, icon, iconPosition = 'left', children, ...props }, - ref - ) => { - const isIconOnly = icon && !children; - - const getIconOnlyClass = () => { - if (!isIconOnly || size === 'icon') return ''; - if (size === 'small') return 'w-8 px-0'; - if (size === 'large') return 'w-11 px-0'; - return 'w-9 px-0'; - }; - - return ( - - {isIconOnly ? ( - icon - ) : ( - <> - {icon && iconPosition === 'left' && icon} - {children} - {icon && iconPosition === 'right' && icon} - - )} - - ); - } -); - -LinkButton.displayName = 'LinkButton'; - -export { LinkButton }; diff --git a/packages/datum-ui/src/card/card.tsx b/packages/datum-ui/src/card/card.tsx deleted file mode 100644 index a0b183c..0000000 --- a/packages/datum-ui/src/card/card.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { - Card as CardPrimitive, - CardHeader as CardHeaderPrimitive, - CardTitle as CardTitlePrimitive, - CardDescription as CardDescriptionPrimitive, - CardContent as CardContentPrimitive, - CardFooter as CardFooterPrimitive, -} from '@repo/shadcn/ui/card'; - -export interface CardProps extends React.ComponentProps {} - -const Card = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -Card.displayName = 'Card'; - -export interface CardHeaderProps - extends React.ComponentProps {} - -const CardHeader = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -CardHeader.displayName = 'CardHeader'; - -export interface CardTitleProps - extends React.ComponentProps {} - -const CardTitle = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -CardTitle.displayName = 'CardTitle'; - -export interface CardDescriptionProps - extends React.ComponentProps {} - -const CardDescription = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -CardDescription.displayName = 'CardDescription'; - -export interface CardContentProps - extends React.ComponentProps {} - -const CardContent = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -CardContent.displayName = 'CardContent'; - -export interface CardFooterProps - extends React.ComponentProps {} - -const CardFooter = React.forwardRef( - ({ className, ...props }, ref) => ( - - ) -); -CardFooter.displayName = 'CardFooter'; - -export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }; diff --git a/packages/datum-ui/src/card/index.tsx b/packages/datum-ui/src/card/index.tsx deleted file mode 100644 index 05e0e4e..0000000 --- a/packages/datum-ui/src/card/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - CardFooter, -} from './card'; -export type { - CardProps, - CardHeaderProps, - CardTitleProps, - CardDescriptionProps, - CardContentProps, - CardFooterProps, -} from './card'; diff --git a/packages/datum-ui/src/checkbox/checkbox.tsx b/packages/datum-ui/src/checkbox/checkbox.tsx deleted file mode 100644 index 4a4336d..0000000 --- a/packages/datum-ui/src/checkbox/checkbox.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { Checkbox as CheckboxPrimitive } from '@repo/shadcn/ui/checkbox'; - -export interface CheckboxProps - extends React.ComponentProps {} - -const Checkbox = React.forwardRef< - React.ElementRef, - CheckboxProps ->(({ className, ...props }, ref) => ( - -)); - -Checkbox.displayName = 'Checkbox'; - -export { Checkbox }; diff --git a/packages/datum-ui/src/checkbox/index.tsx b/packages/datum-ui/src/checkbox/index.tsx deleted file mode 100644 index edcc3da..0000000 --- a/packages/datum-ui/src/checkbox/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Checkbox } from './checkbox'; -export type { CheckboxProps } from './checkbox'; diff --git a/packages/datum-ui/src/combobox/combobox.tsx b/packages/datum-ui/src/combobox/combobox.tsx deleted file mode 100644 index d703a70..0000000 --- a/packages/datum-ui/src/combobox/combobox.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@repo/shadcn/ui/command'; -import { Popover, PopoverContent, PopoverTrigger } from '@repo/shadcn/ui/popover'; -import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react'; - -export interface ComboboxOption { - value: string; - label: string; - count?: number; -} - -export interface ComboboxProps { - options: ComboboxOption[]; - value: string; - onValueChange: (value: string) => void; - placeholder?: string; - searchPlaceholder?: string; - emptyMessage?: string; - disabled?: boolean; - loading?: boolean; - className?: string; - clearable?: boolean; - showAllOption?: boolean; - allOptionLabel?: string; -} - -const Combobox = ({ - options, - value, - onValueChange, - placeholder = 'Select an option...', - searchPlaceholder = 'Search...', - emptyMessage = 'No results found.', - disabled = false, - loading = false, - className, - clearable = false, - showAllOption = false, - allOptionLabel = 'All', -}: ComboboxProps) => { - const [open, setOpen] = React.useState(false); - - const selectedOption = options.find((opt) => opt.value === value); - - const handleSelect = (selectedValue: string) => { - onValueChange(selectedValue === value ? '' : selectedValue); - setOpen(false); - }; - - const handleClear = (e: React.MouseEvent) => { - e.stopPropagation(); - onValueChange(''); - }; - - const displayLabel = loading - ? 'Loading...' - : value === '' - ? placeholder - : (selectedOption?.label ?? placeholder); - - const isPlaceholder = loading || value === '' || !selectedOption; - - return ( - - {/* Wrapper div carries border/bg styling so the clear button can sit alongside the trigger */} -
- - - - {clearable && value && !loading && ( - - )} -
- - - - - {emptyMessage} - - {showAllOption && ( - { onValueChange(''); setOpen(false); }} - className="flex items-center justify-between"> - {allOptionLabel} - {value === '' && } - - )} - {options.map((option) => ( - - - {option.label} - {option.count !== undefined && ( - ({option.count}) - )} - - {value === option.value && } - - ))} - - - - -
- ); -}; - -Combobox.displayName = 'Combobox'; - -export { Combobox }; diff --git a/packages/datum-ui/src/combobox/index.tsx b/packages/datum-ui/src/combobox/index.tsx deleted file mode 100644 index b0252e3..0000000 --- a/packages/datum-ui/src/combobox/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { Combobox } from './combobox'; -export type { ComboboxOption, ComboboxProps } from './combobox'; diff --git a/packages/datum-ui/src/components/alert/README.md b/packages/datum-ui/src/components/alert/README.md new file mode 100644 index 0000000..e1c4056 --- /dev/null +++ b/packages/datum-ui/src/components/alert/README.md @@ -0,0 +1,443 @@ +# Alert Component + +A flexible alert component for displaying important messages, notifications, and status information. Extends shadcn Alert with Datum-specific variants: success, info, and warning. Built with class-variance-authority for type-safe styling. + +## Features + +- **Multiple Variants**: default, secondary, outline, destructive, success, info, warning +- **Icon Support**: Automatic icon positioning with proper spacing +- **Subcomponents**: AlertTitle and AlertDescription for structured content +- **Accessibility**: Proper ARIA role and semantic HTML +- **Dark Mode**: Full dark mode support for all variants +- **Customizable**: Supports custom className and all standard HTML div attributes + +## Usage + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { AlertCircle } from 'lucide-react'; + +// Basic usage + + Alert Title + Alert description text goes here. + + +// With icon + + + Error + Something went wrong. + + +// Success variant + + Success + Operation completed successfully. + +``` + +## Props + +### Alert + +| Prop | Type | Default | Description | +| ----------- | -------------------------------------------------------------------------------------------- | ----------- | ------------------------------------- | +| `variant` | `'default' \| 'secondary' \| 'outline' \| 'destructive' \| 'success' \| 'info' \| 'warning'` | `'default'` | Alert variant/style | +| `closable` | `boolean` | `false` | Whether the alert can be closed | +| `onClose` | `() => void` | - | Callback when close button is clicked | +| `className` | `string` | - | Additional CSS classes | +| `...props` | `React.HTMLAttributes` | - | All standard HTML div attributes | + +### AlertTitle + +| Prop | Type | Default | Description | +| ----------- | -------------------------------------- | ------- | ----------------------- | +| `className` | `string` | - | Additional CSS classes | +| `...props` | `React.HTMLAttributes` | - | Standard div attributes | + +### AlertDescription + +| Prop | Type | Default | Description | +| ----------- | -------------------------------------- | ------- | ----------------------- | +| `className` | `string` | - | Additional CSS classes | +| `...props` | `React.HTMLAttributes` | - | Standard div attributes | + +## Variants + +### Default + +Standard alert with neutral styling. + +```tsx + + Default Alert + This is a default alert message. + +``` + +### Secondary + +Secondary alert with muted styling. + +```tsx + + Secondary Alert + This is a secondary alert message. + +``` + +### Outline + +Bordered alert with transparent background. + +```tsx + + Outline Alert + This is an outline alert message. + +``` + +### Destructive + +Used for error messages and critical alerts. + +```tsx + + + Error + This is a destructive alert message. + +``` + +### Success + +Used for success messages and positive feedback. + +```tsx + + Success + This is a success alert message. + +``` + +### Info + +Used for informational messages. + +```tsx + + Information + This is an info alert message. + +``` + +### Warning + +Used for warning messages and cautionary alerts. + +```tsx + + Warning + This is a warning alert message. + +``` + +## Examples + +### Basic Alerts + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; + +function BasicAlerts() { + return ( +
+ + Default Alert + This is a default alert message. + + + Secondary Alert + This is a secondary alert message. + +
+ ); +} +``` + +### Alerts with Icons + +Icons are automatically positioned when placed as the first child of the Alert component. + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { AlertCircle, CheckCircle, Info, TriangleAlert } from 'lucide-react'; + +function IconAlerts() { + return ( +
+ + + Error + An error occurred while processing your request. + + + + Success + Your changes have been saved successfully. + + + + Information + Here's some helpful information for you. + + + + Warning + Please review this before proceeding. + +
+ ); +} +``` + +### Status Alerts + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +function StatusAlerts() { + return ( +
+ + + Operation Successful + Your request has been processed successfully. + + + + Operation Failed + There was an error processing your request. + + + + Action Required + Please verify your information before continuing. + +
+ ); +} +``` + +### Alerts with Rich Content + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { AlertCircle } from 'lucide-react'; + +function RichContentAlerts() { + return ( + + + Domain Validation Errors + +
+

The following issues must be resolved:

+
    +
  • DNS records are not configured correctly
  • +
  • HTTP verification token is missing
  • +
+
+
+
+ ); +} +``` + +### Alerts without Title + +```tsx +import { Alert, AlertDescription } from '@datum-ui/components'; + +function SimpleAlerts() { + return ( +
+ + This is a simple alert without a title. + + + Operation completed successfully. + +
+ ); +} +``` + +### Closable Alerts + +Alerts can be made closable by setting the `closable` prop to `true` and providing an `onClose` callback. + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { AlertCircle } from 'lucide-react'; +import { useState } from 'react'; + +function ClosableAlerts() { + const [showAlert, setShowAlert] = useState(true); + + if (!showAlert) return null; + + return ( + setShowAlert(false)}> + + Dismissible Alert + + This alert can be closed by clicking the X button in the top-right corner. + + + ); +} +``` + +Or with multiple closable alerts: + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +import { CheckCircle, AlertCircle } from 'lucide-react'; +import { useState } from 'react'; + +function MultipleClosableAlerts() { + const [alerts, setAlerts] = useState([ + { id: 1, variant: 'success' as const, message: 'Operation completed successfully' }, + { id: 2, variant: 'warning' as const, message: 'Please review your settings' }, + ]); + + const removeAlert = (id: number) => { + setAlerts((prev) => prev.filter((alert) => alert.id !== id)); + }; + + return ( +
+ {alerts.map((alert) => ( + removeAlert(alert.id)}> + {alert.variant === 'success' ? ( + + ) : ( + + )} + {alert.variant === 'success' ? 'Success' : 'Warning'} + {alert.message} + + ))} +
+ ); +} +``` + +### Custom Styling + +```tsx +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; + +function CustomStyledAlerts() { + return ( +
+ + Custom Styled Alert + + This alert has custom styling applied. + + +
+ ); +} +``` + +## Icon Positioning + +Icons are automatically positioned when placed as the first child of the Alert component. The component uses CSS selectors to: + +- Position icons absolutely in the top-left corner +- Add left padding to content after icons +- Adjust vertical alignment for proper spacing + +```tsx + + {/* Icon must be first child */} + Title + Description + +``` + +## Styling + +The alert component uses Tailwind CSS classes and supports: + +- **Custom Classes**: Add additional classes via the `className` prop +- **Dark Mode**: Automatic dark mode support for all variants +- **Icon Styling**: Icons automatically inherit variant colors +- **Responsive**: Full-width by default, adapts to container + +### Custom Styling + +```tsx + + Custom Alert + Custom styled description. + +``` + +## Accessibility + +The alert component includes: + +- Proper `role="alert"` attribute for screen readers +- Semantic HTML structure with title and description +- Icon color inheritance for visual consistency +- Keyboard navigation support +- Screen reader compatibility + +## Dependencies + +- `class-variance-authority`: For type-safe variant styling +- `@shadcn/lib/utils`: For class name utilities + +## Migration from shadcn Alert + +The Datum Alert component extends the shadcn Alert with additional variants. If migrating: + +1. Replace imports with the Datum Alert module +2. Update variant names if needed (e.g., use `success`, `info`, `warning` variants) +3. Icons work the same way - place as first child +4. Test all alert variants and dark mode + +```tsx +// Old (shadcn) +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'; +... + +// New (Datum) +import { Alert, AlertTitle, AlertDescription } from '@datum-ui/components'; +... +// Or use new variants +... +... +... +``` + +## Notes + +- Icons must be placed as the first child of the Alert component for proper positioning +- The component uses `w-full` by default, so alerts take full width of their container +- AlertTitle and AlertDescription are optional - you can use just one or neither +- The component automatically handles spacing when icons are present +- All variants support dark mode with appropriate color adjustments +- Alerts are not closable by default - set `closable={true}` and provide an `onClose` callback to enable the close button +- The close button is positioned in the top-right corner and inherits the alert's variant color +- When closable, the alert automatically adds right padding to accommodate the close button diff --git a/packages/datum-ui/src/alert/alert.tsx b/packages/datum-ui/src/components/alert/alert.tsx similarity index 73% rename from packages/datum-ui/src/alert/alert.tsx rename to packages/datum-ui/src/components/alert/alert.tsx index a14d4ba..6e76a19 100644 --- a/packages/datum-ui/src/alert/alert.tsx +++ b/packages/datum-ui/src/components/alert/alert.tsx @@ -1,7 +1,13 @@ -import { cn } from '@repo/shadcn/lib/utils'; -import { type VariantProps, cva } from 'class-variance-authority'; -import { CircleXIcon } from 'lucide-react'; -import * as React from 'react'; +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@repo/shadcn/lib/utils' +import { cva } from 'class-variance-authority' +import { CircleXIcon } from 'lucide-react' +import * as React from 'react' + +/** + * Datum Alert Component + * Extends shadcn Alert with Datum-specific variants: success, info, warning + */ // Variant definitions - both classes and close button color in one place const variantDefinitions = { @@ -34,7 +40,7 @@ const variantDefinitions = { classes: 'border-yellow-500 bg-yellow-50 text-yellow-700! [&>svg]:text-yellow-700', closeButtonColor: 'text-yellow-700', }, -} as const; +} as const const alertVariants = cva( 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', @@ -53,43 +59,44 @@ const alertVariants = cva( defaultVariants: { variant: 'default', }, - } -); + }, +) export interface AlertProps - extends React.ComponentProps<'div'>, - VariantProps { + extends React.ComponentProps<'div'>, VariantProps { /** * Whether the alert can be closed. When true, a close button is displayed. * @default false */ - closable?: boolean; + closable?: boolean /** * Callback function called when the close button is clicked. */ - onClose?: () => void; + onClose?: () => void } -const Alert = ({ className, variant, closable = false, onClose, ...props }: AlertProps) => { - const [isVisible, setIsVisible] = React.useState(true); +function Alert({ className, variant, closable = false, onClose, ...props }: AlertProps) { + const [isVisible, setIsVisible] = React.useState(true) const handleClose = () => { if (onClose) { - onClose(); - } else { - setIsVisible(false); + onClose() } - }; + else { + setIsVisible(false) + } + } if (!isVisible) { - return null; + return null } return (
+ {...props} + > {props.children} {closable && ( { if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleClose(); + e.preventDefault() + handleClose() } }} className="absolute top-4 right-4 z-10 cursor-pointer opacity-70 transition-opacity hover:opacity-100" - aria-label="Close alert"> + aria-label="Close alert" + > )}
- ); -}; + ) +} -const AlertTitle = ({ className, ...props }: React.ComponentProps<'div'>) => { +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { return (
- ); -}; + ) +} -const AlertDescription = ({ className, ...props }: React.ComponentProps<'div'>) => { +function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) { return (
- ); -}; + ) +} -export { Alert, AlertDescription, AlertTitle, alertVariants }; +export { Alert, AlertDescription, AlertTitle } diff --git a/packages/datum-ui/src/components/alert/index.ts b/packages/datum-ui/src/components/alert/index.ts new file mode 100644 index 0000000..17dc897 --- /dev/null +++ b/packages/datum-ui/src/components/alert/index.ts @@ -0,0 +1 @@ +export * from './alert' diff --git a/packages/datum-ui/src/components/autocomplete/autocomplete.tsx b/packages/datum-ui/src/components/autocomplete/autocomplete.tsx new file mode 100644 index 0000000..10cd801 --- /dev/null +++ b/packages/datum-ui/src/components/autocomplete/autocomplete.tsx @@ -0,0 +1,459 @@ +import type { + AutocompleteGroup, + AutocompleteOption, + AutocompleteProps, +} from './autocomplete.types' +import { cn } from '@repo/shadcn/lib/utils' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@repo/shadcn/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@repo/shadcn/ui/popover' +import { useVirtualizer } from '@tanstack/react-virtual' +import { CheckIcon, ChevronDown } from 'lucide-react' +import * as React from 'react' +import { LoaderOverlay } from '../loader-overlay' + +// ============================================================================ +// Helper: detect grouped options +// ============================================================================ + +function isGroupedOptions( + options: T[] | AutocompleteGroup[], +): options is AutocompleteGroup[] { + return options.length > 0 && 'options' in options[0]! +} + +function flattenOptions(options: T[] | AutocompleteGroup[]): T[] { + if (isGroupedOptions(options)) { + return options.flatMap(g => g.options) + } + return options +} + +// ============================================================================ +// Trigger +// ============================================================================ + +interface TriggerProps { + selectedOption: AutocompleteOption | undefined + renderValue?: (option: any) => React.ReactNode + placeholder: string + loading: boolean + disabled: boolean + open: boolean + id?: string + className?: string +} + +function Trigger({ ref, selectedOption, renderValue, placeholder, loading, disabled, open, id, className, ...rest }: TriggerProps & { ref?: React.RefObject }) { + let displayContent: React.ReactNode + if (!selectedOption) { + displayContent = {placeholder} + } + else if (renderValue) { + displayContent = renderValue(selectedOption) + } + else { + displayContent = {selectedOption.label} + } + + return ( + + ) +} + +Trigger.displayName = 'AutocompleteTrigger' + +// ============================================================================ +// Default Option Renderer +// ============================================================================ + +function DefaultOptionContent({ + option, + isSelected, +}: { + option: T + isSelected: boolean +}) { + return ( +
+
+ {option.label} + {option.description && ( +

{option.description}

+ )} +
+ {isSelected && } +
+ ) +} + +// ============================================================================ +// StaticOptions +// ============================================================================ + +interface OptionsRendererProps { + options: T[] | AutocompleteGroup[] + selectedValue: string | undefined + onSelect: (value: string) => void + renderOption?: (option: T, isSelected: boolean) => React.ReactNode +} + +function StaticOptions({ + options, + selectedValue, + onSelect, + renderOption, +}: OptionsRendererProps) { + const renderItem = (option: T) => { + const isSelected = option.value === selectedValue + return ( + onSelect(option.value)} + className="cursor-pointer justify-between px-3 py-2 text-xs" + > + {renderOption + ? ( + renderOption(option, isSelected) + ) + : ( + + )} + + ) + } + + if (isGroupedOptions(options)) { + return ( + <> + {options.map((group, index) => ( + 0 ? 'border-t pt-1' : ''} + > + {group.options.map(renderItem)} + + ))} + + ) + } + + return {(options as T[]).map(renderItem)} +} + +// ============================================================================ +// VirtualizedOptions +// ============================================================================ + +function VirtualizedOptions({ + options, + selectedValue, + onSelect, + renderOption, + itemSize = 36, + listClassName, +}: OptionsRendererProps & { itemSize?: number, listClassName?: string }) { + const flatOptions = flattenOptions(options) + const parentRef = React.useRef(null) + + const virtualizer = useVirtualizer({ + count: flatOptions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => itemSize, + }) + + // Scroll to selected item on open + React.useEffect(() => { + if (selectedValue) { + const index = flatOptions.findIndex(o => o.value === selectedValue) + if (index >= 0) { + virtualizer.scrollToIndex(index, { align: 'center' }) + } + } + }, [selectedValue, flatOptions, virtualizer]) + + return ( +
+ +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const option = flatOptions[virtualItem.index]! + const isSelected = option.value === selectedValue + + return ( + onSelect(option.value)} + className="absolute top-0 left-0 w-full cursor-pointer justify-between px-3 py-2 text-xs" + style={{ + height: `${virtualItem.size}px`, + transform: `translateY(${virtualItem.start}px)`, + }} + > + {renderOption + ? ( + renderOption(option, isSelected) + ) + : ( + + )} + + ) + })} +
+
+
+ ) +} + +// ============================================================================ +// Autocomplete (Main Component) +// ============================================================================ + +/** + * Autocomplete - A searchable select component + * + * Standalone, form-agnostic combobox built on Popover + Command (cmdk). + * Supports flat/grouped options, virtualization, custom rendering, and async search. + * + * @example Basic usage + * ```tsx + * + * ``` + * + * @example Async search + * ```tsx + * + * ``` + */ +export function Autocomplete({ + options, + value, + onValueChange, + onSearchChange, + searchPlaceholder = 'Search...', + disableSearch = false, + renderOption, + renderValue, + placeholder = 'Select...', + emptyContent = 'No results found', + footer, + creatable = false, + creatableLabel, + virtualize = false, + itemSize = 36, + loading = false, + disabled = false, + name, + id, + className, + triggerClassName, + contentClassName, + listClassName, +}: AutocompleteProps) { + const [open, setOpen] = React.useState(false) + const [search, setSearch] = React.useState('') + + const flatOptions = React.useMemo(() => flattenOptions(options), [options]) + const selectedOption = React.useMemo( + () => flatOptions.find(o => o.value === value), + [flatOptions, value], + ) + + // When creatable and value doesn't match any option, show raw value in trigger + const displayOption = React.useMemo(() => { + if (selectedOption) + return selectedOption + if (creatable && value) + return { value, label: value } as T + return undefined + }, [selectedOption, creatable, value]) + + // External search mode when onSearchChange is provided + const isExternalSearch = !!onSearchChange + + // Creatable item visibility + const trimmedSearch = React.useMemo(() => search.trim(), [search]) + const showCreatableItem = React.useMemo(() => { + if (!creatable || trimmedSearch.length === 0) + return false + const needle = trimmedSearch.toLowerCase() + return !flatOptions.some( + o => o.value.toLowerCase() === needle || o.label.toLowerCase() === needle, + ) + }, [creatable, trimmedSearch, flatOptions]) + + const handleSelect = React.useCallback( + (optionValue: string) => { + onValueChange?.(optionValue) + setSearch('') + setOpen(false) + }, + [onValueChange], + ) + + const handleCreatableSelect = React.useCallback(() => { + onValueChange?.(trimmedSearch) + setSearch('') + setOpen(false) + }, [onValueChange, trimmedSearch]) + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen) + if (!nextOpen) { + setSearch('') + if (isExternalSearch) + onSearchChange?.('') + } + }, + [isExternalSearch, onSearchChange], + ) + + const handleSearchChange = React.useCallback( + (val: string) => { + setSearch(val) + if (isExternalSearch) + onSearchChange?.(val) + }, + [isExternalSearch, onSearchChange], + ) + + return ( +
+ + + + + + + {!disableSearch && ( + + )} + + {!showCreatableItem && ( + + {typeof emptyContent === 'string' + ? ( + {emptyContent} + ) + : ( + emptyContent + )} + + )} + + {virtualize + ? ( + + ) + : ( + + )} + + {showCreatableItem && ( + + + {creatableLabel ? creatableLabel(trimmedSearch) : `Use "${trimmedSearch}"`} + + + )} + + + {footer &&
{footer}
} +
+
+
+ + {/* Hidden input for non-Conform usage (plain HTML forms) */} + {name && } +
+ ) +} + +Autocomplete.displayName = 'Autocomplete' diff --git a/packages/datum-ui/src/components/autocomplete/autocomplete.types.ts b/packages/datum-ui/src/components/autocomplete/autocomplete.types.ts new file mode 100644 index 0000000..7924eff --- /dev/null +++ b/packages/datum-ui/src/components/autocomplete/autocomplete.types.ts @@ -0,0 +1,102 @@ +import type * as React from 'react' + +// ============================================================================ +// Option Types +// ============================================================================ + +export interface AutocompleteOption { + /** Unique identifier, submitted to form */ + value: string + /** Display text, used for built-in search filtering */ + label: string + /** Optional secondary text shown below label */ + description?: string + /** Disable selection of this option */ + disabled?: boolean +} + +export interface AutocompleteGroup { + /** Group heading label */ + label: string + /** Options within this group */ + options: T[] +} + +// ============================================================================ +// Primitive Props +// ============================================================================ + +export interface AutocompleteProps { + // Data + /** Flat options array or grouped options. Groups auto-detected by shape. */ + options: T[] | AutocompleteGroup[] + + // Controlled value + /** Currently selected option value */ + value?: string + /** Called when selection changes */ + onValueChange?: (value: string) => void + + // Search + /** When provided, disables built-in filtering and delegates to consumer. */ + onSearchChange?: (value: string) => void + /** Placeholder for the search input */ + searchPlaceholder?: string + /** Hide the search input entirely */ + disableSearch?: boolean + + // Rendering + /** Custom render for each option in the dropdown */ + renderOption?: (option: T, isSelected: boolean) => React.ReactNode + /** Custom render for the selected value in the trigger */ + renderValue?: (option: T) => React.ReactNode + /** Placeholder text when no value selected */ + placeholder?: string + /** Content shown when no options match */ + emptyContent?: React.ReactNode + /** Footer content below options list (e.g., "Add new..." action) */ + footer?: React.ReactNode + + // Behavior + /** Allow custom values not in the options list */ + creatable?: boolean + /** Custom render for the creatable item. Default: (value) => `Use "${value}"` */ + creatableLabel?: (value: string) => React.ReactNode + /** Enable virtualization for large lists (default: false) */ + virtualize?: boolean + /** Item height in pixels for virtualizer estimateSize (default: 36) */ + itemSize?: number + /** External loading state */ + loading?: boolean + + // State + /** Disable the component */ + disabled?: boolean + /** HTML name attribute for hidden input */ + name?: string + /** HTML id attribute */ + id?: string + + // Styling + /** Additional CSS classes for the root wrapper */ + className?: string + /** Additional CSS classes for the trigger button */ + triggerClassName?: string + /** Additional CSS classes for the popover content */ + contentClassName?: string + /** Additional CSS classes for the command list */ + listClassName?: string +} + +// ============================================================================ +// Form Component Props +// ============================================================================ + +/** + * Props for Form.Autocomplete - same as the primitive but without + * value/onValueChange/name/id which come from FieldContext + useInputControl. + */ +export type FormAutocompleteProps = Omit< + AutocompleteProps, + 'value' | 'onValueChange' | 'name' | 'id' +> diff --git a/packages/datum-ui/src/components/autocomplete/index.ts b/packages/datum-ui/src/components/autocomplete/index.ts new file mode 100644 index 0000000..a1a2e3e --- /dev/null +++ b/packages/datum-ui/src/components/autocomplete/index.ts @@ -0,0 +1,7 @@ +export { Autocomplete } from './autocomplete' +export type { + AutocompleteGroup, + AutocompleteOption, + AutocompleteProps, + FormAutocompleteProps, +} from './autocomplete.types' diff --git a/packages/datum-ui/src/components/avatar-stack/avatar-stack.tsx b/packages/datum-ui/src/components/avatar-stack/avatar-stack.tsx new file mode 100644 index 0000000..8c0023c --- /dev/null +++ b/packages/datum-ui/src/components/avatar-stack/avatar-stack.tsx @@ -0,0 +1,95 @@ +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@repo/shadcn/lib/utils' +import { Avatar, AvatarFallback, AvatarImage } from '@repo/shadcn/ui/avatar' +import { cva } from 'class-variance-authority' +import * as React from 'react' +import { Tooltip } from '../tooltip/tooltip' + +const avatarStackVariants = cva('flex', { + variants: { + orientation: { + vertical: 'flex-row', + horizontal: 'flex-col', + }, + spacing: { + sm: '-space-x-5 -space-y-5', + md: '-space-x-4 -space-y-4', + lg: '-space-x-3 -space-y-3', + xl: '-space-x-2 -space-y-2', + }, + }, + defaultVariants: { + orientation: 'vertical', + spacing: 'md', + }, +}) + +export interface AvatarStackProps + extends React.HTMLAttributes, VariantProps { + avatars: { name: string, image: string }[] + maxAvatarsAmount?: number + avatarClassName?: string +} + +function AvatarStack({ + className, + orientation, + avatars, + spacing, + maxAvatarsAmount = 3, + avatarClassName, + ...props +}: AvatarStackProps) { + const shownAvatars = avatars.slice(0, maxAvatarsAmount) + const hiddenAvatars = avatars.slice(maxAvatarsAmount) + + return ( +
+ {shownAvatars.map(({ name, image }, index) => ( + + + + + {name + ?.split(' ') + ?.map(word => word[0]) + ?.join('') + ?.toUpperCase()} + + + + ))} + + {hiddenAvatars.length + ? ( + + {hiddenAvatars.map(({ name }, index) => ( +

{name}

+ ))} + + )} + delayDuration={300} + > + + + + + {avatars.length - shownAvatars.length} + + +
+ ) + : null} +
+ ) +} + +export { AvatarStack, avatarStackVariants } diff --git a/packages/datum-ui/src/components/avatar-stack/index.ts b/packages/datum-ui/src/components/avatar-stack/index.ts new file mode 100644 index 0000000..694a107 --- /dev/null +++ b/packages/datum-ui/src/components/avatar-stack/index.ts @@ -0,0 +1 @@ +export * from './avatar-stack' diff --git a/packages/datum-ui/src/components/badge/README.md b/packages/datum-ui/src/components/badge/README.md new file mode 100644 index 0000000..98ceca8 --- /dev/null +++ b/packages/datum-ui/src/components/badge/README.md @@ -0,0 +1,317 @@ +# Badge Component + +A flexible badge component for displaying labels, status indicators, and tags. Built with class-variance-authority for type-safe styling. Similar to Button component but with badge-specific styling. + +## Features + +- **Multiple Types**: primary, secondary, tertiary, quaternary, warning, danger, success +- **Theme Variants**: solid, outline, light +- **Accessibility**: Proper focus states and keyboard navigation +- **Dark Mode**: Full dark mode support for all variants +- **Customizable**: Supports custom className and all standard HTML div attributes + +## Usage + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; + +// Basic usage +New + +// With types and themes +Primary +Secondary +Danger +Success + +// With custom content +Custom Content +``` + +## Props + +### BadgeProps + +| Prop | Type | Default | Description | +| ----------- | ---------------------------------------------------------------------------------------------- | ----------- | -------------------------------- | +| `type` | `'primary' \| 'secondary' \| 'tertiary' \| 'quaternary' \| 'warning' \| 'danger' \| 'success'` | `'primary'` | Badge type/variant | +| `theme` | `'solid' \| 'outline' \| 'light'` | `'light'` | Badge theme/style | +| `className` | `string` | - | Additional CSS classes | +| `children` | `React.ReactNode` | - | Badge content | +| `...props` | `React.HTMLAttributes` | - | All standard HTML div attributes | + +## Variants + +### Types + +#### Primary + +The main badge type, typically used for primary labels and tags. + +```tsx +Primary +``` + +#### Secondary + +Used for secondary labels or as an alternative to primary badges. + +```tsx +Secondary +``` + +#### Tertiary + +Used for less important labels or navigation. + +```tsx +Tertiary +``` + +#### Quaternary + +Used for additional label variations. + +```tsx +Quaternary +``` + +#### Warning + +Used for warnings or caution indicators. + +```tsx +Warning +``` + +#### Danger + +Used for error states or destructive indicators. + +```tsx +Error +``` + +#### Success + +Used for positive or successful indicators. + +```tsx +Success +``` + +### Themes + +#### Solid + +Default filled badge style with opaque background. + +```tsx +Solid Badge +``` + +#### Outline + +Bordered badge with transparent background, suitable for subtle labels. + +```tsx +Outline Badge +``` + +#### Light + +Badge with semi-transparent background (20% opacity) matching the type color. + +```tsx +Light Badge +``` + +## Examples + +### Basic Badges + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; + +function BadgeExamples() { + return ( +
+ Primary + Secondary + Tertiary + Danger + Success +
+ ); +} +``` + +### Badge Themes + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; + +function BadgeThemes() { + return ( +
+ + Solid + + + Outline + + + Light + +
+ ); +} +``` + +### Status Indicators + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; + +function StatusBadges() { + return ( +
+ Active + Pending + Failed + Completed + Warning +
+ ); +} +``` + +### With Icons + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; + +function IconBadges() { + return ( +
+ + + Success + + + + Error + + + + Warning + +
+ ); +} +``` + +### Custom Styling + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; + +function CustomBadges() { + return ( +
+ + Custom Padding + + + Custom Border + + + With Shadow + +
+ ); +} +``` + +### Interactive Badges + +```tsx +import { Badge } from '@/modules/datum-ui/badge'; +import { X } from 'lucide-react'; + +function InteractiveBadges() { + const handleRemove = () => { + // Handle badge removal + }; + + return ( +
+ + Removable + + +
+ ); +} +``` + +## Styling + +The badge component uses Tailwind CSS classes and supports: + +- **Custom Classes**: Add additional classes via the `className` prop +- **Dark Mode**: Automatic dark mode support for all variants +- **Focus States**: Proper focus ring styling for accessibility +- **Transitions**: Smooth transition effects for state changes +- **No Hover States**: Badges don't have hover effects by default (unlike buttons) + +### Custom Styling + +```tsx + + Custom Styled Badge + +``` + +## Accessibility + +The badge component includes: + +- Proper focus states with visible focus rings +- Semantic HTML structure +- Screen reader compatibility +- Keyboard navigation support (when interactive) + +## Dependencies + +- `class-variance-authority`: For type-safe variant styling +- `@shadcn/lib/utils`: For class name utilities + +## Migration from Other Badge Components + +If migrating from other badge components: + +1. Replace imports with the new badge module +2. Update prop names from `variant` to `type` and `theme` +3. Adjust styling classes if custom styles were applied +4. Test all badge types and themes + +```tsx +// Old +import { Badge } from '@/components/badge'; +Label + +// New +import { Badge } from '@datum-ui/badge'; +Label +``` + +## Notes + +- The badge component is similar to the Button component but with badge-specific styling +- Badges use `rounded-md` borders (not fully rounded like pills) +- Badges don't have hover effects by default +- The light theme uses 20% opacity backgrounds for a subtle tinted appearance +- Badges are inline-flex elements and will wrap naturally in text flows +- Default theme is `light` for a subtle appearance diff --git a/packages/datum-ui/src/components/badge/badge.tsx b/packages/datum-ui/src/components/badge/badge.tsx new file mode 100644 index 0000000..cce63e7 --- /dev/null +++ b/packages/datum-ui/src/components/badge/badge.tsx @@ -0,0 +1,229 @@ +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@repo/shadcn/lib/utils' +import { cva } from 'class-variance-authority' +import * as React from 'react' + +/** + * Datum Badge Component + * Similar to Button component but with badge-specific styling + * Supports types: primary, secondary, tertiary, quaternary, warning, danger, success + * Supports themes: solid, outline, light + */ + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-all focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + type: { + primary: '', + secondary: '', + tertiary: '', + quaternary: '', + info: '', + warning: '', + danger: '', + success: '', + muted: '', + }, + theme: { + solid: '', + outline: 'border', + light: 'border', + }, + }, + compoundVariants: [ + // Primary badge variants + { + type: 'primary', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-primary)] text-[var(--color-badge-primary-foreground)]', + }, + { + type: 'primary', + theme: 'outline', + className: + 'border-[var(--color-badge-primary)] text-[var(--color-badge-primary)] dark:border-[var(--color-badge-primary)] dark:text-[var(--color-badge-primary)]', + }, + { + type: 'primary', + theme: 'light', + className: + 'border-[var(--color-badge-primary)]/30 text-[var(--color-badge-primary)] bg-[var(--color-badge-primary)]/10 dark:border-[var(--color-badge-primary)] dark:text-[var(--color-badge-primary)] dark:bg-[var(--color-badge-primary)]/20', + }, + + // Secondary badge variants + { + type: 'secondary', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-secondary)] text-[var(--color-badge-secondary-foreground)]', + }, + { + type: 'secondary', + theme: 'outline', + className: + 'border-[var(--color-badge-secondary)] text-[var(--color-badge-secondary)] dark:border-[var(--color-badge-secondary)] dark:text-[var(--color-badge-secondary)]', + }, + { + type: 'secondary', + theme: 'light', + className: + 'border-[var(--color-badge-secondary)] text-[var(--color-badge-secondary)] bg-[var(--color-badge-secondary)]/20 dark:border-[var(--color-badge-secondary)] dark:text-[var(--color-badge-secondary)] dark:bg-[var(--color-badge-secondary)]/20', + }, + + // Tertiary badge variants + { + type: 'tertiary', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-tertiary)] text-[var(--color-badge-tertiary-foreground)]', + }, + { + type: 'tertiary', + theme: 'outline', + className: + 'border-[var(--color-badge-tertiary)] text-[var(--color-badge-tertiary)] dark:border-[var(--color-badge-tertiary)] dark:text-[var(--color-badge-tertiary)]', + }, + { + type: 'tertiary', + theme: 'light', + className: + 'border-[var(--color-badge-tertiary)] text-[var(--color-badge-tertiary)] bg-[var(--color-badge-tertiary)]/20 dark:border-[var(--color-badge-tertiary)] dark:text-[var(--color-badge-tertiary)] dark:bg-[var(--color-badge-tertiary)]/20', + }, + + // Quaternary badge variants + { + type: 'quaternary', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-quaternary)] text-[var(--color-badge-quaternary-foreground)]', + }, + { + type: 'quaternary', + theme: 'outline', + className: + 'border-[var(--color-badge-quaternary)] text-[var(--color-badge-quaternary-foreground)] dark:border-[var(--color-badge-quaternary)] dark:text-[var(--color-badge-quaternary-foreground)]', + }, + { + type: 'quaternary', + theme: 'light', + className: + 'border-[var(--color-badge-quaternary)] text-[var(--color-badge-quaternary-foreground)] bg-[var(--color-badge-quaternary)]/20 dark:border-[var(--color-badge-quaternary)] dark:text-[var(--color-badge-quaternary-foreground)] dark:bg-[var(--color-badge-quaternary)]/20', + }, + + // Info badge variants + { + type: 'info', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-info)] text-[var(--color-badge-info-foreground)]', + }, + { + type: 'info', + theme: 'outline', + className: + 'border-[var(--color-badge-info)] text-[var(--color-badge-info)] dark:border-[var(--color-badge-info)] dark:text-[var(--color-badge-info)]', + }, + { + type: 'info', + theme: 'light', + className: + 'border-[var(--color-badge-info)] text-[var(--color-badge-info)] bg-[var(--color-badge-info)]/20 dark:border-[var(--color-badge-info)] dark:text-[var(--color-badge-info)] dark:bg-[var(--color-badge-info)]/20', + }, + + // Warning badge variants + { + type: 'warning', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-warning)] text-[var(--color-badge-warning-foreground)]', + }, + { + type: 'warning', + theme: 'outline', + className: + 'border-[var(--color-badge-warning)] text-[var(--color-badge-warning)] dark:border-[var(--color-badge-warning)] dark:text-[var(--color-badge-warning)]', + }, + { + type: 'warning', + theme: 'light', + className: + 'border-[var(--color-badge-warning)] text-[var(--color-badge-warning)] bg-[var(--color-badge-warning)]/20 dark:border-[var(--color-badge-warning)] dark:text-[var(--color-badge-warning)] dark:bg-[var(--color-badge-warning)]/20', + }, + + // Danger badge variants + { + type: 'danger', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-danger)] text-[var(--color-badge-danger-foreground)]', + }, + { + type: 'danger', + theme: 'outline', + className: + 'border-[var(--color-badge-danger)] text-[var(--color-badge-danger)] dark:border-[var(--color-badge-danger)] dark:text-[var(--color-badge-danger)]', + }, + { + type: 'danger', + theme: 'light', + className: + 'border-[var(--color-badge-danger)] text-[var(--color-badge-danger)] bg-[var(--color-badge-danger)]/20 dark:border-[var(--color-badge-danger)] dark:text-[var(--color-badge-danger)] dark:bg-[var(--color-badge-danger)]/20', + }, + + // Success badge variants + { + type: 'success', + theme: 'solid', + className: + 'border-transparent bg-[var(--color-badge-success)] text-[var(--color-badge-success-foreground)]', + }, + { + type: 'success', + theme: 'outline', + className: + 'border-[var(--color-badge-success)] text-[var(--color-badge-success)] dark:border-[var(--color-badge-success)] dark:text-[var(--color-badge-success)]', + }, + { + type: 'success', + theme: 'light', + className: + 'border-[var(--color-badge-success)] text-[var(--color-badge-success)] bg-[var(--color-badge-success)]/20 dark:border-[var(--color-badge-success)] dark:text-[var(--color-badge-success)] dark:bg-[var(--color-badge-success)]/20', + }, + + // Muted badge variants + { + type: 'muted', + theme: 'solid', + className: + 'border-transparent text-[var(--color-badge-muted-foreground)] bg-[var(--color-badge-muted)] dark:border-[var(--color-badge-muted)]/20 dark:text-[var(--color-badge-muted)] dark:bg-[var(--color-badge-muted)]/20', + }, + { + type: 'muted', + theme: 'outline', + className: + 'border-[var(--color-badge-muted)] text-[var(--color-badge-muted)] dark:border-[var(--color-badge-muted)] dark:text-[var(--color-badge-muted)]', + }, + { + type: 'muted', + theme: 'light', + className: + 'border-[var(--color-badge-muted)] text-[var(--color-badge-muted)] bg-[var(--color-badge-muted)]/20 dark:border-[var(--color-badge-muted)] dark:text-[var(--color-badge-muted)] dark:bg-[var(--color-badge-muted)]/20', + }, + ], + defaultVariants: { + type: 'muted', + theme: 'solid', + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, VariantProps {} + +function Badge({ className, type, theme, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/packages/datum-ui/src/components/badge/index.ts b/packages/datum-ui/src/components/badge/index.ts new file mode 100644 index 0000000..883be94 --- /dev/null +++ b/packages/datum-ui/src/components/badge/index.ts @@ -0,0 +1 @@ +export * from './badge' diff --git a/packages/datum-ui/src/button/README.md b/packages/datum-ui/src/components/button/README.md similarity index 99% rename from packages/datum-ui/src/button/README.md rename to packages/datum-ui/src/components/button/README.md index 450e49e..2fe9432 100644 --- a/packages/datum-ui/src/button/README.md +++ b/packages/datum-ui/src/components/button/README.md @@ -345,5 +345,5 @@ If migrating from other button components: import { Button } from '@/components/button/button-enhanced'; // New -import { Button } from '@datum-cloud/datum-ui'; +import { Button } from '@datum-ui/button'; ``` diff --git a/packages/datum-ui/src/button/button.tsx b/packages/datum-ui/src/components/button/button.tsx similarity index 79% rename from packages/datum-ui/src/button/button.tsx rename to packages/datum-ui/src/components/button/button.tsx index 8f7b4f5..b14840a 100644 --- a/packages/datum-ui/src/button/button.tsx +++ b/packages/datum-ui/src/components/button/button.tsx @@ -1,8 +1,8 @@ -import * as React from 'react'; -import { cn } from '@repo/shadcn/lib/utils'; -import { cva, type VariantProps } from 'class-variance-authority'; -import { SpinnerIcon } from '../icons'; - +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@repo/shadcn/lib/utils' +import { cva } from 'class-variance-authority' +import * as React from 'react' +import { SpinnerIcon } from '../icons/spinner-icon' const buttonVariants = cva( 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50', @@ -226,82 +226,72 @@ const buttonVariants = cva( size: 'default', block: false, }, - } -); + }, +) export interface ButtonProps extends - Omit, 'type'>, - VariantProps { - asChild?: boolean; - loading?: boolean; - icon?: React.ReactNode; - iconPosition?: 'left' | 'right'; - loadingIcon?: React.ReactNode; - htmlType?: 'button' | 'submit' | 'reset'; + Omit, 'type'>, + VariantProps { + asChild?: boolean + loading?: boolean + icon?: React.ReactNode + iconPosition?: 'left' | 'right' + loadingIcon?: React.ReactNode + htmlType?: 'button' | 'submit' | 'reset' } -const Button = React.forwardRef( - ( - { - className, - type, - theme, - size, - block, - loading = false, - disabled, - icon, - iconPosition = 'left', - loadingIcon, - htmlType = 'button', - children, - ...props - }, - ref - ) => { - const isDisabled = disabled || loading; +function Button({ ref, className, type, theme, size, block, loading = false, disabled, icon, iconPosition = 'left', loadingIcon, htmlType = 'button', children, ...props }: ButtonProps & { ref?: React.RefObject }) { + const isDisabled = disabled || loading - // Auto-detect icon-only buttons and adjust to square - const isIconOnly = (icon || loading) && !children; + // Auto-detect icon-only buttons and adjust to square + const isIconOnly = (icon || loading) && !children - // For icon-only buttons, replace icon with loading spinner when loading - const showIcon = !loading && icon; - const getLoadingIcon = () => { - return ( - loadingIcon || ( -
+
+ {hasValues && ( +
{ + e.preventDefault() + e.stopPropagation() + handleClear() + }} + > + +
+ )} + +
+ + + + + {searchable && ( + + )} + + No options found. + + {filteredOptions.map((option) => { + const isSelected = selectedValues.includes(option.value) + return ( + handleSelect(option.value)} + disabled={option.disabled} + className="flex items-center justify-between gap-2" + > +
+ {option.icon && {option.icon}} +
+
{option.label}
+ {option.description && ( +
+ {option.description} +
+ )} +
+
+ + {isSelected && ( + + )} +
+ ) + })} +
+
+
+
+ + + {/* Selected values display for multiple selection */} + {multiple && hasValues && ( +
+ {selectedValues.map((val) => { + const option = getSelectedOption(val) + return option + ? ( + + {option.icon && {option.icon}} + {option.label} + + + ) + : null + })} +
+ )} +
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/filter/components/shared/search-input.tsx b/packages/datum-ui/src/components/data-table/features/filter/components/shared/search-input.tsx new file mode 100644 index 0000000..a427432 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/components/shared/search-input.tsx @@ -0,0 +1,77 @@ +import { cn } from '@repo/shadcn/lib/utils' +import { Search, X } from 'lucide-react' +import { Button, InputWithAddons, Label } from '../../../../..' +import { Icon } from '../../../../../icons/icon-wrapper' + +/** + * Shared search input UI component + * Used by both Search and GlobalSearch components + */ +export interface SearchInputProps { + id?: string + value: string + onChange: (event: React.ChangeEvent) => void + onClear: () => void + placeholder?: string + label?: string + description?: string + disabled?: boolean + className?: string + inputClassName?: string +} + +export function SearchInput({ + id, + value, + onChange, + onClear, + placeholder = 'Search...', + label, + description, + disabled = false, + className, + inputClassName, +}: SearchInputProps) { + return ( +
+ {label && ( +
+ + {description &&

{description}

} +
+ )} + +
+ + } + trailing={ + value && ( + + ) + } + /> +
+
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/filter/components/shared/use-search-state.ts b/packages/datum-ui/src/components/data-table/features/filter/components/shared/use-search-state.ts new file mode 100644 index 0000000..5ce25fd --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/components/shared/use-search-state.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect, useState } from 'react' +import { useDebounce } from '../../../../../../hooks/use-debounce' + +/** + * Shared hook for managing search input state with debouncing + * Used by both Search and GlobalSearch components + */ +export interface UseSearchStateOptions { + initialValue?: string + debounceMs?: number + immediate?: boolean + onDebouncedChange?: (value: string) => void +} + +export function useSearchState({ + initialValue = '', + debounceMs = 300, + immediate = false, + onDebouncedChange, +}: UseSearchStateOptions) { + const [localValue, setLocalValue] = useState(initialValue) + + // Debounced value that triggers the actual filter update + const debouncedValue = useDebounce(localValue, immediate ? 0 : debounceMs) + + // Update local value when external value changes (e.g., URL changes, reset) + useEffect(() => { + setLocalValue(initialValue) + }, [initialValue]) + + // Trigger callback when debounced value changes + useEffect(() => { + if (onDebouncedChange && debouncedValue !== initialValue) { + onDebouncedChange(debouncedValue) + } + }, [debouncedValue, onDebouncedChange, initialValue]) + + // Handle input change + const handleChange = useCallback((event: React.ChangeEvent) => { + setLocalValue(event.target.value) + }, []) + + // Clear search + const handleClear = useCallback(() => { + setLocalValue('') + }, []) + + return { + localValue, + setLocalValue, + debouncedValue, + handleChange, + handleClear, + } +} diff --git a/packages/datum-ui/src/components/data-table/features/filter/components/tag.tsx b/packages/datum-ui/src/components/data-table/features/filter/components/tag.tsx new file mode 100644 index 0000000..4477333 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/components/tag.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from 'react' +import { cn } from '@repo/shadcn/lib/utils' +import { X } from 'lucide-react' +import { useCallback } from 'react' +import { Badge, Label } from '../../../..' +import { Icon } from '../../../../icons/icon-wrapper' +import { useArrayFilter } from '../../../hooks/useFilterQueryState' + +export interface TagOption { + label: string + value: string + icon?: ReactNode + disabled?: boolean +} + +export interface TagFilterProps { + filterKey: string + label?: string + description?: string + className?: string + disabled?: boolean + options: TagOption[] +} + +export function TagFilter({ + filterKey, + label, + description, + className, + disabled = false, + options = [], +}: TagFilterProps) { + const { value, setValue } = useArrayFilter(filterKey) + + // Normalize value to always be an array (handles string from URL on refresh) + const selectedValues = Array.isArray(value) ? value : value ? [value] : [] + + const handleToggle = useCallback( + (optionValue: string) => { + const isSelected = selectedValues.includes(optionValue) + if (isSelected) { + setValue(selectedValues.filter(v => v !== optionValue)) + } + else { + setValue([...selectedValues, optionValue]) + } + }, + [selectedValues, setValue], + ) + + return ( +
+ {label && ( +
+ + {description &&

{description}

} +
+ )} + +
+ {options.map((option) => { + const isSelected = selectedValues.includes(option.value) + const isDisabled = option.disabled || disabled + + return ( + !isDisabled && handleToggle(option.value)} + > + {option.icon && option.icon} + {option.label} + {isSelected && !isDisabled && ( + { + e.stopPropagation() + handleToggle(option.value) + }} + /> + )} + + ) + })} +
+
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/filter/components/time-range.tsx b/packages/datum-ui/src/components/data-table/features/filter/components/time-range.tsx new file mode 100644 index 0000000..735a553 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/components/time-range.tsx @@ -0,0 +1,86 @@ +import type { PresetConfig, TimeRangeValue } from '../../../../time-range-picker' +import { useCallback, useMemo } from 'react' +import { + DEFAULT_PRESETS, + getBrowserTimezone, + getPresetByKey, + getPresetRange, + + TimeRangePicker, + +} from '../../../../time-range-picker' +// app/modules/datum-ui/components/data-table/features/filter/components/time-range.tsx +import { useTimeRangeFilter } from '../../../hooks/useFilterQueryState' + +export interface TimeRangeFilterProps { + /** Filter key for time range state & URL */ + filterKey: string + + /** Preset configurations */ + presets?: PresetConfig[] + + /** Disable future dates */ + disableFuture?: boolean + + /** Custom class name */ + className?: string + /** Disabled state */ + disabled?: boolean + /** Timezone for time range calculations (defaults to browser timezone) */ + timezone?: string +} + +export function TimeRangeFilter({ + filterKey, + presets = DEFAULT_PRESETS, + disableFuture = true, + className, + disabled, + timezone: timezoneProp, +}: TimeRangeFilterProps) { + // Effective timezone: use provided value or fall back to browser timezone + const timezone = useMemo(() => timezoneProp ?? getBrowserTimezone(), [timezoneProp]) + + // Sync time range with DataTable filter state + URL + const { value: timeRange, setValue: setTimeRange } = useTimeRangeFilter(filterKey, null) + + // Compute effective value for display (handles missing timestamps) + const effectiveTimeRange = useMemo(() => { + if (!timeRange) + return null + + // If preset without timestamps, calculate them for display + if (timeRange.type === 'preset' && timeRange.preset && (!timeRange.from || !timeRange.to)) { + const preset = getPresetByKey(timeRange.preset, presets) + if (preset) { + const range = getPresetRange(preset, timezone) + return { + type: 'preset', + preset: preset.key, + from: range.from, + to: range.to, + } + } + } + + return timeRange + }, [timeRange, presets, timezone]) + + // Clear handler - resets to null (clears URL params, uses default) + const handleClear = useCallback(() => { + setTimeRange(null as unknown as TimeRangeValue) // Clear the value + }, [setTimeRange]) + + return ( + + ) +} diff --git a/packages/datum-ui/src/components/data-table/features/filter/data-table-filter.tsx b/packages/datum-ui/src/components/data-table/features/filter/data-table-filter.tsx new file mode 100644 index 0000000..1657096 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/data-table-filter.tsx @@ -0,0 +1,210 @@ +import type { ReactNode } from 'react' +import { cn } from '@repo/shadcn/lib/utils' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@repo/shadcn/ui/collapsible' +import { ChevronDown, Filter, RotateCcw } from 'lucide-react' +import { Children, useState } from 'react' +import { Button, Card, CardContent, CardHeader, CardTitle } from '../../..' +import { Icon } from '../../../icons/icon-wrapper' +import { useDataTableFilter } from '../../core/data-table.context' +import { CheckboxFilter } from './components/checkbox' +import { CheckboxPopoverFilter } from './components/checkbox-popover' +import { DatePickerFilter } from './components/datepicker' +import { GlobalSearchFilter } from './components/global-search' +import { RadioFilter } from './components/radio' +import { RadioPopoverFilter } from './components/radio-popover' +import { SearchFilter } from './components/search' +import { SelectFilter } from './components/select' +import { TagFilter } from './components/tag' +import { TimeRangeFilter } from './components/time-range' + +// Main DataTableFilter component props - simplified since provider handles table/filters +export interface DataTableFilterProps { + children: ReactNode + className?: string + showHeader?: boolean + collapsible?: boolean + defaultExpanded?: boolean + variant?: 'default' | 'card' // 'default' = simple filters without card, 'card' = wrapped in card with optional collapsible +} + +// Filter bar component (internal) +function FilterBar({ + children, + className, + showHeader = false, + collapsible = false, + defaultExpanded = true, + variant = 'default', +}: { + children: ReactNode + className?: string + showHeader?: boolean + collapsible?: boolean + defaultExpanded?: boolean + variant?: 'default' | 'card' +}) { + const { hasActiveFilters, resetAllFilters } = useDataTableFilter() + const [isExpanded, setIsExpanded] = useState(defaultExpanded) + + const hasActiveFiltersValue = hasActiveFilters() + + // Default variant - simple filters without card wrapper + if (variant === 'default') { + return ( + <> + {showHeader && ( +
+
+ + Filters + {/* {hasActiveFiltersValue && {activeCount}} */} +
+ {hasActiveFiltersValue && ( + + )} +
+ )} +
+ {Children.map(children, (child: ReactNode, index: number) => ( +
+ {child} +
+ ))} +
+ + ) + } + + // Card variant - wrapped in card with optional collapsible + + return ( + + {showHeader && ( + +
+ + + Filters + {/* {hasActiveFiltersValue && {activeCount}} */} + +
+ {hasActiveFiltersValue && ( + + )} + {collapsible && ( + + + + + + )} +
+
+
+ )} + + {collapsible + ? ( + + + +
+ {children} +
+
+
+
+ ) + : ( + +
{children}
+
+ )} +
+ ) +} + +// Main component - no longer needs provider wrapper since unified context handles everything +function DataTableFilterBase({ + children, + className, + showHeader = false, + collapsible = false, + defaultExpanded = false, +}: Omit) { + return ( + + {children} + + ) +} + +// Compound component structure +const DataTableFilter = Object.assign(DataTableFilterBase, { + Search: SearchFilter, + GlobalSearch: GlobalSearchFilter, // 🎯 New: Multi-column search + DatePicker: DatePickerFilter, + Select: SelectFilter, + Radio: RadioPopoverFilter, // 🎯 Popover version by default + Checkbox: CheckboxPopoverFilter, // 🎯 Popover version by default + Tag: TagFilter, // 🎯 Inline multi-select with badges + TimeRange: TimeRangeFilter, + + // Inline versions for when you want the old behavior + RadioInline: RadioFilter, + CheckboxInline: CheckboxFilter, +}) + +export { DataTableFilter } + +// Export individual components for advanced usage +export { + CheckboxFilter, + CheckboxPopoverFilter, + DatePickerFilter, + GlobalSearchFilter, + RadioFilter, + RadioPopoverFilter, + SearchFilter, + SelectFilter, + TagFilter, + TimeRangeFilter, +} + +// Export context and hooks +export { useDataTableFilter, useFilter } from '../../core/data-table.context' +export type { FilterState, FilterValue } from '../../core/data-table.context' diff --git a/packages/datum-ui/src/components/data-table/features/filter/index.ts b/packages/datum-ui/src/components/data-table/features/filter/index.ts new file mode 100644 index 0000000..4ae3a70 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/filter/index.ts @@ -0,0 +1,42 @@ +// Context and hooks (from unified context) +export { + useDataTable, + useDataTableFilter, + useFilter, + useFilterData, + useTableData, + useTableState, +} from '../../core/data-table.context' +export type { FilterState, FilterValue } from '../../core/data-table.context' + +// URL-aware filter hooks +export { + useArrayFilter, + useDateFilter, + useDateRangeFilter, + useFilterQueryState, + useStringFilter, +} from '../../hooks/useFilterQueryState' + +export type { CheckboxFilterProps, CheckboxOption } from './components/checkbox' +export type { DatePickerFilterProps } from './components/datepicker' +export type { RadioFilterProps, RadioOption } from './components/radio' +// Component-specific types +export type { SearchFilterProps } from './components/search' +export type { SelectFilterProps, SelectOption } from './components/select' +export type { TagFilterProps, TagOption } from './components/tag' + +// Main component and types +export { DataTableFilter } from './data-table-filter' + +export type { DataTableFilterProps } from './data-table-filter' + +// Individual components for advanced usage +export { + CheckboxFilter, + DatePickerFilter, + RadioFilter, + SearchFilter, + SelectFilter, + TagFilter, +} from './data-table-filter' diff --git a/packages/datum-ui/src/components/data-table/features/inline-content/data-table-inline-content.tsx b/packages/datum-ui/src/components/data-table/features/inline-content/data-table-inline-content.tsx new file mode 100644 index 0000000..ecd4139 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/inline-content/data-table-inline-content.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react' +import { cn } from '@repo/shadcn/lib/utils' +import { TableCell, TableRow } from '@repo/shadcn/ui/table' +import { useEffect, useState } from 'react' + +/** + * DataTableInlineContent - Wrapper component for inline content rendering + * + * This component provides a table row that spans all columns and contains + * custom content (form, preview, details, etc.). It handles the visual + * presentation and animations while the content component handles all logic. + * + * Position is automatically determined based on mode: + * - Create mode: Renders at top (first row) of table + * - Edit mode: Replaces the editing row + */ + +export interface InlineContentRenderParams { + mode: 'create' | 'edit' + data: TData | null + onClose: () => void +} + +export interface DataTableInlineContentProps { + /** + * Current mode: 'create' for new entries, 'edit' for existing rows + */ + mode: 'create' | 'edit' + + /** + * Current data being edited (null for create mode) + */ + data: TData | null + + /** + * Total number of columns (including actions column if present) + * Used for colSpan to make the content row span entire table width + */ + columnCount: number + + /** + * Callback to close the inline content + */ + onClose: () => void + + /** + * Custom className for the table row + */ + className?: string + + /** + * Render function that receives content params and returns content + */ + children: (params: InlineContentRenderParams) => ReactNode +} + +export function DataTableInlineContent({ + mode, + data, + columnCount, + onClose, + className, + children, +}: DataTableInlineContentProps) { + const [isVisible, setIsVisible] = useState(false) + const [isExiting, setIsExiting] = useState(false) + + // Entry animation + useEffect(() => { + // Small delay to ensure DOM is ready + const timer = requestAnimationFrame(() => { + setIsVisible(true) + }) + return () => cancelAnimationFrame(timer) + }, []) + + // Handle close with exit animation + const handleClose = () => { + setIsExiting(true) + setIsVisible(false) + + // Wait for animation to complete before calling onClose + setTimeout(() => { + onClose() + }, 200) // Match animation duration + } + + return ( + + + {children({ mode, data, onClose: handleClose })} + + + ) +} diff --git a/packages/datum-ui/src/components/data-table/features/pagination/data-table-pagination.tsx b/packages/datum-ui/src/components/data-table/features/pagination/data-table-pagination.tsx new file mode 100644 index 0000000..383d0d5 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/pagination/data-table-pagination.tsx @@ -0,0 +1,127 @@ +import type { Table as TTable } from '@tanstack/react-table' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import { useState } from 'react' +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../..' +import { Icon } from '../../../icons/icon-wrapper' + +export interface DataTablePaginationProps { + table: TTable + enableShowAll?: boolean + // Server-side pagination props + serverSide?: boolean + hasNextPage?: boolean + hasPrevPage?: boolean + onPageChange?: (pageIndex: number) => void + onPageSizeChange?: (pageSize: number) => void + currentPage?: number + currentPageSize?: number +} + +export function DataTablePagination({ + table, + enableShowAll = false, + serverSide = false, + hasNextPage = false, + hasPrevPage = false, + onPageChange, + onPageSizeChange, + currentPage = 0, + currentPageSize: controlledPageSize, +}: DataTablePaginationProps) { + const totalRows = table.getFilteredRowModel().rows.length + // Use controlled page size for server-side, otherwise use table state + const currentPageSize + = serverSide && controlledPageSize != null + ? controlledPageSize + : table.getState().pagination.pageSize + + const [isShowingAll, setIsShowingAll] = useState(false) + + return ( +
+
+
+

Rows per page

+ +
+
+ {!isShowingAll && ( +
+
+ {serverSide + ? `Page ${currentPage + 1}` + : `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`} +
+ + +
+ )} +
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-filter-dropdown.tsx b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-filter-dropdown.tsx new file mode 100644 index 0000000..79cbe64 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-filter-dropdown.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import { cn } from '@repo/shadcn/lib/utils' +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@repo/shadcn/ui/dropdown-menu' +import { ListFilter } from 'lucide-react' +import { useState } from 'react' +import { Badge, Button } from '../../..' +import { useDataTableFilter } from '../../core/data-table.context' + +export interface DataTableToolbarFilterDropdownProps { + children: ReactNode + showFilterCount?: boolean + className?: string + dropdownClassName?: string + excludeColumns?: string[] +} + +/** + * DataTableToolbarFilterDropdown + * + * Dropdown component that wraps filters in a collapsible menu. + * Used in compact toolbar layout to save space. + * + * Features: + * - Shows active filter count badge + * - Clear all filters button + * - Accessible dropdown menu + * - Responsive width + */ +export function DataTableToolbarFilterDropdown({ + children, + showFilterCount = true, + className, + dropdownClassName, + excludeColumns, +}: DataTableToolbarFilterDropdownProps) { + const { hasActiveFilters, getActiveFilterCount } = useDataTableFilter() + const [open, setOpen] = useState(false) + + const hasFilters = hasActiveFilters(excludeColumns) + const filterCount = getActiveFilterCount(excludeColumns) + + return ( + + + + + + + {/* Filter Content */} +
{children}
+
+
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-multi-actions.tsx b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-multi-actions.tsx new file mode 100644 index 0000000..45e1750 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-multi-actions.tsx @@ -0,0 +1,123 @@ +import type { MultiAction, MultiActionButtonProps } from '../../core/data-table.types' +import { cn } from '@repo/shadcn/lib/utils' +import { Button } from '../../../button/button' +import { useDataTable } from '../../core/data-table.context' + +export interface DataTableToolbarMultiActionsProps { + /** + * Array of multi-select actions to render + * Can be button actions or custom render functions + */ + actions: MultiAction[] + + /** + * Custom className for the container + */ + className?: string +} + +/** + * Type guard to check if action is a button action (has 'action' property) + */ +function isButtonAction( + action: MultiAction, +): action is MultiActionButtonProps { + return 'action' in action +} + +/** + * DataTableToolbarMultiActions + * + * Renders bulk action buttons/components when rows are selected. + * Automatically hides when no rows are selected. + * + * Supports two action types: + * - Button actions: Standard button with label, icon, variant, size, and click handler + * - Render actions: Custom render function for complex UI (e.g., popovers) + * + * @example + * // Button actions + * , + * type: 'danger', + * theme: 'outline', + * action: (rows) => handleDelete(rows), + * }, + * { + * key: 'export', + * label: 'Export', + * type: 'quaternary', + * theme: 'outline', + * size: 'small', + * action: (rows) => handleExport(rows), + * }, + * ]} + * /> + * + * @example + * // Custom render action (e.g., popover) + * ( + * + * ), + * }, + * ]} + * /> + */ +export function DataTableToolbarMultiActions({ + actions, + className, +}: DataTableToolbarMultiActionsProps) { + const { selectedRows, hasSelection, clearSelection, rowSelection } = useDataTable() + + // Don't render if no rows are selected + if (!hasSelection) { + return null + } + + // Get selected row IDs from rowSelection state + const selectedRowIds = Object.keys(rowSelection).filter(id => rowSelection[id]) + + return ( +
+ {actions.map((action) => { + if (isButtonAction(action)) { + const isDisabled = action.disabled?.(selectedRows) ?? false + + return ( + + ) + } + + // Custom render action + return ( + + {action.render({ + selectedRows, + selectedRowIds, + clearSelection, + })} + + ) + })} +
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-row-count.tsx b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-row-count.tsx new file mode 100644 index 0000000..b2e2b6c --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-row-count.tsx @@ -0,0 +1,61 @@ +import { useDataTable } from '../../core/data-table.context' + +/** + * Component to display the filtered row count with pagination and selection information + * + * Display modes: + * - Selection: "X selected" (when rows are selected) + * - Paginated: "Showing X-Y of Z records" (when paginated, no selection) + * - All shown: "Showing X records" (when all fit on one page, no selection) + */ +export function DataTableToolbarRowCount() { + const { table, selectionCount, hasSelection } = useDataTable() + const totalFiltered = table.getFilteredRowModel().rows.length + + // Hide if no records + if (totalFiltered === 0) { + return null + } + + // If rows are selected, show selection count instead + if (hasSelection) { + return ( + + {selectionCount} + {' '} + of + {totalFiltered} + {' '} + selected + + ) + } + + const currentPageRows = table.getRowModel().rows.length + const pagination = table.getState().pagination + const pageIndex = pagination.pageIndex + const pageSize = pagination.pageSize + + // Check if pagination is enabled (if pageSize is less than total, we're paginating) + const isPaginated = pageSize < totalFiltered && currentPageRows > 0 + + let displayText: string + + if (isPaginated) { + // Calculate the range being shown + const start = pageIndex * pageSize + 1 + const end = Math.min((pageIndex + 1) * pageSize, totalFiltered) + + displayText = `Showing ${start}-${end} of ${totalFiltered} ${totalFiltered === 1 ? 'record' : 'records'}` + } + else { + // All records are shown (no pagination or all fit on one page) + displayText = `Showing ${totalFiltered} ${totalFiltered === 1 ? 'record' : 'records'}` + } + + return ( + + {displayText} + + ) +} diff --git a/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-search.tsx b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-search.tsx new file mode 100644 index 0000000..f7d22be --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar-search.tsx @@ -0,0 +1,66 @@ +import type { DataTableSearchConfig } from '../../core/data-table.types' +import { cn } from '@repo/shadcn/lib/utils' +import { useDataTable } from '../../core/data-table.context' +import { GlobalSearchFilter } from '../filter/components/global-search' +import { SearchFilter } from '../filter/components/search' + +export interface DataTableToolbarSearchProps { + config: DataTableSearchConfig + className?: string +} + +/** + * DataTableToolbarSearch + * + * Built-in search component for the DataTable toolbar. + * Automatically uses the appropriate filter component based on mode and serverSideFiltering: + * - Default: GlobalSearchFilter (multi-column client-side search) + * - serverSideFiltering=true → SearchFilter (single column server-side) + * - mode='search' → SearchFilter (single column) + * + * Features: + * - Reuses existing filter components + * - Automatic mode selection based on filtering strategy + * - Consistent behavior with other filters + */ +export function DataTableToolbarSearch({ config, className }: DataTableToolbarSearchProps) { + const { serverSideFiltering } = useDataTable() + + const { + placeholder = 'Search...', + filterKey = 'q', + debounce = 300, + mode = 'global-search', // Default to global-search + searchableColumns, + } = config + + // Determine which search component to use: + // 1. If mode is explicitly set to 'search', use SearchFilter (single column) + // 2. If serverSideFiltering=true, use SearchFilter (server handles search logic) + // 3. Otherwise (default), use GlobalSearchFilter (multi-column client-side) + const useGlobalSearch = mode !== 'search' && !serverSideFiltering + + if (useGlobalSearch) { + // Client-side global search across multiple columns + return ( + + ) + } + + // Server-side or single-column search + return ( + + ) +} diff --git a/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar.tsx b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar.tsx new file mode 100644 index 0000000..2c28863 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/features/toolbar/data-table-toolbar.tsx @@ -0,0 +1,377 @@ +import type { ReactElement, ReactNode } from 'react' +import type { + DataTableSearchConfig, + DataTableTitleProps, + DataTableToolbarConfig, + MultiAction, +} from '../../core/data-table.types' +import { cn } from '@repo/shadcn/lib/utils' +import { Children, isValidElement, useMemo } from 'react' +import { PageTitle } from '../../../page-title' +import { useDataTable } from '../../core/data-table.context' +import { DataTableFilter } from '../filter/data-table-filter' +import { DataTableToolbarFilterDropdown } from './data-table-toolbar-filter-dropdown' +import { DataTableToolbarMultiActions } from './data-table-toolbar-multi-actions' +import { DataTableToolbarRowCount } from './data-table-toolbar-row-count' +import { DataTableToolbarSearch } from './data-table-toolbar-search' + +interface FilterProps { + filterKey?: string + children?: ReactNode +} + +/** + * Recursively extracts filter children from a React node tree. + * Handles fragments, arrays, and wrapper divs to find actual filter components. + */ +function flattenFilterChildren(children: ReactNode): ReactElement[] { + const result: ReactElement[] = [] + + Children.forEach(children, (child) => { + if (!isValidElement(child)) + return + + const props = child.props as FilterProps + + // Check if this is a filter component (has filterKey prop) + if (props.filterKey) { + result.push(child as ReactElement) + return + } + + // If it's a fragment or has children, recurse into it + if (props.children) { + result.push(...flattenFilterChildren(props.children)) + } + }) + + return result +} + +export interface DataTableToolbarProps { + /** + * Table title, description, and actions configuration + * @deprecated Use title, description, and actions props directly + */ + tableTitle?: DataTableTitleProps + + /** + * Filter component (DataTableFilter or custom filter component) + * @deprecated Use `filters` prop instead - this will auto-wrap filters + */ + filterComponent?: React.ReactNode + + /** + * New unified filters prop - filters are auto-wrapped in DataTableFilter context + * No manual wrapping needed + */ + filters?: React.ReactNode + + /** + * Whether to show the toolbar section + * @default true if either tableTitle, filterComponent, or filters is provided + */ + show?: boolean + + /** + * Toolbar configuration for new compact layout + */ + config?: DataTableToolbarConfig + + /** + * Title for the table (new API) + */ + title?: string + + /** + * Description for the table (new API) + */ + description?: string + + /** + * Actions for the table (new API) + */ + actions?: ReactNode + + /** + * Custom className for the toolbar + */ + className?: string + + /** + * Custom className for left section + */ + leftSectionClassName?: string + + /** + * Custom className for right section + */ + rightSectionClassName?: string + + /** + * Multi-select bulk actions + * Shown in the left section when rows are selected + */ + multiActions?: MultiAction[] +} + +/** + * DataTableToolbar + * + * Renders the toolbar section of the DataTable with two layout modes: + * + * **Stacked Layout (default/legacy)**: + * - Page title and description at top + * - Filters displayed inline below + * - Traditional vertical layout + * + * **Compact Layout (new)**: + * - Horizontal toolbar with left/right sections + * - Left: Built-in search + primary filters + * - Right: Filter dropdown + actions + * - Space-efficient for modern UIs + * + * @example + * // Legacy API (still supported) + * }} + * filterComponent={...} + * /> + * + * @example + * // New Compact API + * Add User} + * config={{ + * layout: 'compact', + * search: { placeholder: 'Search users...' }, + * filtersDisplay: 'dropdown' + * }} + * filterComponent={...} + * /> + */ +export function DataTableToolbar({ + tableTitle, + filterComponent, + filters, + show = true, + config, + title, + description, + actions, + className, + leftSectionClassName, + rightSectionClassName, + multiActions, +}: DataTableToolbarProps) { + // Access table context to check for data and search query + const { table, globalFilter, getFilterValue } = useDataTable() + + // Check if there's data (rows in the table) + const hasData = table.getRowModel().rows.length > 0 + + // Check for search query: either globalFilter (for global-search) or filter 'q' (for single-column search) + const searchQuery = globalFilter?.trim() || '' + const filterSearchQuery = getFilterValue('q')?.trim() || '' + const hasSearchQuery = searchQuery.length > 0 || filterSearchQuery.length > 0 + + // Merge title/description/actions from both old and new API + const finalTitle = title || tableTitle?.title + const finalDescription = description || tableTitle?.description + const finalActions = actions || tableTitle?.actions + + // Determine which filter source to use (new filters prop takes precedence) + // Auto-wrap filters if using new API + const finalFilterComponent = useMemo(() => { + if (filters) { + // New API: auto-wrap filters in DataTableFilter context + return {filters} + } + // Legacy API: use filterComponent as-is (already wrapped) + return filterComponent + }, [filters, filterComponent]) + + // Default config + const toolbarConfig: DataTableToolbarConfig = useMemo( + () => ({ + layout: config?.layout || 'stacked', + // Support both old 'search' and new 'includeSearch' (new takes precedence) + search: config?.includeSearch ?? config?.search, + includeSearch: config?.includeSearch ?? config?.search, + filtersDisplay: config?.filtersDisplay || 'inline', + maxInlineFilters: config?.maxInlineFilters || 3, + primaryFilters: config?.primaryFilters, + showFilterCount: config?.showFilterCount ?? true, + showRowCount: config?.showRowCount ?? false, // Default to false, must be explicitly enabled + responsive: config?.responsive ?? true, + }), + [config], + ) + + // Parse search config + const searchConfig: DataTableSearchConfig | null = useMemo(() => { + if (!toolbarConfig.includeSearch) + return null + + if (typeof toolbarConfig.includeSearch === 'boolean') { + return { + placeholder: 'Search...', + filterKey: 'q', + mode: 'global-search', // Default to global-search + debounce: 300, + } + } + + return { + placeholder: toolbarConfig.includeSearch.placeholder || 'Search...', + filterKey: toolbarConfig.includeSearch.filterKey || 'q', + mode: toolbarConfig.includeSearch.mode || 'global-search', // Default to global-search + searchableColumns: toolbarConfig.includeSearch.searchableColumns, + debounce: toolbarConfig.includeSearch.debounce || 300, + } + }, [toolbarConfig.includeSearch]) + + // Split filters into inline and dropdown based on config + const { inlineFilters, dropdownFilters } = useMemo(() => { + if (!finalFilterComponent) { + return { inlineFilters: null, dropdownFilters: null } + } + + // Flatten filter children to handle wrapped/nested filters + // This extracts actual filter components (those with filterKey prop) + const flatFilters = flattenFilterChildren(finalFilterComponent) + + // Fallback to Children.toArray if no filter components found + // (for backwards compatibility with non-standard filter structures) + const filterArray + = flatFilters.length > 0 ? flatFilters : Children.toArray(finalFilterComponent) + + // If stacked layout or inline display, show all filters inline + if (toolbarConfig.layout === 'stacked' || toolbarConfig.filtersDisplay === 'inline') { + return { inlineFilters: filterArray, dropdownFilters: null } + } + + // If dropdown display, all in dropdown + if (toolbarConfig.filtersDisplay === 'dropdown') { + return { inlineFilters: null, dropdownFilters: filterArray } + } + + // Auto mode: smart split based on primaryFilters and maxInlineFilters + const primaryFilterKeys = toolbarConfig.primaryFilters || [] + const maxInline = toolbarConfig.maxInlineFilters || 3 + + // If primaryFilters is specified, use it to determine inline vs dropdown + if (primaryFilterKeys.length > 0) { + const inline: ReactNode[] = [] + const dropdown: ReactNode[] = [] + + filterArray.forEach((filter) => { + const filterKey = isValidElement(filter) ? filter.props?.filterKey : undefined + if (filterKey && primaryFilterKeys.includes(filterKey)) { + inline.push(filter) + } + else { + dropdown.push(filter) + } + }) + + return { + inlineFilters: inline.length > 0 ? inline : null, + dropdownFilters: dropdown.length > 0 ? dropdown : null, + } + } + + // No primaryFilters specified - use maxInlineFilters count + if (filterArray.length <= maxInline) { + return { inlineFilters: filterArray, dropdownFilters: null } + } + + // Split: first N inline, rest in dropdown + return { + inlineFilters: filterArray.slice(0, maxInline), + dropdownFilters: filterArray.slice(maxInline), + } + }, [finalFilterComponent, toolbarConfig]) + + // Don't render if explicitly hidden or if no content to show + if (!show || (!finalTitle && !finalFilterComponent && !toolbarConfig.includeSearch)) { + return null + } + + // Render stacked layout (legacy/default) + if (toolbarConfig.layout === 'stacked') { + return ( +
+ {/* Page Title Section */} + {(finalTitle || finalDescription || finalActions) && ( + + )} + + {/* Filter Section */} + {finalFilterComponent &&
{finalFilterComponent}
} +
+ ) + } + + // Render compact layout (new) + return ( +
+ {/* Title and Description at Top (if provided) */} + {(finalTitle || finalDescription) && ( + + )} + + {/* Compact Toolbar Row - Only show if there's data or a search query */} + {(hasData || hasSearchQuery) && ( + + {/* Left Section: Search */} +
+ {searchConfig && ( + + )} +
+ + {/* Right Section: Row Count + Multi-actions + Inline/Primary Filters + Dropdown Filters + Actions */} +
+ {/* Row count (shows "X of Y selected" when rows are selected) */} + {toolbarConfig.showRowCount && } + + {/* Multi-actions (shown when rows are selected) */} + {multiActions && multiActions.length > 0 && ( + + )} + + {/* Primary/Inline filters next to dropdown */} + {inlineFilters && inlineFilters.length > 0 && ( +
+ {inlineFilters.map((filter, index) => ( +
{filter}
+ ))} +
+ )} + + {/* Dropdown filters button */} + {dropdownFilters && dropdownFilters.length > 0 && ( + + {dropdownFilters.map((filter, index) => ( +
+ {filter} +
+ ))} +
+ )} + + {finalActions &&
{finalActions}
} +
+
+ )} +
+ ) +} diff --git a/packages/datum-ui/src/components/data-table/hooks/useFilterQueryState.ts b/packages/datum-ui/src/components/data-table/hooks/useFilterQueryState.ts new file mode 100644 index 0000000..993b524 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/hooks/useFilterQueryState.ts @@ -0,0 +1,176 @@ +import type { TimeRangeValue } from '../../time-range-picker' +import type { FilterValue } from '../core/data-table.context' +import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs' +import { useEffect, useMemo } from 'react' +import { useDataTableFilter } from '../core/data-table.context' +import { + deserializeDate, + deserializeDateRange, + serializeDate, + serializeDateRange, +} from '../utils/date-serialization' +import { deserializeTimeRange, serializeTimeRange } from '../utils/time-range-serialization' + +// Parser factory for different filter types +function createParser(type: 'string' | 'array' | 'date' | 'dateRange' | 'timeRange', defaultValue?: any) { + switch (type) { + case 'string': + return parseAsString.withDefault(defaultValue || '') + case 'array': + return parseAsArrayOf(parseAsString).withDefault(defaultValue || []) + case 'date': + // Use string parser and handle date conversion manually to avoid null issues + return parseAsString.withDefault(defaultValue || '') + case 'dateRange': + // For date ranges, we'll serialize as JSON string + return parseAsString.withDefault(defaultValue || '') + case 'timeRange': + return parseAsString.withDefault(defaultValue || '') + default: + return parseAsString.withDefault('') + } +} + +interface UseFilterQueryStateOptions { + filterKey: string + type: 'string' | 'array' | 'date' | 'dateRange' | 'timeRange' + defaultValue?: any +} + +/** + * Hook that manages individual filter state with nuqs URL synchronization + * This replaces the dynamic parser approach with individual useQueryState hooks + */ +export function useFilterQueryState({ + filterKey, + type, + defaultValue, +}: UseFilterQueryStateOptions) { + const { setFilter, getFilterValue, registerFilterParser } = useDataTableFilter() + + // Create parser for this filter type + const parser = useMemo(() => createParser(type, defaultValue), [type, defaultValue]) + + // Register parser in the context (for potential future use) + useEffect(() => { + registerFilterParser(filterKey, parser) + }, [filterKey, parser, registerFilterParser]) + + // Use individual useQueryState for this filter + const [urlValue, setUrlValue] = useQueryState(filterKey, parser) + + // Get current filter value from context + const contextValue = getFilterValue(filterKey) + + // Determine the current value (context takes precedence) + const currentValue = useMemo(() => { + if (contextValue !== undefined && contextValue !== null) { + return contextValue + } + if (type === 'dateRange' && typeof urlValue === 'string') { + return deserializeDateRange(urlValue) as T + } + if (type === 'date' && typeof urlValue === 'string') { + return deserializeDate(urlValue) as T + } + if (type === 'timeRange' && typeof urlValue === 'string') { + return deserializeTimeRange(urlValue) as T + } + return urlValue as T + }, [contextValue, urlValue, type]) + + // Update function that syncs both context and URL + const setValue = useMemo( + () => (newValue: T) => { + // Update context (this will trigger table filters and callbacks) + setFilter(filterKey, newValue as FilterValue) + + // Update URL state with additional safety checks + try { + if (type === 'dateRange' && typeof newValue === 'object' && newValue !== null) { + const serialized = serializeDateRange(newValue as any) + setUrlValue(serialized) + } + else if (type === 'date') { + // Single date: serialize to ISO string or empty string + const serialized = serializeDate(newValue as Date | null) + setUrlValue(serialized) + } + else if (type === 'timeRange' && typeof newValue === 'object' && newValue !== null) { + const serialized = serializeTimeRange(newValue as any) + setUrlValue(serialized) + } + else if (newValue === null || newValue === undefined) { + setUrlValue(null) + } + else { + setUrlValue(newValue as any) + } + } + catch (error) { + console.error(`🚨 Error in setValue for ${filterKey}:`, error, { newValue, type }) + // Fallback to null on any error + setUrlValue(null) + } + }, + [filterKey, setFilter, setUrlValue, type], + ) + + // Reset function + const reset = useMemo( + () => () => { + setValue(defaultValue as T) + }, + [setValue, defaultValue], + ) + + return { + value: currentValue, + setValue, + reset, + } +} + +// Pre-configured hooks for common filter types +export function useStringFilter(filterKey: string, defaultValue: string = '') { + return useFilterQueryState({ + filterKey, + type: 'string', + defaultValue, + }) +} + +export function useArrayFilter(filterKey: string, defaultValue: string[] = []) { + return useFilterQueryState({ + filterKey, + type: 'array', + defaultValue, + }) +} + +export function useDateFilter(filterKey: string, defaultValue: Date | null = null) { + return useFilterQueryState({ + filterKey, + type: 'date', + defaultValue, + }) +} + +export function useDateRangeFilter( + filterKey: string, + defaultValue: { from?: Date, to?: Date } | null = null, +) { + return useFilterQueryState<{ from?: Date, to?: Date } | null>({ + filterKey, + type: 'dateRange', + defaultValue, + }) +} + +export function useTimeRangeFilter(filterKey: string, defaultValue: TimeRangeValue | null = null) { + return useFilterQueryState({ + filterKey, + type: 'timeRange', + defaultValue, + }) +} diff --git a/packages/datum-ui/src/components/data-table/hooks/useInlineContent.ts b/packages/datum-ui/src/components/data-table/hooks/useInlineContent.ts new file mode 100644 index 0000000..cf9e25b --- /dev/null +++ b/packages/datum-ui/src/components/data-table/hooks/useInlineContent.ts @@ -0,0 +1,67 @@ +import { useCallback, useState } from 'react' + +/** + * Internal hook for managing inline content state + * ⚠️ INTERNAL USE ONLY - Not exported to consumers + * + * This hook powers the DataTable's inline content functionality by managing: + * - Content open/close state + * - Create vs Edit mode + * - Which row is being edited + * - Current editing data + */ + +export interface InlineContentState { + isOpen: boolean + mode: 'create' | 'edit' | null + editingRowId: string | null + editingRowData: TData | null +} + +export interface UseInlineContentReturn { + state: InlineContentState + open: (mode: 'create' | 'edit', rowData?: TData, rowId?: string) => void + close: () => void + isRowEditing: (rowId: string) => boolean +} + +export function useInlineContent(): UseInlineContentReturn { + const [state, setState] = useState>({ + isOpen: false, + mode: null, + editingRowId: null, + editingRowData: null, + }) + + const open = useCallback((mode: 'create' | 'edit', rowData?: TData, rowId?: string) => { + setState({ + isOpen: true, + mode, + editingRowId: rowId || null, + editingRowData: rowData || null, + }) + }, []) + + const close = useCallback(() => { + setState({ + isOpen: false, + mode: null, + editingRowId: null, + editingRowData: null, + }) + }, []) + + const isRowEditing = useCallback( + (rowId: string) => { + return state.isOpen && state.mode === 'edit' && state.editingRowId === rowId + }, + [state.isOpen, state.mode, state.editingRowId], + ) + + return { + state, + open, + close, + isRowEditing, + } +} diff --git a/packages/datum-ui/src/components/data-table/index.ts b/packages/datum-ui/src/components/data-table/index.ts new file mode 100644 index 0000000..f1ca4be --- /dev/null +++ b/packages/datum-ui/src/components/data-table/index.ts @@ -0,0 +1,176 @@ +// ============================================================================= +// DataTable Main Exports +// ============================================================================= +// This is the main entry point for the DataTable component system. +// It exports all components, types, hooks, and utilities needed to use the DataTable. + +// ============================================================================= +// Core Components +// ============================================================================= + +// Main DataTable component and provider +// Column types (includes module augmentation for TanStack Table) +import './features/columns/data-table-column-meta' + +export { DataTable } from './core/data-table' +export { DataTableCardView } from './core/data-table-card-view' + +export type { DataTableCardViewProps } from './core/data-table-card-view' +export { DataTableLoadingContent } from './core/data-table-loading' +// Table view components +export { DataTableView } from './core/data-table-view' + +// Table sub-component types +export type { DataTableViewProps } from './core/data-table-view' +export { DataTableProvider } from './core/data-table.context' + +// ============================================================================= +// Feature Components +// ============================================================================= + +// Context types +export type { + DataTableProviderProps, + FilterParser, + FilterParserRegistry, + FilterState, + FilterValue, +} from './core/data-table.context' +// Context +export { DataTableContext } from './core/data-table.context' + +// Core hooks +export { + useDataTable, + useDataTableFilter, + useFilter, + useFilterData, + useTableData, + useTableState, +} from './core/data-table.context' +// Main component types +export type { + DataTableProps, + DataTableRef, + DataTableRowActionsProps, + DataTableSearchConfig, + DataTableTitleProps, + DataTableToolbarConfig, + // Multi-select types + MultiAction, + MultiActionButtonProps, + MultiActionRenderProps, + SearchParams, +} from './core/data-table.types' +// Action components +export { DataTableRowActions } from './features/actions/data-table-row-actions' +// Column components +export { DataTableColumnHeader } from './features/columns/data-table-column-header' +export type { DataTableColumnHeaderProps } from './features/columns/data-table-column-header' +export type { ColumnHeaderTooltip } from './features/columns/data-table-column.types' +export { isTooltipConfig, normalizeTooltip } from './features/columns/data-table-column.types' +// Re-export everything from filter for backward compatibility +// This ensures existing imports from '@/modules/datum-ui/components/data-table/filter' continue to work +export * from './features/filter' +export type { CheckboxFilterProps, CheckboxOption } from './features/filter/components/checkbox' + +export type { DatePickerFilterProps } from './features/filter/components/datepicker' + +export type { GlobalSearchFilterProps } from './features/filter/components/global-search' + +export type { RadioFilterProps, RadioOption } from './features/filter/components/radio' +export type { SearchFilterProps } from './features/filter/components/search' + +// ============================================================================= +// Core Types +// ============================================================================= + +export type { SelectFilterProps, SelectOption } from './features/filter/components/select' + +// Main filter component and individual filter components +export { + CheckboxFilter, + DataTableFilter, + DatePickerFilter, + GlobalSearchFilter, + RadioFilter, + SearchFilter, + SelectFilter, +} from './features/filter/data-table-filter' +// Filter component types +export type { DataTableFilterProps } from './features/filter/data-table-filter' + +// Inline content components +export { DataTableInlineContent } from './features/inline-content/data-table-inline-content' + +// ============================================================================= +// Core Context & Hooks +// ============================================================================= + +export type { + DataTableInlineContentProps, + InlineContentRenderParams, +} from './features/inline-content/data-table-inline-content' + +// Pagination components +export { DataTablePagination } from './features/pagination/data-table-pagination' + +// ============================================================================= +// URL-aware Filter Hooks +// ============================================================================= + +// Toolbar components +export { DataTableToolbar } from './features/toolbar/data-table-toolbar' + +// ============================================================================= +// Filter System +// ============================================================================= + +export type { DataTableToolbarProps } from './features/toolbar/data-table-toolbar' + +export { DataTableToolbarFilterDropdown } from './features/toolbar/data-table-toolbar-filter-dropdown' +export type { DataTableToolbarFilterDropdownProps } from './features/toolbar/data-table-toolbar-filter-dropdown' +export { DataTableToolbarMultiActions } from './features/toolbar/data-table-toolbar-multi-actions' +export type { DataTableToolbarMultiActionsProps } from './features/toolbar/data-table-toolbar-multi-actions' +export { DataTableToolbarRowCount } from './features/toolbar/data-table-toolbar-row-count' +export { DataTableToolbarSearch } from './features/toolbar/data-table-toolbar-search' +export type { DataTableToolbarSearchProps } from './features/toolbar/data-table-toolbar-search' + +// ============================================================================= +// Re-exports for Convenience +// ============================================================================= + +// Advanced filter hooks with URL state management +export { + useArrayFilter, + useDateFilter, + useDateRangeFilter, + useFilterQueryState, + useStringFilter, +} from './hooks/useFilterQueryState' + +// ============================================================================= +// Utilities +// ============================================================================= + +// Date serialization utilities for filter state management +export { + deserializeDate, + deserializeDateRange, + isDateRangeFormat, + serializeDate, + serializeDateRange, +} from './utils/date-serialization' + +// Global search utilities +export { + createGlobalSearchFilter, + extractSearchableValue, + getSearchableColumnIds, + getSearchableColumnNames, + getSearchableColumns, + type GlobalSearchOptions, + isColumnSearchable, + type MatchMode, + valueToSearchableString, +} from './utils/global-search.helpers' diff --git a/packages/datum-ui/src/components/data-table/utils/date-serialization.ts b/packages/datum-ui/src/components/data-table/utils/date-serialization.ts new file mode 100644 index 0000000..9d3d70a --- /dev/null +++ b/packages/datum-ui/src/components/data-table/utils/date-serialization.ts @@ -0,0 +1,104 @@ +/** + * Date serialization utilities for DataTable filters + * Handles conversion between Date objects and URL-safe string formats + */ + +/** + * Serializes a date range to a compact timestamp format + * Format: timestamp_timestamp (e.g., "1728172800_1728345599") + * + * @param value - Date range object with optional from and to dates + * @returns URL-safe string representation of the date range + */ +export function serializeDateRange(value: { from?: Date, to?: Date } | null): string { + if (!value || (!value.from && !value.to)) + return '' + + // Convert dates to Unix timestamps (seconds) + const startTs = value.from ? Math.floor(value.from.getTime() / 1000) : '' + const endTs = value.to ? Math.floor(value.to.getTime() / 1000) : '' + + // If both timestamps exist, use compact format + if (startTs && endTs) { + return `${startTs}_${endTs}` + } + + // If only one exists, still use underscore format + return `${startTs}_${endTs}` +} + +/** + * Deserializes a date range from URL string format to Date objects + * Supports both compact timestamp format and JSON format (backward compatibility) + * + * @param value - URL string in timestamp_timestamp or JSON format + * @returns Date range object with from and to dates, or null if invalid + */ +export function deserializeDateRange(value: string): { from?: Date, to?: Date } | null { + if (!value) + return null + + // Try compact timestamp format (number_number) + if (/^\d*_\d*$/.test(value)) { + const [startStr, endStr] = value.split('_') + return { + from: startStr ? new Date(Number.parseInt(startStr, 10) * 1000) : undefined, + to: endStr ? new Date(Number.parseInt(endStr, 10) * 1000) : undefined, + } + } + + // Backward compatibility: try JSON format + try { + const parsed = JSON.parse(value) + return { + from: parsed.from ? new Date(parsed.from) : undefined, + to: parsed.to ? new Date(parsed.to) : undefined, + } + } + catch { + return null + } +} + +/** + * Serializes a single date to ISO string format + * + * @param value - Date object + * @returns ISO string representation or empty string if invalid + */ +export function serializeDate(value: Date | null): string { + if (!value || !(value instanceof Date) || Number.isNaN(value.getTime())) { + return '' + } + return value.toISOString() +} + +/** + * Deserializes a single date from ISO string format + * + * @param value - ISO date string + * @returns Date object or null if invalid + */ +export function deserializeDate(value: string): Date | null { + if (!value || value === '' || value === 'null') { + return null + } + + try { + const date = new Date(value) + return !Number.isNaN(date.getTime()) ? date : null + } + catch { + return null + } +} + +/** + * Checks if a string matches the timestamp_timestamp date range format + * + * @param value - String to check + * @returns true if the string is in timestamp_timestamp format + */ +export function isDateRangeFormat(value: string): boolean { + return /^\d+_\d+$/.test(value) +} diff --git a/packages/datum-ui/src/components/data-table/utils/global-search.helpers.ts b/packages/datum-ui/src/components/data-table/utils/global-search.helpers.ts new file mode 100644 index 0000000..5aec93b --- /dev/null +++ b/packages/datum-ui/src/components/data-table/utils/global-search.helpers.ts @@ -0,0 +1,291 @@ +import type { ColumnDef, Row } from '@tanstack/react-table' + +/** + * Global search utilities for DataTable + * Provides functions to search across multiple columns with various strategies + */ + +export type MatchMode = 'contains' | 'startsWith' | 'exact' + +export interface GlobalSearchOptions { + searchableColumns?: string[] + excludeColumns?: string[] + caseSensitive?: boolean + matchMode?: MatchMode + searchNestedFields?: boolean +} + +/** + * Normalize search string for comparison + */ +export function normalizeSearchString(value: string, caseSensitive: boolean): string { + if (!value) + return '' + const normalized = value.trim() + return caseSensitive ? normalized : normalized.toLowerCase() +} + +/** + * Match search term against value based on match mode + */ +export function matchSearchTerm(value: string, searchTerm: string, mode: MatchMode): boolean { + if (!value || !searchTerm) + return false + + switch (mode) { + case 'startsWith': + return value.startsWith(searchTerm) + case 'exact': + return value === searchTerm + case 'contains': + default: + return value.includes(searchTerm) + } +} + +/** + * Extract value from nested object using dot notation path + */ +export function getNestedValue(obj: any, path: string): any { + if (!obj || !path) + return undefined + + const keys = path.split('.') + let value = obj + + for (const key of keys) { + if (value === null || value === undefined) + return undefined + value = value[key] + } + + return value +} + +/** + * Convert any value to searchable string + */ +export function valueToSearchableString(value: any): string { + if (value === null || value === undefined) + return '' + + // Handle arrays + if (Array.isArray(value)) { + return value + .map(item => valueToSearchableString(item)) + .filter(Boolean) + .join(' ') + } + + // Handle objects (extract all string values) + if (typeof value === 'object') { + // Handle Date objects + if (value instanceof Date) { + return value.toISOString() + } + + // Extract all values from object + return Object.values(value) + .map(v => valueToSearchableString(v)) + .filter(Boolean) + .join(' ') + } + + // Handle primitives + return String(value) +} + +/** + * Check if a column is searchable based on its definition + */ +export function isColumnSearchable(column: ColumnDef): boolean { + // Check meta.searchable flag + if (column.meta?.searchable === false) { + return false + } + + // If explicitly marked as searchable + if (column.meta?.searchable === true) { + return true + } + + // Auto-detect: has accessorKey or accessorFn + if ('accessorKey' in column || 'accessorFn' in column) { + return true + } + + return false +} + +/** + * Get searchable columns from column definitions + */ +export function getSearchableColumns( + columns: ColumnDef[], + options: GlobalSearchOptions = {}, +): ColumnDef[] { + const { searchableColumns, excludeColumns = [] } = options + + // Filter columns + const filtered = columns.filter((col) => { + const columnId = col.id || (col as any).accessorKey + if (!columnId) + return false + + // Always respect meta.searchable = false (highest priority) + if (col.meta?.searchable === false) { + return false + } + + // Exclude specified columns + if (excludeColumns.includes(columnId)) { + return false + } + + // If searchableColumns specified, only include those + if (searchableColumns && searchableColumns.length > 0) { + return searchableColumns.includes(columnId) + } + + // Otherwise, auto-detect searchable columns + return isColumnSearchable(col) + }) + + return filtered +} + +/** + * Extract searchable value from row for a specific column + */ +export function extractSearchableValue( + row: Row, + column: ColumnDef, + options: GlobalSearchOptions = {}, +): string { + const { searchNestedFields = true } = options + + // Use custom search transform if provided + if (column.meta?.searchTransform) { + const value = row.getValue(column.id!) + return valueToSearchableString(column.meta.searchTransform(value)) + } + + // Use custom search path if provided + if (column.meta?.searchPath) { + const paths = Array.isArray(column.meta.searchPath) + ? column.meta.searchPath + : [column.meta.searchPath] + + const values = paths.map((path: string) => getNestedValue(row.original, path)) + return valueToSearchableString(values) + } + + // Get value using column's accessor + const columnId = column.id || (column as any).accessorKey + if (!columnId) + return '' + + try { + const value = row.getValue(columnId) + + // If nested fields disabled and value is object, skip + if (!searchNestedFields && typeof value === 'object' && value !== null) { + return '' + } + + return valueToSearchableString(value) + } + catch { + // Fallback: try to get value from original data using accessorKey + const accessorKey = (column as any).accessorKey + if (accessorKey) { + const value = getNestedValue(row.original, accessorKey) + return valueToSearchableString(value) + } + + // Last resort: try using columnId as path + const value = getNestedValue(row.original, columnId) + return valueToSearchableString(value) + } +} + +/** + * Create global search filter function for TanStack Table + * + * @param columns - Column definitions + * @param options - Static options (caseSensitive, matchMode, searchNestedFields) + * @param getOptionsRef - Function to get dynamic options (searchableColumns, excludeColumns) + */ +export function createGlobalSearchFilter( + columns: ColumnDef[], + options: GlobalSearchOptions = {}, + getOptionsRef?: () => { searchableColumns?: string[], excludeColumns?: string[] }, +) { + const { caseSensitive = false, matchMode = 'contains', searchNestedFields = true } = options + + // Return filter function + return (row: Row, columnId: string, filterValue: string): boolean => { + // Empty search shows all rows + if (!filterValue || filterValue.trim() === '') { + return true + } + + // Get dynamic options at runtime + const dynamicOptions = getOptionsRef?.() || {} + const columnsToSearch = getSearchableColumns(columns, { + searchableColumns: dynamicOptions.searchableColumns || options.searchableColumns, + excludeColumns: dynamicOptions.excludeColumns || options.excludeColumns, + }) + + const normalizedSearchTerm = normalizeSearchString(filterValue, caseSensitive) + + // Search across all searchable columns + for (const column of columnsToSearch) { + const searchableValue = extractSearchableValue(row, column, { + searchNestedFields, + }) + + const normalizedValue = normalizeSearchString(searchableValue, caseSensitive) + + // Check if matches + if (matchSearchTerm(normalizedValue, normalizedSearchTerm, matchMode)) { + return true // Found match, include this row + } + } + + // No matches found + return false + } +} + +/** + * Get list of column IDs that will be searched + * Useful for displaying which columns are being searched + */ +export function getSearchableColumnIds( + columns: ColumnDef[], + options: GlobalSearchOptions = {}, +): string[] { + const searchableColumns = getSearchableColumns(columns, options) + return searchableColumns + .map(col => col.id || (col as any).accessorKey) + .filter(Boolean) as string[] +} + +/** + * Get human-readable column names for display + */ +export function getSearchableColumnNames( + columns: ColumnDef[], + options: GlobalSearchOptions = {}, +): string[] { + const searchableColumns = getSearchableColumns(columns, options) + return searchableColumns + .map((col) => { + if (typeof col.header === 'string') { + return col.header + } + return col.id || (col as any).accessorKey + }) + .filter(Boolean) as string[] +} diff --git a/packages/datum-ui/src/components/data-table/utils/sort-labels.ts b/packages/datum-ui/src/components/data-table/utils/sort-labels.ts new file mode 100644 index 0000000..2756a47 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/utils/sort-labels.ts @@ -0,0 +1,46 @@ +/** + * Get context-aware sort labels based on column type + */ +export function getSortLabels(sortType?: 'text' | 'number' | 'date' | 'boolean' | 'array', customLabels?: { asc?: string, desc?: string }): { asc: string, desc: string } { + // Use custom labels if provided + if (customLabels?.asc && customLabels?.desc) { + return { + asc: customLabels.asc, + desc: customLabels.desc, + } + } + + // Return type-specific labels + switch (sortType) { + case 'text': + return { + asc: 'A → Z', + desc: 'Z → A', + } + case 'number': + return { + asc: 'Low → High', + desc: 'High → Low', + } + case 'date': + return { + asc: 'Oldest First', + desc: 'Newest First', + } + case 'array': + return { + asc: 'Fewest First', + desc: 'Most First', + } + case 'boolean': + return { + asc: 'False → True', + desc: 'True → False', + } + default: + return { + asc: 'Ascending', + desc: 'Descending', + } + } +} diff --git a/packages/datum-ui/src/components/data-table/utils/sorting.helpers.ts b/packages/datum-ui/src/components/data-table/utils/sorting.helpers.ts new file mode 100644 index 0000000..273afac --- /dev/null +++ b/packages/datum-ui/src/components/data-table/utils/sorting.helpers.ts @@ -0,0 +1,228 @@ +import type { Row, SortingFn } from '@tanstack/react-table' + +/** + * Safely access nested properties using dot notation + * Example: getNestedValue(obj, 'status.registration.registrar.name') + */ +export function getNestedValue(obj: TData, path: string): any { + const keys = path.split('.') + let value: any = obj + + for (const key of keys) { + if (value === null || value === undefined) { + return undefined + } + value = value[key] + } + + return value +} + +/** + * Create an accessor function for nested paths + */ +export function createNestedAccessor(path: string) { + return (row: TData) => getNestedValue(row, path) +} + +/** + * Detect the type of a value for automatic sorting + */ +export function detectValueType(value: any): 'text' | 'number' | 'date' | 'boolean' { + if (value === null || value === undefined) + return 'text' + if (typeof value === 'boolean') + return 'boolean' + if (typeof value === 'number') + return 'number' + if (value instanceof Date) + return 'date' + if (typeof value === 'string') { + // Check if it's a date string (ISO format) + if (/^\d{4}-\d{2}-\d{2}/.test(value)) + return 'date' + // Check if it's a number string + if (!Number.isNaN(Number(value)) && value.trim() !== '') + return 'number' + } + return 'text' +} + +/** + * Sorting function for text/string values + */ +export const textSortingFn: SortingFn = (rowA, rowB, columnId) => { + const a = (rowA.getValue(columnId) as string) ?? '' + const b = (rowB.getValue(columnId) as string) ?? '' + return a.localeCompare(b, undefined, { sensitivity: 'base' }) +} + +/** + * Sorting function for date values + */ +export const dateSortingFn: SortingFn = (rowA, rowB, columnId) => { + const aValue = rowA.getValue(columnId) + const bValue = rowB.getValue(columnId) + + const a = aValue ? new Date(aValue as string).getTime() : 0 + const b = bValue ? new Date(bValue as string).getTime() : 0 + + // Handle invalid dates + if (Number.isNaN(a)) + return Number.isNaN(b) ? 0 : 1 + if (Number.isNaN(b)) + return -1 + + return a - b +} + +/** + * Sorting function for number values + */ +export const numberSortingFn: SortingFn = (rowA, rowB, columnId) => { + const a = (rowA.getValue(columnId) as number) ?? 0 + const b = (rowB.getValue(columnId) as number) ?? 0 + return a - b +} + +/** + * Sorting function for boolean values + */ +export const booleanSortingFn: SortingFn = (rowA, rowB, columnId) => { + const a = rowA.getValue(columnId) as boolean + const b = rowB.getValue(columnId) as boolean + return a === b ? 0 : a ? -1 : 1 +} + +/** + * Sorting function for array values (by length) + */ +export const arraySortingFn: SortingFn = (rowA, rowB, columnId) => { + const a = rowA.getValue(columnId) + const b = rowB.getValue(columnId) + + const aLength = Array.isArray(a) ? a.length : 0 + const bLength = Array.isArray(b) ? b.length : 0 + + return aLength - bLength +} + +/** + * Recursively flatten arrays to extract all values at any nesting level + */ +function flattenDeep(arr: any[]): any[] { + return arr.reduce((acc, val) => { + if (Array.isArray(val)) { + return acc.concat(flattenDeep(val)) + } + return acc.concat(val) + }, []) +} + +/** + * Create a custom sorting function for arrays with nested property sorting + * Example: sortArrayBy('ips.registrantName') will count unique registrant names from nested arrays + * Handles: nameservers[] -> ips[] -> registrantName + */ +export function createArrayPropertySortingFn(propertyPath: string): SortingFn { + return (rowA, rowB, columnId) => { + const aValue = rowA.getValue(columnId) + const bValue = rowB.getValue(columnId) + + if (!Array.isArray(aValue) && !Array.isArray(bValue)) + return 0 + if (!Array.isArray(aValue)) + return 1 + if (!Array.isArray(bValue)) + return -1 + + // Extract unique values from the nested property path, handling nested arrays + const getUniqueValues = (arr: any[]) => { + const values = new Set() + + // Split the path to handle nested navigation + const pathParts = propertyPath.split('.') + + // Start with the root array + let currentLevel: any[] = arr + + // Navigate through each part of the path + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]! + const nextLevel: any[] = [] + + // Flatten current level if it contains arrays + const flattened = flattenDeep(currentLevel) + + // Extract the property from each item + flattened.forEach((item) => { + if (item && typeof item === 'object') { + const value = item[part] + if (value !== null && value !== undefined) { + nextLevel.push(value) + } + } + }) + + currentLevel = nextLevel + } + + // Flatten final level and collect unique values + const finalValues = flattenDeep(currentLevel) + finalValues.forEach((value) => { + if (value !== null && value !== undefined) { + values.add(String(value)) + } + }) + + return values.size + } + + const aCount = getUniqueValues(aValue) + const bCount = getUniqueValues(bValue) + + return aCount - bCount + } +} + +/** + * Get sorting function by type + */ +export function getSortingFnByType(type: 'text' | 'number' | 'date' | 'boolean' | 'array', arrayOptions?: { sortArrayBy?: 'length' | string }): SortingFn { + switch (type) { + case 'text': + return textSortingFn + case 'number': + return numberSortingFn + case 'date': + return dateSortingFn + case 'boolean': + return booleanSortingFn + case 'array': + if (arrayOptions?.sortArrayBy && arrayOptions.sortArrayBy !== 'length') { + return createArrayPropertySortingFn(arrayOptions.sortArrayBy) + } + return arraySortingFn + default: + return textSortingFn + } +} + +/** + * Auto-detect and apply sorting function based on column meta and data + */ +export function autoDetectSortingFn(rows: Row[], columnId: string): SortingFn { + if (rows.length === 0) + return textSortingFn + + // Get first non-null value to detect type + const sampleValue = rows + .find((row) => { + const value = row.getValue(columnId) + return value !== null && value !== undefined + }) + ?.getValue(columnId) + + const detectedType = detectValueType(sampleValue) + return getSortingFnByType(detectedType) +} diff --git a/packages/datum-ui/src/components/data-table/utils/time-range-serialization.ts b/packages/datum-ui/src/components/data-table/utils/time-range-serialization.ts new file mode 100644 index 0000000..1262056 --- /dev/null +++ b/packages/datum-ui/src/components/data-table/utils/time-range-serialization.ts @@ -0,0 +1,70 @@ +// app/modules/datum-ui/components/data-table/utils/time-range-serialization.ts +import type { TimeRangeValue } from '../../time-range-picker' + +/** + * Serialize TimeRangeValue for URL + * + * Format: + * - Preset: "p:1h" (just the preset key - timestamps recalculated on deserialize) + * - Custom: "c:1767930600000_1768571400000" (millisecond timestamps) + */ +export function serializeTimeRange(value: TimeRangeValue | null): string { + if (!value || !value.from || !value.to) + return '' + + // For presets, just store the key - timestamps will be recalculated + // This keeps URLs short and ensures "Last hour" always means "last hour from now" + if (value.type === 'preset' && value.preset) { + return `p:${value.preset}` + } + + // For custom ranges, store millisecond timestamps (shorter, no encoding needed) + if (value.type === 'custom') { + const fromMs = new Date(value.from).getTime() + const toMs = new Date(value.to).getTime() + return `c:${fromMs}_${toMs}` + } + + return '' +} + +/** + * Deserialize URL string to TimeRangeValue + * Note: For presets, only the key is returned - caller must calculate timestamps + */ +export function deserializeTimeRange(value: string): TimeRangeValue | null { + if (!value) + return null + + // Preset format: "p:1h" + if (value.startsWith('p:')) { + const preset = value.slice(2) + return { + type: 'preset', + preset, + from: '', // Will be calculated by the component + to: '', + } + } + + // Custom format: "c:1767930600000_1768571400000" (millisecond timestamps) + if (value.startsWith('c:')) { + const content = value.slice(2) + const separatorIndex = content.indexOf('_') + if (separatorIndex > 0) { + const fromMs = Number.parseInt(content.slice(0, separatorIndex), 10) + const toMs = Number.parseInt(content.slice(separatorIndex + 1), 10) + + // Validate parsed values + if (!Number.isNaN(fromMs) && !Number.isNaN(toMs)) { + return { + type: 'custom', + from: new Date(fromMs).toISOString(), + to: new Date(toMs).toISOString(), + } + } + } + } + + return null +} diff --git a/packages/datum-ui/src/components/dialog/README.md b/packages/datum-ui/src/components/dialog/README.md new file mode 100644 index 0000000..a963601 --- /dev/null +++ b/packages/datum-ui/src/components/dialog/README.md @@ -0,0 +1,180 @@ +# Dialog Component + +A compound dialog component built on top of shadcn's Dialog with consistent Datum UI styling. + +## Usage + +```tsx +import { Dialog } from '@datum-ui/components'; + +function MyComponent() { + const [open, setOpen] = useState(false); + + return ( + + + + + + + setOpen(false)} + /> + +

Your content here

+
+ + + + +
+
+ ); +} +``` + +## Components + +### Dialog + +Root component that manages dialog state. + +| Prop | Type | Default | Description | +| -------------- | ------------------------- | ------- | ------------------------------------ | +| `open` | `boolean` | - | Controlled open state | +| `onOpenChange` | `(open: boolean) => void` | - | Callback when open state changes | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `children` | `ReactNode` | - | Dialog content (Trigger and Content) | + +### Dialog.Trigger + +Button or element that triggers the dialog to open. + +| Prop | Type | Default | Description | +| ---------- | ----------- | ------- | ---------------------------------------- | +| `children` | `ReactNode` | - | Trigger element | +| `asChild` | `boolean` | `true` | Merge props onto child instead of adding | + +### Dialog.Content + +Container for the dialog content. Applies Datum UI styling. + +| Prop | Type | Default | Description | +| ----------- | ----------- | ------- | ---------------- | +| `children` | `ReactNode` | - | Dialog content | +| `className` | `string` | - | Additional class | + +### Dialog.Header + +Header section with title, description, and close button. + +| Prop | Type | Default | Description | +| ------------- | ------------ | ------- | -------------------------------- | +| `title` | `ReactNode` | - | Dialog title (required) | +| `description` | `ReactNode` | - | Optional description below title | +| `onClose` | `() => void` | - | Close button click handler | +| `className` | `string` | - | Additional class | + +### Dialog.Body + +Main content area of the dialog. + +| Prop | Type | Default | Description | +| ----------- | ----------- | ------- | ---------------- | +| `children` | `ReactNode` | - | Body content | +| `className` | `string` | - | Additional class | + +### Dialog.Footer + +Footer section for action buttons. + +| Prop | Type | Default | Description | +| ----------- | ----------- | ------- | ---------------- | +| `children` | `ReactNode` | - | Footer content | +| `className` | `string` | - | Additional class | + +## Examples + +### Basic Dialog + +```tsx + + + + + + {}} /> + +

Are you sure you want to proceed?

+
+ + + +
+
+``` + +### Controlled Dialog + +```tsx +function ControlledDialog() { + const [open, setOpen] = useState(false); + + return ( + <> + + + + setOpen(false)} /> + +

This dialog is controlled externally.

+
+ + + +
+
+ + ); +} +``` + +### Without Footer + +```tsx + + + + + + {}} /> + +

Content without footer actions.

+
+
+
+``` + +### Custom Styling + +```tsx + + + + + + {}} /> + +

Custom padded content in a wider dialog.

+
+ + + + +
+
+``` diff --git a/packages/datum-ui/src/components/dialog/dialog.tsx b/packages/datum-ui/src/components/dialog/dialog.tsx new file mode 100644 index 0000000..f454e5c --- /dev/null +++ b/packages/datum-ui/src/components/dialog/dialog.tsx @@ -0,0 +1,182 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { cn } from '@repo/shadcn/lib/utils' +import { + DialogClose, + DialogDescription, + DialogPortal, + DialogTitle, + DialogTrigger, + Dialog as ShadcnDialog, + DialogFooter as ShadcnDialogFooter, + DialogOverlay as ShadcnDialogOverlay, +} from '@repo/shadcn/ui/dialog' +import * as React from 'react' +import { CloseIcon } from '../icons/close-icon' + +/* ----------------------------------------------------------------------------- + * Dialog Root + * -------------------------------------------------------------------------- */ + +interface DialogProps { + open?: boolean + onOpenChange?: (open: boolean) => void + defaultOpen?: boolean + children: React.ReactNode +} + +function Dialog({ children, ...props }: DialogProps) { + return {children} +} + +/* ----------------------------------------------------------------------------- + * Dialog Overlay + * -------------------------------------------------------------------------- */ + +interface DialogOverlayProps { + className?: string +} + +function DialogOverlay({ className, ...props }: DialogOverlayProps) { + return ( + + ) +} + +/* ----------------------------------------------------------------------------- + * Dialog Trigger + * -------------------------------------------------------------------------- */ + +interface DialogTriggerProps { + children: React.ReactNode + asChild?: boolean +} + +function Trigger({ children, asChild = true }: DialogTriggerProps) { + return {children} +} + +/* ----------------------------------------------------------------------------- + * Dialog Content + * -------------------------------------------------------------------------- */ + +interface DialogContentProps { + children: React.ReactNode + className?: string +} + +function Content({ children, className }: DialogContentProps) { + return ( + + + button:last-child]:hidden', + className, + )} + > + {children} + + + ) +} + +/* ----------------------------------------------------------------------------- + * Dialog Header + * -------------------------------------------------------------------------- */ + +interface DialogHeaderProps { + title: React.ReactNode + description?: React.ReactNode + onClose?: () => void + className?: string + descriptionClassName?: string +} + +function Header({ + title, + description, + onClose, + className, + descriptionClassName, +}: DialogHeaderProps) { + return ( +
+ {title} + {description && ( + + {description} + + )} + + {onClose && ( + + + + )} +
+ ) +} + +/* ----------------------------------------------------------------------------- + * Dialog Body + * -------------------------------------------------------------------------- */ + +interface DialogBodyProps { + children: React.ReactNode + className?: string +} + +function Body({ children, className }: DialogBodyProps) { + return
{children}
+} + +/* ----------------------------------------------------------------------------- + * Dialog Footer + * -------------------------------------------------------------------------- */ + +interface DialogFooterProps { + children: React.ReactNode + className?: string +} + +function Footer({ children, className }: DialogFooterProps) { + return ( + + {children} + + ) +} + +/* ----------------------------------------------------------------------------- + * Compound Component Export + * -------------------------------------------------------------------------- */ + +Dialog.Trigger = Trigger +Dialog.Content = Content +Dialog.Header = Header +Dialog.Body = Body +Dialog.Footer = Footer +Dialog.Overlay = DialogOverlay + +export { Dialog } +export type { + DialogBodyProps, + DialogContentProps, + DialogFooterProps, + DialogHeaderProps, + DialogProps, + DialogTriggerProps, +} diff --git a/packages/datum-ui/src/components/dialog/index.ts b/packages/datum-ui/src/components/dialog/index.ts new file mode 100644 index 0000000..f947ec9 --- /dev/null +++ b/packages/datum-ui/src/components/dialog/index.ts @@ -0,0 +1,9 @@ +export { Dialog } from './dialog' +export type { + DialogBodyProps, + DialogContentProps, + DialogFooterProps, + DialogHeaderProps, + DialogProps, + DialogTriggerProps, +} from './dialog' diff --git a/packages/datum-ui/src/components/dropdown/dropdown.tsx b/packages/datum-ui/src/components/dropdown/dropdown.tsx new file mode 100644 index 0000000..0e63cb3 --- /dev/null +++ b/packages/datum-ui/src/components/dropdown/dropdown.tsx @@ -0,0 +1,225 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { cn } from '@repo/shadcn/lib/utils' +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react' +import * as React from 'react' +import { Icon } from '../icons/icon-wrapper' + +/** + * Datum Dropdown Menu Component + * Extends shadcn DropdownMenu with: + * - Destructive variant for DropdownMenuItem + */ + +function DropdownMenu({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} diff --git a/packages/datum-ui/src/components/dropdown/index.ts b/packages/datum-ui/src/components/dropdown/index.ts new file mode 100644 index 0000000..8954bfd --- /dev/null +++ b/packages/datum-ui/src/components/dropdown/index.ts @@ -0,0 +1 @@ +export * from './dropdown' diff --git a/packages/datum-ui/src/components/dropzone/dropzone.tsx b/packages/datum-ui/src/components/dropzone/dropzone.tsx new file mode 100644 index 0000000..8dc7237 --- /dev/null +++ b/packages/datum-ui/src/components/dropzone/dropzone.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from 'react' +import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone' +import { cn } from '@repo/shadcn/lib/utils' +import { Button } from '@repo/shadcn/ui/button' +import { UploadIcon } from 'lucide-react' +import { createContext, use } from 'react' +import { useDropzone } from 'react-dropzone' +import { Icon } from '../icons/icon-wrapper' + +interface DropzoneContextType { + src?: File[] + accept?: DropzoneOptions['accept'] + maxSize?: DropzoneOptions['maxSize'] + minSize?: DropzoneOptions['minSize'] + maxFiles?: DropzoneOptions['maxFiles'] +} + +function renderBytes(bytes: number) { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] + let size = bytes + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex++ + } + + return `${size.toFixed(2)}${units[unitIndex]}` +} + +const DropzoneContext = createContext(undefined) + +export type DropzoneProps = Omit & { + src?: File[] + className?: string + onDrop?: (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => void + children?: ReactNode +} + +export function Dropzone({ + accept, + maxFiles = 1, + maxSize, + minSize, + onDrop, + onError, + disabled, + src, + className, + children, + ...props +}: DropzoneProps) { + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept, + maxFiles, + maxSize, + minSize, + onError, + disabled, + onDrop: (acceptedFiles, fileRejections, event) => { + if (fileRejections.length > 0) { + const message = fileRejections.at(0)?.errors.at(0)?.message + onError?.(new Error(message)) + return + } + + onDrop?.(acceptedFiles, fileRejections, event) + }, + ...props, + }) + + return ( + + + + ) +} + +function useDropzoneContext() { + const context = use(DropzoneContext) + + if (!context) { + throw new Error('useDropzoneContext must be used within a Dropzone') + } + + return context +} + +export interface DropzoneContentProps { + children?: ReactNode + className?: string + icon?: ReactNode + label?: ReactNode | ((files: File[]) => ReactNode) + description?: ReactNode +} + +const maxLabelItems = 3 + +function defaultContentLabel(files: File[]) { + if (files.length > maxLabelItems) { + return `${new Intl.ListFormat('en').format( + files.slice(0, maxLabelItems).map(file => file.name), + )} and ${files.length - maxLabelItems} more` + } + return new Intl.ListFormat('en').format(files.map(file => file.name)) +} + +export function DropzoneContent({ + children, + className, + icon, + label, + description, +}: DropzoneContentProps) { + const { src } = useDropzoneContext() + + if (!src) { + return null + } + + if (children) { + return children + } + + const renderedLabel = typeof label === 'function' ? label(src) : label + + return ( +
+ {icon ?? } +
+ {renderedLabel ?? defaultContentLabel(src)} +
+
+ {description ?? 'Drag and drop or click to replace'} +
+
+ ) +} + +export interface DropzoneEmptyStateProps { + children?: ReactNode + className?: string + icon?: ReactNode + label?: ReactNode + description?: ReactNode + /** Set to false to hide the auto-generated caption (accepts, size limits) */ + showCaption?: boolean +} + +export function DropzoneEmptyState({ + children, + className, + icon, + label, + description, + showCaption = false, +}: DropzoneEmptyStateProps) { + const { src, accept, maxSize, minSize } = useDropzoneContext() + + if (src) { + return null + } + + if (children) { + return children + } + + let caption = '' + + if (showCaption) { + if (accept) { + caption += 'Accepts ' + caption += new Intl.ListFormat('en').format(Object.keys(accept)) + } + + if (minSize && maxSize) { + caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}` + } + else if (minSize) { + caption += ` at least ${renderBytes(minSize)}` + } + else if (maxSize) { + caption += ` less than ${renderBytes(maxSize)}` + } + } + + return ( +
+ {icon ?? } + {label &&

{label}

} + {description && ( +
{description}
+ )} + {caption && ( +

+ {caption} + . +

+ )} +
+ ) +} diff --git a/packages/datum-ui/src/components/dropzone/index.ts b/packages/datum-ui/src/components/dropzone/index.ts new file mode 100644 index 0000000..19ef8f5 --- /dev/null +++ b/packages/datum-ui/src/components/dropzone/index.ts @@ -0,0 +1,2 @@ +export { Dropzone, DropzoneContent, DropzoneEmptyState } from './dropzone' +export type { DropzoneContentProps, DropzoneEmptyStateProps, DropzoneProps } from './dropzone' diff --git a/packages/datum-ui/src/components/empty-content/empty-content.tsx b/packages/datum-ui/src/components/empty-content/empty-content.tsx new file mode 100644 index 0000000..beb4730 --- /dev/null +++ b/packages/datum-ui/src/components/empty-content/empty-content.tsx @@ -0,0 +1,242 @@ +import type { VariantProps } from 'class-variance-authority' +import { cn } from '@repo/shadcn/lib/utils' +import { cva } from 'class-variance-authority' +import { Button } from '../button/button' + +export interface EmptyContentAction { + type: 'button' | 'link' | 'external-link' + label: string + onClick?: () => void + to?: string + variant?: 'default' | 'destructive' | 'outline' + icon?: React.ReactNode + iconPosition?: 'start' | 'end' +} + +// Container variants +const containerVariants = cva( + 'flex items-center justify-center relative overflow-hidden bg-[#F4F3F2] dark:bg-transparent', + { + variants: { + variant: { + default: 'rounded-lg border border-border', + dashed: 'rounded-lg border border-dashed border-border', + minimal: '', + }, + size: { + xs: 'h-32 p-3', + sm: 'h-48 p-4', + md: 'h-[226px] py-7 px-6', + lg: 'h-80 p-8', + xl: 'h-96 p-12', + }, + orientation: { + vertical: 'flex-col', + horizontal: 'flex-row', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + orientation: 'vertical', + }, + }, +) + +// Title variants +const titleVariants = cva('font-normal text-foreground text-center', { + variants: { + size: { + xs: 'text-xs', + sm: 'text-sm', + md: 'text-sm', + lg: 'text-base', + xl: 'text-lg', + }, + }, + defaultVariants: { + size: 'md', + }, +}) + +// Subtitle variants +const subtitleVariants = cva('text-muted-foreground text-center text-xs font-normal', { + variants: { + size: { + xs: 'text-xs', + sm: 'text-xs', + md: 'text-xs', + lg: 'text-sm', + xl: 'text-base', + }, + }, + defaultVariants: { + size: 'md', + }, +}) + +// Actions container variants +const actionsContainerVariants = cva('flex items-center flex-col', { + variants: { + size: { + xs: 'gap-1', + sm: 'gap-1', + md: 'gap-1.5', + lg: 'gap-2', + xl: 'gap-3', + }, + spacing: { + compact: '', + normal: '', + relaxed: '', + }, + }, + defaultVariants: { + size: 'sm', + spacing: 'normal', + }, +}) + +// Action button variants +const actionButtonVariants = cva('flex items-center gap-1', { + variants: { + size: { + xs: 'text-xs', + sm: 'text-xs', + md: 'text-xs', + lg: 'text-sm', + xl: 'text-sm', + }, + }, + defaultVariants: { + size: 'md', + }, +}) + +// Size to button size mapping +const BUTTON_SIZE_MAP = { + xs: 'xs', + sm: 'xs', + md: 'xs', + lg: 'default', + xl: 'default', +} as const + +export interface EmptyContentProps extends VariantProps { + title?: string + subtitle?: string + className?: string + actions?: EmptyContentAction[] + spacing?: 'compact' | 'normal' | 'relaxed' + /** User's display name for greeting (e.g., "Hey John, ..."). Defaults to "there". */ + userName?: string + /** Link component for action links (e.g., React Router's Link). Defaults to
. */ + linkComponent?: React.ElementType +} + +export function EmptyContent({ + title = 'No data found', + subtitle, + variant = 'default', + size = 'md', + className, + actions = [], + orientation = 'vertical', + spacing = 'normal', + userName, + linkComponent, +}: EmptyContentProps) { + const buttonSize = BUTTON_SIZE_MAP[size ?? 'md'] + const LinkComp = linkComponent || 'a' + + const renderAction = (action: EmptyContentAction) => { + const { icon: actionIcon, iconPosition = 'start' } = action + + const buttonContent = ( + + ) + + if (action.type === 'link' || action.type === 'external-link') { + const linkProps = LinkComp === 'a' ? { href: action.to ?? '' } : { to: action.to ?? '' } + + return ( + + {buttonContent} + + ) + } + + return ( + + ) + } + + return ( +
+ {/* Decorative corner images */} + + + + + +
+

+ {`Hey ${userName ?? 'there'}, ${title || ''}`} +

+ {subtitle && {subtitle}} + {actions.length > 0 && ( +
+ {actions.map(renderAction)} +
+ )} + + +
+
+ ) +} diff --git a/packages/datum-ui/src/components/empty-content/index.ts b/packages/datum-ui/src/components/empty-content/index.ts new file mode 100644 index 0000000..3383fa7 --- /dev/null +++ b/packages/datum-ui/src/components/empty-content/index.ts @@ -0,0 +1,2 @@ +export { EmptyContent } from './empty-content' +export type { EmptyContentAction, EmptyContentProps } from './empty-content' diff --git a/packages/datum-ui/src/components/file-input-button/file-input-button.tsx b/packages/datum-ui/src/components/file-input-button/file-input-button.tsx new file mode 100644 index 0000000..3859e98 --- /dev/null +++ b/packages/datum-ui/src/components/file-input-button/file-input-button.tsx @@ -0,0 +1,139 @@ +import type { ChangeEvent } from 'react' +import type { ButtonProps } from '../button' +import { UploadIcon } from 'lucide-react' +import { useRef } from 'react' +import { Button } from '../button' +import { Icon } from '../icons/icon-wrapper' + +export interface FileInputButtonProps extends Omit { + /** Accepted file types (e.g., { 'text/plain': ['.txt', '.zone'] }) */ + accept?: Record + /** Maximum file size in bytes */ + maxSize?: number + /** Minimum file size in bytes */ + minSize?: number + /** Allow multiple file selection */ + multiple?: boolean + /** Callback when files are selected */ + onFileSelect?: (files: File[]) => void + /** Callback when a file validation error occurs (file type/size) */ + onFileError?: (error: Error) => void +} + +/** + * A simple button that triggers a file input dialog. + * Alternative to Dropzone for cases where drag-and-drop is not needed. + * + * @example + * ```tsx + * console.log('Selected:', files)} + * onFileError={(error) => console.error(error.message)} + * > + * Upload File + * + * ``` + */ +export function FileInputButton({ + accept, + maxSize, + minSize, + multiple = false, + onFileSelect, + onFileError, + children, + icon, + disabled, + ...buttonProps +}: FileInputButtonProps) { + const inputRef = useRef(null) + + const handleClick = () => { + inputRef.current?.click() + } + + const handleChange = (event: ChangeEvent) => { + const fileList = event.target.files + if (!fileList || fileList.length === 0) + return + + const files = Array.from(fileList) + + // Validate each file + for (const file of files) { + // Validate file type if accept is specified + if (accept) { + const acceptedTypes = Object.keys(accept) + const acceptedExtensions = Object.values(accept).flat() + + const isValidType = acceptedTypes.some((type) => { + if (type.endsWith('/*')) { + // Handle wildcard types like 'image/*' + const baseType = type.replace('/*', '') + return file.type.startsWith(baseType) + } + return file.type === type + }) + + const isValidExtension = acceptedExtensions.some(ext => + file.name.toLowerCase().endsWith(ext.toLowerCase()), + ) + + if (!isValidType && !isValidExtension) { + onFileError?.(new Error(`File type not accepted: ${file.name}`)) + // Reset input so the same file can be selected again + event.target.value = '' + return + } + } + + // Validate file size + if (maxSize && file.size > maxSize) { + onFileError?.(new Error(`File is too large: ${file.name}`)) + event.target.value = '' + return + } + + if (minSize && file.size < minSize) { + onFileError?.(new Error(`File is too small: ${file.name}`)) + event.target.value = '' + return + } + } + + onFileSelect?.(files) + + // Reset input so the same file can be selected again + event.target.value = '' + } + + // Convert accept object to HTML accept attribute format + const acceptAttribute = accept + ? [...Object.keys(accept), ...Object.values(accept).flat()].join(',') + : undefined + + return ( + <> + + + + ) +} diff --git a/packages/datum-ui/src/components/file-input-button/index.ts b/packages/datum-ui/src/components/file-input-button/index.ts new file mode 100644 index 0000000..f439ebe --- /dev/null +++ b/packages/datum-ui/src/components/file-input-button/index.ts @@ -0,0 +1 @@ +export { FileInputButton } from './file-input-button' diff --git a/packages/datum-ui/src/components/form/README.md b/packages/datum-ui/src/components/form/README.md new file mode 100644 index 0000000..8507f6a --- /dev/null +++ b/packages/datum-ui/src/components/form/README.md @@ -0,0 +1,574 @@ +# Datum Form Library + +A compound component pattern form library built on top of Conform.js and Zod for easy form creation with built-in validation, error handling, and accessibility features. + +## Installation + +The library is part of the datum-ui module. Import it directly: + +```tsx +import { Form } from '@datum-ui/components/form'; +``` + +## Quick Start + +### Basic Form + +```tsx +import { Form } from '@datum-ui/components/form'; +import { z } from 'zod'; + +const userSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Invalid email address'), + role: z.enum(['admin', 'user', 'viewer']), +}); + +function UserForm() { + return ( + { + console.log('Form submitted:', data); + await saveUser(data); + }}> + + + + + + + + + + + Admin + User + Viewer + + + + Create User + + ); +} +``` + +## Components + +### Core Components + +#### `Form.Root` + +The root form component that provides context to all children. Supports two patterns: + +**Standard Pattern** - For simple forms: + +```tsx + {}} // Client-side submit handler + action="/api/users" // OR: React Router action path + method="POST" // HTTP method (default: POST) + defaultValues={{ role: 'user' }} // Default form values + mode="onBlur" // Validation mode: onBlur | onChange | onSubmit + isSubmitting={false} // External submitting state (e.g., from useFetcher) + onError={(errors) => {}} // Validation error callback + onSuccess={(data) => {}} // Success callback + className="space-y-4" // Additional CSS classes +> + {children} + +``` + +**Render Function Pattern** - For accessing form state: + +```tsx + + {({ form, fields, isSubmitting, submit, reset }) => ( + <> + + + + + {/* Direct access to form state - no Form.Custom needed */} + + + {/* Access field values directly */} + {fields.email?.value &&

Current: {fields.email.value}

} + + Save + + )} +
+``` + +The render function receives: + +- `form` - Conform form metadata (for `form.update()`, `form.reset()`, etc.) +- `fields` - All form fields with their metadata and values +- `isSubmitting` - Whether the form is currently submitting +- `submit` - Function to programmatically submit the form +- `reset` - Function to reset form to default values + +#### `Form.Field` + +Field wrapper with automatic label and error handling. Supports two patterns: + +**Standard Pattern** - For built-in Form inputs: + +```tsx + + + +``` + +**Render Function Pattern** - For custom components needing field access: + +```tsx + + {({ control, meta, fields, field, form, isSubmitting }) => ( + control.change(value)} + disabled={meta.disabled || isSubmitting} + /> + )} + +``` + +The render function receives: + +- `field` - Conform field metadata +- `control` - Input control with `value`, `change()`, `blur()`, `focus()` +- `meta` - Field meta: `name`, `id`, `errors`, `required`, `disabled` +- `fields` - All form fields (for multi-field scenarios) +- `form` - Form metadata +- `isSubmitting` - Whether form is submitting + +**Multi-Field Example** - When one field affects another: + +```tsx + + {({ control, meta, fields }) => { + // Access another field's control + const namespaceControl = useInputControl(fields.roleNamespace as any); + + return ( + { + control.change(value.role); + namespaceControl.change(value.namespace); + }} + /> + ); + }} + +``` + +#### `Form.Submit` + +Submit button with automatic loading state. + +```tsx + + Save Changes + +``` + +#### `Form.Button` + +Non-submit button for actions like cancel, reset, etc. Automatically disabled during form submission. + +```tsx +// Cancel button (defaults to quaternary/borderless style) + navigate(-1)}> + Cancel + + +// Reset button with custom styling + form.reset()} type="secondary" theme="light"> + Reset Form + + +// Button that stays enabled during submission + + Need Help? + +``` + +Props: + +- `onClick` - Click handler +- `type` - Button variant: `primary` | `secondary` | `tertiary` | `quaternary` (default) +- `theme` - Button theme: `solid` | `light` | `borderless` (default) +- `size` - Button size: `small` | `default` | `large` +- `disabled` - Disable button manually +- `disableOnSubmit` - Auto-disable during form submission (default: `true`) + +### Input Components + +#### `Form.Input` + +```tsx + + + +``` + +#### `Form.Textarea` + +```tsx + + + +``` + +#### `Form.Select` + +```tsx + + + United States + United Kingdom + + Canada + + + +``` + +#### `Form.Checkbox` + +```tsx + + + +``` + +#### `Form.Switch` + +```tsx + + + +``` + +#### `Form.RadioGroup` + +```tsx + + + + + + + +``` + +### Advanced Components + +#### `Form.When` - Conditional Rendering + +Render children based on field values. + +```tsx +// Render when field equals value + + + + +// Render when field does not equal value + + + + +// Render when field value is in array + + + + +// Render when field value is not in array + + + +``` + +#### `Form.FieldArray` - Dynamic Fields + +Manage arrays of form fields. + +```tsx + + {({ fields, append, remove, move }) => ( + <> + {fields.map((field, index) => ( +
+ + + + + + Admin + Member + + + +
+ ))} + + + )} +
+``` + +#### `Form.Custom` - Escape Hatch + +Access raw form context for complex use cases. + +```tsx + + {({ form, fields, submit, reset }) => ( + { + // Custom logic + submit(); + }} + /> + )} + +``` + +### Stepper Components + +#### Multi-Step Form + +```tsx +const steps = [ + { id: 'account', label: 'Account', description: 'Create your account', schema: accountSchema }, + { id: 'profile', label: 'Profile', schema: profileSchema }, + { id: 'confirm', label: 'Confirm', schema: confirmSchema }, +]; + + { + console.log('All data:', data); + await submitForm(data); + }} + onStepChange={(stepId, direction) => { + console.log(`Moving ${direction} to step: ${stepId}`); + }} + initialStep="account"> + + + + + + + + + + + + + + + + + + + + + +

Please review your information before submitting.

+
+ + (isFirst ? 'Cancel' : 'Previous')} + nextLabel={(isLast) => (isLast ? 'Submit' : 'Next')} + /> +
; +``` + +## Hooks + +### `Form.useFormContext()` + +Access the form context from any component inside `Form.Root`. + +```tsx +function MyComponent() { + const { form, fields, isSubmitting, submit, reset } = Form.useFormContext(); + + return ( + + ); +} +``` + +### `Form.useFieldContext()` + +Access the current field context from inside `Form.Field`. + +```tsx +function MyInput() { + const { name, id, errors, required, disabled, fieldMeta } = Form.useFieldContext(); + + return ( + + ); +} +``` + +### `Form.useField(name)` + +Access and control a specific field by name. + +```tsx +function MyCustomField({ name }: { name: string }) { + const { field, control, meta, errors } = Form.useField(name); + + return ( + control.change(e.target.value)} + onBlur={control.blur} + /> + ); +} +``` + +### `Form.useWatch(name)` + +Watch a field's value and re-render when it changes. + +```tsx +function PriceDisplay() { + const quantity = Form.useWatch('quantity'); + const price = Form.useWatch('price'); + + const total = (Number(quantity) || 0) * (Number(price) || 0); + + return

Total: ${total.toFixed(2)}

; +} +``` + +### `Form.useStepper()` + +Access stepper context inside `Form.Stepper`. + +```tsx +function StepIndicator() { + const { current, currentIndex, steps, isFirst, isLast } = Form.useStepper(); + + return ( +

+ Step {currentIndex + 1} of {steps.length}: {current.label} +

+ ); +} +``` + +## TypeScript Support + +All components are fully typed. Import types directly: + +```tsx +import { Form } from '@datum-ui/components/form'; +import type { FormRootProps, FormFieldProps, StepConfig } from '@datum-ui/components/form'; +``` + +## Migration from Old Form System + +### Before + +```tsx +const [form, fields] = useForm({ + id: 'user-form', + constraint: getZodConstraint(userSchema), + shouldValidate: 'onBlur', + shouldRevalidate: 'onInput', + onValidate({ formData }) { + return parseWithZod(formData, { schema: userSchema }); + }, +}); + + +
+ + + + + + +
; +``` + +### After + +```tsx + + + + + Submit + +``` + +## Accessibility + +All components include built-in accessibility features: + +- ARIA labels and descriptions +- Error announcements with `role="alert"` +- Keyboard navigation support +- Required field indicators +- Focus management + +## Dependencies + +- `@conform-to/react` - Form state management +- `@conform-to/zod` - Zod integration +- `zod` - Schema validation +- `@radix-ui/*` - UI primitives +- `@shadcn/lib/utils` - Utility functions diff --git a/packages/datum-ui/src/components/form/components/form-autocomplete.tsx b/packages/datum-ui/src/components/form/components/form-autocomplete.tsx new file mode 100644 index 0000000..0c8dd3b --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-autocomplete.tsx @@ -0,0 +1,73 @@ +import type { AutocompleteOption, FormAutocompleteProps } from '../../autocomplete/autocomplete.types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import { Autocomplete } from '../../autocomplete' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Autocomplete - Searchable select component + * + * Automatically wired to the parent Form.Field context. + * Supports flat/grouped options, virtualization, custom rendering, and async search. + * + * @example Basic usage + * ```tsx + * + * + * + * ``` + * + * @example Async search + * ```tsx + * + * + * + * ``` + * + * @example Grouped options + * ```tsx + * + * + * + * ``` + */ +export function FormAutocomplete({ + disabled, + className, + ...props +}: FormAutocompleteProps) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + const control = useInputControl(fieldMeta as any) + + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + // Ensure value is always a string + const selectValue = Array.isArray(control.value) ? control.value[0] : control.value + + return ( + + {...props} + name={fieldMeta.name} + id={fieldMeta.id} + value={selectValue ?? ''} + onValueChange={control.change} + disabled={isDisabled} + triggerClassName={cn(hasErrors && 'border-destructive', props.triggerClassName)} + className={className} + /> + ) +} + +FormAutocomplete.displayName = 'Form.Autocomplete' diff --git a/packages/datum-ui/src/components/form/components/form-button.tsx b/packages/datum-ui/src/components/form/components/form-button.tsx new file mode 100644 index 0000000..676bde9 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-button.tsx @@ -0,0 +1,52 @@ +import type { FormButtonProps } from '../types' +import * as React from 'react' +import { Button } from '../..' +import { useFormContext } from '../context/form-context' + +/** + * Form.Button - A button for non-submit actions within a form + * + * Automatically gets disabled when the form is submitting. + * Use this for cancel buttons, reset buttons, or other actions. + * + * @example + * ```tsx + * navigate(-1)}> + * Cancel + * + * + * form.reset()} type="secondary"> + * Reset + * + * ``` + */ +export function FormButton({ + children, + onClick, + type = 'quaternary', + theme = 'borderless', + size, + disabled, + className, + disableOnSubmit = true, +}: FormButtonProps) { + const { isSubmitting } = useFormContext() + + const isDisabled = disabled || (disableOnSubmit && isSubmitting) + + return ( + + ) +} + +FormButton.displayName = 'Form.Button' diff --git a/packages/datum-ui/src/components/form/components/form-checkbox.tsx b/packages/datum-ui/src/components/form/components/form-checkbox.tsx new file mode 100644 index 0000000..04abc21 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-checkbox.tsx @@ -0,0 +1,63 @@ +import type { FormCheckboxProps } from '../types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Checkbox } from '../../checkbox' +import { Label } from '../../label' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Checkbox - Checkbox input component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function FormCheckbox({ label, disabled, className }: FormCheckboxProps) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const control = useInputControl(fieldMeta as any) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + // Convert string value to boolean + const isChecked = control.value === 'on' || control.value === 'true' + + const handleCheckedChange = (checked: boolean) => { + control.change(checked ? 'on' : '') + } + + const checkboxId = fieldMeta.id + + return ( +
+ + {label && ( + + )} +
+ ) +} + +FormCheckbox.displayName = 'Form.Checkbox' diff --git a/packages/datum-ui/src/components/form/components/form-copy-box.tsx b/packages/datum-ui/src/components/form/components/form-copy-box.tsx new file mode 100644 index 0000000..c327cf6 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-copy-box.tsx @@ -0,0 +1,124 @@ +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import { CheckIcon, CopyIcon } from 'lucide-react' +import * as React from 'react' +import { Button, toast } from '../..' +import { useCopyToClipboard } from '../../../hooks/use-copy-to-clipboard' +import { useFieldContext } from '../context/field-context' + +export interface FormCopyBoxProps { + /** Display variant: 'default' shows Copy button, 'icon-only' shows icon */ + variant?: 'default' | 'icon-only' + /** Custom className for the wrapper */ + className?: string + /** Custom className for the content area */ + contentClassName?: string + /** Custom className for the button */ + buttonClassName?: string + /** Placeholder text when value is empty */ + placeholder?: string +} + +/** + * Form.CopyBox - Read-only field with copy-to-clipboard functionality + * + * Displays field value in a read-only box with a copy button. + * Automatically gets value from Form.Field context. + * + * @example Basic usage + * ```tsx + * + * + * + * ``` + * + * @example With icon-only button + * ```tsx + * + * + * + * ``` + * + * @example With placeholder + * ```tsx + * + * + * + * ``` + */ +export function FormCopyBox({ + variant = 'default', + className, + contentClassName, + buttonClassName, + placeholder = '', +}: FormCopyBoxProps) { + const { fieldMeta } = useFieldContext() + // Cast to any to bypass TypeScript's strict checking for useInputControl + // This is safe because fieldMeta comes from Conform and has the right shape + const control = useInputControl(fieldMeta as any) + const [_, copy] = useCopyToClipboard() + const [copied, setCopied] = React.useState(false) + + // Get the reactive value from input control + const value = control.value ?? placeholder + + const copyToClipboard = () => { + const stringValue = String(value) + if (!stringValue) + return + + copy(stringValue).then(() => { + toast.success('Copied to clipboard') + setCopied(true) + setTimeout(() => { + setCopied(false) + }, 2000) + }) + } + + return ( +
+
+ {String(value)} +
+
+ {variant === 'icon-only' + ? ( + + ) + : ( + + )} +
+
+ ) +} diff --git a/packages/datum-ui/src/components/form/components/form-custom.tsx b/packages/datum-ui/src/components/form/components/form-custom.tsx new file mode 100644 index 0000000..a7bf6d9 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-custom.tsx @@ -0,0 +1,40 @@ +import type { FormCustomProps, FormCustomRenderProps } from '../types' +import * as React from 'react' +import { useFormContext } from '../context/form-context' + +/** + * Form.Custom - Escape hatch for custom implementations + * + * Provides access to the underlying form context for complex use cases + * that don't fit the standard component patterns. + * + * @example + * ```tsx + * + * {({ form, fields, submit, reset }) => ( + * { + * // Do something custom + * submit(); + * }} + * /> + * )} + * + * ``` + */ +export function FormCustom({ children }: FormCustomProps) { + const { form, fields, isSubmitting, submit, reset } = useFormContext() + + const renderProps: FormCustomRenderProps = { + form: form as any, + fields: fields as any, + isSubmitting, + submit, + reset, + } + + return <>{children(renderProps)} +} + +FormCustom.displayName = 'Form.Custom' diff --git a/packages/datum-ui/src/components/form/components/form-description.tsx b/packages/datum-ui/src/components/form/components/form-description.tsx new file mode 100644 index 0000000..bfd8788 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-description.tsx @@ -0,0 +1,30 @@ +import type { FormDescriptionProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { useOptionalFieldContext } from '../context/field-context' + +/** + * Form.Description - Display field description/helper text + * + * @example + * ```tsx + * + * + * + * Must be at least 8 characters with one uppercase letter + * + * + * ``` + */ +export function FormDescription({ children, className }: FormDescriptionProps) { + const fieldContext = useOptionalFieldContext() + const id = fieldContext ? `${fieldContext.id}-description` : undefined + + return ( +

+ {children} +

+ ) +} + +FormDescription.displayName = 'Form.Description' diff --git a/packages/datum-ui/src/components/form/components/form-dialog.tsx b/packages/datum-ui/src/components/form/components/form-dialog.tsx new file mode 100644 index 0000000..1f3d28c --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-dialog.tsx @@ -0,0 +1,201 @@ +import type { z } from 'zod' +import type { FormDialogProps, FormRootRenderProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Form } from '..' +import { Dialog } from '../../dialog' + +/** + * Form.Dialog - A dialog with an integrated form + * + * Combines Dialog and Form.Root into a single component with: + * - Automatic dialog state management (controlled or uncontrolled) + * - Built-in header with title and description + * - Built-in footer with submit and cancel buttons + * - Auto-close on successful submission + * - Prevents accidental close during submission + * - Supports render function pattern for form state access + * + * @example Basic usage + * ```tsx + * Add User} + * > + * + * + * + * + * + * + * + * ``` + * + * @example With render function for form state access + * ```tsx + * Edit} + * > + * {({ form, fields, isSubmitting, reset }) => ( + * <> + * + * + * + * + * + * )} + * + * ``` + */ +export function FormDialog({ + // Dialog props + open, + onOpenChange, + defaultOpen, + title, + description, + trigger, + + // Form props + schema, + defaultValues, + onSubmit, + onSuccess, + onError, + + // Footer props + submitText = 'Submit', + submitTextLoading = 'Submitting...', + cancelText = 'Cancel', + showCancel = true, + submitType = 'primary', + + // Loading state + loading, + + // Form customization + formComponent, + telemetry, + + // Styling + className, + formClassName, + + // Children + children, +}: FormDialogProps) { + const [internalOpen, setInternalOpen] = React.useState(defaultOpen ?? false) + const [internalIsSubmitting, setInternalIsSubmitting] = React.useState(false) + + // Use external loading if provided, otherwise use internal state + const isSubmitting = loading ?? internalIsSubmitting + + // Determine if controlled or uncontrolled + const isControlled = open !== undefined + const isOpen = isControlled ? open : internalOpen + + const handleOpenChange = React.useCallback( + (value: boolean) => { + // Prevent closing while submitting + if (!value && isSubmitting) { + return + } + + if (!isControlled) { + setInternalOpen(value) + } + onOpenChange?.(value) + }, + [isControlled, isSubmitting, onOpenChange], + ) + + const handleSubmit = React.useCallback( + async (data: z.infer) => { + // Only manage internal state if not using external loading + if (loading === undefined) { + setInternalIsSubmitting(true) + } + try { + await onSubmit?.(data) + onSuccess?.(data) + } + catch (error) { + console.error('Form submission error:', error) + throw error + } + finally { + if (loading === undefined) { + setInternalIsSubmitting(false) + } + } + }, + [onSubmit, onSuccess, loading], + ) + + const handleCancel = React.useCallback(() => { + handleOpenChange(false) + }, [handleOpenChange]) + + return ( + + {trigger && {trigger}} + + + + {(renderProps: FormRootRenderProps) => ( + <> + + + + {/* Render children - support both patterns */} + {typeof children === 'function' ? children(renderProps) : children} + + + {showCancel && ( + + {cancelText} + + )} + + {isSubmitting ? submitTextLoading : submitText} + + + + )} + + + + ) +} + +FormDialog.displayName = 'Form.Dialog' diff --git a/packages/datum-ui/src/components/form/components/form-error.tsx b/packages/datum-ui/src/components/form/components/form-error.tsx new file mode 100644 index 0000000..d1d601a --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-error.tsx @@ -0,0 +1,62 @@ +import type { FormErrorProps } from '../types' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { useOptionalFieldContext } from '../context/field-context' + +/** + * Form.Error - Display field errors + * + * Can be used inside Form.Field to display errors automatically, + * or standalone with custom rendering. + * + * @example + * ```tsx + * // Inside Form.Field - displays field errors automatically + * + * + * + * + * + * // Custom rendering + * + * + * + * {(errors) => errors.map(e => {e})} + * + * + * ``` + */ +export function FormError({ children, className }: FormErrorProps) { + const fieldContext = useOptionalFieldContext() + const errors = fieldContext?.errors + + if (!errors || errors.length === 0) { + return null + } + + // Custom render function + if (typeof children === 'function') { + return <>{children(errors)} + } + + // Default rendering + return ( +
    1 && 'list-disc pl-4', + className, + )} + role="alert" + aria-live="polite" + > + {errors.map(error => ( +
  • + {error} +
  • + ))} +
+ ) +} + +FormError.displayName = 'Form.Error' diff --git a/packages/datum-ui/src/components/form/components/form-field-array.tsx b/packages/datum-ui/src/components/form/components/form-field-array.tsx new file mode 100644 index 0000000..fdade9c --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-field-array.tsx @@ -0,0 +1,135 @@ +import type { FormFieldArrayProps, FormFieldArrayRenderProps } from '../types' +import { useFormMetadata } from '@conform-to/react' +import * as React from 'react' +import { useFormContext } from '../context/form-context' + +/** + * Form.FieldArray - Dynamic array of fields + * + * Provides helpers for managing arrays of form fields. + * + * @example + * ```tsx + * + * {({ fields, append, remove }) => ( + * <> + * {fields.map((field, index) => ( + *
+ * + * + * + * + * + * Admin + * User + * + * + * + *
+ * ))} + * + * + * )} + *
+ * ``` + */ +export function FormFieldArray({ name, children }: FormFieldArrayProps) { + const { fields, formId } = useFormContext() + const form = useFormMetadata(formId) + + // Get the array field metadata + const arrayField = React.useMemo(() => { + const parts = name.split('.') + let current: any = fields + + for (const part of parts) { + if (!current) + break + + if (typeof current.getFieldset === 'function') { + current = current.getFieldset()[part] + } + else { + current = current[part] + } + } + + return current + }, [fields, name]) + + // Get the array field name for callbacks (use empty string if not found) + const arrayFieldName = arrayField?.name ?? '' + + // Append handler - defined before early return to follow hooks rules + const append = React.useCallback( + (value: Record = {}) => { + if (!arrayFieldName) + return + form.insert({ + name: arrayFieldName, + defaultValue: value as any, + }) + }, + [form, arrayFieldName], + ) + + // Remove handler - defined before early return to follow hooks rules + const remove = React.useCallback( + (index: number) => { + if (!arrayFieldName) + return + form.remove({ + name: arrayFieldName, + index, + }) + }, + [form, arrayFieldName], + ) + + // Move handler - defined before early return to follow hooks rules + const move = React.useCallback( + (from: number, to: number) => { + if (!arrayFieldName) + return + form.reorder({ + name: arrayFieldName, + from, + to, + }) + }, + [form, arrayFieldName], + ) + + // Early return after all hooks + if (!arrayField) { + console.warn(`Form.FieldArray: Field "${name}" not found in form schema`) + return null + } + + // Get the field list + const fieldList = arrayField.getFieldList?.() ?? [] + + // Create the fields array with id, key, and name + const formFields: FormFieldArrayRenderProps['fields'] = fieldList.map( + (field: any, index: number) => ({ + id: field.id, + key: field.key, + name: `${name}.${index}`, + }), + ) + + const renderProps: FormFieldArrayRenderProps = { + fields: formFields, + append, + remove, + move, + } + + return <>{children(renderProps)} +} + +FormFieldArray.displayName = 'Form.FieldArray' diff --git a/packages/datum-ui/src/components/form/components/form-field.tsx b/packages/datum-ui/src/components/form/components/form-field.tsx new file mode 100644 index 0000000..722e198 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-field.tsx @@ -0,0 +1,310 @@ +import type { FormFieldContextValue, FormFieldProps, FormFieldRenderProps } from '../types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import { CircleHelp } from 'lucide-react' +import * as React from 'react' +import { Tooltip } from '../../tooltip' +import { Icon } from '../../icons/icon-wrapper' +import { Label } from '../../label' +import { FieldProvider } from '../context/field-context' +import { useFormContext } from '../context/form-context' + +/** + * Internal FieldLabel component with hover-reveal tooltip + */ +function FieldLabel({ + htmlFor, + label, + hasErrors, + required, + tooltip, + className, +}: { + htmlFor: string + label: React.ReactNode + hasErrors?: boolean + required?: boolean + tooltip?: string | React.ReactNode + className?: string +}) { + const [isTooltipVisible, setIsTooltipVisible] = React.useState(false) + + return ( +
+ + {tooltip && ( + + + + )} +
+ ) +} + +/** + * Form.Field - Field wrapper component + * + * Provides field context to children with: + * - Automatic label rendering + * - Error display + * - Description text + * - Required indicator + * - Accessibility attributes + * + * Supports two patterns: + * 1. ReactNode children - for standard Form inputs + * 2. Render function - for custom components needing field access + * + * @example Standard usage + * ```tsx + * + * + * + * ``` + * + * @example Render function for custom components + * ```tsx + * + * {({ control, meta, fields }) => ( + * + * )} + * + * ``` + */ +export function FormField({ + name, + children, + label, + description, + tooltip, + required = false, + disabled = false, + className, + labelClassName, +}: FormFieldProps) { + const { fields, form, isSubmitting } = useFormContext() + + // Get field metadata - support nested paths + const fieldMeta = React.useMemo(() => { + const parts = name.split('.') + let current: any = fields + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + if (!current) + break + + // Handle array access like "items.0.name" + if (/^\d+$/.test(part)) { + const fieldList = current.getFieldList?.() + if (fieldList) { + const item = fieldList[Number.parseInt(part, 10)] + // If there are more parts, get the fieldset + if (i < parts.length - 1 && item?.getFieldset) { + current = item.getFieldset() + } + else { + current = item + } + } + else { + current = current[part as keyof typeof current] + } + } + else { + // First check if it's a direct property (top-level field) + if (current[part as keyof typeof current] !== undefined) { + current = current[part as keyof typeof current] + } + else if (typeof current.getFieldset === 'function') { + // Try getFieldset for nested objects + current = current.getFieldset()[part as keyof ReturnType] + } + else { + current = undefined + } + } + } + + return current + }, [fields, name]) + + // Derive values from fieldMeta (may be undefined) + const errors = fieldMeta?.errors + const hasErrors = errors && errors.length > 0 + const fieldId = fieldMeta?.id ?? '' + const descriptionId = description ? `${fieldId}-description` : undefined + const errorId = hasErrors ? `${fieldId}-error` : undefined + + // Context value - defined before early return to follow hooks rules + const contextValue: FormFieldContextValue = React.useMemo( + () => ({ + name: fieldMeta?.name ?? '', + id: fieldId, + errors, + required, + disabled, + fieldMeta, + }), + [fieldMeta, fieldId, errors, required, disabled], + ) + + // Early return after all hooks + if (!fieldMeta) { + console.warn(`Form.Field: Field "${name}" not found in form schema`) + return null + } + + // Determine if children is a render function + const isRenderFunction = typeof children === 'function' + + // Render the field content + const renderContent = () => { + if (isRenderFunction) { + // Use the render function pattern + return ( + + {children} + + ) + } + // Standard ReactNode children + return children + } + + return ( + +
+ {/* Label */} + {label && ( + + )} + + {/* Field Input */} + {renderContent()} + + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Errors */} + {hasErrors && ( +
    1 && 'list-disc pl-4', + )} + role="alert" + aria-live="polite" + > + {errors.map((error: string) => ( +
  • + {error} +
  • + ))} +
+ )} +
+
+ ) +} + +/** + * Internal component to handle render function pattern + * This is needed because hooks (useInputControl) must be called unconditionally + */ +function FormFieldRenderContent({ + fieldMeta, + fields, + form, + isSubmitting, + required, + disabled, + children, +}: { + fieldMeta: any + fields: Record + form: any + isSubmitting: boolean + required: boolean + disabled: boolean + children: (props: FormFieldRenderProps) => React.ReactNode +}) { + const control = useInputControl(fieldMeta) + + const meta = React.useMemo( + () => ({ + name: fieldMeta.name, + id: fieldMeta.id, + errors: fieldMeta.errors, + required, + disabled, + }), + [fieldMeta.name, fieldMeta.id, fieldMeta.errors, required, disabled], + ) + + const renderProps: FormFieldRenderProps = { + field: fieldMeta, + control: { + value: control.value, + change: control.change, + blur: control.blur, + focus: control.focus, + }, + meta, + fields, + form, + isSubmitting, + } + + return <>{children(renderProps)} +} + +FormField.displayName = 'Form.Field' diff --git a/packages/datum-ui/src/components/form/components/form-input-group.tsx b/packages/datum-ui/src/components/form/components/form-input-group.tsx new file mode 100644 index 0000000..b28ed47 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-input-group.tsx @@ -0,0 +1,55 @@ +import type { InputWithAddonsProps } from '../../input-with-addons' +import { getInputProps } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { InputWithAddons } from '../../input-with-addons' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Input - Text input component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function FormInputGroup({ ref, type = 'text', className, disabled, ...props }: InputWithAddonsProps & { ref?: React.RefObject }) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + // getInputProps expects a narrower type than HTMLInputTypeAttribute + // Type assertion is safe here since invalid types will be handled by the input element + const inputProps = getInputProps(fieldMeta, { + type: type as + | 'text' + | 'email' + | 'password' + | 'number' + | 'tel' + | 'url' + | 'search' + | 'date' + | 'time' + | 'datetime-local', + }) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + return ( + + ) +} + +FormInputGroup.displayName = 'Form.InputGroup' diff --git a/packages/datum-ui/src/components/form/components/form-input.tsx b/packages/datum-ui/src/components/form/components/form-input.tsx new file mode 100644 index 0000000..4f826ca --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-input.tsx @@ -0,0 +1,41 @@ +import type { FormInputProps } from '../types' +import { getInputProps } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Input } from '../../input' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Input - Text input component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function FormInput({ ref, type = 'text', className, disabled, ...props }: FormInputProps & { ref?: React.RefObject }) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const inputProps = getInputProps(fieldMeta, { type }) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + return ( + + ) +} + +FormInput.displayName = 'Form.Input' diff --git a/packages/datum-ui/src/components/form/components/form-radio-group.tsx b/packages/datum-ui/src/components/form/components/form-radio-group.tsx new file mode 100644 index 0000000..6930971 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-radio-group.tsx @@ -0,0 +1,90 @@ +import type { FormRadioGroupProps, FormRadioItemProps } from '../types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Label } from '../../label' +import { RadioGroup, RadioGroupItem } from '../../radio-group' +import { useFieldContext } from '../context/field-context' + +/** + * Form.RadioGroup - Radio button group component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * ``` + */ +export function FormRadioGroup({ + orientation = 'vertical', + disabled, + className, + children, +}: FormRadioGroupProps) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const control = useInputControl(fieldMeta as any) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + // Ensure value is always a string for RadioGroup + const radioValue = Array.isArray(control.value) ? control.value[0] : control.value + + return ( + + {children} + + ) +} + +FormRadioGroup.displayName = 'Form.RadioGroup' + +/** + * Form.RadioItem - Individual radio button option + * + * @example + * ```tsx + * + * ``` + */ +export function FormRadioItem({ value, label, description, disabled }: FormRadioItemProps) { + const radioId = `radio-${value}` + + return ( +
+ +
+ + {description && {description}} +
+
+ ) +} + +FormRadioItem.displayName = 'Form.RadioItem' diff --git a/packages/datum-ui/src/components/form/components/form-root.tsx b/packages/datum-ui/src/components/form/components/form-root.tsx new file mode 100644 index 0000000..dafb427 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-root.tsx @@ -0,0 +1,218 @@ +import type { z } from 'zod' +import type { FormRootProps, FormRootRenderProps } from '../types' +import { FormProvider as ConformFormProvider, getFormProps, useForm } from '@conform-to/react' +import { getZodConstraint, parseWithZod } from '@conform-to/zod/v4' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { FormProvider } from '../context/form-context' + +/** + * Form.Root - The root form component + * + * Provides form context to all children with built-in: + * - Zod schema validation + * - Conform integration + * - Optional telemetry callbacks + * + * Supports two patterns: + * 1. ReactNode children - for standard forms + * 2. Render function - for forms needing access to form state + * + * @example Standard usage + * ```tsx + * + * + * + * + * Save + * + * ``` + * + * @example Render function for form state access + * ```tsx + * + * {({ form, fields, isSubmitting }) => ( + * <> + * + * + * + * + * Save + * + * )} + * + * ``` + */ +export function FormRoot({ + schema, + children, + onSubmit, + action, + method = 'POST', + formComponent: FormComp = 'form', + id, + name, + defaultValues, + mode = 'onBlur', + isSubmitting: externalIsSubmitting, + onError, + onSuccess, + telemetry, + className, +}: FormRootProps) { + const [internalIsSubmitting, setInternalIsSubmitting] = React.useState(false) + // Use external isSubmitting if provided, otherwise use internal state + const isSubmitting = externalIsSubmitting ?? internalIsSubmitting + const formRef = React.useRef(null) + + // Map mode to Conform's expected values + const shouldValidate = mode === 'onChange' ? 'onInput' : mode + + const [form, fields] = useForm({ + id, + constraint: getZodConstraint(schema), + shouldValidate, + shouldRevalidate: mode === 'onSubmit' ? 'onSubmit' : 'onInput', + defaultValue: defaultValues as any, + onValidate({ formData }) { + return parseWithZod(formData, { schema }) as any + }, + async onSubmit(event, { submission }) { + const formName = name || id || 'unnamed-form' + + // Track form submission attempt + telemetry?.onSubmit?.({ formName, formId: id }) + + // If no onSubmit handler is provided, let React Router handle the submission + // This allows the form to submit to the current route's action or a specified action + if (!onSubmit) { + // Set submitting state for UI feedback + setInternalIsSubmitting(true) + return + } + + // Client-side submission - prevent default to handle it ourselves + event.preventDefault() + + if (submission?.status === 'success') { + setInternalIsSubmitting(true) + try { + await onSubmit(submission.value as z.infer) + onSuccess?.(submission.value as z.infer) + telemetry?.onSuccess?.({ formName, formId: id }) + } + catch (error) { + telemetry?.onError?.({ formName, formId: id, error: error as Error }) + telemetry?.captureError?.(error as Error, { + message: `Form submission error: ${formName}`, + tags: { 'form.name': formName, 'form.id': id || 'unknown' }, + }) + onError?.(error as z.ZodError>) + } + finally { + setInternalIsSubmitting(false) + } + } + else if (submission?.status === 'error') { + // Track validation errors + telemetry?.onValidationError?.({ + formName, + formId: id, + fieldErrors: (submission.error as Record) ?? {}, + }) + + if (onError) { + // Handle validation errors + const { ZodError } = await import('zod') + const zodError = new ZodError( + Object.entries(submission.error ?? {}).flatMap(([path, messages]) => + (messages ?? []).map(message => ({ + code: 'custom' as const, + path: path.split('.'), + message, + })), + ), + ) + onError(zodError as z.ZodError>) + } + } + }, + }) + + const submit = React.useCallback(() => { + formRef.current?.requestSubmit() + }, []) + + const reset = React.useCallback(() => { + form.reset() + }, [form]) + + const contextValue = React.useMemo( + () => ({ + form: form as any, + fields: fields as unknown as Record, + isSubmitting, + submit, + reset, + formId: form.id, + }), + [form, fields, isSubmitting, submit, reset], + ) + + // Determine if children is a render function + const isRenderFunction = typeof children === 'function' + + // Create render props for render function pattern + const renderProps: FormRootRenderProps = React.useMemo( + () => ({ + form: form as any, + fields: fields as unknown as Record, + isSubmitting, + submit, + reset, + }), + [form, fields, isSubmitting, submit, reset], + ) + + // Render children - either as ReactNode or render function + const renderChildren = () => { + if (isRenderFunction) { + return (children as (props: FormRootRenderProps) => React.ReactNode)(renderProps) + } + return children + } + + // Extract Conform's onSubmit so we can wrap it with stopPropagation. + // This prevents nested forms (e.g. Form.Dialog inside another form) + // from triggering the parent form's Conform handler via React's + // synthetic event bubbling through portals. + const { onSubmit: conformOnSubmit, ...conformFormProps } = getFormProps(form) + + return ( + + + ) => { + e.stopPropagation() + conformOnSubmit(e) + }} + method={method} + action={action} + className={cn('space-y-6', className)} + autoComplete="off" + > + {renderChildren()} + + + + ) +} + +FormRoot.displayName = 'Form.Root' diff --git a/packages/datum-ui/src/components/form/components/form-select.tsx b/packages/datum-ui/src/components/form/components/form-select.tsx new file mode 100644 index 0000000..9d66d90 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-select.tsx @@ -0,0 +1,78 @@ +import type { FormSelectItemProps, FormSelectProps } from '../types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../../select' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Select - Select dropdown component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * United States + * United Kingdom + * Canada + * + * + * ``` + */ +export function FormSelect({ placeholder, disabled, className, children }: FormSelectProps) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const control = useInputControl(fieldMeta as any) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + // Ensure value is always a string for Select + const selectValue = Array.isArray(control.value) ? control.value[0] : control.value + + return ( + + ) +} + +FormSelect.displayName = 'Form.Select' + +/** + * Form.SelectItem - Individual select option + * + * @example + * ```tsx + * Option 1 + * ``` + */ +export function FormSelectItem({ value, children, disabled }: FormSelectItemProps) { + return ( + + {children} + + ) +} + +FormSelectItem.displayName = 'Form.SelectItem' diff --git a/packages/datum-ui/src/components/form/components/form-submit.tsx b/packages/datum-ui/src/components/form/components/form-submit.tsx new file mode 100644 index 0000000..7472057 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-submit.tsx @@ -0,0 +1,27 @@ +import type { FormSubmitProps } from '../types' +import { Button } from '../..' +import { useFormContext } from '../context/form-context' + +/** + * Form.Submit - Submit button with automatic loading state + * + * @example + * ```tsx + * + * Save Changes + * + * ``` + */ +export function FormSubmit({ children, loadingText, loading = false, ...props }: FormSubmitProps) { + const { isSubmitting } = useFormContext() + + const isLoading = loading || isSubmitting + + return ( + + ) +} + +FormSubmit.displayName = 'Form.Submit' diff --git a/packages/datum-ui/src/components/form/components/form-switch.tsx b/packages/datum-ui/src/components/form/components/form-switch.tsx new file mode 100644 index 0000000..94f5e34 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-switch.tsx @@ -0,0 +1,63 @@ +import type { FormSwitchProps } from '../types' +import { useInputControl } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Label } from '../../label' +import { Switch } from '../../switch' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Switch - Toggle switch component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function FormSwitch({ label, disabled, className }: FormSwitchProps) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const control = useInputControl(fieldMeta as any) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + // Convert string value to boolean + const isChecked = control.value === 'on' || control.value === 'true' + + const handleCheckedChange = (checked: boolean) => { + control.change(checked ? 'on' : '') + } + + const switchId = fieldMeta.id + + return ( +
+ + {label && ( + + )} +
+ ) +} + +FormSwitch.displayName = 'Form.Switch' diff --git a/packages/datum-ui/src/components/form/components/form-textarea.tsx b/packages/datum-ui/src/components/form/components/form-textarea.tsx new file mode 100644 index 0000000..76d3fc9 --- /dev/null +++ b/packages/datum-ui/src/components/form/components/form-textarea.tsx @@ -0,0 +1,41 @@ +import type { FormTextareaProps } from '../types' +import { getTextareaProps } from '@conform-to/react' +import { cn } from '@repo/shadcn/lib/utils' +import * as React from 'react' +import { Textarea } from '../../textarea' +import { useFieldContext } from '../context/field-context' + +/** + * Form.Textarea - Multi-line text input component + * + * Automatically wired to the parent Form.Field context. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function FormTextarea({ ref, className, disabled, rows = 3, ...props }: FormTextareaProps & { ref?: React.RefObject }) { + const { fieldMeta, disabled: fieldDisabled, errors } = useFieldContext() + + const textareaProps = getTextareaProps(fieldMeta) + const isDisabled = disabled ?? fieldDisabled + const hasErrors = errors && errors.length > 0 + + return ( +