-
-
+
+ {AVAILABLE_FRUITS.map((fruit) => (
+
+ {fruit.label}
+
+ ))}
+
Submit
@@ -113,22 +119,22 @@ export const Default: Story = {
},
},
},
- // play: async ({ canvasElement }) => {
- // const canvas = within(canvasElement);
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
- // // Open the dropdown
- // const dropdownButton = canvas.getByRole('combobox');
- // await userEvent.click(dropdownButton);
+ // Open the dropdown
+ const dropdownButton = canvas.getByRole('button', { name: 'Select an option' });
+ await userEvent.click(dropdownButton);
- // // Select an option
- // const option = canvas.getByRole('option', { name: 'Banana' });
- // await userEvent.click(option);
+ // Select an option (portal renders outside the canvas)
+ const option = screen.getByRole('menuitem', { name: 'Banana' });
+ await userEvent.click(option);
- // // Submit the form
- // const submitButton = canvas.getByRole('button', { name: 'Submit' });
- // await userEvent.click(submitButton);
+ // Submit the form
+ const submitButton = canvas.getByRole('button', { name: 'Submit' });
+ await userEvent.click(submitButton);
- // // Check if the selected option is displayed
- // await expect(await canvas.findByText('Banana')).toBeInTheDocument();
- // },
+ // Check if the selected option is displayed
+ await expect(await canvas.findByText('Banana')).toBeInTheDocument();
+ },
};
diff --git a/apps/docs/vite.config.mjs b/apps/docs/vite.config.mjs
index 5dacbb17..d5447bcc 100644
--- a/apps/docs/vite.config.mjs
+++ b/apps/docs/vite.config.mjs
@@ -24,6 +24,6 @@ export default defineConfig({
historyApiFallback: true,
},
optimizeDeps: {
- include: ['nuqs'],
+ include: [],
},
});
diff --git a/packages/components/package.json b/packages/components/package.json
index 9b8146e6..3ba0899c 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -62,7 +62,6 @@
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.4",
- "nuqs": "^2.4.1",
"react-day-picker": "8.10.1",
"react-hook-form": "^7.53.1",
"react-router": "^7.0.0",
diff --git a/packages/components/src/remix-hook-form/data-table-router-form.tsx b/packages/components/src/remix-hook-form/data-table-router-form.tsx
index c93428f1..9535d103 100644
--- a/packages/components/src/remix-hook-form/data-table-router-form.tsx
+++ b/packages/components/src/remix-hook-form/data-table-router-form.tsx
@@ -11,8 +11,7 @@ import {
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
-import { useQueryStates } from 'nuqs';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigation } from 'react-router-dom';
import { RemixFormProvider, useRemixForm } from 'remix-hook-form';
import { z } from 'zod';
@@ -21,8 +20,9 @@ import { DataTablePagination } from '../ui/data-table/data-table-pagination';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { DataTableRouterToolbar, type DataTableRouterToolbarProps } from './data-table-router-toolbar';
-// Import the nuqs parsers and the inferred type
-import { type DataTableRouterState, type FilterValue, dataTableRouterParsers } from './data-table-router-parsers';
+// Import the parsers and the inferred type
+import type { DataTableRouterState, FilterValue } from './data-table-router-parsers';
+import { getDefaultDataTableState, useDataTableUrlState } from './use-data-table-url-state';
// Schema for form data validation and type safety
const dataTableSchema = z.object({
@@ -56,23 +56,13 @@ export function DataTableRouterForm
({
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
- // --- nuqs state management ---
- // Use nuqs to manage URL state. Debounce options can be set here per parser if needed.
- const [urlState, setUrlState] = useQueryStates(dataTableRouterParsers, {
- // Default nuqs options (shallow routing, replace history, no scroll)
- history: 'replace', // Default
- shallow: false, // we want to re-run the loader when the url changes
- // scroll: false, // Default
- // Configure debounce globally if needed (though nuqs batches by default)
- // throttleMs: 300,
- });
- // --- End nuqs state management ---
+ // Use our custom hook for URL state management
+ const { urlState, setUrlState } = useDataTableUrlState();
- // Initialize RHF to *reflect* the nuqs state
+ // Initialize RHF to *reflect* the URL state
const methods = useRemixForm({
- // Use the nuqs inferred type
// No resolver needed if Zod isn't primary validation driver here
- defaultValues: urlState, // Initialize with current URL state from nuqs
+ defaultValues: urlState, // Initialize with current URL state
});
// Sync RHF state if urlState changes (e.g., back/forward, external link)
@@ -87,7 +77,7 @@ export function DataTableRouterForm({
const [columnVisibility, setColumnVisibility] = useState({});
const [rowSelection, setRowSelection] = useState({});
- // Table instance uses RHF state (which mirrors nuqs/URL state)
+ // Table instance uses RHF state (which mirrors URL state)
const table = useReactTable({
data,
columns,
@@ -132,7 +122,16 @@ export function DataTableRouterForm({
},
});
- // Pagination handler updates nuqs state
+ // Determine default pageSize and visible columns for skeleton loader
+ const defaultDataTableState = getDefaultDataTableState(defaultStateValues);
+ const visibleColumns = table.getVisibleFlatColumns();
+ // Generate stable IDs for skeleton rows based on current pageSize or fallback
+ const skeletonRowIds = useMemo(() => {
+ const count = urlState.pageSize > 0 ? urlState.pageSize : defaultDataTableState.pageSize;
+ return Array.from({ length: count }, () => window.crypto.randomUUID());
+ }, [urlState.pageSize, defaultDataTableState.pageSize]);
+
+ // Pagination handler updates URL state
const handlePaginationChange = useCallback(
(pageIndex: number, newPageSize: number) => {
setUrlState({ page: pageIndex, pageSize: newPageSize });
@@ -140,16 +139,8 @@ export function DataTableRouterForm({
[setUrlState],
);
- // Derive default values directly from parsers for reset
- const standardStateValues: DataTableRouterState = {
- search: '',
- filters: [],
- page: 0,
- pageSize: 10,
- sortField: '',
- sortOrder: 'asc',
- ...defaultStateValues,
- };
+ // Get default state values using our utility function
+ const standardStateValues = getDefaultDataTableState(defaultStateValues);
// Handle pagination props separately
const paginationProps = {
@@ -184,14 +175,19 @@ export function DataTableRouterForm({
{isLoading ? (
-
-
- Loading...
-
-
+ // Skeleton rows matching pageSize with zebra background
+ skeletonRowIds.map((rowId) => (
+
+ {visibleColumns.map((column) => (
+
+
+
+ ))}
+
+ ))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
-
+
{row.getVisibleCells().map((cell) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
diff --git a/packages/components/src/remix-hook-form/data-table-router-parsers.ts b/packages/components/src/remix-hook-form/data-table-router-parsers.ts
index 3f2fac30..cce39518 100644
--- a/packages/components/src/remix-hook-form/data-table-router-parsers.ts
+++ b/packages/components/src/remix-hook-form/data-table-router-parsers.ts
@@ -1,4 +1,3 @@
-import { parseAsInteger, parseAsJson, parseAsString } from 'nuqs';
// Define and export the shape of a single filter
export interface FilterValue {
@@ -24,20 +23,84 @@ const parseFilterValueArray = (value: unknown): FilterValue[] => {
});
};
+// Custom parsers to replace nuqs parsers
+export const parseAsString = {
+ parse: (value: string | null): string => {
+ return value || '';
+ },
+ serialize: (value: string | null): string | null => {
+ return value === '' ? null : value;
+ },
+};
+
+export const parseAsInteger = {
+ parse: (value: string | null): number => {
+ if (!value) return 0;
+ const parsed = parseInt(value, 10);
+ return isNaN(parsed) ? 0 : parsed;
+ },
+ serialize: (value: number | null): string | null => {
+ return value === 0 ? null : value?.toString() || null;
+ },
+};
+
+export const parseAsJson = (validator: (value: unknown) => T) => ({
+ parse: (value: string | null): T | null => {
+ if (!value) return null;
+ try {
+ const parsed = JSON.parse(value);
+ return validator(parsed);
+ } catch (e) {
+ console.error('Error parsing JSON:', e);
+ return null;
+ }
+ },
+ serialize: (value: T | null): string | null => {
+ if (value === null) return null;
+ return JSON.stringify(value);
+ },
+});
+
+// Export the parsers with default values
export const dataTableRouterParsers = {
- search: parseAsString.withDefault('').withOptions({ clearOnDefault: true }),
- filters: parseAsJson(parseFilterValueArray).withDefault([]).withOptions({ clearOnDefault: true }),
- page: parseAsInteger.withDefault(0),
- pageSize: parseAsInteger.withDefault(10),
- sortField: parseAsString.withDefault(''), // Provide a sensible default if possible
- // Use parseAsString for sortOrder, rely on component logic for valid values
- sortOrder: parseAsString.withDefault('asc'),
+ search: {
+ parse: parseAsString.parse,
+ serialize: parseAsString.serialize,
+ defaultValue: '',
+ },
+ filters: {
+ parse: (value: string | null) => parseAsJson(parseFilterValueArray).parse(value) || [],
+ serialize: (value: FilterValue[] | null) => {
+ return value && value.length > 0 ? parseAsJson(parseFilterValueArray).serialize(value) : null;
+ },
+ defaultValue: [] as FilterValue[],
+ },
+ page: {
+ parse: parseAsInteger.parse,
+ serialize: parseAsInteger.serialize,
+ defaultValue: 0,
+ },
+ pageSize: {
+ parse: parseAsInteger.parse,
+ serialize: parseAsInteger.serialize,
+ defaultValue: 10,
+ },
+ sortField: {
+ parse: parseAsString.parse,
+ serialize: parseAsString.serialize,
+ defaultValue: '',
+ },
+ sortOrder: {
+ parse: parseAsString.parse,
+ serialize: parseAsString.serialize,
+ defaultValue: 'asc',
+ },
};
// Export the inferred type for convenience
export type DataTableRouterState = {
- search: string | null;
- filters: FilterValue[] | null;
+ search: string;
+ filters: FilterValue[];
page: number;
pageSize: number;
sortField: string;
diff --git a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx
index 60a9cd10..ec6f964d 100644
--- a/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx
+++ b/packages/components/src/remix-hook-form/data-table-router-toolbar.tsx
@@ -1,32 +1,36 @@
import { Cross2Icon } from '@radix-ui/react-icons';
import type { Table } from '@tanstack/react-table';
-import type { ChangeEvent, ComponentType } from 'react';
-import { useCallback } from 'react';
+import { type ChangeEvent, useCallback } from 'react';
import { useRemixFormContext } from 'remix-hook-form';
-import { cn } from '../ui';
+
import { Button } from '../ui/button';
import { DataTableFacetedFilter } from '../ui/data-table/data-table-faceted-filter';
import { DataTableViewOptions } from '../ui/data-table/data-table-view-options';
import type { DataTableRouterState, FilterValue } from './data-table-router-parsers';
import { TextField } from './text-field';
+export interface DataTableFilterOption {
+ label: string;
+ value: string;
+ icon?: React.ComponentType<{ className?: string }>;
+}
+
+export interface DataTableFilterableColumn {
+ id: keyof TData | string;
+ title: string;
+ options: DataTableFilterOption[];
+}
+
+export interface DataTableSearchableColumn {
+ id: keyof TData | string;
+ title: string;
+}
+
export interface DataTableRouterToolbarProps {
- className?: string;
table: Table;
- filterableColumns?: {
- id: keyof TData;
- title: string;
- options: {
- label: string;
- value: string;
- icon?: ComponentType<{ className?: string }>;
- }[];
- }[];
- searchableColumns?: {
- id: keyof TData;
- title: string;
- }[];
- setUrlState: (newState: Partial) => void;
+ filterableColumns?: DataTableFilterableColumn[];
+ searchableColumns?: DataTableSearchableColumn[];
+ setUrlState: (state: Partial) => void;
defaultStateValues: DataTableRouterState;
}
@@ -34,40 +38,37 @@ export function DataTableRouterToolbar({
table,
filterableColumns = [],
searchableColumns = [],
- className,
setUrlState,
defaultStateValues,
}: DataTableRouterToolbarProps) {
- const { watch } = useRemixFormContext();
-
- const watchedSearch = watch('search');
- const watchedFilters = watch('filters');
+ const { watch } = useRemixFormContext();
+ const watchedFilters = (watch('filters') || []) as FilterValue[];
+ const watchedSearch = watch('search') || '';
const handleSearchChange = useCallback(
(event: ChangeEvent) => {
- setUrlState({ search: event.target.value || null, page: 0 });
+ setUrlState({ search: event.target.value || '', page: 0 });
},
[setUrlState],
);
- const handleFilterUpdate = useCallback(
- (columnId: string, value: unknown) => {
- const currentFilters = watchedFilters || [];
+ const handleFilterChange = useCallback(
+ (columnId: string, value: string[]) => {
+ const currentFilters = [...watchedFilters];
+ const existingFilterIndex = currentFilters.findIndex((filter: FilterValue) => filter.id === columnId);
let newFilters: FilterValue[];
- const existingFilterIndex = currentFilters.findIndex((f: FilterValue) => f.id === columnId);
-
- if (value === undefined || value === null || (Array.isArray(value) && value.length === 0)) {
- newFilters = currentFilters.filter((f: FilterValue) => f.id !== columnId);
- } else if (existingFilterIndex > -1) {
- newFilters = [
- ...currentFilters.slice(0, existingFilterIndex),
- { id: columnId, value },
- ...currentFilters.slice(existingFilterIndex + 1),
- ];
+
+ if (value.length === 0 && existingFilterIndex !== -1) {
+ newFilters = currentFilters.filter((_, i) => i !== existingFilterIndex);
+ } else if (value.length === 0) {
+ newFilters = currentFilters;
+ } else if (existingFilterIndex !== -1) {
+ newFilters = [...currentFilters];
+ newFilters[existingFilterIndex] = { id: columnId, value };
} else {
newFilters = [...currentFilters, { id: columnId, value }];
}
- setUrlState({ filters: newFilters.length > 0 ? newFilters : null, page: 0 });
+ setUrlState({ filters: newFilters, page: 0 });
},
[setUrlState, watchedFilters],
);
@@ -75,30 +76,32 @@ export function DataTableRouterToolbar({
const handleReset = useCallback(() => {
setUrlState({
...defaultStateValues,
- search: null,
- filters: null,
+ search: '',
+ filters: [],
});
}, [setUrlState, defaultStateValues]);
- const isFiltered = Boolean(watchedSearch) || (watchedFilters?.length || 0) > 0;
+ const hasFiltersOrSearch = watchedFilters.length > 0 || watchedSearch.length > 0;
return (
-
-
+
+ {/* Search */}
+ {searchableColumns.length > 0 && (
- {searchableColumns.length > 0 && (
+
c.title).join(', ')}...`}
+ placeholder={`Search ${searchableColumns.map((column) => column.title).join(', ')}...`}
+ value={watchedSearch}
onChange={handleSearchChange}
- className="w-[150px] lg:w-[250px]"
+ className="w-full"
suffix={
watchedSearch ? (
setUrlState({ search: null, page: 0 })}
+ onClick={() => setUrlState({ search: '', page: 0 })}
>
Clear search
@@ -106,32 +109,42 @@ export function DataTableRouterToolbar({
) : null
}
/>
- )}
-
- {filterableColumns.map((column) => {
- const tableColumn = table.getColumn(String(column.id));
- if (!tableColumn) return null;
- const currentFilterValue = watchedFilters?.find((f: FilterValue) => f.id === String(column.id))?.value;
- return (
- handleFilterUpdate(String(column.id), value)}
- />
- );
- })}
-
- {isFiltered && (
-
- Reset
-
-
- )}
+
-
col.getCanHide())} />
+ )}
+
+ {/* Filters */}
+
+ {filterableColumns.length > 0 && (
+
+ {filterableColumns.map((column) => {
+ // Find the current filter value for this column
+ const currentFilter = watchedFilters.find((filter: FilterValue) => filter.id === column.id);
+ const selectedValues = (currentFilter?.value as string[]) || [];
+
+ return (
+ handleFilterChange(String(column.id), values)}
+ />
+ );
+ })}
+
+ )}
+
+ {/* Reset Button */}
+ {hasFiltersOrSearch && (
+
+ Reset
+
+
+ )}
+
+ {/* View Options */}
+
);
diff --git a/packages/components/src/remix-hook-form/index.ts b/packages/components/src/remix-hook-form/index.ts
index 797cd611..1256860d 100644
--- a/packages/components/src/remix-hook-form/index.ts
+++ b/packages/components/src/remix-hook-form/index.ts
@@ -1,13 +1,14 @@
export * from './checkbox';
export * from './form';
-export * from './input';
-export * from './label';
-export * from './select';
+export * from './date-picker';
+export * from './dropdown-menu-select';
export * from './text-field';
export * from './radio-group';
export * from './radio-group-item';
export * from './switch';
export * from './textarea';
+export * from './otp-input';
export * from './data-table-router-form';
export * from './data-table-router-parsers';
export * from './data-table-router-toolbar';
+export * from './use-data-table-url-state';
diff --git a/packages/components/src/remix-hook-form/use-data-table-url-state.ts b/packages/components/src/remix-hook-form/use-data-table-url-state.ts
new file mode 100644
index 00000000..56d3562b
--- /dev/null
+++ b/packages/components/src/remix-hook-form/use-data-table-url-state.ts
@@ -0,0 +1,89 @@
+import { useCallback } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { type DataTableRouterState, dataTableRouterParsers } from './data-table-router-parsers';
+
+/**
+ * Custom hook for managing URL state in data tables
+ *
+ * This hook provides a clean interface for working with URL search parameters
+ * in data table components, replacing the functionality previously provided by nuqs.
+ *
+ * @returns An object containing the current URL state and a function to update it
+ */
+export function useDataTableUrlState() {
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Parse URL search parameters using our custom parsers
+ const urlState: DataTableRouterState = {
+ search: dataTableRouterParsers.search.parse(searchParams.get('search')),
+ filters: dataTableRouterParsers.filters.parse(searchParams.get('filters')),
+ page: dataTableRouterParsers.page.parse(searchParams.get('page')),
+ pageSize: dataTableRouterParsers.pageSize.parse(searchParams.get('pageSize')),
+ sortField: dataTableRouterParsers.sortField.parse(searchParams.get('sortField')),
+ sortOrder: dataTableRouterParsers.sortOrder.parse(searchParams.get('sortOrder')),
+ };
+
+ // Function to update URL search parameters
+ const setUrlState = useCallback(
+ (newState: Partial
) => {
+ const updatedState = { ...urlState, ...newState };
+ const newParams = new URLSearchParams();
+
+ // Only add parameters that are not default values
+ if (updatedState.search !== dataTableRouterParsers.search.defaultValue) {
+ const serialized = dataTableRouterParsers.search.serialize(updatedState.search);
+ if (serialized !== null) newParams.set('search', serialized);
+ }
+
+ if (updatedState.filters.length > 0) {
+ const serialized = dataTableRouterParsers.filters.serialize(updatedState.filters);
+ if (serialized !== null) newParams.set('filters', serialized);
+ }
+
+ if (updatedState.page !== dataTableRouterParsers.page.defaultValue) {
+ const serialized = dataTableRouterParsers.page.serialize(updatedState.page);
+ if (serialized !== null) newParams.set('page', serialized);
+ }
+
+ if (updatedState.pageSize !== dataTableRouterParsers.pageSize.defaultValue) {
+ const serialized = dataTableRouterParsers.pageSize.serialize(updatedState.pageSize);
+ if (serialized !== null) newParams.set('pageSize', serialized);
+ }
+
+ if (updatedState.sortField !== dataTableRouterParsers.sortField.defaultValue) {
+ const serialized = dataTableRouterParsers.sortField.serialize(updatedState.sortField);
+ if (serialized !== null) newParams.set('sortField', serialized);
+ }
+
+ if (updatedState.sortOrder !== dataTableRouterParsers.sortOrder.defaultValue) {
+ const serialized = dataTableRouterParsers.sortOrder.serialize(updatedState.sortOrder);
+ if (serialized !== null) newParams.set('sortOrder', serialized);
+ }
+
+ // Update the URL with the new search parameters
+ setSearchParams(newParams, { replace: true });
+ },
+ [urlState, setSearchParams]
+ );
+
+ // Return the current URL state and the function to update it
+ return { urlState, setUrlState };
+}
+
+/**
+ * Get the default state values for a data table
+ *
+ * @param defaultStateValues Optional custom default values to override the standard ones
+ * @returns A DataTableRouterState object with default values
+ */
+export function getDefaultDataTableState(defaultStateValues?: Partial): DataTableRouterState {
+ return {
+ search: dataTableRouterParsers.search.defaultValue,
+ filters: dataTableRouterParsers.filters.defaultValue,
+ page: dataTableRouterParsers.page.defaultValue,
+ pageSize: dataTableRouterParsers.pageSize.defaultValue,
+ sortField: dataTableRouterParsers.sortField.defaultValue,
+ sortOrder: dataTableRouterParsers.sortOrder.defaultValue,
+ ...defaultStateValues,
+ };
+}
diff --git a/packages/components/src/ui/data-table/data-table-column-header.tsx b/packages/components/src/ui/data-table/data-table-column-header.tsx
index 96095895..dd951a44 100644
--- a/packages/components/src/ui/data-table/data-table-column-header.tsx
+++ b/packages/components/src/ui/data-table/data-table-column-header.tsx
@@ -1,6 +1,6 @@
import type { Column } from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, EyeOff } from 'lucide-react';
-import { parseAsString, useQueryState } from 'nuqs';
+import { useSearchParams } from 'react-router-dom';
import { Button } from '../button';
import {
@@ -17,25 +17,30 @@ interface DataTableColumnHeaderProps {
}
export function DataTableColumnHeader({ column, title }: DataTableColumnHeaderProps) {
- const [sort, setSort] = useQueryState('sortField', parseAsString);
- const [order, setOrder] = useQueryState('sortOrder', parseAsString.withDefault('asc'));
+ const [searchParams, setSearchParams] = useSearchParams();
+ const sort = searchParams.get('sortField');
+ const order = searchParams.get('sortOrder') || 'asc';
const isSorted = sort === column.id;
- const handleSort = async () => {
+ const handleSort = () => {
+ const newParams = new URLSearchParams(searchParams);
+
if (isSorted) {
if (order === 'asc') {
- await setOrder('desc');
+ newParams.set('sortOrder', 'desc');
column.toggleSorting(true);
} else {
- await setSort(null);
- await setOrder('asc');
+ newParams.delete('sortField');
+ newParams.set('sortOrder', 'asc');
column.toggleSorting(false);
}
} else {
- await setSort(column.id);
- await setOrder('asc');
+ newParams.set('sortField', column.id);
+ newParams.set('sortOrder', 'asc');
column.toggleSorting(false);
}
+
+ setSearchParams(newParams, { replace: true });
};
if (!column.getCanSort()) {
diff --git a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx
index 640fee3d..7428b6eb 100644
--- a/packages/components/src/ui/data-table/data-table-faceted-filter.tsx
+++ b/packages/components/src/ui/data-table/data-table-faceted-filter.tsx
@@ -25,29 +25,27 @@ interface DataTableFacetedFilterProps {
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
- initialValue?: string[];
- onValueChange?: (value: string[] | undefined) => void;
+ selectedValues?: string[];
+ onValuesChange?: (values: string[]) => void;
}
export function DataTableFacetedFilter({
column,
title,
options,
- initialValue,
- onValueChange,
+ selectedValues = [],
+ onValuesChange,
}: DataTableFacetedFilterProps) {
const facets = column?.getFacetedUniqueValues();
- const [selectedValues, setSelectedValues] = useState>(
- () => new Set(initialValue || (column?.getFilterValue() as string[])),
- );
+ const [selected, setSelected] = useState>(new Set(selectedValues));
// Sync with external changes
useEffect(() => {
- setSelectedValues(new Set(initialValue || (column?.getFilterValue() as string[])));
- }, [initialValue, column]);
+ setSelected(new Set(selectedValues || (column?.getFilterValue() as string[])));
+ }, [selectedValues, column]);
const handleValueChange = (value: string) => {
- setSelectedValues((current) => {
+ setSelected((current) => {
const next = new Set(current);
if (next.has(value)) {
next.delete(value);
@@ -55,21 +53,23 @@ export function DataTableFacetedFilter({
next.add(value);
}
const filterValues = Array.from(next);
- if (onValueChange) {
- onValueChange(filterValues.length ? filterValues : undefined);
- } else {
- column?.setFilterValue(filterValues.length ? filterValues : undefined);
+
+ if (onValuesChange) {
+ onValuesChange(filterValues);
+ } else if (column) {
+ column.setFilterValue(filterValues.length ? filterValues : undefined);
}
+
return next;
});
};
const handleClear = () => {
- setSelectedValues(new Set());
- if (onValueChange) {
- onValueChange(undefined);
- } else {
- column?.setFilterValue(undefined);
+ setSelected(new Set());
+ if (onValuesChange) {
+ onValuesChange([]);
+ } else if (column) {
+ column.setFilterValue(undefined);
}
};
@@ -79,20 +79,20 @@ export function DataTableFacetedFilter({
{title}
- {selectedValues.size > 0 && (
+ {selected.size > 0 && (
<>
- {selectedValues.size}
+ {selected.size}
- {selectedValues.size > 2 ? (
+ {selected.size > 2 ? (
- {selectedValues.size} selected
+ {selected.size} selected
) : (
options
- .filter((option) => selectedValues.has(option.value))
+ .filter((option) => selected.has(option.value))
.map((option) => (
{option.label}
@@ -111,7 +111,7 @@ export function DataTableFacetedFilter({
No results found.
{options.map((option) => {
- const isSelected = selectedValues.has(option.value);
+ const isSelected = selected.has(option.value);
return (
handleValueChange(option.value)}>
({
);
})}
- {selectedValues.size > 0 && (
+ {selected.size > 0 && (
<>
diff --git a/packages/components/src/ui/data-table/data-table-pagination.tsx b/packages/components/src/ui/data-table/data-table-pagination.tsx
index d9acbef5..396b1833 100644
--- a/packages/components/src/ui/data-table/data-table-pagination.tsx
+++ b/packages/components/src/ui/data-table/data-table-pagination.tsx
@@ -1,5 +1,5 @@
import { ChevronLeftIcon, ChevronRightIcon, DoubleArrowLeftIcon, DoubleArrowRightIcon } from '@radix-ui/react-icons';
-import { parseAsInteger, useQueryState } from 'nuqs';
+import { useSearchParams } from 'react-router-dom';
import { Button } from '../button';
import { Select } from '../select';
@@ -9,20 +9,30 @@ interface DataTablePaginationProps {
}
export function DataTablePagination({ pageCount, onPaginationChange }: DataTablePaginationProps) {
- const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(0));
- const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(10));
+ const [searchParams, setSearchParams] = useSearchParams();
+ const page = parseInt(searchParams.get('page') || '0', 10);
+ const pageSize = parseInt(searchParams.get('pageSize') || '10', 10);
+
+ const updateParams = (newPage: number, newPageSize: number) => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set('page', newPage.toString());
+ newParams.set('pageSize', newPageSize.toString());
+ setSearchParams(newParams, { replace: true });
+ onPaginationChange?.(newPage, newPageSize);
+ };
return (
-
-
{pageSize} rows per page
-
-
-
Rows per page
+
+
+ {pageSize} rows per page
+
+
+
+
Rows per page
{
- await setPageSize(Number.parseInt(value));
- onPaginationChange?.(page, Number.parseInt(value));
+ onValueChange={(value) => {
+ updateParams(page, Number.parseInt(value));
}}
options={[
{ value: '10', label: '10' },
@@ -33,17 +43,14 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable
]}
/>
-
+
Page {page + 1} of {pageCount}
-
+
{
- await setPage(0);
- onPaginationChange?.(0, pageSize);
- }}
+ className="h-8 w-8 p-0"
+ onClick={() => updateParams(0, pageSize)}
disabled={page === 0}
>
Go to first page
@@ -52,10 +59,7 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable
{
- await setPage(page - 1);
- onPaginationChange?.(page - 1, pageSize);
- }}
+ onClick={() => updateParams(page - 1, pageSize)}
disabled={page === 0}
>
Go to previous page
@@ -64,10 +68,7 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable
{
- await setPage(page + 1);
- onPaginationChange?.(page + 1, pageSize);
- }}
+ onClick={() => updateParams(page + 1, pageSize)}
disabled={page === pageCount - 1}
>
Go to next page
@@ -75,11 +76,8 @@ export function DataTablePagination({ pageCount, onPaginationChange }: DataTable
{
- await setPage(pageCount - 1);
- onPaginationChange?.(pageCount - 1, pageSize);
- }}
+ className="h-8 w-8 p-0"
+ onClick={() => updateParams(pageCount - 1, pageSize)}
disabled={page === pageCount - 1}
>
Go to last page
diff --git a/packages/components/src/ui/data-table/data-table-toolbar.tsx b/packages/components/src/ui/data-table/data-table-toolbar.tsx
index f479787e..0a74863f 100644
--- a/packages/components/src/ui/data-table/data-table-toolbar.tsx
+++ b/packages/components/src/ui/data-table/data-table-toolbar.tsx
@@ -1,6 +1,6 @@
import { Cross2Icon } from '@radix-ui/react-icons';
import type { Table } from '@tanstack/react-table';
-import { parseAsString, useQueryState } from 'nuqs';
+import { useSearchParams } from 'react-router-dom';
import type * as React from 'react';
import { Button } from '../button';
@@ -30,25 +30,39 @@ export function DataTableToolbar({
filterableColumns = [],
searchableColumns = [],
}: DataTableToolbarProps) {
- const [globalFilter, setGlobalFilter] = useQueryState('search', parseAsString);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const globalFilter = searchParams.get('search') || '';
- const resetFilters = async () => {
- await setGlobalFilter(null);
+ const resetFilters = () => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.delete('search');
+ setSearchParams(newParams, { replace: true });
table.resetColumnFilters();
};
+ const updateSearch = (value: string) => {
+ const newParams = new URLSearchParams(searchParams);
+ if (value) {
+ newParams.set('search', value);
+ } else {
+ newParams.delete('search');
+ }
+ setSearchParams(newParams, { replace: true });
+
+ searchableColumns.forEach((column) => {
+ table.getColumn(column.id as string)?.setFilterValue(value);
+ });
+ };
+
return (
{searchableColumns.length > 0 && (
) => {
- await setGlobalFilter(event.target.value || null);
- searchableColumns.forEach((column) => {
- table.getColumn(column.id as string)?.setFilterValue(event.target.value);
- });
+ value={globalFilter}
+ onChange={(event: React.ChangeEvent) => {
+ updateSearch(event.target.value);
}}
className="h-10 w-[150px] lg:w-[250px]"
/>
diff --git a/packages/components/src/ui/dropdown-menu-select-field.tsx b/packages/components/src/ui/dropdown-menu-select-field.tsx
index 07246cbe..8ca4fb28 100644
--- a/packages/components/src/ui/dropdown-menu-select-field.tsx
+++ b/packages/components/src/ui/dropdown-menu-select-field.tsx
@@ -1,9 +1,15 @@
// biome-ignore lint/style/noNamespaceImport: from Radix
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import type * as React from 'react';
+import { createContext, useContext, useState } from 'react';
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
import { Button } from './button';
import { DropdownMenuContent } from './dropdown-menu';
+import {
+ DropdownMenuCheckboxItem as BaseDropdownMenuCheckboxItem,
+ DropdownMenuItem as BaseDropdownMenuItem,
+ DropdownMenuRadioItem as BaseDropdownMenuRadioItem,
+} from './dropdown-menu';
import {
type FieldComponents,
FormControl,
@@ -44,11 +50,13 @@ export function DropdownMenuSelectField<
components,
...props
}: DropdownMenuSelectProps) {
+ const [open, setOpen] = useState(false);
+
return (
(
+ render={({ field, fieldState, formState }) => (
{label && (
@@ -56,13 +64,21 @@ export function DropdownMenuSelectField<
)}
-
-
-
- {field.value ? field.value : 'Select an option'}
-
-
- {children}
+
+
+
+
+ {field.value ? field.value : 'Select an option'}
+
+
+ {children}
+
{description && {description} }
@@ -74,3 +90,66 @@ export function DropdownMenuSelectField<
}
DropdownMenuSelectField.displayName = 'DropdownMenuSelect';
+
+// Context to wire menu items to form field
+interface DropdownMenuSelectContextValue {
+ onValueChange: (value: T) => void;
+ value: T;
+}
+const DropdownMenuSelectContext = createContext | null>(null);
+
+/** Hook to access select context in item wrappers */
+export function useDropdownMenuSelectContext() {
+ const ctx = useContext(DropdownMenuSelectContext);
+ if (!ctx) {
+ throw new Error('useDropdownMenuSelectContext must be used within DropdownMenuSelectField');
+ }
+ return ctx as { onValueChange: (value: T) => void; value: T };
+}
+
+/** Single-select menu item */
+export function DropdownMenuSelectItem({
+ value,
+ children,
+ ...props
+}: { value: string; children: React.ReactNode } & React.ComponentProps) {
+ const { onValueChange } = useDropdownMenuSelectContext();
+ return (
+ onValueChange(value)}>
+ {children}
+
+ );
+}
+
+/** Multi-select checkbox menu item */
+export function DropdownMenuSelectCheckboxItem({
+ value,
+ children,
+ ...props
+}: { value: string; children: React.ReactNode } & React.ComponentProps) {
+ const { onValueChange, value: selected } = useDropdownMenuSelectContext();
+ const isChecked = Array.isArray(selected) && selected.includes(value);
+ const handleChange = () => {
+ const newValue = isChecked ? selected.filter((v) => v !== value) : [...(selected || []), value];
+ onValueChange(newValue);
+ };
+ return (
+
+ {children}
+
+ );
+}
+
+/** Radio-select menu item */
+export function DropdownMenuSelectRadioItem({
+ value: itemValue,
+ children,
+ ...props
+}: { value: string; children: React.ReactNode } & React.ComponentProps) {
+ const { onValueChange } = useDropdownMenuSelectContext();
+ return (
+ onValueChange(itemValue)}>
+ {children}
+
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index 5506f0e4..c16c963a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1809,7 +1809,6 @@ __metadata:
input-otp: "npm:^1.4.1"
lucide-react: "npm:^0.468.0"
next-themes: "npm:^0.4.4"
- nuqs: "npm:^2.4.1"
react: "npm:^19.0.0"
react-day-picker: "npm:8.10.1"
react-hook-form: "npm:^7.53.1"
@@ -8875,13 +8874,6 @@ __metadata:
languageName: node
linkType: hard
-"mitt@npm:^3.0.1":
- version: 3.0.1
- resolution: "mitt@npm:3.0.1"
- checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913
- languageName: node
- linkType: hard
-
"mkdirp@npm:^1.0.4":
version: 1.0.4
resolution: "mkdirp@npm:1.0.4"
@@ -9104,30 +9096,6 @@ __metadata:
languageName: node
linkType: hard
-"nuqs@npm:^2.4.1":
- version: 2.4.1
- resolution: "nuqs@npm:2.4.1"
- dependencies:
- mitt: "npm:^3.0.1"
- peerDependencies:
- "@remix-run/react": ">=2"
- next: ">=14.2.0"
- react: ">=18.2.0 || ^19.0.0-0"
- react-router: ^6 || ^7
- react-router-dom: ^6 || ^7
- peerDependenciesMeta:
- "@remix-run/react":
- optional: true
- next:
- optional: true
- react-router:
- optional: true
- react-router-dom:
- optional: true
- checksum: 10c0/128faf503ca7d7373c7a82e6c0e96640b27014295f832778b7d01c9e9d2621c3825570d3530624dd4a2636e91587a8768d77d7595b5b0045f5d0c42a98d5076f
- languageName: node
- linkType: hard
-
"nyc@npm:^15.1.0":
version: 15.1.0
resolution: "nyc@npm:15.1.0"