From 2225d85748491cc9b32153fa4c5f3f9411f3d12b Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sat, 13 Jun 2026 17:49:51 +0600 Subject: [PATCH 1/7] feat: presere user id on the multiplayer demo --- party/constants.ts | 59 +++++++++++++++++++++++++++ party/index.ts | 68 +++---------------------------- src/hooks/use-multiplayer-room.ts | 37 ++++++++++++++++- 3 files changed, 100 insertions(+), 64 deletions(-) diff --git a/party/constants.ts b/party/constants.ts index 754358f68..0e166b455 100644 --- a/party/constants.ts +++ b/party/constants.ts @@ -1,3 +1,62 @@ +export const ADJECTIVES = [ + "Swift", + "Bold", + "Calm", + "Daring", + "Eager", + "Fierce", + "Gentle", + "Happy", + "Icy", + "Jolly", + "Kind", + "Lively", + "Merry", + "Noble", + "Odd", + "Proud", + "Quiet", + "Rapid", + "Silly", + "Tiny", +] as const; + +export const ANIMALS = [ + "Badger", + "Bear", + "Crane", + "Deer", + "Eagle", + "Fox", + "Goat", + "Hawk", + "Ibis", + "Jay", + "Kite", + "Lamb", + "Mink", + "Newt", + "Otter", + "Puma", + "Quail", + "Raven", + "Seal", + "Tiger", +] as const; + +export const COLORS = [ + "#ef4444", + "#f97316", + "#eab308", + "#22c55e", + "#06b6d4", + "#3b82f6", + "#8b5cf6", + "#ec4899", + "#14b8a6", + "#f59e0b", +] as const; + export const STANCES = ["regular", "goofy"] as const; export const STYLES = [ diff --git a/party/index.ts b/party/index.ts index 05bda8eb3..535099477 100644 --- a/party/index.ts +++ b/party/index.ts @@ -3,6 +3,7 @@ // Each room seeds from party/seeds.ts on first use and stores mutations from there. import type * as Party from "partykit/server"; +import { ADJECTIVES, ANIMALS, COLORS } from "./constants"; import { seedRows } from "./seeds"; import type { ClientMessage, @@ -11,65 +12,6 @@ import type { UserPresence, } from "./types"; -const ADJECTIVES = [ - "Swift", - "Bold", - "Calm", - "Daring", - "Eager", - "Fierce", - "Gentle", - "Happy", - "Icy", - "Jolly", - "Kind", - "Lively", - "Merry", - "Noble", - "Odd", - "Proud", - "Quiet", - "Rapid", - "Silly", - "Tiny", -]; - -const ANIMALS = [ - "Badger", - "Bear", - "Crane", - "Deer", - "Eagle", - "Fox", - "Goat", - "Hawk", - "Ibis", - "Jay", - "Kite", - "Lamb", - "Mink", - "Newt", - "Otter", - "Puma", - "Quail", - "Raven", - "Seal", - "Tiger", -]; - -const COLORS = [ - "#ef4444", - "#f97316", - "#eab308", - "#22c55e", - "#06b6d4", - "#3b82f6", - "#8b5cf6", - "#ec4899", - "#14b8a6", - "#f59e0b", -]; - interface RoomState { users: Record; usedColors: string[]; @@ -115,11 +57,13 @@ export default class SkaterRoom implements Party.Server { } onConnect(conn: Party.Connection) { - const color = pickColor(this.state.usedColors); - this.state.usedColors.push(color); + const url = new URL(conn.uri); + const name = url.searchParams.get("name") ?? generateUserName(); + const color = url.searchParams.get("color") ?? pickColor(this.state.usedColors); + if (!this.state.usedColors.includes(color)) this.state.usedColors.push(color); const user: UserPresence = { - name: generateUserName(), + name, color, activeCell: { rowId: null, columnId: null }, }; diff --git a/src/hooks/use-multiplayer-room.ts b/src/hooks/use-multiplayer-room.ts index 78a1fb5b6..96a23735a 100644 --- a/src/hooks/use-multiplayer-room.ts +++ b/src/hooks/use-multiplayer-room.ts @@ -1,5 +1,6 @@ "use client"; +import { ADJECTIVES, ANIMALS, COLORS } from "@party/constants"; import type { RowPayload, ServerMessage, UserPresence } from "@party/types"; import PartySocket from "partysocket"; import * as React from "react"; @@ -7,6 +8,33 @@ import { skaterSchema } from "@/app/data-grid-live/lib/validation"; import { multiplayerCollection } from "@/app/data-grid-multiplayer/lib/multiplayer-collection"; const PARTYKIT_HOST = process.env.NEXT_PUBLIC_PARTYKIT_HOST ?? "localhost:1999"; +const STORAGE_KEY = "multiplayer-identity"; + +interface Identity { + name: string; + color: string; +} + +function getOrCreateIdentity(): Identity { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return JSON.parse(stored) as Identity; + } catch {} + + const adj = + ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)] ?? "Swift"; + const animal = ANIMALS[Math.floor(Math.random() * ANIMALS.length)] ?? "Fox"; + const color = COLORS[Math.floor(Math.random() * COLORS.length)] ?? "#3b82f6"; + const identity: Identity = { name: `${adj} ${animal}`, color }; + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(identity)); + } catch { + // Fail silently if localStorage is not available + } + + return identity; +} function parseRow(raw: RowPayload) { const result = skaterSchema.safeParse(raw); @@ -15,7 +43,7 @@ function parseRow(raw: RowPayload) { return null; } -export interface UseMultiplayerRoomReturn { +interface UseMultiplayerRoomReturn { isConnected: boolean; users: Record; currentUserId: string; @@ -35,8 +63,13 @@ export function useMultiplayerRoom(roomId: string): UseMultiplayerRoomReturn { React.useEffect(() => { const knownIds = new Set(); + const identity = getOrCreateIdentity(); - const socket = new PartySocket({ host: PARTYKIT_HOST, room: roomId }); + const socket = new PartySocket({ + host: PARTYKIT_HOST, + room: roomId, + query: { name: identity.name, color: identity.color }, + }); socketRef.current = socket; socket.addEventListener("open", () => setIsConnected(true)); From 095453bc97e980f83400a2573a3d65ce673ea5d4 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sat, 13 Jun 2026 18:20:48 +0600 Subject: [PATCH 2/7] feat: update readme --- CONTRIBUTING.md | 16 +++++ README.md | 42 +++++++++---- party/index.ts | 6 +- public/r/data-table-filter-menu.json | 2 +- src/app/components/feature-flags-provider.tsx | 2 +- .../data-table/data-table-filter-menu.tsx | 62 +++++++------------ 6 files changed, 74 insertions(+), 56 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7a2c286f..4ea7c92a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,22 @@ Install the project dependencies: pnpm install ``` +### Start the dev server + +For the standard data table and data grid demos: + +```shell +pnpm dev +``` + +For the multiplayer demo (requires PartyKit): + +```shell +pnpm dev:multiplayer +``` + +This starts both the Next.js server and the PartyKit WebSocket server concurrently. + ### Create a branch Create and check out your feature branch: diff --git a/README.md b/README.md index 521930e10..ad0ea2ced 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ See the [documentation](https://diceui.com/docs/components/data-table) to get st ## Running Locally -### Quick Setup (with docker) +### Quick Setup (with Docker) 1. **Clone the repository** @@ -83,31 +83,47 @@ See the [documentation](https://diceui.com/docs/components/data-table) to get st **Option A: Use Docker PostgreSQL** ```bash - # Start PostgreSQL container - pnpm db:start - - # Set up database schema and seed data - pnpm db:setup - - # Start development server - pnpm dev + pnpm db:start # start the PostgreSQL container + pnpm db:setup # push schema and seed data + pnpm dev # start the Next.js dev server ``` **Option B: Use existing PostgreSQL database** ```bash - # Update .env with your database URL - # Then set up database schema and seed data + # Update .env with your DATABASE_URL, then: pnpm db:setup - - # Start development server pnpm dev ``` +## Scripts + +| Script | Description | +| --- | --- | +| `pnpm dev` | Start the Next.js dev server | +| `pnpm dev:multiplayer` | Start Next.js + PartyKit dev servers together | +| `pnpm dev:docker` | Start Docker PostgreSQL then Next.js | +| `pnpm build` | Production build | +| `pnpm check` | Run Biome lint + TypeScript type-check | +| `pnpm lint` | Lint with Biome | +| `pnpm lint:fix` | Lint and auto-fix | +| `pnpm typecheck` | TypeScript type-check only | +| `pnpm test` | Run tests with Vitest | +| `pnpm db:start` | Start the Docker PostgreSQL container | +| `pnpm db:stop` | Stop the Docker PostgreSQL container | +| `pnpm db:setup` | Push schema + seed the database | +| `pnpm db:reset` | Wipe and re-seed the database | +| `pnpm db:generate` | Generate Drizzle migrations | +| `pnpm db:migrate` | Run pending migrations | +| `pnpm build:registry` | Build the shadcn component registry | +| `pnpm ollie` | Full first-time setup (install + db:start + db:setup) | + ## How do I deploy this? Follow the deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. +The multiplayer demo uses [PartyKit](https://partykit.io) as a separate deployment. Run `pnpm dlx partykit deploy` from the project root and set `NEXT_PUBLIC_PARTYKIT_HOST` in your Vercel environment variables. + ## Credits - [shadcn/ui](https://github.com/shadcn-ui/ui/tree/main/apps/v4/app/(app)/examples/tasks) - For the initial implementation of the data table. diff --git a/party/index.ts b/party/index.ts index 535099477..0e19849e8 100644 --- a/party/index.ts +++ b/party/index.ts @@ -59,8 +59,10 @@ export default class SkaterRoom implements Party.Server { onConnect(conn: Party.Connection) { const url = new URL(conn.uri); const name = url.searchParams.get("name") ?? generateUserName(); - const color = url.searchParams.get("color") ?? pickColor(this.state.usedColors); - if (!this.state.usedColors.includes(color)) this.state.usedColors.push(color); + const color = + url.searchParams.get("color") ?? pickColor(this.state.usedColors); + if (!this.state.usedColors.includes(color)) + this.state.usedColors.push(color); const user: UserPresence = { name, diff --git a/public/r/data-table-filter-menu.json b/public/r/data-table-filter-menu.json index 48a821baa..b8fd2cfa3 100644 --- a/public/r/data-table-filter-menu.json +++ b/public/r/data-table-filter-menu.json @@ -20,7 +20,7 @@ "files": [ { "path": "src/components/data-table/data-table-filter-menu.tsx", - "content": "\"use client\";\n\nimport type { Column, Table } from \"@tanstack/react-table\";\nimport {\n BadgeCheck,\n CalendarIcon,\n Check,\n ListFilter,\n Text,\n X,\n} from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type { ExtendedColumnFilter, FilterOperator } from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterMenuProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterMenu({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n className,\n ...props\n}: DataTableFilterMenuProps) {\n const id = React.useId();\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [open, setOpen] = React.useState(false);\n const [selectedColumn, setSelectedColumn] =\n React.useState | null>(null);\n const [inputValue, setInputValue] = React.useState(\"\");\n const triggerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n\n const onOpenChange = React.useCallback((open: boolean) => {\n setOpen(open);\n\n if (!open) {\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n }\n }, []);\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n !inputValue &&\n selectedColumn\n ) {\n event.preventDefault();\n setSelectedColumn(null);\n }\n },\n [inputValue, selectedColumn],\n );\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const onFilterAdd = React.useCallback(\n (column: Column, value: string) => {\n if (!value.trim() && column.columnDef.meta?.variant !== \"boolean\") {\n return;\n }\n\n const filterValue =\n column.columnDef.meta?.variant === \"multiSelect\" ? [value] : value;\n\n const newFilter: ExtendedColumnFilter = {\n id: column.id as Extract,\n value: filterValue,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n };\n\n debouncedSetFilters([...filters, newFilter]);\n setOpen(false);\n\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n debouncedSetFilters(updatedFilters);\n requestAnimationFrame(() => {\n triggerRef.current?.focus();\n });\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n debouncedSetFilters([]);\n }, [debouncedSetFilters]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n
\n {filters.map((filter) => (\n \n ))}\n {filters.length > 0 && (\n \n \n \n )}\n \n \n 0 ? \"icon\" : \"sm\"}\n className={cn(filters.length > 0 && \"size-8\", \"h-8 font-normal\")}\n ref={triggerRef}\n onKeyDown={onTriggerKeyDown}\n disabled={disabled}\n >\n \n {filters.length > 0 ? null : \"Filter\"}\n \n \n \n \n \n \n {selectedColumn ? (\n <>\n {selectedColumn.columnDef.meta?.options && (\n No options found.\n )}\n onFilterAdd(selectedColumn, value)}\n />\n \n ) : (\n <>\n No fields found.\n \n {columns.map((column) => (\n {\n setSelectedColumn(column);\n setInputValue(\"\");\n requestAnimationFrame(() => {\n inputRef.current?.focus();\n });\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n )}\n \n \n \n \n
\n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n filterItemId: string;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n filterItemId,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] =\n React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n \n \n {columnMeta?.icon && (\n \n )}\n {columnMeta?.label ?? column.id}\n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: column.id as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n \n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n {onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n onFilterRemove(filter.filterId)}\n >\n \n \n \n );\n }\n}\n\ninterface FilterValueSelectorProps {\n column: Column;\n value: string;\n onSelect: (value: string) => void;\n}\n\nfunction FilterValueSelector({\n column,\n value,\n onSelect,\n}: FilterValueSelectorProps) {\n const variant = column.columnDef.meta?.variant ?? \"text\";\n\n switch (variant) {\n case \"boolean\":\n return (\n \n onSelect(\"true\")}>\n True\n \n onSelect(\"false\")}>\n False\n \n \n );\n\n case \"select\":\n case \"multiSelect\":\n return (\n \n {column.columnDef.meta?.options?.map((option) => (\n onSelect(option.value)}\n >\n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n );\n\n case \"date\":\n case \"dateRange\":\n return (\n onSelect(date?.getTime().toString() ?? \"\")}\n />\n );\n\n default: {\n const isEmpty = !value.trim();\n\n return (\n \n onSelect(value)}\n disabled={isEmpty}\n >\n {isEmpty ? (\n <>\n \n Type to add filter...\n \n ) : (\n <>\n \n Filter by "{value}"\n \n )}\n \n \n );\n }\n }\n}\n\nfunction onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n column: Column;\n inputId: string;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, { value: event.target.value })\n }\n />\n );\n }\n\n case \"boolean\": {\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, { value })\n }\n >\n \n \n \n \n True\n False\n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const options = column.columnDef.meta?.options ?? [];\n const selectedValues = Array.isArray(filter.value)\n ? filter.value\n : [filter.value];\n\n const selectedOptions = options.filter((option) =>\n selectedValues.includes(option.value),\n );\n\n return (\n \n \n \n {selectedOptions.length === 0 ? (\n filter.variant === \"multiSelect\" ? (\n \"Select options...\"\n ) : (\n \"Select option...\"\n )\n ) : (\n <>\n
\n {selectedOptions.map((selectedOption) =>\n selectedOption.icon ? (\n \n \n
\n ) : null,\n )}\n \n \n {selectedOptions.length > 1\n ? `${selectedOptions.length} selected`\n : selectedOptions[0]?.label}\n \n \n )}\n \n
\n \n \n \n \n No options found.\n \n {options.map((option) => (\n {\n const value =\n filter.variant === \"multiSelect\"\n ? selectedValues.includes(option.value)\n ? selectedValues.filter((v) => v !== option.value)\n : [...selectedValues, option.value]\n : option.value;\n onFilterUpdate(filter.filterId, { value });\n }}\n >\n {option.icon && }\n {option.label}\n {filter.variant === \"multiSelect\" && (\n \n )}\n \n ))}\n \n \n \n \n
\n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick date...\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", + "content": "\"use client\";\n\nimport type { Column, Table } from \"@tanstack/react-table\";\nimport { BadgeCheck, CalendarIcon, ListFilter, Text, X } from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type { ExtendedColumnFilter, FilterOperator } from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterMenuProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterMenu({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n className,\n ...props\n}: DataTableFilterMenuProps) {\n const id = React.useId();\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [open, setOpen] = React.useState(false);\n const [selectedColumn, setSelectedColumn] =\n React.useState | null>(null);\n const [inputValue, setInputValue] = React.useState(\"\");\n const triggerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n\n const onOpenChange = React.useCallback((open: boolean) => {\n setOpen(open);\n\n if (!open) {\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n }\n }, []);\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n !inputValue &&\n selectedColumn\n ) {\n event.preventDefault();\n setSelectedColumn(null);\n }\n },\n [inputValue, selectedColumn],\n );\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const onFilterAdd = React.useCallback(\n (column: Column, value: string) => {\n if (!value.trim() && column.columnDef.meta?.variant !== \"boolean\") {\n return;\n }\n\n const filterValue =\n column.columnDef.meta?.variant === \"multiSelect\" ? [value] : value;\n\n const newFilter: ExtendedColumnFilter = {\n id: column.id as Extract,\n value: filterValue,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n };\n\n debouncedSetFilters([...filters, newFilter]);\n setOpen(false);\n\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n debouncedSetFilters(updatedFilters);\n requestAnimationFrame(() => {\n triggerRef.current?.focus();\n });\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n debouncedSetFilters([]);\n }, [debouncedSetFilters]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n
\n {filters.map((filter) => (\n \n ))}\n {filters.length > 0 && (\n \n \n \n )}\n \n \n 0 ? \"icon\" : \"sm\"}\n className={cn(filters.length > 0 && \"size-8\", \"h-8 font-normal\")}\n ref={triggerRef}\n onKeyDown={onTriggerKeyDown}\n disabled={disabled}\n >\n \n {filters.length > 0 ? null : \"Filter\"}\n \n \n \n \n \n \n {selectedColumn ? (\n <>\n {selectedColumn.columnDef.meta?.options && (\n No options found.\n )}\n onFilterAdd(selectedColumn, value)}\n />\n \n ) : (\n <>\n No fields found.\n \n {columns.map((column) => (\n {\n setSelectedColumn(column);\n setInputValue(\"\");\n requestAnimationFrame(() => {\n inputRef.current?.focus();\n });\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n )}\n \n \n \n \n
\n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n filterItemId: string;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n filterItemId,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] =\n React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n \n \n {columnMeta?.icon && (\n \n )}\n {columnMeta?.label ?? column.id}\n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: column.id as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n \n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n {onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n onFilterRemove(filter.filterId)}\n >\n \n \n \n );\n }\n}\n\ninterface FilterValueSelectorProps {\n column: Column;\n value: string;\n onSelect: (value: string) => void;\n}\n\nfunction FilterValueSelector({\n column,\n value,\n onSelect,\n}: FilterValueSelectorProps) {\n const variant = column.columnDef.meta?.variant ?? \"text\";\n\n switch (variant) {\n case \"boolean\":\n return (\n \n onSelect(\"true\")}>\n True\n \n onSelect(\"false\")}>\n False\n \n \n );\n\n case \"select\":\n case \"multiSelect\":\n return (\n \n {column.columnDef.meta?.options?.map((option) => (\n svg:last-child]:hidden\"\n onSelect={() => onSelect(option.value)}\n >\n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n );\n\n case \"date\":\n case \"dateRange\":\n return (\n onSelect(date?.getTime().toString() ?? \"\")}\n />\n );\n\n default: {\n const isEmpty = !value.trim();\n\n return (\n \n onSelect(value)}\n disabled={isEmpty}\n >\n {isEmpty ? (\n <>\n \n Type to add filter...\n \n ) : (\n <>\n \n Filter by "{value}"\n \n )}\n \n \n );\n }\n }\n}\n\nfunction onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n column: Column;\n inputId: string;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, { value: event.target.value })\n }\n />\n );\n }\n\n case \"boolean\": {\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, { value })\n }\n >\n \n \n \n \n True\n False\n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const options = column.columnDef.meta?.options ?? [];\n const selectedValues = Array.isArray(filter.value)\n ? filter.value\n : [filter.value];\n\n const selectedOptions = options.filter((option) =>\n selectedValues.includes(option.value),\n );\n\n return (\n \n \n \n {selectedOptions.length === 0 ? (\n filter.variant === \"multiSelect\" ? (\n \"Select options...\"\n ) : (\n \"Select option...\"\n )\n ) : (\n <>\n
\n {selectedOptions.map((selectedOption) =>\n selectedOption.icon ? (\n \n \n
\n ) : null,\n )}\n \n \n {selectedOptions.length > 1\n ? `${selectedOptions.length} selected`\n : selectedOptions[0]?.label}\n \n \n )}\n \n
\n \n \n \n \n No options found.\n \n {options.map((option) => {\n const isMultiSelect = filter.variant === \"multiSelect\";\n const isSelected = selectedValues.includes(option.value);\n\n return (\n {\n const value = isMultiSelect\n ? isSelected\n ? selectedValues.filter((v) => v !== option.value)\n : [...selectedValues, option.value]\n : option.value;\n onFilterUpdate(filter.filterId, { value });\n }}\n >\n {option.icon && }\n {option.label}\n \n );\n })}\n \n \n \n \n
\n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick date...\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", "type": "registry:component", "target": "src/components/data-table/data-table-filter-menu.tsx" }, diff --git a/src/app/components/feature-flags-provider.tsx b/src/app/components/feature-flags-provider.tsx index 2ff5c22f8..4b704c0eb 100644 --- a/src/app/components/feature-flags-provider.tsx +++ b/src/app/components/feature-flags-provider.tsx @@ -84,7 +84,7 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { diff --git a/src/components/data-table/data-table-filter-menu.tsx b/src/components/data-table/data-table-filter-menu.tsx index 9f1912b37..a38c38d62 100644 --- a/src/components/data-table/data-table-filter-menu.tsx +++ b/src/components/data-table/data-table-filter-menu.tsx @@ -1,14 +1,7 @@ "use client"; import type { Column, Table } from "@tanstack/react-table"; -import { - BadgeCheck, - CalendarIcon, - Check, - ListFilter, - Text, - X, -} from "lucide-react"; +import { BadgeCheck, CalendarIcon, ListFilter, Text, X } from "lucide-react"; import { useQueryState } from "nuqs"; import * as React from "react"; @@ -417,6 +410,7 @@ function DataTableFilterItem({ { onFilterUpdate(filter.filterId, { id: column.id as Extract, @@ -436,12 +430,6 @@ function DataTableFilterItem({ {column.columnDef.meta?.label ?? column.id} - ))} @@ -536,6 +524,7 @@ function FilterValueSelector({ onSelect(option.value)} > {option.icon && } @@ -747,34 +736,29 @@ function onFilterInputRender({ No options found. - {options.map((option) => ( - { - const value = - filter.variant === "multiSelect" - ? selectedValues.includes(option.value) + {options.map((option) => { + const isMultiSelect = filter.variant === "multiSelect"; + const isSelected = selectedValues.includes(option.value); + + return ( + { + const value = isMultiSelect + ? isSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value] : option.value; - onFilterUpdate(filter.filterId, { value }); - }} - > - {option.icon && } - {option.label} - {filter.variant === "multiSelect" && ( - - )} - - ))} + onFilterUpdate(filter.filterId, { value }); + }} + > + {option.icon && } + {option.label} + + ); + })} From 0334de47a026ebc4b6f57a740da885223b9a4802 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sat, 13 Jun 2026 18:30:24 +0600 Subject: [PATCH 3/7] feat: update data-table-filter-menu row and wrap selectcontent in selectgroup --- public/r/data-grid.json | 2 +- public/r/data-table-filter-list.json | 2 +- public/r/data-table-filter-menu.json | 2 +- public/r/data-table-sort-list.json | 2 +- .../data-grid/data-grid-cell-variants.tsx | 13 ++++--- .../data-table/data-table-filter-list.tsx | 39 +++++++++++-------- .../data-table/data-table-filter-menu.tsx | 35 ++++++++++------- .../data-table/data-table-sort-list.tsx | 13 ++++--- 8 files changed, 63 insertions(+), 45 deletions(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index b3083af26..9fe1cea13 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -52,7 +52,7 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport { Check, Upload, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { DataGridCellWrapper } from \"@/components/data-grid/data-grid-cell-wrapper\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useBadgeOverflow } from \"@/hooks/use-badge-overflow\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport {\n formatDateForDisplay,\n formatDateToString,\n formatFileSize,\n getCellKey,\n getFileIcon,\n getLineCount,\n getUrlHref,\n parseLocalDate,\n} from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { DataGridCellProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }\n\n const onBlur = React.useCallback(() => {\n // Read the current value directly from the DOM to avoid stale state\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const textareaRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const pendingCharRef = React.useRef(null);\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n const debouncedSave = useDebouncedCallback((newValue: string) => {\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n }\n }, 300);\n\n const onSave = React.useCallback(() => {\n // Immediately save any pending changes and close the popover\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, initialValue, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, value, initialValue, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n if (textareaRef.current) {\n textareaRef.current.focus();\n const length = textareaRef.current.value.length;\n textareaRef.current.setSelectionRange(length, length);\n\n // Insert pending character using execCommand so it's part of undo history\n // Use requestAnimationFrame to ensure focus has fully settled\n if (pendingCharRef.current) {\n const char = pendingCharRef.current;\n pendingCharRef.current = null;\n requestAnimationFrame(() => {\n if (\n textareaRef.current &&\n document.activeElement === textareaRef.current\n ) {\n document.execCommand(\"insertText\", false, char);\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n });\n } else {\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n }\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !isEditing &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Store the character to be inserted after textarea focuses\n // This ensures it's part of the textarea's undo history\n pendingCharRef.current = event.key;\n }\n },\n [isFocused, isEditing, readOnly],\n );\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const newValue = event.target.value;\n setValue(newValue);\n debouncedSave(newValue);\n },\n [debouncedSave],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n } else if (event.key === \"Enter\" && (event.ctrlKey || event.metaKey)) {\n event.preventDefault();\n onSave();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n // Save any pending changes\n if (value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n return;\n }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onSave, onCancel, value, initialValue, tableMeta, rowIndex, columnId],\n );\n\n return (\n \n \n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as number;\n const [value, setValue] = React.useState(String(initialValue ?? \"\"));\n const inputRef = React.useRef(null);\n const containerRef = React.useRef(null);\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const numberCellOpts = cellOpts?.variant === \"number\" ? cellOpts : null;\n const min = numberCellOpts?.min;\n const max = numberCellOpts?.max;\n const step = numberCellOpts?.step;\n\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(String(initialValue ?? \"\"));\n }\n\n const onBlur = React.useCallback(() => {\n const numValue = value === \"\" ? null : Number(value);\n if (!readOnly && numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, value, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setValue(event.target.value);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(String(initialValue ?? \"\"));\n inputRef.current?.blur();\n }\n } else if (isFocused) {\n // Handle Backspace to start editing with empty value\n if (event.key === \"Backspace\") {\n setValue(\"\");\n } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n }\n }\n },\n [isEditing, isFocused, initialValue, tableMeta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n // Only focus when we start editing (transition from false to true)\n if (isEditing && !wasEditing && inputRef.current) {\n inputRef.current.focus();\n }\n }, [isEditing]);\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nexport function UrlCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue ?? \"\";\n }\n }\n\n const onBlur = React.useCallback(() => {\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue ?? \"\");\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [\n isEditing,\n isFocused,\n initialValue,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n ],\n );\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isEditing) {\n event.preventDefault();\n return;\n }\n\n // Check if URL was rejected due to dangerous protocol\n const href = getUrlHref(value);\n if (!href) {\n event.preventDefault();\n toast.error(\"Invalid URL\", {\n description:\n \"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)\",\n });\n return;\n }\n\n // Stop propagation to prevent grid from interfering with link navigation\n event.stopPropagation();\n },\n [isEditing, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n const urlHref = displayValue ? getUrlHref(displayValue) : \"\";\n const isDangerousUrl = displayValue && !urlHref;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {!isEditing && displayValue ? (\n \n \n {displayValue}\n \n \n ) : (\n \n {displayValue}\n \n )}\n \n );\n}\n\nexport function CheckboxCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: Omit, \"isEditing\">) {\n const initialValue = cell.getValue() as boolean;\n const [value, setValue] = React.useState(Boolean(initialValue));\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(Boolean(initialValue));\n }\n\n const onCheckedChange = React.useCallback(\n (checked: boolean) => {\n if (readOnly) return;\n setValue(checked);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !readOnly &&\n (event.key === \" \" || event.key === \"Enter\")\n ) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, tableMeta, readOnly],\n );\n\n const onWrapperClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isFocused && !readOnly) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n }\n },\n [isFocused, value, onCheckedChange, readOnly],\n );\n\n const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {\n event.stopPropagation();\n }, []);\n\n const onCheckboxMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n const onCheckboxDoubleClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={false}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className=\"flex size-full justify-center\"\n onClick={onWrapperClick}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n );\n}\n\nexport function SelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n }\n\n const onValueChange = React.useCallback(\n (newValue: string) => {\n if (readOnly) return;\n setValue(newValue);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n const displayLabel = optionByValue.get(value)?.label ?? value;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n {displayLabel ? (\n \n \n \n ) : (\n \n )}\n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : displayLabel ? (\n \n {displayLabel}\n \n ) : null}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const cellValue = React.useMemo(() => {\n const value = cell.getValue() as string[];\n return value ?? [];\n }, [cell]);\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [searchValue, setSearchValue] = React.useState(\"\");\n const containerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"multi-select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setSelectedValues(cellValue);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setSearchValue(\"\");\n }\n\n const onValueChange = React.useCallback(\n (value: string) => {\n if (readOnly) return;\n let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.includes(value)\n ? curr.filter((v) => v !== value)\n : [...curr, value];\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n setSearchValue(\"\");\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const removeValue = React.useCallback(\n (valueToRemove: string, event?: React.MouseEvent) => {\n if (readOnly) return;\n event?.stopPropagation();\n event?.preventDefault();\n let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.filter((v) => v !== valueToRemove);\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n setSelectedValues([]);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [tableMeta, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, tableMeta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Backspace\" && searchValue === \"\") {\n event.preventDefault();\n let newValues: string[] | null = null;\n setSelectedValues((curr) => {\n if (curr.length === 0) return curr;\n newValues = curr.slice(0, -1);\n return newValues;\n });\n queueMicrotask(() => {\n if (newValues !== null) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n }\n inputRef.current?.focus();\n });\n }\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, tableMeta, rowIndex, columnId],\n );\n\n const displayLabels = selectedValues\n .map((val) => optionByValue.get(val)?.label ?? val)\n .filter(Boolean);\n\n const selectedValuesSet = React.useMemo(\n () => new Set(selectedValues),\n [selectedValues],\n );\n\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =\n useBadgeOverflow({\n items: displayLabels,\n getLabel: (label) => label,\n containerRef,\n lineCount,\n });\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const label = optionByValue.get(value)?.label ?? value;\n\n return (\n \n {label}\n removeValue(value, event)}\n onPointerDown={(event) => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n \n \n \n );\n })}\n \n
\n \n No options found.\n \n {options.map((option) => {\n const isSelected = selectedValuesSet.has(option.value);\n\n return (\n onValueChange(option.value)}\n >\n \n \n
\n {option.label}\n
\n );\n })}\n \n {selectedValues.length > 0 && (\n <>\n \n \n \n Clear all\n \n \n \n )}\n \n \n \n \n ) : null}\n {displayLabels.length > 0 ? (\n
\n {visibleLabels.map((label, index) => (\n \n {label}\n \n ))}\n {hiddenBadgeCount > 0 && (\n \n +{hiddenBadgeCount}\n \n )}\n
\n ) : null}\n \n );\n}\n\nexport function DateCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Parse date as local time to avoid timezone shifts\n const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date || readOnly) return;\n\n // Format using local date components to avoid timezone issues\n const formattedDate = formatDateToString(date);\n setValue(formattedDate);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nexport function FileCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const cellValue = React.useMemo(\n () => (cell.getValue() as FileCellData[]) ?? [],\n [cell],\n );\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const labelId = React.useId();\n const descriptionId = React.useId();\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [deletingFiles, setDeletingFiles] = React.useState>(\n new Set(),\n );\n const [isDraggingOver, setIsDraggingOver] = React.useState(false);\n const [isDragging, setIsDragging] = React.useState(false);\n const [error, setError] = React.useState(null);\n\n const isUploading = uploadingFiles.size > 0;\n const isDeleting = deletingFiles.size > 0;\n const isPending = isUploading || isDeleting;\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const fileCellOpts = cellOpts?.variant === \"file\" ? cellOpts : null;\n const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;\n const maxFiles = fileCellOpts?.maxFiles ?? 10;\n const accept = fileCellOpts?.accept;\n const multiple = fileCellOpts?.multiple ?? false;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles(cellValue);\n setError(null);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setError(null);\n }\n\n const validateFile = React.useCallback(\n (file: File): string | null => {\n if (maxFileSize && file.size > maxFileSize) {\n return `File size exceeds ${formatFileSize(maxFileSize)}`;\n }\n if (acceptedTypes) {\n const fileExtension = `.${file.name.split(\".\").pop()}`;\n const isAccepted = acceptedTypes.some((type) => {\n if (type.endsWith(\"/*\")) {\n const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\n return file.type === type;\n });\n if (!isAccepted) {\n return \"File type not accepted\";\n }\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n if (readOnly || isPending) return;\n setError(null);\n\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n const errorMessage = `Maximum ${maxFiles} files allowed`;\n setError(errorMessage);\n toast(errorMessage);\n setTimeout(() => {\n setError(null);\n }, 2000);\n return;\n }\n\n const rejectedFiles: Array<{ name: string; reason: string }> = [];\n const filesToValidate: File[] = [];\n\n for (const file of newFiles) {\n const validationError = validateFile(file);\n if (validationError) {\n rejectedFiles.push({ name: file.name, reason: validationError });\n continue;\n }\n filesToValidate.push(file);\n }\n\n if (rejectedFiles.length > 0) {\n const firstError = rejectedFiles[0];\n if (firstError) {\n setError(firstError.reason);\n\n const truncatedName =\n firstError.name.length > 20\n ? `${firstError.name.slice(0, 20)}...`\n : firstError.name;\n\n if (rejectedFiles.length === 1) {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" has been rejected`,\n });\n } else {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" and ${rejectedFiles.length - 1} more rejected`,\n });\n }\n\n setTimeout(() => {\n setError(null);\n }, 2000);\n }\n }\n\n if (filesToValidate.length > 0) {\n if (!skipUpload) {\n const tempFiles = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: undefined,\n }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n const uploadingIds = new Set(tempFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n let uploadedFiles: FileCellData[] = [];\n\n if (tableMeta?.onFilesUpload) {\n try {\n uploadedFiles = await tableMeta.onFilesUpload({\n files: filesToValidate,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? \"s\" : \"\"}`,\n );\n setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));\n setUploadingFiles(new Set());\n return;\n }\n } else {\n uploadedFiles = filesToValidate.map((f, i) => ({\n id: tempFiles[i]?.id ?? crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n }\n\n const finalFiles = filesWithTemp\n .map((f) => {\n if (uploadingIds.has(f.id)) {\n return uploadedFiles.find((uf) => uf.name === f.name) ?? f;\n }\n return f;\n })\n .filter((f) => f.url !== undefined);\n\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n const newFilesData: FileCellData[] = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n const updatedFiles = [...files, ...newFilesData];\n setFiles(updatedFiles);\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: updatedFiles,\n });\n }\n }\n },\n [\n files,\n maxFiles,\n validateFile,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n isPending,\n ],\n );\n\n const removeFile = React.useCallback(\n async (fileId: string) => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileToRemove = files.find((f) => f.id === fileId);\n if (!fileToRemove) return;\n\n setDeletingFiles((prev) => new Set(prev).add(fileId));\n\n if (tableMeta?.onFilesDelete) {\n try {\n await tableMeta.onFilesDelete({\n fileIds: [fileId],\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to delete ${fileToRemove.name}`,\n );\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n return;\n }\n }\n\n if (fileToRemove.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, tableMeta, rowIndex, columnId, readOnly, isPending],\n );\n\n const clearAll = React.useCallback(async () => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileIds = files.map((f) => f.id);\n setDeletingFiles(new Set(fileIds));\n\n if (tableMeta?.onFilesDelete && files.length > 0) {\n try {\n await tableMeta.onFilesDelete({\n fileIds,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to delete files\",\n );\n setDeletingFiles(new Set());\n return;\n }\n }\n\n for (const file of files) {\n if (file.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setDeletingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, tableMeta, rowIndex, columnId, readOnly, isPending]);\n\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n if (event.dataTransfer.types.includes(\"Files\")) {\n setIsDraggingOver(true);\n }\n }, []);\n\n const onCellDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDraggingOver(false);\n }\n }, []);\n\n const onCellDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onCellDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDraggingOver(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n if (droppedFiles.length > 0) {\n addFiles(droppedFiles, false);\n }\n },\n [addFiles],\n );\n\n const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDropzoneDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDragging(false);\n }\n }, []);\n\n const onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDropzoneDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n addFiles(droppedFiles, false);\n },\n [addFiles],\n );\n\n const onDropzoneClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onDropzoneKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n },\n [onDropzoneClick],\n );\n\n const onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, false);\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n setError(null);\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onEscapeKeyDown: NonNullable<\n React.ComponentProps[\"onEscapeKeyDown\"]\n > = React.useCallback((event) => {\n // Prevent the escape key from propagating to the data grid's keyboard handler\n // which would call blurCell() and remove focus from the cell\n event.stopPropagation();\n }, []);\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n queueMicrotask(() => {\n dropzoneRef.current?.focus();\n });\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setFiles(cellValue);\n setError(null);\n tableMeta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n } else if (isFocused && event.key === \"Enter\") {\n event.preventDefault();\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n tableMeta,\n onDropzoneClick,\n rowIndex,\n columnId,\n ],\n );\n\n React.useEffect(() => {\n return () => {\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n };\n }, [files]);\n\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =\n useBadgeOverflow({\n items: files,\n getLabel: (file) => file.name,\n containerRef,\n lineCount,\n cacheKeyPrefix: \"file\",\n iconSize: 12,\n maxWidth: 100,\n });\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className={cn({\n \"ring-1 ring-primary/80 ring-inset\": isDraggingOver,\n })}\n onDragEnter={onCellDragEnter}\n onDragLeave={onCellDragLeave}\n onDragOver={onCellDragOver}\n onDrop={onCellDrop}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n
\n \n File upload\n \n \n \n
\n

