From 2f7cac993b232a2a0964ae82642ca9474f3e229e Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 19:30:44 +0000 Subject: [PATCH 1/5] LC-219: Implement bazza/ui canary branch filter components --- .../docs/src/ui/data-table-filter.stories.tsx | 451 ++++++++++++++++++ .../src/ui/data-table-filter/README.md | 182 +++++++ .../data-table-filter/data-table-filter.tsx | 317 ++++++++++++ .../src/ui/data-table-filter/i18n.ts | 145 ++++++ .../src/ui/data-table-filter/index.ts | 6 + .../property-filter-item.tsx | 421 ++++++++++++++++ .../data-table-filter/quick-search-filter.tsx | 159 ++++++ .../src/ui/data-table-filter/types.ts | 317 ++++++++++++ .../components/src/ui/data-table/index.ts | 1 + 9 files changed, 1999 insertions(+) create mode 100644 apps/docs/src/ui/data-table-filter.stories.tsx create mode 100644 packages/components/src/ui/data-table-filter/README.md create mode 100644 packages/components/src/ui/data-table-filter/data-table-filter.tsx create mode 100644 packages/components/src/ui/data-table-filter/i18n.ts create mode 100644 packages/components/src/ui/data-table-filter/index.ts create mode 100644 packages/components/src/ui/data-table-filter/property-filter-item.tsx create mode 100644 packages/components/src/ui/data-table-filter/quick-search-filter.tsx create mode 100644 packages/components/src/ui/data-table-filter/types.ts diff --git a/apps/docs/src/ui/data-table-filter.stories.tsx b/apps/docs/src/ui/data-table-filter.stories.tsx new file mode 100644 index 00000000..ad7a87fc --- /dev/null +++ b/apps/docs/src/ui/data-table-filter.stories.tsx @@ -0,0 +1,451 @@ +import * as React from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import { + DataTableFilter, + defineMeta, + filterFn, + type DataTableFilterState, +} from '@lambda-curry/components/ui/data-table-filter'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@lambda-curry/components/ui/table'; +import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'; +import { Badge } from '@lambda-curry/components/ui/badge'; +import { CalendarIcon, CircleDotDashedIcon, TagIcon, UserIcon } from 'lucide-react'; + +// Define the data type +interface Issue { + id: string; + title: string; + status: 'in-progress' | 'todo' | 'done' | 'backlog'; + assignee: string; + labels: string[]; + estimatedHours: number; + startDate?: Date; + endDate?: Date; +} + +// Sample data +const issues: Issue[] = [ + { + id: '1', + title: 'Implement user login', + status: 'in-progress', + assignee: 'JS', + labels: ['Feature'], + estimatedHours: 101, + startDate: new Date('2025-03-28'), + }, + { + id: '2', + title: 'Fix payment processing', + status: 'todo', + assignee: 'RE', + labels: ['Bug'], + estimatedHours: 6, + }, + { + id: '3', + title: 'Design database schema', + status: 'done', + assignee: 'AY', + labels: ['Database', 'Feature'], + estimatedHours: 10, + startDate: new Date('2025-03-24'), + endDate: new Date('2025-03-29'), + }, + { + id: '4', + title: 'Update API docs', + status: 'backlog', + assignee: '', + labels: ['Documentation', 'API'], + estimatedHours: 4, + }, + { + id: '5', + title: 'Optimize frontend', + status: 'in-progress', + assignee: 'MS', + labels: ['Performance', 'User Interface'], + estimatedHours: 12, + startDate: new Date('2025-03-29'), + }, + { + id: '6', + title: 'Add unit tests', + status: 'todo', + assignee: 'JS', + labels: ['Testing', 'Feature'], + estimatedHours: 8, + }, + { + id: '7', + title: 'Implement dark mode', + status: 'done', + assignee: '', + labels: ['Feature', 'User Interface'], + estimatedHours: 6, + startDate: new Date('2025-03-26'), + endDate: new Date('2025-03-31'), + }, + { + id: '8', + title: 'Fix search filter', + status: 'backlog', + assignee: '', + labels: ['Bug', 'User Interface'], + estimatedHours: 3, + }, + { + id: '9', + title: 'Refactor auth middleware', + status: 'todo', + assignee: 'RE', + labels: ['Refactor'], + estimatedHours: 5, + }, + { + id: '10', + title: 'Update user profiles', + status: 'in-progress', + assignee: 'AY', + labels: ['Enhancement', 'User Interface'], + estimatedHours: 7, + startDate: new Date('2025-03-27'), + }, +]; + +// Define status options +const ISSUE_STATUSES = [ + { + label: 'In Progress', + value: 'in-progress', + }, + { + label: 'Todo', + value: 'todo', + }, + { + label: 'Done', + value: 'done', + }, + { + label: 'Backlog', + value: 'backlog', + }, +]; + +// Define label options +const ISSUE_LABELS = [ + { + label: 'Feature', + value: 'Feature', + icon: TagIcon, + }, + { + label: 'Bug', + value: 'Bug', + icon: TagIcon, + }, + { + label: 'Enhancement', + value: 'Enhancement', + icon: TagIcon, + }, + { + label: 'Documentation', + value: 'Documentation', + icon: TagIcon, + }, + { + label: 'Performance', + value: 'Performance', + icon: TagIcon, + }, + { + label: 'Testing', + value: 'Testing', + icon: TagIcon, + }, + { + label: 'Refactor', + value: 'Refactor', + icon: TagIcon, + }, + { + label: 'API', + value: 'API', + icon: TagIcon, + }, + { + label: 'Database', + value: 'Database', + icon: TagIcon, + }, + { + label: 'User Interface', + value: 'User Interface', + icon: TagIcon, + }, +]; + +// Define columns +const columns: ColumnDef[] = [ + { + accessorKey: 'title', + header: 'Title', + filterFn: filterFn('text'), + meta: defineMeta('title', { + displayName: 'Title', + type: 'text', + icon: UserIcon, + }), + }, + { + accessorKey: 'status', + header: 'Status', + filterFn: filterFn('option'), + meta: defineMeta('status', { + displayName: 'Status', + type: 'option', + icon: CircleDotDashedIcon, + options: ISSUE_STATUSES, + }), + cell: ({ row }) => { + const status = row.getValue('status') as string; + const statusObj = ISSUE_STATUSES.find((s) => s.value === status); + + return ( + + {statusObj?.label || status} + + ); + }, + }, + { + accessorKey: 'assignee', + header: 'Assignee', + filterFn: filterFn('option'), + meta: defineMeta('assignee', { + displayName: 'Assignee', + type: 'option', + icon: UserIcon, + options: [ + { label: 'JS', value: 'JS' }, + { label: 'RE', value: 'RE' }, + { label: 'AY', value: 'AY' }, + { label: 'MS', value: 'MS' }, + { label: 'Unassigned', value: '' }, + ], + }), + }, + { + accessorKey: 'labels', + header: 'Labels', + filterFn: filterFn('multiOption'), + meta: defineMeta('labels', { + displayName: 'Labels', + type: 'multiOption', + icon: TagIcon, + options: ISSUE_LABELS, + }), + cell: ({ row }) => { + const labels = row.getValue('labels') as string[]; + + return ( +
+ {labels.map((label) => ( + + {label} + + ))} +
+ ); + }, + }, + { + accessorKey: 'estimatedHours', + header: 'Estimated Hours', + filterFn: filterFn('number'), + meta: defineMeta('estimatedHours', { + displayName: 'Estimated Hours', + type: 'number', + icon: CalendarIcon, + max: 120, + }), + cell: ({ row }) => { + const hours = row.getValue('estimatedHours') as number; + + return
{hours} h
; + }, + }, + { + accessorKey: 'startDate', + header: 'Start Date', + filterFn: filterFn('date'), + meta: defineMeta('startDate', { + displayName: 'Start Date', + type: 'date', + icon: CalendarIcon, + }), + cell: ({ row }) => { + const date = row.getValue('startDate') as Date | undefined; + + return
{date ? date.toLocaleDateString() : '-'}
; + }, + }, + { + accessorKey: 'endDate', + header: 'End Date', + filterFn: filterFn('date'), + meta: defineMeta('endDate', { + displayName: 'End Date', + type: 'date', + icon: CalendarIcon, + }), + cell: ({ row }) => { + const date = row.getValue('endDate') as Date | undefined; + + return
{date ? date.toLocaleDateString() : '-'}
; + }, + }, +]; + +export default function DataTableFilterDemo() { + const [filters, setFilters] = React.useState([]); + + // Create table instance + const table = useReactTable({ + data: issues, + columns, + getCoreRowModel: getCoreRowModel(), + state: { + columnFilters: filters.map((filter) => ({ + id: filter.id, + value: filter.value.values, + })), + }, + filterFns: { + text: filterFn('text'), + number: filterFn('number'), + date: filterFn('date'), + option: filterFn('option'), + multiOption: filterFn('multiOption'), + }, + }); + + return ( +
+

Data Table Filter

+ +
+
+ +
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+

Introduction

+

+ This is an add-on to your existing shadcn/ui data table component. It adds client-side filtering with a clean, modern UI inspired by Linear. +

+

+ This component relies on TanStack Table, a headless UI for building powerful tables & datagrids. +

+ +

Features

+
    +
  • Complete refactoring with a new API structure
  • +
  • Internationalization (i18n) support
  • +
  • Quick Search Filters for option and multiOption columns
  • +
  • Number Filtering Overhaul with range slider support
  • +
  • UI improvements and performance enhancements
  • +
  • Comprehensive filtering capabilities for different data types
  • +
+ +

Usage

+

+ To use the new DataTableFilter component, you need to: +

+
    +
  1. Define your columns with proper metadata using the defineMeta helper
  2. +
  3. Use the filterFn function for filtering
  4. +
  5. Manage filter state with useState or a state management library
  6. +
  7. Pass the columns, filters, and actions to the DataTableFilter component
  8. +
+ +

API

+

+ The new API structure requires different props: +

+
+{``}
+        
+
+
+ ); +} + diff --git a/packages/components/src/ui/data-table-filter/README.md b/packages/components/src/ui/data-table-filter/README.md new file mode 100644 index 00000000..2aa62b26 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/README.md @@ -0,0 +1,182 @@ +# Data Table Filter + +This is an enhanced data table filter component for the forms repository, based on the bazza/ui canary branch implementation. It adds client-side filtering with a clean, modern UI inspired by Linear. + +## Features + +- Complete refactoring with a new API structure +- Internationalization (i18n) support +- Quick Search Filters for option and multiOption columns +- Number Filtering Overhaul with range slider support +- UI improvements and performance enhancements +- Comprehensive filtering capabilities for different data types + +## API + +The new API structure requires different props: + +```tsx + +``` + +### Props + +- `columns`: The columns to filter on. +- `filters`: The current filter state. +- `actions`: Actions to perform when filters change. + - `onFiltersChange`: Callback function to update the filter state. +- `locale`: The locale to use for translations (default: 'en'). +- `strategy`: The strategy to use for filtering (default: 'tanstack-table'). +- `table`: Optional table instance for backward compatibility. + +## Column Configuration + +To make a column filterable, you need to: + +1. Use the `filterFn` function for filtering. +2. Add the `meta` property using the `defineMeta` helper function. + +```tsx +import { defineMeta, filterFn } from '@lambda-curry/components/ui/data-table-filter'; + +const columns: ColumnDef[] = [ + { + accessorKey: 'status', + header: 'Status', + filterFn: filterFn('option'), + meta: defineMeta('status', { + displayName: 'Status', + type: 'option', + icon: CircleDotDashedIcon, + options: STATUS_OPTIONS, + }), + }, + // ... other columns +]; +``` + +### Column Meta Properties + +- `displayName`: The display name for the column. +- `icon`: The icon for the column. +- `type`: The data type of the column (text, number, date, option, multiOption). +- `options`: An optional list of options for option and multiOption columns. +- `transformOptionFn`: An optional function to transform column values into options. +- `max`: An optional "soft" maximum value for the range slider when filtering on a number column. + +## Supported Column Types + +- `text`: Text data with operators like contains, starts with, ends with, etc. +- `number`: Numerical data with operators like equals, greater than, less than, between, etc. +- `date`: Dates with operators like equals, greater than, less than, between, etc. +- `option`: Single-valued option (e.g., status) with operators like equals, not equals, etc. +- `multiOption`: Multi-valued option (e.g., labels) with operators like is any of, is none of, etc. + +## Internationalization + +The component supports internationalization through the `locale` prop. Currently supported locales: + +- English (en) +- Spanish (es) +- French (fr) +- German (de) + +You can add more locales by extending the translations object in the `i18n.ts` file. + +## Usage Example + +```tsx +import * as React from 'react'; +import { type ColumnDef } from '@tanstack/react-table'; +import { + DataTableFilter, + defineMeta, + filterFn, + type DataTableFilterState, +} from '@lambda-curry/components/ui/data-table-filter'; + +// Define your columns with proper metadata +const columns: ColumnDef[] = [ + // ... your columns +]; + +export default function YourComponent() { + const [filters, setFilters] = React.useState([]); + + // Create table instance + const table = useReactTable({ + // ... your table configuration + state: { + columnFilters: filters.map((filter) => ({ + id: filter.id, + value: filter.value.values, + })), + }, + filterFns: { + text: filterFn('text'), + number: filterFn('number'), + date: filterFn('date'), + option: filterFn('option'), + multiOption: filterFn('multiOption'), + }, + }); + + return ( +
+ + + {/* Your table component */} +
+ ); +} +``` + +## Migration Guide + +If you're migrating from the old DataTableFacetedFilter component, you'll need to: + +1. Update your column definitions to include the `meta` property using the `defineMeta` helper. +2. Use the `filterFn` function for filtering. +3. Manage filter state with useState or a state management library. +4. Replace the old DataTableFacetedFilter component with the new DataTableFilter component. + +### Old API + +```tsx + +``` + +### New API + +```tsx + +``` + diff --git a/packages/components/src/ui/data-table-filter/data-table-filter.tsx b/packages/components/src/ui/data-table-filter/data-table-filter.tsx new file mode 100644 index 00000000..2905175e --- /dev/null +++ b/packages/components/src/ui/data-table-filter/data-table-filter.tsx @@ -0,0 +1,317 @@ +import * as React from 'react'; +import { type ColumnDef, type RowData, type Table } from '@tanstack/react-table'; +import { ChevronDown, Filter, X } from 'lucide-react'; +import { Button } from '../button'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { Badge } from '../badge'; +import { cn } from '../utils'; +import { ScrollArea } from '../scroll-area'; +import { PropertyFilterItem } from './property-filter-item'; +import { QuickSearchFilter } from './quick-search-filter'; +import { type ColumnDataType, type ColumnMeta, type ColumnOption, type DataTableFilterState, type FilterOperator, type FilterValue } from './types'; +import { useTranslation } from './i18n'; + +export interface DataTableFilterProps { + /** + * The columns to filter on. + */ + columns: ColumnDef[]; + + /** + * The current filter state. + */ + filters: DataTableFilterState; + + /** + * Actions to perform when filters change. + */ + actions: { + onFiltersChange: (filters: DataTableFilterState) => void; + }; + + /** + * The strategy to use for filtering. + * @default 'tanstack-table' + */ + strategy?: 'tanstack-table' | 'custom'; + + /** + * The locale to use for translations. + * @default 'en' + */ + locale?: string; + + /** + * Optional table instance for backward compatibility. + */ + table?: Table; +} + +export function DataTableFilter({ + columns, + filters, + actions, + strategy = 'tanstack-table', + locale = 'en', + table, +}: DataTableFilterProps) { + const { t } = useTranslation(locale); + const [open, setOpen] = React.useState(false); + + // Get filterable columns (columns with meta.type defined) + const filterableColumns = React.useMemo(() => { + return columns.filter((column) => { + const meta = column.meta as ColumnMeta | undefined; + return meta?.type !== undefined; + }); + }, [columns]); + + // Handle filter changes + const handleFilterChange = React.useCallback( + (columnId: string, operator: FilterOperator, values: any) => { + const newFilters = [...filters]; + const existingFilterIndex = newFilters.findIndex((filter) => filter.id === columnId); + + if (values === undefined || (Array.isArray(values) && values.length === 0)) { + // Remove filter if values are empty + if (existingFilterIndex !== -1) { + newFilters.splice(existingFilterIndex, 1); + } + } else if (existingFilterIndex !== -1) { + // Update existing filter + newFilters[existingFilterIndex] = { + id: columnId, + value: { + operator, + values, + columnMeta: getColumnMeta(columnId), + }, + }; + } else { + // Add new filter + newFilters.push({ + id: columnId, + value: { + operator, + values, + columnMeta: getColumnMeta(columnId), + }, + }); + } + + actions.onFiltersChange(newFilters); + }, + [filters, actions, columns] + ); + + // Get column meta for a given column ID + const getColumnMeta = React.useCallback( + (columnId: string) => { + const column = columns.find((col) => col.id === columnId); + return column?.meta as ColumnMeta | undefined; + }, + [columns] + ); + + // Clear all filters + const handleClearFilters = React.useCallback(() => { + actions.onFiltersChange([]); + }, [actions]); + + // Get filter value for a column + const getFilterValue = React.useCallback( + (columnId: string) => { + return filters.find((filter) => filter.id === columnId)?.value; + }, + [filters] + ); + + return ( +
+ {/* Quick search filters */} +
+ {filterableColumns + .filter((column) => { + const meta = column.meta as ColumnMeta; + return meta.type === 'option' || meta.type === 'multiOption'; + }) + .map((column) => { + const meta = column.meta as ColumnMeta; + const filterValue = getFilterValue(column.id as string); + + return ( + + ); + })} +
+ + {/* Main filter button */} + + + + + +
+

{t('filters')}

+ {filters.length > 0 && ( + + )} +
+ +
+ {filterableColumns.map((column) => { + const meta = column.meta as ColumnMeta; + const filterValue = getFilterValue(column.id as string); + + return ( + + ); + })} +
+
+
+
+ + {/* Active filters display */} + {filters.length > 0 && ( +
+ {filters.map((filter) => { + const meta = filter.value.columnMeta; + if (!meta) return null; + + return ( + + {meta.displayName}: + + {formatFilterValue(filter.value, meta.type, t)} + + + + ); + })} + + {filters.length > 1 && ( + + )} +
+ )} +
+ ); +} + +// Helper function to format filter values for display +function formatFilterValue( + filterValue: FilterValue, + type: ColumnDataType, + t: (key: string) => string +): string { + const { operator, values } = filterValue; + + if (values === undefined || (Array.isArray(values) && values.length === 0)) { + return ''; + } + + switch (type) { + case 'text': + return `${t(`operators.${operator}`)} ${values}`; + + case 'number': + if (operator === 'between') { + return `${values[0]} - ${values[1]}`; + } + return `${t(`operators.${operator}`)} ${values}`; + + case 'date': + if (operator === 'between') { + return `${formatDate(values[0])} - ${formatDate(values[1])}`; + } + return `${t(`operators.${operator}`)} ${formatDate(values)}`; + + case 'option': + return values as string; + + case 'multiOption': + if (Array.isArray(values)) { + return values.length === 1 + ? values[0] as string + : `${values.length} ${t('selected')}`; + } + return ''; + + default: + return String(values); + } +} + +// Helper function to format dates +function formatDate(date: Date | string): string { + if (typeof date === 'string') { + return date; + } + + return date.toLocaleDateString(); +} + diff --git a/packages/components/src/ui/data-table-filter/i18n.ts b/packages/components/src/ui/data-table-filter/i18n.ts new file mode 100644 index 00000000..33175783 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/i18n.ts @@ -0,0 +1,145 @@ +import * as React from 'react'; + +// Define the translations for each locale +const translations: Record> = { + en: { + filter: 'Filter', + filters: 'Filters', + resetFilters: 'Reset filters', + clearFilters: 'Clear filters', + clearAll: 'Clear all', + search: 'Search', + noResults: 'No results found', + enterValue: 'Enter value', + selectOption: 'Select option', + selectOptions: 'Select options', + selectDate: 'Select date', + startDate: 'Start date', + endDate: 'End date', + selected: 'selected', + clearFilter: 'Clear filter', + 'operators.eq': 'equals', + 'operators.neq': 'not equals', + 'operators.gt': 'greater than', + 'operators.gte': 'greater than or equal', + 'operators.lt': 'less than', + 'operators.lte': 'less than or equal', + 'operators.contains': 'contains', + 'operators.startsWith': 'starts with', + 'operators.endsWith': 'ends with', + 'operators.between': 'between', + 'operators.in': 'is any of', + 'operators.nin': 'is none of', + 'operators.empty': 'is empty', + 'operators.nempty': 'is not empty', + }, + es: { + filter: 'Filtrar', + filters: 'Filtros', + resetFilters: 'Restablecer filtros', + clearFilters: 'Limpiar filtros', + clearAll: 'Limpiar todo', + search: 'Buscar', + noResults: 'No se encontraron resultados', + enterValue: 'Ingresar valor', + selectOption: 'Seleccionar opción', + selectOptions: 'Seleccionar opciones', + selectDate: 'Seleccionar fecha', + startDate: 'Fecha de inicio', + endDate: 'Fecha de fin', + selected: 'seleccionados', + clearFilter: 'Limpiar filtro', + 'operators.eq': 'igual a', + 'operators.neq': 'no igual a', + 'operators.gt': 'mayor que', + 'operators.gte': 'mayor o igual que', + 'operators.lt': 'menor que', + 'operators.lte': 'menor o igual que', + 'operators.contains': 'contiene', + 'operators.startsWith': 'comienza con', + 'operators.endsWith': 'termina con', + 'operators.between': 'entre', + 'operators.in': 'es cualquiera de', + 'operators.nin': 'no es ninguno de', + 'operators.empty': 'está vacío', + 'operators.nempty': 'no está vacío', + }, + fr: { + filter: 'Filtrer', + filters: 'Filtres', + resetFilters: 'Réinitialiser les filtres', + clearFilters: 'Effacer les filtres', + clearAll: 'Tout effacer', + search: 'Rechercher', + noResults: 'Aucun résultat trouvé', + enterValue: 'Entrer une valeur', + selectOption: 'Sélectionner une option', + selectOptions: 'Sélectionner des options', + selectDate: 'Sélectionner une date', + startDate: 'Date de début', + endDate: 'Date de fin', + selected: 'sélectionnés', + clearFilter: 'Effacer le filtre', + 'operators.eq': 'égal à', + 'operators.neq': 'différent de', + 'operators.gt': 'supérieur à', + 'operators.gte': 'supérieur ou égal à', + 'operators.lt': 'inférieur à', + 'operators.lte': 'inférieur ou égal à', + 'operators.contains': 'contient', + 'operators.startsWith': 'commence par', + 'operators.endsWith': 'se termine par', + 'operators.between': 'entre', + 'operators.in': 'est l\'un de', + 'operators.nin': 'n\'est aucun de', + 'operators.empty': 'est vide', + 'operators.nempty': 'n\'est pas vide', + }, + de: { + filter: 'Filter', + filters: 'Filter', + resetFilters: 'Filter zurücksetzen', + clearFilters: 'Filter löschen', + clearAll: 'Alle löschen', + search: 'Suchen', + noResults: 'Keine Ergebnisse gefunden', + enterValue: 'Wert eingeben', + selectOption: 'Option auswählen', + selectOptions: 'Optionen auswählen', + selectDate: 'Datum auswählen', + startDate: 'Startdatum', + endDate: 'Enddatum', + selected: 'ausgewählt', + clearFilter: 'Filter löschen', + 'operators.eq': 'gleich', + 'operators.neq': 'ungleich', + 'operators.gt': 'größer als', + 'operators.gte': 'größer oder gleich', + 'operators.lt': 'kleiner als', + 'operators.lte': 'kleiner oder gleich', + 'operators.contains': 'enthält', + 'operators.startsWith': 'beginnt mit', + 'operators.endsWith': 'endet mit', + 'operators.between': 'zwischen', + 'operators.in': 'ist eines von', + 'operators.nin': 'ist keines von', + 'operators.empty': 'ist leer', + 'operators.nempty': 'ist nicht leer', + }, +}; + +// Create a hook for accessing translations +export function useTranslation(locale: string = 'en') { + // Fallback to English if the locale is not supported + const currentLocale = translations[locale] ? locale : 'en'; + + const t = React.useCallback( + (key: string): string => { + return translations[currentLocale][key] || key; + }, + [currentLocale] + ); + + return { t }; +} + diff --git a/packages/components/src/ui/data-table-filter/index.ts b/packages/components/src/ui/data-table-filter/index.ts new file mode 100644 index 00000000..694aeb87 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/index.ts @@ -0,0 +1,6 @@ +export * from './data-table-filter'; +export * from './property-filter-item'; +export * from './quick-search-filter'; +export * from './types'; +export * from './i18n'; + diff --git a/packages/components/src/ui/data-table-filter/property-filter-item.tsx b/packages/components/src/ui/data-table-filter/property-filter-item.tsx new file mode 100644 index 00000000..ee833cfe --- /dev/null +++ b/packages/components/src/ui/data-table-filter/property-filter-item.tsx @@ -0,0 +1,421 @@ +import * as React from 'react'; +import { type ColumnDef, type RowData } from '@tanstack/react-table'; +import { Check, ChevronsUpDown } from 'lucide-react'; +import { Button } from '../button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Separator } from '../separator'; +import { cn } from '../utils'; +import { Slider } from '../slider'; +import { DatePicker } from '../date-picker'; +import { TextInput } from '../text-input'; +import { Checkbox } from '../checkbox'; +import { type ColumnDataType, type ColumnMeta, type ColumnOption, type FilterOperator, type FilterValue } from './types'; +import { useTranslation } from './i18n'; + +interface PropertyFilterItemProps { + column: ColumnDef; + meta: ColumnMeta; + filterValue?: FilterValue; + onFilterChange: (columnId: string, operator: FilterOperator, values: any) => void; + locale?: string; +} + +export function PropertyFilterItem({ + column, + meta, + filterValue, + onFilterChange, + locale = 'en', +}: PropertyFilterItemProps) { + const { t } = useTranslation(locale); + const [operator, setOperator] = React.useState( + filterValue?.operator || getDefaultOperator(meta.type) + ); + + // Get available operators for the column type + const operators = React.useMemo(() => { + return getOperatorsForType(meta.type).map((op) => ({ + label: t(`operators.${op}`), + value: op, + })); + }, [meta.type, t]); + + // Handle operator change + const handleOperatorChange = React.useCallback( + (newOperator: FilterOperator) => { + setOperator(newOperator); + + // Update filter with new operator and existing values + if (filterValue?.values !== undefined) { + onFilterChange(column.id as string, newOperator, filterValue.values); + } + }, + [column.id, filterValue, onFilterChange] + ); + + // Handle value change + const handleValueChange = React.useCallback( + (values: any) => { + onFilterChange(column.id as string, operator, values); + }, + [column.id, operator, onFilterChange] + ); + + // Render filter input based on column type + const renderFilterInput = () => { + switch (meta.type) { + case 'text': + return ( + handleValueChange(e.target.value)} + className="h-8" + /> + ); + + case 'number': + if (operator === 'between') { + return ( +
+ +
+ 0 + {meta.max || 100} +
+
+ ); + } + + return ( + handleValueChange(Number(e.target.value))} + className="h-8" + /> + ); + + case 'date': + if (operator === 'between') { + return ( +
+ { + const endDate = filterValue?.values?.[1] ? new Date(filterValue.values[1]) : undefined; + handleValueChange([date, endDate]); + }} + placeholder={t('startDate')} + /> + { + const startDate = filterValue?.values?.[0] ? new Date(filterValue.values[0]) : undefined; + handleValueChange([startDate, date]); + }} + placeholder={t('endDate')} + /> +
+ ); + } + + return ( + + ); + + case 'option': + return ( + + ); + + case 'multiOption': + return ( + + ); + + default: + return null; + } + }; + + return ( +
+
+
+ {meta.icon && } + {meta.displayName} +
+
+ +
+
+ {renderFilterInput()} + +
+ ); +} + +// Helper component for selecting operators +function OperatorSelector({ + operators, + value, + onChange, +}: { + operators: { label: string; value: FilterOperator }[]; + value: FilterOperator; + onChange: (value: FilterOperator) => void; +}) { + const [open, setOpen] = React.useState(false); + const selectedOperator = operators.find((op) => op.value === value); + + return ( + + + + + + + + {operators.map((op) => ( + { + onChange(op.value); + setOpen(false); + }} + > + {op.label} + {op.value === value && } + + ))} + + + + + ); +} + +// Helper component for selecting a single option +function OptionSelector({ + options, + value, + onChange, + placeholder, +}: { + options: ColumnOption[]; + value?: string; + onChange: (value: string) => void; + placeholder: string; +}) { + const [open, setOpen] = React.useState(false); + const selectedOption = options.find((op) => op.value === value); + + return ( + + + + + + + + No options found + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + {option.icon && ( +
+ {React.isValidElement(option.icon) + ? option.icon + : React.createElement(option.icon as React.ComponentType<{ className?: string }>, { + className: 'h-4 w-4', + })} +
+ )} + {option.label} + {option.value === value && } +
+ ))} +
+
+
+
+ ); +} + +// Helper component for selecting multiple options +function MultiOptionSelector({ + options, + values, + onChange, + placeholder, +}: { + options: ColumnOption[]; + values: string[]; + onChange: (values: string[]) => void; + placeholder: string; +}) { + return ( +
+
+ {placeholder} + {values.length > 0 && ( + + )} +
+
+ {options.map((option) => ( +
+ { + if (checked) { + onChange([...values, option.value]); + } else { + onChange(values.filter((v) => v !== option.value)); + } + }} + /> + +
+ ))} +
+
+ ); +} + +// Helper function to get default operator for a column type +function getDefaultOperator(type: ColumnDataType): FilterOperator { + switch (type) { + case 'text': + return 'contains'; + case 'number': + return 'eq'; + case 'date': + return 'eq'; + case 'option': + return 'eq'; + case 'multiOption': + return 'in'; + default: + return 'eq'; + } +} + +// Helper function to get available operators for a column type +function getOperatorsForType(type: ColumnDataType): FilterOperator[] { + switch (type) { + case 'text': + return ['eq', 'neq', 'contains', 'startsWith', 'endsWith', 'empty', 'nempty']; + case 'number': + return ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between', 'empty', 'nempty']; + case 'date': + return ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between', 'empty', 'nempty']; + case 'option': + return ['eq', 'neq', 'empty', 'nempty']; + case 'multiOption': + return ['in', 'nin', 'empty', 'nempty']; + default: + return ['eq', 'neq']; + } +} + +// Helper function to get column options +function getColumnOptions( + column: ColumnDef, + meta: ColumnMeta +): ColumnOption[] { + // If options are provided in meta, use them + if (meta.options) { + return meta.options; + } + + // Otherwise, try to generate options from column values + // This would typically be done by the parent component + return []; +} + diff --git a/packages/components/src/ui/data-table-filter/quick-search-filter.tsx b/packages/components/src/ui/data-table-filter/quick-search-filter.tsx new file mode 100644 index 00000000..2761c910 --- /dev/null +++ b/packages/components/src/ui/data-table-filter/quick-search-filter.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { type ColumnDef, type RowData } from '@tanstack/react-table'; +import { Check, ChevronsUpDown, X } from 'lucide-react'; +import { Button } from '../button'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../command'; +import { Popover, PopoverContent, PopoverTrigger } from '../popover'; +import { Badge } from '../badge'; +import { cn } from '../utils'; +import { type ColumnMeta, type FilterOperator, type FilterValue } from './types'; +import { useTranslation } from './i18n'; + +interface QuickSearchFilterProps { + column: ColumnDef; + meta: ColumnMeta; + filterValue?: FilterValue; + onFilterChange: (columnId: string, operator: FilterOperator, values: any) => void; + locale?: string; +} + +export function QuickSearchFilter({ + column, + meta, + filterValue, + onFilterChange, + locale = 'en', +}: QuickSearchFilterProps) { + const { t } = useTranslation(locale); + const [open, setOpen] = React.useState(false); + + // Only render for option and multiOption columns + if (meta.type !== 'option' && meta.type !== 'multiOption') { + return null; + } + + // Get options for the column + const options = meta.options || []; + if (options.length === 0) { + return null; + } + + // Get selected values + const selectedValues = filterValue?.values || []; + const isMulti = meta.type === 'multiOption'; + + // Handle selection change + const handleSelect = (value: string) => { + if (isMulti) { + // For multiOption, toggle the value in the array + const currentValues = Array.isArray(selectedValues) ? selectedValues : []; + const newValues = currentValues.includes(value) + ? currentValues.filter((v) => v !== value) + : [...currentValues, value]; + + onFilterChange(column.id as string, 'in', newValues.length > 0 ? newValues : undefined); + } else { + // For option, set or clear the value + onFilterChange(column.id as string, 'eq', value === selectedValues ? undefined : value); + } + }; + + // Clear all selected values + const handleClear = () => { + onFilterChange(column.id as string, isMulti ? 'in' : 'eq', undefined); + }; + + return ( + + + + + + + + {t('noResults')} + + {options.map((option) => { + const isSelected = isMulti + ? Array.isArray(selectedValues) && selectedValues.includes(option.value) + : selectedValues === option.value; + + return ( + handleSelect(option.value)} + className="flex items-center" + > +
+ +
+ {option.icon && ( +
+ {React.isValidElement(option.icon) + ? option.icon + : React.createElement(option.icon as React.ComponentType<{ className?: string }>, { + className: 'h-4 w-4', + })} +
+ )} + {option.label} +
+ ); + })} +
+ {((isMulti && Array.isArray(selectedValues) && selectedValues.length > 0) || (!isMulti && selectedValues)) && ( +
+ +
+ )} +
+
+
+ ); +} + diff --git a/packages/components/src/ui/data-table-filter/types.ts b/packages/components/src/ui/data-table-filter/types.ts new file mode 100644 index 00000000..b795bb4f --- /dev/null +++ b/packages/components/src/ui/data-table-filter/types.ts @@ -0,0 +1,317 @@ +import { type ColumnDef, type RowData } from '@tanstack/react-table'; +import { type LucideIcon } from 'lucide-react'; + +/** + * The types of data that can be filtered. + */ +export type ColumnDataType = + | 'text' /* Text data */ + | 'number' /* Numerical data */ + | 'date' /* Dates */ + | 'option' /* Single-valued option (e.g. status) */ + | 'multiOption'; /* Multi-valued option (e.g. labels) */ + +/** + * The operators that can be used for filtering. + */ +export type FilterOperator = + | 'eq' /* Equals */ + | 'neq' /* Not equals */ + | 'gt' /* Greater than */ + | 'gte' /* Greater than or equal */ + | 'lt' /* Less than */ + | 'lte' /* Less than or equal */ + | 'contains' /* Contains */ + | 'startsWith' /* Starts with */ + | 'endsWith' /* Ends with */ + | 'between' /* Between */ + | 'in' /* In */ + | 'nin' /* Not in */ + | 'empty' /* Empty */ + | 'nempty'; /* Not empty */ + +/** + * Helper type to get the element type of an array. + */ +export type ElementType = T extends (infer U)[] ? U : T; + +/** + * Represents an option for a column. + */ +export interface ColumnOption { + /** + * The label to display for the option. + */ + label: string; + + /** + * The internal value of the option. + */ + value: string; + + /** + * An optional icon to display next to the label. + */ + icon?: React.ReactElement | React.ComponentType<{ className?: string }>; +} + +/** + * Metadata for a column. + */ +export interface ColumnMeta { + /** + * The display name of the column. + */ + displayName: string; + + /** + * The column icon. + */ + icon: LucideIcon; + + /** + * The data type of the column. + */ + type: ColumnDataType; + + /** + * An optional list of options for the column. + * This is used for columns with type 'option' or 'multiOption'. + * If the options are known ahead of time, they can be defined here. + * Otherwise, they will be dynamically generated based on the data. + */ + options?: ColumnOption[]; + + /** + * An optional function to transform columns with type 'option' or 'multiOption'. + * This is used to convert each raw option into a ColumnOption. + */ + transformOptionFn?: ( + value: ElementType>, + ) => ColumnOption; + + /** + * An optional "soft" max for the range slider. + * This is used for columns with type 'number'. + */ + max?: number; +} + +/** + * The value of a filter. + */ +export interface FilterValue { + /** + * The operator to use for filtering. + */ + operator: FilterOperator; + + /** + * The values to filter by. + */ + values: any; + + /** + * The metadata for the column being filtered. + */ + columnMeta?: ColumnMeta; +} + +/** + * A filter for a column. + */ +export interface ColumnFilter { + /** + * The ID of the column to filter. + */ + id: string; + + /** + * The filter value. + */ + value: FilterValue; +} + +/** + * The state of the data table filter. + */ +export type DataTableFilterState = ColumnFilter[]; + +/** + * Helper function to define column metadata. + */ +export function defineMeta( + property: keyof TData, + meta: ColumnMeta +): ColumnMeta { + return meta; +} + +/** + * Filter functions for different column types. + */ +export function filterFn(type: ColumnDataType) { + return ( + row: any, + columnId: string, + filterValue: FilterValue + ): boolean => { + if (!filterValue) return true; + + const { operator, values } = filterValue; + const value = row.getValue(columnId); + + if (value === undefined || value === null) { + return operator === 'empty'; + } + + switch (type) { + case 'text': + return filterText(value, operator, values); + + case 'number': + return filterNumber(value, operator, values); + + case 'date': + return filterDate(value, operator, values); + + case 'option': + return filterOption(value, operator, values); + + case 'multiOption': + return filterMultiOption(value, operator, values); + + default: + return true; + } + }; +} + +// Helper functions for filtering different data types +function filterText(value: string, operator: FilterOperator, filterValue: any): boolean { + const stringValue = String(value).toLowerCase(); + const filterString = String(filterValue).toLowerCase(); + + switch (operator) { + case 'eq': + return stringValue === filterString; + case 'neq': + return stringValue !== filterString; + case 'contains': + return stringValue.includes(filterString); + case 'startsWith': + return stringValue.startsWith(filterString); + case 'endsWith': + return stringValue.endsWith(filterString); + case 'empty': + return stringValue === ''; + case 'nempty': + return stringValue !== ''; + default: + return true; + } +} + +function filterNumber(value: number, operator: FilterOperator, filterValue: any): boolean { + const numValue = Number(value); + + switch (operator) { + case 'eq': + return numValue === Number(filterValue); + case 'neq': + return numValue !== Number(filterValue); + case 'gt': + return numValue > Number(filterValue); + case 'gte': + return numValue >= Number(filterValue); + case 'lt': + return numValue < Number(filterValue); + case 'lte': + return numValue <= Number(filterValue); + case 'between': + return numValue >= Number(filterValue[0]) && numValue <= Number(filterValue[1]); + case 'empty': + return isNaN(numValue); + case 'nempty': + return !isNaN(numValue); + default: + return true; + } +} + +function filterDate(value: Date | string, operator: FilterOperator, filterValue: any): boolean { + const dateValue = value instanceof Date ? value : new Date(value); + const filterDate = filterValue instanceof Date ? filterValue : new Date(filterValue); + + if (isNaN(dateValue.getTime())) { + return operator === 'empty'; + } + + switch (operator) { + case 'eq': + return dateValue.toDateString() === filterDate.toDateString(); + case 'neq': + return dateValue.toDateString() !== filterDate.toDateString(); + case 'gt': + return dateValue > filterDate; + case 'gte': + return dateValue >= filterDate; + case 'lt': + return dateValue < filterDate; + case 'lte': + return dateValue <= filterDate; + case 'between': + const startDate = filterValue[0] instanceof Date ? filterValue[0] : new Date(filterValue[0]); + const endDate = filterValue[1] instanceof Date ? filterValue[1] : new Date(filterValue[1]); + return dateValue >= startDate && dateValue <= endDate; + case 'empty': + return isNaN(dateValue.getTime()); + case 'nempty': + return !isNaN(dateValue.getTime()); + default: + return true; + } +} + +function filterOption(value: string, operator: FilterOperator, filterValue: any): boolean { + switch (operator) { + case 'eq': + return value === filterValue; + case 'neq': + return value !== filterValue; + case 'in': + return Array.isArray(filterValue) ? filterValue.includes(value) : false; + case 'nin': + return Array.isArray(filterValue) ? !filterValue.includes(value) : true; + case 'empty': + return value === '' || value === undefined || value === null; + case 'nempty': + return value !== '' && value !== undefined && value !== null; + default: + return true; + } +} + +function filterMultiOption(value: string[], operator: FilterOperator, filterValue: any): boolean { + if (!Array.isArray(value)) { + return operator === 'empty'; + } + + switch (operator) { + case 'eq': + return value.includes(filterValue); + case 'neq': + return !value.includes(filterValue); + case 'in': + return Array.isArray(filterValue) ? filterValue.some(v => value.includes(v)) : false; + case 'nin': + return Array.isArray(filterValue) ? !filterValue.some(v => value.includes(v)) : true; + case 'empty': + return value.length === 0; + case 'nempty': + return value.length > 0; + default: + return true; + } +} + diff --git a/packages/components/src/ui/data-table/index.ts b/packages/components/src/ui/data-table/index.ts index 2076174e..45f7cd33 100644 --- a/packages/components/src/ui/data-table/index.ts +++ b/packages/components/src/ui/data-table/index.ts @@ -8,3 +8,4 @@ export * from '../../remix-hook-form/data-table-router-form'; export * from '../../remix-hook-form/data-table-router-toolbar'; export * from './data-table-schema'; export * from './data-table-hooks'; +export * from '../data-table-filter'; From c26337430ece00bbc0af9e6534beb544eca7349a Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 19:34:06 +0000 Subject: [PATCH 2/5] Fix data-table-filter.stories.tsx to use proper Storybook format --- apps/docs/src/ui/data-table-filter.stories.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/ui/data-table-filter.stories.tsx b/apps/docs/src/ui/data-table-filter.stories.tsx index ad7a87fc..a37e9da8 100644 --- a/apps/docs/src/ui/data-table-filter.stories.tsx +++ b/apps/docs/src/ui/data-table-filter.stories.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; import { type ColumnDef } from '@tanstack/react-table'; import { DataTableFilter, @@ -317,7 +318,21 @@ const columns: ColumnDef[] = [ }, ]; -export default function DataTableFilterDemo() { +const meta: Meta = { + title: 'UI/DataTableFilter', + component: DataTableFilterDemo, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +function DataTableFilterDemo() { const [filters, setFilters] = React.useState([]); // Create table instance @@ -448,4 +463,3 @@ export default function DataTableFilterDemo() { ); } - From f227cdd654432774c7057022f883dfa1c23ebef5 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 19:42:39 +0000 Subject: [PATCH 3/5] Fix exports for data-table-filter component --- packages/components/src/index.ts | 2 ++ packages/components/src/remix/index.ts | 2 ++ packages/components/src/ui/index.ts | 1 + 3 files changed, 5 insertions(+) create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/src/remix/index.ts diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts new file mode 100644 index 00000000..ccf1f664 --- /dev/null +++ b/packages/components/src/index.ts @@ -0,0 +1,2 @@ +export * from './ui'; + diff --git a/packages/components/src/remix/index.ts b/packages/components/src/remix/index.ts new file mode 100644 index 00000000..f4999193 --- /dev/null +++ b/packages/components/src/remix/index.ts @@ -0,0 +1,2 @@ +export * from '../remix-hook-form'; + diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 2b5ecaa9..903b785e 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -20,6 +20,7 @@ export * from './textarea'; export * from './utils'; export * from './table'; export * from './data-table'; +export * from './data-table-filter'; export * from './badge'; export * from './command'; export * from './select'; From 411bb45ca77ee664a7420124e6cf962470914296 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 19:47:51 +0000 Subject: [PATCH 4/5] Fix data-table-filter implementation by adding missing components --- .../docs/src/ui/data-table-filter.stories.tsx | 6 +- apps/docs/vite.config.mjs | 10 ++- packages/components/package.json | 1 + .../src/remix-hook-form/checkbox.tsx | 2 +- packages/components/src/ui/checkbox-field.tsx | 2 +- packages/components/src/ui/checkbox.tsx | 29 ++++++ .../property-filter-item.tsx | 88 ++++++++++++++----- packages/components/src/ui/index.ts | 19 ++-- packages/components/src/ui/scroll-area.tsx | 47 ++++++++++ packages/components/src/ui/slider.tsx | 32 +++++++ yarn.lock | 30 +++++++ 11 files changed, 231 insertions(+), 35 deletions(-) create mode 100644 packages/components/src/ui/checkbox.tsx create mode 100644 packages/components/src/ui/scroll-area.tsx create mode 100644 packages/components/src/ui/slider.tsx diff --git a/apps/docs/src/ui/data-table-filter.stories.tsx b/apps/docs/src/ui/data-table-filter.stories.tsx index a37e9da8..d24c0a14 100644 --- a/apps/docs/src/ui/data-table-filter.stories.tsx +++ b/apps/docs/src/ui/data-table-filter.stories.tsx @@ -6,7 +6,7 @@ import { defineMeta, filterFn, type DataTableFilterState, -} from '@lambda-curry/components/ui/data-table-filter'; +} from '../../../packages/components/src/ui/data-table-filter'; import { Table, TableBody, @@ -14,9 +14,9 @@ import { TableHead, TableHeader, TableRow, -} from '@lambda-curry/components/ui/table'; +} from '../../../packages/components/src/ui/table'; import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'; -import { Badge } from '@lambda-curry/components/ui/badge'; +import { Badge } from '../../../packages/components/src/ui/badge'; import { CalendarIcon, CircleDotDashedIcon, TagIcon, UserIcon } from 'lucide-react'; // Define the data type diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs index d5447bcc..d41e0f4d 100644 --- a/apps/docs/vite.config.mjs +++ b/apps/docs/vite.config.mjs @@ -12,11 +12,19 @@ export default defineConfig({ '@/lib/utils': path.resolve(__dirname, '../../packages/components/lib/utils'), '@lambdacurry/forms': path.resolve(__dirname, '../../packages/components/src'), '@lambdacurry/forms/lib': path.resolve(__dirname, '../../packages/components/lib'), + '@lambdacurry/forms/ui': path.resolve(__dirname, '../../packages/components/src/ui'), + '@lambdacurry/forms/ui/data-table-filter': path.resolve(__dirname, '../../packages/components/src/ui/data-table-filter'), }, }, build: { rollupOptions: { - external: ['react-router', 'react-router-dom'], + external: [ + 'react-router', + 'react-router-dom', + // Ignore "use client" directive errors + /@radix-ui\/.*\/dist\/index\.mjs/, + /cmdk\/dist\/index\.mjs/ + ], }, }, plugins: [tailwindcss()], diff --git a/packages/components/package.json b/packages/components/package.json index e93a811a..a28094a7 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -51,6 +51,7 @@ "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", diff --git a/packages/components/src/remix-hook-form/checkbox.tsx b/packages/components/src/remix-hook-form/checkbox.tsx index b0bf0967..4c5540cc 100644 --- a/packages/components/src/remix-hook-form/checkbox.tsx +++ b/packages/components/src/remix-hook-form/checkbox.tsx @@ -1,6 +1,6 @@ import { useRemixFormContext } from 'remix-hook-form'; import { - Checkbox as BaseCheckbox, + CheckboxField as BaseCheckbox, type CheckboxProps as BaseCheckboxProps, type CheckboxFieldComponents, } from '../ui/checkbox-field'; diff --git a/packages/components/src/ui/checkbox-field.tsx b/packages/components/src/ui/checkbox-field.tsx index 6c1be49d..dcfb9b38 100644 --- a/packages/components/src/ui/checkbox-field.tsx +++ b/packages/components/src/ui/checkbox-field.tsx @@ -101,4 +101,4 @@ const CheckboxField = ({ CheckboxField.displayName = CheckboxPrimitive.Root.displayName; -export { CheckboxField as Checkbox }; +export { CheckboxField }; diff --git a/packages/components/src/ui/checkbox.tsx b/packages/components/src/ui/checkbox.tsx new file mode 100644 index 00000000..fe46e0f7 --- /dev/null +++ b/packages/components/src/ui/checkbox.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; + +import { cn } from './utils'; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; + diff --git a/packages/components/src/ui/data-table-filter/property-filter-item.tsx b/packages/components/src/ui/data-table-filter/property-filter-item.tsx index ee833cfe..4f127929 100644 --- a/packages/components/src/ui/data-table-filter/property-filter-item.tsx +++ b/packages/components/src/ui/data-table-filter/property-filter-item.tsx @@ -109,32 +109,77 @@ export function PropertyFilterItem({ if (operator === 'between') { return (
- { - const endDate = filterValue?.values?.[1] ? new Date(filterValue.values[1]) : undefined; - handleValueChange([date, endDate]); - }} - placeholder={t('startDate')} - /> - { - const startDate = filterValue?.values?.[0] ? new Date(filterValue.values[0]) : undefined; - handleValueChange([startDate, date]); - }} - placeholder={t('endDate')} - /> +
+ + + + + + { + const endDate = filterValue?.values?.[1] ? new Date(filterValue.values[1]) : undefined; + handleValueChange([date, endDate]); + }} + /> + + +
+
+ + + + + + { + const startDate = filterValue?.values?.[0] ? new Date(filterValue.values[0]) : undefined; + handleValueChange([startDate, date]); + }} + /> + + +
); } return ( - +
+ + + + + + + + +
); case 'option': @@ -418,4 +463,3 @@ function getColumnOptions( // This would typically be done by the parent component return []; } - diff --git a/packages/components/src/ui/index.ts b/packages/components/src/ui/index.ts index 2b5ecaa9..a61f68ab 100644 --- a/packages/components/src/ui/index.ts +++ b/packages/components/src/ui/index.ts @@ -1,5 +1,10 @@ +export * from './badge'; export * from './button'; +export * from './checkbox'; export * from './checkbox-field'; +export * from './command'; +export * from './data-table'; +export * from './data-table-filter'; export * from './date-picker'; export * from './date-picker-field'; export * from './dropdown-menu'; @@ -11,16 +16,16 @@ export * from './otp-input-field'; export * from './popover'; export * from './radio-group'; export * from './radio-group-field'; +export * from './radio-group-item-field'; +export * from './scroll-area'; +export * from './select'; +export * from './separator'; +export * from './slider'; export * from './switch'; export * from './switch-field'; +export * from './table'; export * from './text-field'; export * from './text-input'; -export * from './textarea-field'; export * from './textarea'; +export * from './textarea-field'; export * from './utils'; -export * from './table'; -export * from './data-table'; -export * from './badge'; -export * from './command'; -export * from './select'; -export * from './separator'; diff --git a/packages/components/src/ui/scroll-area.tsx b/packages/components/src/ui/scroll-area.tsx new file mode 100644 index 00000000..c356a0a7 --- /dev/null +++ b/packages/components/src/ui/scroll-area.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; + +import { cn } from './utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; + diff --git a/packages/components/src/ui/slider.tsx b/packages/components/src/ui/slider.tsx new file mode 100644 index 00000000..69c7b6b6 --- /dev/null +++ b/packages/components/src/ui/slider.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import * as SliderPrimitive from '@radix-ui/react-slider'; + +import { cn } from './utils'; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + {props.defaultValue?.map((_, i) => ( + + ))} + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; + diff --git a/yarn.lock b/yarn.lock index bd60f85e..adb138b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1702,6 +1702,7 @@ __metadata: "@radix-ui/react-radio-group": "npm:^1.2.2" "@radix-ui/react-scroll-area": "npm:^1.2.2" "@radix-ui/react-separator": "npm:^1.1.2" + "@radix-ui/react-slider": "npm:^1.1.2" "@radix-ui/react-slot": "npm:^1.1.2" "@radix-ui/react-switch": "npm:^1.1.2" "@radix-ui/react-tooltip": "npm:^1.1.6" @@ -2529,6 +2530,35 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-slider@npm:^1.1.2": + version: 1.3.2 + resolution: "@radix-ui/react-slider@npm:1.3.2" + dependencies: + "@radix-ui/number": "npm:1.1.1" + "@radix-ui/primitive": "npm:1.1.2" + "@radix-ui/react-collection": "npm:1.1.4" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-primitive": "npm:2.1.0" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-layout-effect": "npm:1.1.1" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/9e177aee6306397ecf6b0775f72e553df935b7049c2178e220d3582eb4d3542ed66aa4b07b5cc4bdc765641307f9d9b63dce97b905686da8b73d3025b2efd7fd + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.2.0, @radix-ui/react-slot@npm:^1.1.2": version: 1.2.0 resolution: "@radix-ui/react-slot@npm:1.2.0" From d413bf44c9e4246a7c05d2472a3b83895c6a0438 Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Sun, 4 May 2025 19:55:12 +0000 Subject: [PATCH 5/5] Fix import paths in data-table-filter.stories.tsx --- apps/docs/src/ui/data-table-filter.stories.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/ui/data-table-filter.stories.tsx b/apps/docs/src/ui/data-table-filter.stories.tsx index d24c0a14..d17c0856 100644 --- a/apps/docs/src/ui/data-table-filter.stories.tsx +++ b/apps/docs/src/ui/data-table-filter.stories.tsx @@ -6,7 +6,7 @@ import { defineMeta, filterFn, type DataTableFilterState, -} from '../../../packages/components/src/ui/data-table-filter'; +} from '@lambdacurry/forms/ui/data-table-filter'; import { Table, TableBody, @@ -14,9 +14,9 @@ import { TableHead, TableHeader, TableRow, -} from '../../../packages/components/src/ui/table'; +} from '@lambdacurry/forms/ui/table'; import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table'; -import { Badge } from '../../../packages/components/src/ui/badge'; +import { Badge } from '@lambdacurry/forms/ui/badge'; import { CalendarIcon, CircleDotDashedIcon, TagIcon, UserIcon } from 'lucide-react'; // Define the data type