\n {isDragging ? \"Drop files here\" : \"Drag files here\"}\n

\n

\n or click to browse\n

\n
\n

\n {maxFileSize\n ? `Max size: ${formatFileSize(maxFileSize)}${maxFiles ? ` • Max ${maxFiles} files` : \"\"}`\n : maxFiles\n ? `Max ${maxFiles} files`\n : \"Select files to upload\"}\n

\n
\n \n {files.length > 0 && (\n
\n
\n

\n {files.length} {files.length === 1 ? \"file\" : \"files\"}\n

\n \n Clear all\n \n
\n
\n {files.map((file) => {\n const FileIcon = getFileIcon(file.type);\n const isFileUploading = uploadingFiles.has(file.id);\n const isFileDeleting = deletingFiles.has(file.id);\n const isFilePending = isFileUploading || isFileDeleting;\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {isFileUploading\n ? \"Uploading...\"\n : isFileDeleting\n ? \"Deleting...\"\n : formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n disabled={isPending}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n return (\n \n );\n }\n\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && }\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", + "content": "\"use client\";\n\nimport { Check, Upload, X } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { DataGridCellWrapper } from \"@/components/data-grid/data-grid-cell-wrapper\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useBadgeOverflow } from \"@/hooks/use-badge-overflow\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport {\n formatDateForDisplay,\n formatDateToString,\n formatFileSize,\n getCellKey,\n getFileIcon,\n getLineCount,\n getUrlHref,\n parseLocalDate,\n} from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { DataGridCellProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }\n\n const onBlur = React.useCallback(() => {\n // Read the current value directly from the DOM to avoid stale state\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const textareaRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const pendingCharRef = React.useRef(null);\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n const debouncedSave = useDebouncedCallback((newValue: string) => {\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n }\n }, 300);\n\n const onSave = React.useCallback(() => {\n // Immediately save any pending changes and close the popover\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, initialValue, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, value, initialValue, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n if (textareaRef.current) {\n textareaRef.current.focus();\n const length = textareaRef.current.value.length;\n textareaRef.current.setSelectionRange(length, length);\n\n // Insert pending character using execCommand so it's part of undo history\n // Use requestAnimationFrame to ensure focus has fully settled\n if (pendingCharRef.current) {\n const char = pendingCharRef.current;\n pendingCharRef.current = null;\n requestAnimationFrame(() => {\n if (\n textareaRef.current &&\n document.activeElement === textareaRef.current\n ) {\n document.execCommand(\"insertText\", false, char);\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n });\n } else {\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n }\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !isEditing &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Store the character to be inserted after textarea focuses\n // This ensures it's part of the textarea's undo history\n pendingCharRef.current = event.key;\n }\n },\n [isFocused, isEditing, readOnly],\n );\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const newValue = event.target.value;\n setValue(newValue);\n debouncedSave(newValue);\n },\n [debouncedSave],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n } else if (event.key === \"Enter\" && (event.ctrlKey || event.metaKey)) {\n event.preventDefault();\n onSave();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n // Save any pending changes\n if (value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n return;\n }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onSave, onCancel, value, initialValue, tableMeta, rowIndex, columnId],\n );\n\n return (\n \n \n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as number;\n const [value, setValue] = React.useState(String(initialValue ?? \"\"));\n const inputRef = React.useRef(null);\n const containerRef = React.useRef(null);\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const numberCellOpts = cellOpts?.variant === \"number\" ? cellOpts : null;\n const min = numberCellOpts?.min;\n const max = numberCellOpts?.max;\n const step = numberCellOpts?.step;\n\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(String(initialValue ?? \"\"));\n }\n\n const onBlur = React.useCallback(() => {\n const numValue = value === \"\" ? null : Number(value);\n if (!readOnly && numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, value, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setValue(event.target.value);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(String(initialValue ?? \"\"));\n inputRef.current?.blur();\n }\n } else if (isFocused) {\n // Handle Backspace to start editing with empty value\n if (event.key === \"Backspace\") {\n setValue(\"\");\n } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n }\n }\n },\n [isEditing, isFocused, initialValue, tableMeta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n // Only focus when we start editing (transition from false to true)\n if (isEditing && !wasEditing && inputRef.current) {\n inputRef.current.focus();\n }\n }, [isEditing]);\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nexport function UrlCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const prevIsEditingRef = React.useRef(false);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue ?? \"\";\n }\n }\n\n const onBlur = React.useCallback(() => {\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue ?? \"\");\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [\n isEditing,\n isFocused,\n initialValue,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n ],\n );\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isEditing) {\n event.preventDefault();\n return;\n }\n\n // Check if URL was rejected due to dangerous protocol\n const href = getUrlHref(value);\n if (!href) {\n event.preventDefault();\n toast.error(\"Invalid URL\", {\n description:\n \"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)\",\n });\n return;\n }\n\n // Stop propagation to prevent grid from interfering with link navigation\n event.stopPropagation();\n },\n [isEditing, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n const urlHref = displayValue ? getUrlHref(displayValue) : \"\";\n const isDangerousUrl = displayValue && !urlHref;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {!isEditing && displayValue ? (\n \n \n {displayValue}\n \n \n ) : (\n \n {displayValue}\n \n )}\n \n );\n}\n\nexport function CheckboxCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: Omit, \"isEditing\">) {\n const initialValue = cell.getValue() as boolean;\n const [value, setValue] = React.useState(Boolean(initialValue));\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(Boolean(initialValue));\n }\n\n const onCheckedChange = React.useCallback(\n (checked: boolean) => {\n if (readOnly) return;\n setValue(checked);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !readOnly &&\n (event.key === \" \" || event.key === \"Enter\")\n ) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, tableMeta, readOnly],\n );\n\n const onWrapperClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isFocused && !readOnly) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n }\n },\n [isFocused, value, onCheckedChange, readOnly],\n );\n\n const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {\n event.stopPropagation();\n }, []);\n\n const onCheckboxMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n const onCheckboxDoubleClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={false}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className=\"flex size-full justify-center\"\n onClick={onWrapperClick}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n );\n}\n\nexport function SelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n }\n\n const onValueChange = React.useCallback(\n (newValue: string) => {\n if (readOnly) return;\n setValue(newValue);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n const displayLabel = optionByValue.get(value)?.label ?? value;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n {displayLabel ? (\n \n \n \n ) : (\n \n )}\n \n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n \n ) : displayLabel ? (\n \n {displayLabel}\n \n ) : null}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const cellValue = React.useMemo(() => {\n const value = cell.getValue() as string[];\n return value ?? [];\n }, [cell]);\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [searchValue, setSearchValue] = React.useState(\"\");\n const containerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"multi-select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setSelectedValues(cellValue);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setSearchValue(\"\");\n }\n\n const onValueChange = React.useCallback(\n (value: string) => {\n if (readOnly) return;\n let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.includes(value)\n ? curr.filter((v) => v !== value)\n : [...curr, value];\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n setSearchValue(\"\");\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const removeValue = React.useCallback(\n (valueToRemove: string, event?: React.MouseEvent) => {\n if (readOnly) return;\n event?.stopPropagation();\n event?.preventDefault();\n let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.filter((v) => v !== valueToRemove);\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n setSelectedValues([]);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [tableMeta, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, tableMeta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Backspace\" && searchValue === \"\") {\n event.preventDefault();\n let newValues: string[] | null = null;\n setSelectedValues((curr) => {\n if (curr.length === 0) return curr;\n newValues = curr.slice(0, -1);\n return newValues;\n });\n queueMicrotask(() => {\n if (newValues !== null) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n }\n inputRef.current?.focus();\n });\n }\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, tableMeta, rowIndex, columnId],\n );\n\n const displayLabels = selectedValues\n .map((val) => optionByValue.get(val)?.label ?? val)\n .filter(Boolean);\n\n const selectedValuesSet = React.useMemo(\n () => new Set(selectedValues),\n [selectedValues],\n );\n\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =\n useBadgeOverflow({\n items: displayLabels,\n getLabel: (label) => label,\n containerRef,\n lineCount,\n });\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const label = optionByValue.get(value)?.label ?? value;\n\n return (\n \n {label}\n removeValue(value, event)}\n onPointerDown={(event) => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n \n \n \n );\n })}\n \n
\n \n No options found.\n \n {options.map((option) => {\n const isSelected = selectedValuesSet.has(option.value);\n\n return (\n onValueChange(option.value)}\n >\n \n \n
\n {option.label}\n \n );\n })}\n \n {selectedValues.length > 0 && (\n <>\n \n \n \n Clear all\n \n \n \n )}\n \n \n \n
\n ) : null}\n {displayLabels.length > 0 ? (\n
\n {visibleLabels.map((label, index) => (\n \n {label}\n \n ))}\n {hiddenBadgeCount > 0 && (\n \n +{hiddenBadgeCount}\n \n )}\n
\n ) : null}\n \n );\n}\n\nexport function DateCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Parse date as local time to avoid timezone shifts\n const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date || readOnly) return;\n\n // Format using local date components to avoid timezone issues\n const formattedDate = formatDateToString(date);\n setValue(formattedDate);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nexport function FileCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const cellValue = React.useMemo(\n () => (cell.getValue() as FileCellData[]) ?? [],\n [cell],\n );\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const labelId = React.useId();\n const descriptionId = React.useId();\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [deletingFiles, setDeletingFiles] = React.useState>(\n new Set(),\n );\n const [isDraggingOver, setIsDraggingOver] = React.useState(false);\n const [isDragging, setIsDragging] = React.useState(false);\n const [error, setError] = React.useState(null);\n\n const isUploading = uploadingFiles.size > 0;\n const isDeleting = deletingFiles.size > 0;\n const isPending = isUploading || isDeleting;\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const fileCellOpts = cellOpts?.variant === \"file\" ? cellOpts : null;\n const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;\n const maxFiles = fileCellOpts?.maxFiles ?? 10;\n const accept = fileCellOpts?.accept;\n const multiple = fileCellOpts?.multiple ?? false;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles(cellValue);\n setError(null);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setError(null);\n }\n\n const validateFile = React.useCallback(\n (file: File): string | null => {\n if (maxFileSize && file.size > maxFileSize) {\n return `File size exceeds ${formatFileSize(maxFileSize)}`;\n }\n if (acceptedTypes) {\n const fileExtension = `.${file.name.split(\".\").pop()}`;\n const isAccepted = acceptedTypes.some((type) => {\n if (type.endsWith(\"/*\")) {\n const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\n return file.type === type;\n });\n if (!isAccepted) {\n return \"File type not accepted\";\n }\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n if (readOnly || isPending) return;\n setError(null);\n\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n const errorMessage = `Maximum ${maxFiles} files allowed`;\n setError(errorMessage);\n toast(errorMessage);\n setTimeout(() => {\n setError(null);\n }, 2000);\n return;\n }\n\n const rejectedFiles: Array<{ name: string; reason: string }> = [];\n const filesToValidate: File[] = [];\n\n for (const file of newFiles) {\n const validationError = validateFile(file);\n if (validationError) {\n rejectedFiles.push({ name: file.name, reason: validationError });\n continue;\n }\n filesToValidate.push(file);\n }\n\n if (rejectedFiles.length > 0) {\n const firstError = rejectedFiles[0];\n if (firstError) {\n setError(firstError.reason);\n\n const truncatedName =\n firstError.name.length > 20\n ? `${firstError.name.slice(0, 20)}...`\n : firstError.name;\n\n if (rejectedFiles.length === 1) {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" has been rejected`,\n });\n } else {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" and ${rejectedFiles.length - 1} more rejected`,\n });\n }\n\n setTimeout(() => {\n setError(null);\n }, 2000);\n }\n }\n\n if (filesToValidate.length > 0) {\n if (!skipUpload) {\n const tempFiles = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: undefined,\n }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n const uploadingIds = new Set(tempFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n let uploadedFiles: FileCellData[] = [];\n\n if (tableMeta?.onFilesUpload) {\n try {\n uploadedFiles = await tableMeta.onFilesUpload({\n files: filesToValidate,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? \"s\" : \"\"}`,\n );\n setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));\n setUploadingFiles(new Set());\n return;\n }\n } else {\n uploadedFiles = filesToValidate.map((f, i) => ({\n id: tempFiles[i]?.id ?? crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n }\n\n const finalFiles = filesWithTemp\n .map((f) => {\n if (uploadingIds.has(f.id)) {\n return uploadedFiles.find((uf) => uf.name === f.name) ?? f;\n }\n return f;\n })\n .filter((f) => f.url !== undefined);\n\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n const newFilesData: FileCellData[] = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n const updatedFiles = [...files, ...newFilesData];\n setFiles(updatedFiles);\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: updatedFiles,\n });\n }\n }\n },\n [\n files,\n maxFiles,\n validateFile,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n isPending,\n ],\n );\n\n const removeFile = React.useCallback(\n async (fileId: string) => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileToRemove = files.find((f) => f.id === fileId);\n if (!fileToRemove) return;\n\n setDeletingFiles((prev) => new Set(prev).add(fileId));\n\n if (tableMeta?.onFilesDelete) {\n try {\n await tableMeta.onFilesDelete({\n fileIds: [fileId],\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to delete ${fileToRemove.name}`,\n );\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n return;\n }\n }\n\n if (fileToRemove.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, tableMeta, rowIndex, columnId, readOnly, isPending],\n );\n\n const clearAll = React.useCallback(async () => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileIds = files.map((f) => f.id);\n setDeletingFiles(new Set(fileIds));\n\n if (tableMeta?.onFilesDelete && files.length > 0) {\n try {\n await tableMeta.onFilesDelete({\n fileIds,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to delete files\",\n );\n setDeletingFiles(new Set());\n return;\n }\n }\n\n for (const file of files) {\n if (file.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setDeletingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, tableMeta, rowIndex, columnId, readOnly, isPending]);\n\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n if (event.dataTransfer.types.includes(\"Files\")) {\n setIsDraggingOver(true);\n }\n }, []);\n\n const onCellDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDraggingOver(false);\n }\n }, []);\n\n const onCellDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onCellDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDraggingOver(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n if (droppedFiles.length > 0) {\n addFiles(droppedFiles, false);\n }\n },\n [addFiles],\n );\n\n const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDropzoneDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDragging(false);\n }\n }, []);\n\n const onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDropzoneDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n addFiles(droppedFiles, false);\n },\n [addFiles],\n );\n\n const onDropzoneClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onDropzoneKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n },\n [onDropzoneClick],\n );\n\n const onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, false);\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n setError(null);\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onEscapeKeyDown: NonNullable<\n React.ComponentProps[\"onEscapeKeyDown\"]\n > = React.useCallback((event) => {\n // Prevent the escape key from propagating to the data grid's keyboard handler\n // which would call blurCell() and remove focus from the cell\n event.stopPropagation();\n }, []);\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n queueMicrotask(() => {\n dropzoneRef.current?.focus();\n });\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setFiles(cellValue);\n setError(null);\n tableMeta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n } else if (isFocused && event.key === \"Enter\") {\n event.preventDefault();\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n tableMeta,\n onDropzoneClick,\n rowIndex,\n columnId,\n ],\n );\n\n React.useEffect(() => {\n return () => {\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n };\n }, [files]);\n\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =\n useBadgeOverflow({\n items: files,\n getLabel: (file) => file.name,\n containerRef,\n lineCount,\n cacheKeyPrefix: \"file\",\n iconSize: 12,\n maxWidth: 100,\n });\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className={cn({\n \"ring-1 ring-primary/80 ring-inset\": isDraggingOver,\n })}\n onDragEnter={onCellDragEnter}\n onDragLeave={onCellDragLeave}\n onDragOver={onCellDragOver}\n onDrop={onCellDrop}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n
\n \n File upload\n \n \n \n
\n

\n {isDragging ? \"Drop files here\" : \"Drag files here\"}\n

\n

\n or click to browse\n

\n
\n

\n {maxFileSize\n ? `Max size: ${formatFileSize(maxFileSize)}${maxFiles ? ` • Max ${maxFiles} files` : \"\"}`\n : maxFiles\n ? `Max ${maxFiles} files`\n : \"Select files to upload\"}\n

\n
\n \n {files.length > 0 && (\n
\n
\n

\n {files.length} {files.length === 1 ? \"file\" : \"files\"}\n

\n \n Clear all\n \n
\n
\n {files.map((file) => {\n const FileIcon = getFileIcon(file.type);\n const isFileUploading = uploadingFiles.has(file.id);\n const isFileDeleting = deletingFiles.has(file.id);\n const isFilePending = isFileUploading || isFileDeleting;\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {isFileUploading\n ? \"Uploading...\"\n : isFileDeleting\n ? \"Deleting...\"\n : formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n disabled={isPending}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n return (\n \n );\n }\n\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && }\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell-variants.tsx" }, diff --git a/public/r/data-table-filter-list.json b/public/r/data-table-filter-list.json index 93daf0213..7d82601cf 100644 --- a/public/r/data-table-filter-list.json +++ b/public/r/data-table-filter-list.json @@ -20,7 +20,7 @@ "files": [ { "path": "src/components/data-table/data-table-filter-list.tsx", - "content": "\"use client\";\n\nimport type { Column, ColumnMeta, Table } from \"@tanstack/react-table\";\nimport {\n CalendarIcon,\n Check,\n ChevronsUpDown,\n GripVertical,\n ListFilter,\n Trash2,\n} from \"lucide-react\";\nimport { parseAsStringEnum, useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Faceted,\n FacetedBadgeList,\n FacetedContent,\n FacetedEmpty,\n FacetedGroup,\n FacetedInput,\n FacetedItem,\n FacetedList,\n FacetedTrigger,\n} from \"@/components/ui/faceted\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport {\n Sortable,\n SortableContent,\n SortableItem,\n SortableItemHandle,\n SortableOverlay,\n} from \"@/components/ui/sortable\";\nimport { dataTableConfig } from \"@/config/data-table\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n ExtendedColumnFilter,\n FilterOperator,\n JoinOperator,\n} from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterListProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterList({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n ...props\n}: DataTableFilterListProps) {\n const id = React.useId();\n const labelId = React.useId();\n const descriptionId = React.useId();\n const [open, setOpen] = React.useState(false);\n const addButtonRef = React.useRef(null);\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const [joinOperator, setJoinOperator] = useQueryState(\n table.options.meta?.queryKeys?.joinOperator ?? \"\",\n parseAsStringEnum([\"and\", \"or\"]).withDefault(\"and\").withOptions({\n clearOnDefault: true,\n shallow,\n }),\n );\n\n const onFilterAdd = React.useCallback(() => {\n const column = columns[0];\n\n if (!column) return;\n\n debouncedSetFilters([\n ...filters,\n {\n id: column.id as Extract,\n value: \"\",\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n },\n ]);\n }, [columns, filters, debouncedSetFilters]);\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n void setFilters(updatedFilters);\n requestAnimationFrame(() => {\n addButtonRef.current?.focus();\n });\n },\n [filters, setFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n void setFilters(null);\n void setJoinOperator(\"and\");\n }, [setFilters, setJoinOperator]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n item.filterId}\n >\n \n \n \n \n Filter\n {filters.length > 0 && (\n \n {filters.length}\n \n )}\n \n \n \n
\n

\n {filters.length > 0 ? \"Filters\" : \"No filters applied\"}\n

\n 0 && \"sr-only\",\n )}\n >\n {filters.length > 0\n ? \"Modify filters to refine your rows.\"\n : \"Add filters to refine your rows.\"}\n

\n
\n {filters.length > 0 ? (\n \n \n {filters.map((filter, index) => (\n \n key={filter.filterId}\n filter={filter}\n index={index}\n filterItemId={`${id}-filter-${filter.filterId}`}\n joinOperator={joinOperator}\n setJoinOperator={setJoinOperator}\n columns={columns}\n onFilterUpdate={onFilterUpdate}\n onFilterRemove={onFilterRemove}\n />\n ))}\n \n \n ) : null}\n
\n \n Add filter\n \n {filters.length > 0 ? (\n \n Reset filters\n \n ) : null}\n
\n \n
\n \n
\n
\n
\n
\n
\n
\n
\n
\n \n \n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n index: number;\n filterItemId: string;\n joinOperator: JoinOperator;\n setJoinOperator: (value: JoinOperator) => void;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n index,\n filterItemId,\n joinOperator,\n setJoinOperator,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] = React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const joinOperatorListboxId = `${filterItemId}-join-operator-listbox`;\n const fieldListboxId = `${filterItemId}-field-listbox`;\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n
\n {index === 0 ? (\n Where\n ) : index === 1 ? (\n setJoinOperator(value)}\n >\n \n \n \n \n {dataTableConfig.joinOperators.map((joinOperator) => (\n \n {joinOperator}\n \n ))}\n \n \n ) : (\n \n {joinOperator}\n \n )}\n
\n \n \n \n \n {columns.find((column) => column.id === filter.id)?.columnDef\n .meta?.label ?? \"Select field\"}\n \n \n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: value as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n \n {column.columnDef.meta?.label}\n \n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n
\n \n
\n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n
\n {onFilterInputRender({\n filter,\n inputId,\n column,\n columnMeta,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n
\n onFilterRemove(filter.filterId)}\n >\n \n \n \n \n \n
\n \n );\n}\n\nfunction onFilterInputRender({\n filter,\n inputId,\n column,\n columnMeta,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n inputId: string;\n column: Column;\n columnMeta?: ColumnMeta;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, {\n value: event.target.value,\n })\n }\n />\n );\n }\n\n case \"boolean\": {\n if (Array.isArray(filter.value)) return null;\n\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, {\n value,\n })\n }\n >\n \n \n \n \n True\n False\n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const multiple = filter.variant === \"multiSelect\";\n const selectedValues = multiple\n ? Array.isArray(filter.value)\n ? filter.value\n : []\n : typeof filter.value === \"string\"\n ? filter.value\n : undefined;\n\n return (\n {\n onFilterUpdate(filter.filterId, {\n value,\n });\n }}\n multiple={multiple}\n >\n \n \n \n \n \n \n \n \n No options found.\n \n {columnMeta?.options?.map((option) => (\n \n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n \n \n \n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick a date\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n setShowValueSelector(false);\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", + "content": "\"use client\";\n\nimport type { Column, ColumnMeta, Table } from \"@tanstack/react-table\";\nimport {\n CalendarIcon,\n Check,\n ChevronsUpDown,\n GripVertical,\n ListFilter,\n Trash2,\n} from \"lucide-react\";\nimport { parseAsStringEnum, useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Faceted,\n FacetedBadgeList,\n FacetedContent,\n FacetedEmpty,\n FacetedGroup,\n FacetedInput,\n FacetedItem,\n FacetedList,\n FacetedTrigger,\n} from \"@/components/ui/faceted\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport {\n Sortable,\n SortableContent,\n SortableItem,\n SortableItemHandle,\n SortableOverlay,\n} from \"@/components/ui/sortable\";\nimport { dataTableConfig } from \"@/config/data-table\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n ExtendedColumnFilter,\n FilterOperator,\n JoinOperator,\n} from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterListProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterList({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n ...props\n}: DataTableFilterListProps) {\n const id = React.useId();\n const labelId = React.useId();\n const descriptionId = React.useId();\n const [open, setOpen] = React.useState(false);\n const addButtonRef = React.useRef(null);\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const [joinOperator, setJoinOperator] = useQueryState(\n table.options.meta?.queryKeys?.joinOperator ?? \"\",\n parseAsStringEnum([\"and\", \"or\"]).withDefault(\"and\").withOptions({\n clearOnDefault: true,\n shallow,\n }),\n );\n\n const onFilterAdd = React.useCallback(() => {\n const column = columns[0];\n\n if (!column) return;\n\n debouncedSetFilters([\n ...filters,\n {\n id: column.id as Extract,\n value: \"\",\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n },\n ]);\n }, [columns, filters, debouncedSetFilters]);\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n void setFilters(updatedFilters);\n requestAnimationFrame(() => {\n addButtonRef.current?.focus();\n });\n },\n [filters, setFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n void setFilters(null);\n void setJoinOperator(\"and\");\n }, [setFilters, setJoinOperator]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n item.filterId}\n >\n \n \n \n \n Filter\n {filters.length > 0 && (\n \n {filters.length}\n \n )}\n \n \n \n
\n

\n {filters.length > 0 ? \"Filters\" : \"No filters applied\"}\n

\n 0 && \"sr-only\",\n )}\n >\n {filters.length > 0\n ? \"Modify filters to refine your rows.\"\n : \"Add filters to refine your rows.\"}\n

\n
\n {filters.length > 0 ? (\n \n \n {filters.map((filter, index) => (\n \n key={filter.filterId}\n filter={filter}\n index={index}\n filterItemId={`${id}-filter-${filter.filterId}`}\n joinOperator={joinOperator}\n setJoinOperator={setJoinOperator}\n columns={columns}\n onFilterUpdate={onFilterUpdate}\n onFilterRemove={onFilterRemove}\n />\n ))}\n
\n \n ) : null}\n
\n \n Add filter\n \n {filters.length > 0 ? (\n \n Reset filters\n \n ) : null}\n
\n \n \n \n
\n
\n
\n
\n
\n
\n
\n
\n \n \n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n index: number;\n filterItemId: string;\n joinOperator: JoinOperator;\n setJoinOperator: (value: JoinOperator) => void;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n index,\n filterItemId,\n joinOperator,\n setJoinOperator,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] = React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const joinOperatorListboxId = `${filterItemId}-join-operator-listbox`;\n const fieldListboxId = `${filterItemId}-field-listbox`;\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n
\n {index === 0 ? (\n Where\n ) : index === 1 ? (\n setJoinOperator(value)}\n >\n \n \n \n \n \n {dataTableConfig.joinOperators.map((joinOperator) => (\n \n {joinOperator}\n \n ))}\n \n \n \n ) : (\n \n {joinOperator}\n \n )}\n
\n \n \n \n \n {columns.find((column) => column.id === filter.id)?.columnDef\n .meta?.label ?? \"Select field\"}\n \n \n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: value as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n \n {column.columnDef.meta?.label}\n \n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n
\n \n
\n \n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n \n
\n {onFilterInputRender({\n filter,\n inputId,\n column,\n columnMeta,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n
\n onFilterRemove(filter.filterId)}\n >\n \n \n \n \n \n
\n \n );\n}\n\nfunction onFilterInputRender({\n filter,\n inputId,\n column,\n columnMeta,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n inputId: string;\n column: Column;\n columnMeta?: ColumnMeta;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, {\n value: event.target.value,\n })\n }\n />\n );\n }\n\n case \"boolean\": {\n if (Array.isArray(filter.value)) return null;\n\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, {\n value,\n })\n }\n >\n \n \n \n \n \n True\n False\n \n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const multiple = filter.variant === \"multiSelect\";\n const selectedValues = multiple\n ? Array.isArray(filter.value)\n ? filter.value\n : []\n : typeof filter.value === \"string\"\n ? filter.value\n : undefined;\n\n return (\n {\n onFilterUpdate(filter.filterId, {\n value,\n });\n }}\n multiple={multiple}\n >\n \n \n \n \n \n \n \n \n No options found.\n \n {columnMeta?.options?.map((option) => (\n \n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n \n \n \n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick a date\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n setShowValueSelector(false);\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", "type": "registry:component", "target": "src/components/data-table/data-table-filter-list.tsx" }, diff --git a/public/r/data-table-filter-menu.json b/public/r/data-table-filter-menu.json index b8fd2cfa3..771e4f7c5 100644 --- a/public/r/data-table-filter-menu.json +++ b/public/r/data-table-filter-menu.json @@ -20,7 +20,7 @@ "files": [ { "path": "src/components/data-table/data-table-filter-menu.tsx", - "content": "\"use client\";\n\nimport type { Column, Table } from \"@tanstack/react-table\";\nimport { BadgeCheck, CalendarIcon, ListFilter, Text, X } from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type { ExtendedColumnFilter, FilterOperator } from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterMenuProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterMenu({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n className,\n ...props\n}: DataTableFilterMenuProps) {\n const id = React.useId();\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [open, setOpen] = React.useState(false);\n const [selectedColumn, setSelectedColumn] =\n React.useState | null>(null);\n const [inputValue, setInputValue] = React.useState(\"\");\n const triggerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n\n const onOpenChange = React.useCallback((open: boolean) => {\n setOpen(open);\n\n if (!open) {\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n }\n }, []);\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n !inputValue &&\n selectedColumn\n ) {\n event.preventDefault();\n setSelectedColumn(null);\n }\n },\n [inputValue, selectedColumn],\n );\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const onFilterAdd = React.useCallback(\n (column: Column, value: string) => {\n if (!value.trim() && column.columnDef.meta?.variant !== \"boolean\") {\n return;\n }\n\n const filterValue =\n column.columnDef.meta?.variant === \"multiSelect\" ? [value] : value;\n\n const newFilter: ExtendedColumnFilter = {\n id: column.id as Extract,\n value: filterValue,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n };\n\n debouncedSetFilters([...filters, newFilter]);\n setOpen(false);\n\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n debouncedSetFilters(updatedFilters);\n requestAnimationFrame(() => {\n triggerRef.current?.focus();\n });\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n debouncedSetFilters([]);\n }, [debouncedSetFilters]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n
\n {filters.map((filter) => (\n \n ))}\n {filters.length > 0 && (\n \n \n \n )}\n \n \n 0 ? \"icon\" : \"sm\"}\n className={cn(filters.length > 0 && \"size-8\", \"h-8 font-normal\")}\n ref={triggerRef}\n onKeyDown={onTriggerKeyDown}\n disabled={disabled}\n >\n \n {filters.length > 0 ? null : \"Filter\"}\n \n \n \n \n \n \n {selectedColumn ? (\n <>\n {selectedColumn.columnDef.meta?.options && (\n No options found.\n )}\n onFilterAdd(selectedColumn, value)}\n />\n \n ) : (\n <>\n No fields found.\n \n {columns.map((column) => (\n {\n setSelectedColumn(column);\n setInputValue(\"\");\n requestAnimationFrame(() => {\n inputRef.current?.focus();\n });\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n )}\n \n \n \n \n
\n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n filterItemId: string;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n filterItemId,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] =\n React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n \n \n {columnMeta?.icon && (\n \n )}\n {columnMeta?.label ?? column.id}\n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: column.id as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n \n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n {onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n onFilterRemove(filter.filterId)}\n >\n \n \n
\n );\n }\n}\n\ninterface FilterValueSelectorProps {\n column: Column;\n value: string;\n onSelect: (value: string) => void;\n}\n\nfunction FilterValueSelector({\n column,\n value,\n onSelect,\n}: FilterValueSelectorProps) {\n const variant = column.columnDef.meta?.variant ?? \"text\";\n\n switch (variant) {\n case \"boolean\":\n return (\n \n onSelect(\"true\")}>\n True\n \n onSelect(\"false\")}>\n False\n \n \n );\n\n case \"select\":\n case \"multiSelect\":\n return (\n \n {column.columnDef.meta?.options?.map((option) => (\n svg:last-child]:hidden\"\n onSelect={() => onSelect(option.value)}\n >\n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n );\n\n case \"date\":\n case \"dateRange\":\n return (\n onSelect(date?.getTime().toString() ?? \"\")}\n />\n );\n\n default: {\n const isEmpty = !value.trim();\n\n return (\n \n onSelect(value)}\n disabled={isEmpty}\n >\n {isEmpty ? (\n <>\n \n Type to add filter...\n \n ) : (\n <>\n \n Filter by "{value}"\n \n )}\n \n \n );\n }\n }\n}\n\nfunction onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n column: Column;\n inputId: string;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, { value: event.target.value })\n }\n />\n );\n }\n\n case \"boolean\": {\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, { value })\n }\n >\n \n \n \n \n True\n False\n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const options = column.columnDef.meta?.options ?? [];\n const selectedValues = Array.isArray(filter.value)\n ? filter.value\n : [filter.value];\n\n const selectedOptions = options.filter((option) =>\n selectedValues.includes(option.value),\n );\n\n return (\n \n \n \n {selectedOptions.length === 0 ? (\n filter.variant === \"multiSelect\" ? (\n \"Select options...\"\n ) : (\n \"Select option...\"\n )\n ) : (\n <>\n
\n {selectedOptions.map((selectedOption) =>\n selectedOption.icon ? (\n \n \n
\n ) : null,\n )}\n
\n \n {selectedOptions.length > 1\n ? `${selectedOptions.length} selected`\n : selectedOptions[0]?.label}\n \n \n )}\n \n \n \n \n \n \n No options found.\n \n {options.map((option) => {\n const isMultiSelect = filter.variant === \"multiSelect\";\n const isSelected = selectedValues.includes(option.value);\n\n return (\n {\n const value = isMultiSelect\n ? isSelected\n ? selectedValues.filter((v) => v !== option.value)\n : [...selectedValues, option.value]\n : option.value;\n onFilterUpdate(filter.filterId, { value });\n }}\n >\n {option.icon && }\n {option.label}\n \n );\n })}\n \n \n \n \n \n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick date...\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", + "content": "\"use client\";\n\nimport type { Column, Table } from \"@tanstack/react-table\";\nimport { BadgeCheck, CalendarIcon, ListFilter, Text, X } from \"lucide-react\";\nimport { useQueryState } from \"nuqs\";\nimport * as React from \"react\";\n\nimport { DataTableRangeFilter } from \"@/components/data-table/data-table-range-filter\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport { Input } from \"@/components/ui/input\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getDefaultFilterOperator, getFilterOperators } from \"@/lib/data-table\";\nimport { formatDate } from \"@/lib/format\";\nimport { generateId } from \"@/lib/id\";\nimport { getFiltersStateParser } from \"@/lib/parsers\";\nimport { cn } from \"@/lib/utils\";\nimport type { ExtendedColumnFilter, FilterOperator } from \"@/types/data-table\";\n\nconst DEBOUNCE_MS = 300;\nconst THROTTLE_MS = 50;\nconst FILTER_SHORTCUT_KEY = \"f\";\nconst REMOVE_FILTER_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableFilterMenuProps\n extends React.ComponentProps {\n table: Table;\n debounceMs?: number;\n throttleMs?: number;\n shallow?: boolean;\n disabled?: boolean;\n}\n\nexport function DataTableFilterMenu({\n table,\n debounceMs = DEBOUNCE_MS,\n throttleMs = THROTTLE_MS,\n shallow = true,\n disabled,\n className,\n ...props\n}: DataTableFilterMenuProps) {\n const id = React.useId();\n\n const columns = React.useMemo(() => {\n return table\n .getAllColumns()\n .filter((column) => column.columnDef.enableColumnFilter);\n }, [table]);\n\n const [open, setOpen] = React.useState(false);\n const [selectedColumn, setSelectedColumn] =\n React.useState | null>(null);\n const [inputValue, setInputValue] = React.useState(\"\");\n const triggerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n\n const onOpenChange = React.useCallback((open: boolean) => {\n setOpen(open);\n\n if (!open) {\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n }\n }, []);\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n !inputValue &&\n selectedColumn\n ) {\n event.preventDefault();\n setSelectedColumn(null);\n }\n },\n [inputValue, selectedColumn],\n );\n\n const [filters, setFilters] = useQueryState(\n table.options.meta?.queryKeys?.filters ?? \"filters\",\n getFiltersStateParser(columns.map((field) => field.id))\n .withDefault([])\n .withOptions({\n clearOnDefault: true,\n shallow,\n throttleMs,\n }),\n );\n const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);\n\n const onFilterAdd = React.useCallback(\n (column: Column, value: string) => {\n if (!value.trim() && column.columnDef.meta?.variant !== \"boolean\") {\n return;\n }\n\n const filterValue =\n column.columnDef.meta?.variant === \"multiSelect\" ? [value] : value;\n\n const newFilter: ExtendedColumnFilter = {\n id: column.id as Extract,\n value: filterValue,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n filterId: generateId({ length: 8 }),\n };\n\n debouncedSetFilters([...filters, newFilter]);\n setOpen(false);\n\n setTimeout(() => {\n setSelectedColumn(null);\n setInputValue(\"\");\n }, 100);\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterRemove = React.useCallback(\n (filterId: string) => {\n const updatedFilters = filters.filter(\n (filter) => filter.filterId !== filterId,\n );\n debouncedSetFilters(updatedFilters);\n requestAnimationFrame(() => {\n triggerRef.current?.focus();\n });\n },\n [filters, debouncedSetFilters],\n );\n\n const onFilterUpdate = React.useCallback(\n (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => {\n debouncedSetFilters((prevFilters) => {\n const updatedFilters = prevFilters.map((filter) => {\n if (filter.filterId === filterId) {\n return { ...filter, ...updates } as ExtendedColumnFilter;\n }\n return filter;\n });\n return updatedFilters;\n });\n },\n [debouncedSetFilters],\n );\n\n const onFiltersReset = React.useCallback(() => {\n debouncedSetFilters([]);\n }, [debouncedSetFilters]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase()) &&\n filters.length > 0\n ) {\n event.preventDefault();\n onFilterRemove(filters[filters.length - 1]?.filterId ?? \"\");\n }\n },\n [filters, onFilterRemove],\n );\n\n return (\n
\n {filters.map((filter) => (\n \n ))}\n {filters.length > 0 && (\n \n \n \n )}\n \n \n 0 ? \"icon\" : \"sm\"}\n className={cn(filters.length > 0 && \"size-8\", \"h-8 font-normal\")}\n ref={triggerRef}\n onKeyDown={onTriggerKeyDown}\n disabled={disabled}\n >\n \n {filters.length > 0 ? null : \"Filter\"}\n \n \n \n \n \n \n {selectedColumn ? (\n <>\n {selectedColumn.columnDef.meta?.options && (\n No options found.\n )}\n onFilterAdd(selectedColumn, value)}\n />\n \n ) : (\n <>\n No fields found.\n \n {columns.map((column) => (\n {\n setSelectedColumn(column);\n setInputValue(\"\");\n requestAnimationFrame(() => {\n inputRef.current?.focus();\n });\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n )}\n \n \n \n \n
\n );\n}\n\ninterface DataTableFilterItemProps {\n filter: ExtendedColumnFilter;\n filterItemId: string;\n columns: Column[];\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n onFilterRemove: (filterId: string) => void;\n}\n\nfunction DataTableFilterItem({\n filter,\n filterItemId,\n columns,\n onFilterUpdate,\n onFilterRemove,\n}: DataTableFilterItemProps) {\n {\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showOperatorSelector, setShowOperatorSelector] =\n React.useState(false);\n const [showValueSelector, setShowValueSelector] = React.useState(false);\n\n const column = columns.find((column) => column.id === filter.id);\n\n const operatorListboxId = `${filterItemId}-operator-listbox`;\n const inputId = `${filterItemId}-input`;\n\n const columnMeta = column?.columnDef.meta;\n const filterOperators = getFilterOperators(filter.variant);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showOperatorSelector || showValueSelector) {\n return;\n }\n\n if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onFilterRemove(filter.filterId);\n }\n },\n [\n filter.filterId,\n showFieldSelector,\n showOperatorSelector,\n showValueSelector,\n onFilterRemove,\n ],\n );\n\n if (!column) return null;\n\n return (\n \n \n \n \n {columnMeta?.icon && (\n \n )}\n {columnMeta?.label ?? column.id}\n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n {\n onFilterUpdate(filter.filterId, {\n id: column.id as Extract,\n variant: column.columnDef.meta?.variant ?? \"text\",\n operator: getDefaultFilterOperator(\n column.columnDef.meta?.variant ?? \"text\",\n ),\n value: \"\",\n });\n\n setShowFieldSelector(false);\n }}\n >\n {column.columnDef.meta?.icon && (\n \n )}\n \n {column.columnDef.meta?.label ?? column.id}\n \n \n ))}\n \n \n \n \n \n \n onFilterUpdate(filter.filterId, {\n operator: value,\n value:\n value === \"isEmpty\" || value === \"isNotEmpty\"\n ? \"\"\n : filter.value,\n })\n }\n >\n \n \n \n \n \n {filterOperators.map((operator) => (\n \n {operator.label}\n \n ))}\n \n \n \n {onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n })}\n onFilterRemove(filter.filterId)}\n >\n \n \n
\n );\n }\n}\n\ninterface FilterValueSelectorProps {\n column: Column;\n value: string;\n onSelect: (value: string) => void;\n}\n\nfunction FilterValueSelector({\n column,\n value,\n onSelect,\n}: FilterValueSelectorProps) {\n const variant = column.columnDef.meta?.variant ?? \"text\";\n\n switch (variant) {\n case \"boolean\":\n return (\n \n onSelect(\"true\")}>\n True\n \n onSelect(\"false\")}>\n False\n \n \n );\n\n case \"select\":\n case \"multiSelect\":\n return (\n \n {column.columnDef.meta?.options?.map((option) => (\n svg:last-child]:hidden\"\n onSelect={() => onSelect(option.value)}\n >\n {option.icon && }\n {option.label}\n {option.count && (\n \n {option.count}\n \n )}\n \n ))}\n \n );\n\n case \"date\":\n case \"dateRange\":\n return (\n onSelect(date?.getTime().toString() ?? \"\")}\n />\n );\n\n default: {\n const isEmpty = !value.trim();\n\n return (\n \n onSelect(value)}\n disabled={isEmpty}\n >\n {isEmpty ? (\n <>\n \n Type to add filter...\n \n ) : (\n <>\n \n Filter by "{value}"\n \n )}\n \n \n );\n }\n }\n}\n\nfunction onFilterInputRender({\n filter,\n column,\n inputId,\n onFilterUpdate,\n showValueSelector,\n setShowValueSelector,\n}: {\n filter: ExtendedColumnFilter;\n column: Column;\n inputId: string;\n onFilterUpdate: (\n filterId: string,\n updates: Partial, \"filterId\">>,\n ) => void;\n showValueSelector: boolean;\n setShowValueSelector: (value: boolean) => void;\n}) {\n if (filter.operator === \"isEmpty\" || filter.operator === \"isNotEmpty\") {\n return (\n \n );\n }\n\n switch (filter.variant) {\n case \"text\":\n case \"number\":\n case \"range\": {\n if (\n (filter.variant === \"range\" && filter.operator === \"isBetween\") ||\n filter.operator === \"isBetween\"\n ) {\n return (\n \n );\n }\n\n const isNumber =\n filter.variant === \"number\" || filter.variant === \"range\";\n\n return (\n \n onFilterUpdate(filter.filterId, { value: event.target.value })\n }\n />\n );\n }\n\n case \"boolean\": {\n const inputListboxId = `${inputId}-listbox`;\n\n return (\n \n onFilterUpdate(filter.filterId, { value })\n }\n >\n \n \n \n \n \n True\n False\n \n \n \n );\n }\n\n case \"select\":\n case \"multiSelect\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const options = column.columnDef.meta?.options ?? [];\n const selectedValues = Array.isArray(filter.value)\n ? filter.value\n : [filter.value];\n\n const selectedOptions = options.filter((option) =>\n selectedValues.includes(option.value),\n );\n\n return (\n \n \n \n {selectedOptions.length === 0 ? (\n filter.variant === \"multiSelect\" ? (\n \"Select options...\"\n ) : (\n \"Select option...\"\n )\n ) : (\n <>\n
\n {selectedOptions.map((selectedOption) =>\n selectedOption.icon ? (\n \n \n
\n ) : null,\n )}\n
\n \n {selectedOptions.length > 1\n ? `${selectedOptions.length} selected`\n : selectedOptions[0]?.label}\n \n \n )}\n \n \n \n \n \n \n No options found.\n \n {options.map((option) => {\n const isMultiSelect = filter.variant === \"multiSelect\";\n const isSelected = selectedValues.includes(option.value);\n\n return (\n {\n const value = isMultiSelect\n ? isSelected\n ? selectedValues.filter((v) => v !== option.value)\n : [...selectedValues, option.value]\n : option.value;\n onFilterUpdate(filter.filterId, { value });\n }}\n >\n {option.icon && }\n {option.label}\n \n );\n })}\n \n \n \n \n \n );\n }\n\n case \"date\":\n case \"dateRange\": {\n const inputListboxId = `${inputId}-listbox`;\n\n const dateValue = Array.isArray(filter.value)\n ? filter.value.filter(Boolean)\n : [filter.value, filter.value].filter(Boolean);\n\n const startDate = dateValue[0]\n ? new Date(Number(dateValue[0]))\n : undefined;\n const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;\n\n const isSameDate =\n startDate &&\n endDate &&\n startDate.toDateString() === endDate.toDateString();\n\n const displayValue =\n filter.operator === \"isBetween\" && dateValue.length === 2 && !isSameDate\n ? `${formatDate(startDate, { month: \"short\" })} - ${formatDate(endDate, { month: \"short\" })}`\n : startDate\n ? formatDate(startDate, { month: \"short\" })\n : \"Pick date...\";\n\n return (\n \n \n \n \n {displayValue}\n \n \n \n {filter.operator === \"isBetween\" ? (\n {\n onFilterUpdate(filter.filterId, {\n value: date\n ? [\n (date.from?.getTime() ?? \"\").toString(),\n (date.to?.getTime() ?? \"\").toString(),\n ]\n : [],\n });\n }}\n />\n ) : (\n {\n onFilterUpdate(filter.filterId, {\n value: (date?.getTime() ?? \"\").toString(),\n });\n }}\n />\n )}\n \n \n );\n }\n\n default:\n return null;\n }\n}\n", "type": "registry:component", "target": "src/components/data-table/data-table-filter-menu.tsx" }, diff --git a/public/r/data-table-sort-list.json b/public/r/data-table-sort-list.json index 7dd25e125..6475607ec 100644 --- a/public/r/data-table-sort-list.json +++ b/public/r/data-table-sort-list.json @@ -17,7 +17,7 @@ "files": [ { "path": "src/components/data-table/data-table-sort-list.tsx", - "content": "\"use client\";\n\nimport type { ColumnSort, SortDirection, Table } from \"@tanstack/react-table\";\nimport {\n ArrowDownUp,\n ChevronsUpDown,\n GripVertical,\n Trash2,\n} from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport {\n Sortable,\n SortableContent,\n SortableItem,\n SortableItemHandle,\n SortableOverlay,\n} from \"@/components/ui/sortable\";\nimport { dataTableConfig } from \"@/config/data-table\";\nimport { cn } from \"@/lib/utils\";\n\nconst SORT_SHORTCUT_KEY = \"s\";\nconst REMOVE_SORT_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableSortListProps\n extends React.ComponentProps {\n table: Table;\n disabled?: boolean;\n}\n\nexport function DataTableSortList({\n table,\n disabled,\n className,\n ...props\n}: DataTableSortListProps) {\n const id = React.useId();\n const labelId = React.useId();\n const descriptionId = React.useId();\n const [open, setOpen] = React.useState(false);\n const addButtonRef = React.useRef(null);\n\n const sorting = table.getState().sorting;\n const onSortingChange = table.setSorting;\n\n const { columnLabels, columns } = React.useMemo(() => {\n const labels = new Map();\n const sortingIds = new Set(sorting.map((s) => s.id));\n const availableColumns: { id: string; label: string }[] = [];\n\n for (const column of table.getAllColumns()) {\n if (!column.getCanSort()) continue;\n\n const label = column.columnDef.meta?.label ?? column.id;\n labels.set(column.id, label);\n\n if (!sortingIds.has(column.id)) {\n availableColumns.push({ id: column.id, label });\n }\n }\n\n return {\n columnLabels: labels,\n columns: availableColumns,\n };\n }, [sorting, table]);\n\n const onSortAdd = React.useCallback(() => {\n const firstColumn = columns[0];\n if (!firstColumn) return;\n\n onSortingChange((prevSorting) => [\n ...prevSorting,\n { id: firstColumn.id, desc: false },\n ]);\n }, [columns, onSortingChange]);\n\n const onSortUpdate = React.useCallback(\n (sortId: string, updates: Partial) => {\n onSortingChange((prevSorting) => {\n if (!prevSorting) return prevSorting;\n return prevSorting.map((sort) =>\n sort.id === sortId ? { ...sort, ...updates } : sort,\n );\n });\n },\n [onSortingChange],\n );\n\n const onSortRemove = React.useCallback(\n (sortId: string) => {\n onSortingChange((prevSorting) =>\n prevSorting.filter((item) => item.id !== sortId),\n );\n },\n [onSortingChange],\n );\n\n const onSortingReset = React.useCallback(\n () => onSortingChange(table.initialState.sorting),\n [onSortingChange, table.initialState.sorting],\n );\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === SORT_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase()) &&\n sorting.length > 0\n ) {\n event.preventDefault();\n onSortingReset();\n }\n },\n [sorting.length, onSortingReset],\n );\n\n return (\n item.id}\n >\n \n \n \n \n Sort\n {sorting.length > 0 && (\n \n {sorting.length}\n \n )}\n \n \n \n
\n

\n {sorting.length > 0 ? \"Sort by\" : \"No sorting applied\"}\n

\n 0 && \"sr-only\",\n )}\n >\n {sorting.length > 0\n ? \"Modify sorting to organize your rows.\"\n : \"Add sorting to organize your rows.\"}\n

\n
\n {sorting.length > 0 && (\n \n \n {sorting.map((sort) => (\n \n ))}\n
\n \n )}\n
\n \n Add sort\n \n {sorting.length > 0 && (\n \n Reset sorting\n \n )}\n
\n \n \n \n
\n
\n
\n
\n
\n
\n \n \n );\n}\n\ninterface DataTableSortItemProps {\n sort: ColumnSort;\n sortItemId: string;\n columns: { id: string; label: string }[];\n columnLabels: Map;\n onSortUpdate: (sortId: string, updates: Partial) => void;\n onSortRemove: (sortId: string) => void;\n}\n\nfunction DataTableSortItem({\n sort,\n sortItemId,\n columns,\n columnLabels,\n onSortUpdate,\n onSortRemove,\n}: DataTableSortItemProps) {\n const fieldListboxId = `${sortItemId}-field-listbox`;\n const fieldTriggerId = `${sortItemId}-field-trigger`;\n const directionListboxId = `${sortItemId}-direction-listbox`;\n\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showDirectionSelector, setShowDirectionSelector] =\n React.useState(false);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showDirectionSelector) {\n return;\n }\n\n if (REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onSortRemove(sort.id);\n }\n },\n [sort.id, showFieldSelector, showDirectionSelector, onSortRemove],\n );\n\n return (\n \n \n \n \n \n {columnLabels.get(sort.id)}\n \n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n onSortUpdate(sort.id, { id: value })}\n >\n {column.label}\n \n ))}\n \n \n \n \n \n \n onSortUpdate(sort.id, { desc: value === \"desc\" })\n }\n >\n \n \n \n \n {dataTableConfig.sortOrders.map((order) => (\n \n {order.label}\n \n ))}\n \n \n onSortRemove(sort.id)}\n >\n \n \n \n \n \n \n \n
\n \n );\n}\n", + "content": "\"use client\";\n\nimport type { ColumnSort, SortDirection, Table } from \"@tanstack/react-table\";\nimport {\n ArrowDownUp,\n ChevronsUpDown,\n GripVertical,\n Trash2,\n} from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectGroup,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport {\n Sortable,\n SortableContent,\n SortableItem,\n SortableItemHandle,\n SortableOverlay,\n} from \"@/components/ui/sortable\";\nimport { dataTableConfig } from \"@/config/data-table\";\nimport { cn } from \"@/lib/utils\";\n\nconst SORT_SHORTCUT_KEY = \"s\";\nconst REMOVE_SORT_SHORTCUTS = [\"backspace\", \"delete\"];\n\ninterface DataTableSortListProps\n extends React.ComponentProps {\n table: Table;\n disabled?: boolean;\n}\n\nexport function DataTableSortList({\n table,\n disabled,\n className,\n ...props\n}: DataTableSortListProps) {\n const id = React.useId();\n const labelId = React.useId();\n const descriptionId = React.useId();\n const [open, setOpen] = React.useState(false);\n const addButtonRef = React.useRef(null);\n\n const sorting = table.getState().sorting;\n const onSortingChange = table.setSorting;\n\n const { columnLabels, columns } = React.useMemo(() => {\n const labels = new Map();\n const sortingIds = new Set(sorting.map((s) => s.id));\n const availableColumns: { id: string; label: string }[] = [];\n\n for (const column of table.getAllColumns()) {\n if (!column.getCanSort()) continue;\n\n const label = column.columnDef.meta?.label ?? column.id;\n labels.set(column.id, label);\n\n if (!sortingIds.has(column.id)) {\n availableColumns.push({ id: column.id, label });\n }\n }\n\n return {\n columnLabels: labels,\n columns: availableColumns,\n };\n }, [sorting, table]);\n\n const onSortAdd = React.useCallback(() => {\n const firstColumn = columns[0];\n if (!firstColumn) return;\n\n onSortingChange((prevSorting) => [\n ...prevSorting,\n { id: firstColumn.id, desc: false },\n ]);\n }, [columns, onSortingChange]);\n\n const onSortUpdate = React.useCallback(\n (sortId: string, updates: Partial) => {\n onSortingChange((prevSorting) => {\n if (!prevSorting) return prevSorting;\n return prevSorting.map((sort) =>\n sort.id === sortId ? { ...sort, ...updates } : sort,\n );\n });\n },\n [onSortingChange],\n );\n\n const onSortRemove = React.useCallback(\n (sortId: string) => {\n onSortingChange((prevSorting) =>\n prevSorting.filter((item) => item.id !== sortId),\n );\n },\n [onSortingChange],\n );\n\n const onSortingReset = React.useCallback(\n () => onSortingChange(table.initialState.sorting),\n [onSortingChange, table.initialState.sorting],\n );\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement ||\n (event.target instanceof HTMLElement &&\n event.target.contentEditable === \"true\")\n ) {\n return;\n }\n\n if (\n event.key.toLowerCase() === SORT_SHORTCUT_KEY &&\n (event.ctrlKey || event.metaKey) &&\n event.shiftKey\n ) {\n event.preventDefault();\n setOpen((prev) => !prev);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => window.removeEventListener(\"keydown\", onKeyDown);\n }, []);\n\n const onTriggerKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase()) &&\n sorting.length > 0\n ) {\n event.preventDefault();\n onSortingReset();\n }\n },\n [sorting.length, onSortingReset],\n );\n\n return (\n item.id}\n >\n \n \n \n \n Sort\n {sorting.length > 0 && (\n \n {sorting.length}\n \n )}\n \n \n \n
\n

\n {sorting.length > 0 ? \"Sort by\" : \"No sorting applied\"}\n

\n 0 && \"sr-only\",\n )}\n >\n {sorting.length > 0\n ? \"Modify sorting to organize your rows.\"\n : \"Add sorting to organize your rows.\"}\n

\n
\n {sorting.length > 0 && (\n \n \n {sorting.map((sort) => (\n \n ))}\n
\n \n )}\n
\n \n Add sort\n \n {sorting.length > 0 && (\n \n Reset sorting\n \n )}\n
\n \n \n \n
\n
\n
\n
\n
\n
\n \n \n );\n}\n\ninterface DataTableSortItemProps {\n sort: ColumnSort;\n sortItemId: string;\n columns: { id: string; label: string }[];\n columnLabels: Map;\n onSortUpdate: (sortId: string, updates: Partial) => void;\n onSortRemove: (sortId: string) => void;\n}\n\nfunction DataTableSortItem({\n sort,\n sortItemId,\n columns,\n columnLabels,\n onSortUpdate,\n onSortRemove,\n}: DataTableSortItemProps) {\n const fieldListboxId = `${sortItemId}-field-listbox`;\n const fieldTriggerId = `${sortItemId}-field-trigger`;\n const directionListboxId = `${sortItemId}-direction-listbox`;\n\n const [showFieldSelector, setShowFieldSelector] = React.useState(false);\n const [showDirectionSelector, setShowDirectionSelector] =\n React.useState(false);\n\n const onItemKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n event.target instanceof HTMLInputElement ||\n event.target instanceof HTMLTextAreaElement\n ) {\n return;\n }\n\n if (showFieldSelector || showDirectionSelector) {\n return;\n }\n\n if (REMOVE_SORT_SHORTCUTS.includes(event.key.toLowerCase())) {\n event.preventDefault();\n onSortRemove(sort.id);\n }\n },\n [sort.id, showFieldSelector, showDirectionSelector, onSortRemove],\n );\n\n return (\n \n \n \n \n \n {columnLabels.get(sort.id)}\n \n \n \n \n \n \n \n No fields found.\n \n {columns.map((column) => (\n onSortUpdate(sort.id, { id: value })}\n >\n {column.label}\n \n ))}\n \n \n \n \n \n \n onSortUpdate(sort.id, { desc: value === \"desc\" })\n }\n >\n \n \n \n \n \n {dataTableConfig.sortOrders.map((order) => (\n \n {order.label}\n \n ))}\n \n \n \n onSortRemove(sort.id)}\n >\n \n \n \n \n \n \n \n
\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-table/data-table-sort-list.tsx" }, diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 2a3e4e705..298bb27eb 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -25,6 +25,7 @@ import { import { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -978,11 +979,13 @@ export function SelectCell({ sideOffset={-8} className="min-w-[calc(var(--radix-select-trigger-width)+16px)]" > - {options.map((option) => ( - - {option.label} - - ))} + + {options.map((option) => ( + + {option.label} + + ))} + ) : displayLabel ? ( diff --git a/src/components/data-table/data-table-filter-list.tsx b/src/components/data-table/data-table-filter-list.tsx index 77f8369cd..2c52bd37e 100644 --- a/src/components/data-table/data-table-filter-list.tsx +++ b/src/components/data-table/data-table-filter-list.tsx @@ -44,6 +44,7 @@ import { import { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -415,11 +416,13 @@ function DataTableFilterItem({ position="popper" className="min-w-(--radix-select-trigger-width) lowercase" > - {dataTableConfig.joinOperators.map((joinOperator) => ( - - {joinOperator} - - ))} + + {dataTableConfig.joinOperators.map((joinOperator) => ( + + {joinOperator} + + ))} + ) : ( @@ -508,15 +511,17 @@ function DataTableFilterItem({
- {filterOperators.map((operator) => ( - - {operator.label} - - ))} + + {filterOperators.map((operator) => ( + + {operator.label} + + ))} +
@@ -650,8 +655,10 @@ function onFilterInputRender({ - True - False + + True + False + ); diff --git a/src/components/data-table/data-table-filter-menu.tsx b/src/components/data-table/data-table-filter-menu.tsx index a38c38d62..5bcfd7e5a 100644 --- a/src/components/data-table/data-table-filter-menu.tsx +++ b/src/components/data-table/data-table-filter-menu.tsx @@ -25,6 +25,7 @@ import { import { Select, SelectContent, + SelectGroup, SelectItem, SelectTrigger, SelectValue, @@ -392,7 +393,7 @@ function DataTableFilterItem({ + + + + Menu + + + + -
); diff --git a/src/config/site.ts b/src/config/site.ts index ac1896648..97cbc8804 100644 --- a/src/config/site.ts +++ b/src/config/site.ts @@ -6,4 +6,9 @@ export const siteConfig = { "Shadcn table with server side sorting, pagination, and filtering", url: "https://tablecn.com", links: { github: "https://github.com/sadmann7/tablecn" }, + navLinks: [ + { href: "/data-grid", label: "Data Grid" }, + { href: "/data-grid-live", label: "Data Grid Live" }, + { href: "/data-grid-multiplayer", label: "Data Grid Multiplayer" }, + ], }; From 1dcec3874a66e463bc744a5c26f4f46109b68868 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sat, 13 Jun 2026 18:54:09 +0600 Subject: [PATCH 5/7] feat: update links --- src/components/layouts/mobile-nav.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/layouts/mobile-nav.tsx b/src/components/layouts/mobile-nav.tsx index e9a37cb18..ad8350d2a 100644 --- a/src/components/layouts/mobile-nav.tsx +++ b/src/components/layouts/mobile-nav.tsx @@ -7,19 +7,19 @@ import { siteConfig } from "@/config/site"; export function MobileNav() { return ( -