- "content": "\"use client\";\n\nimport type { Column } from \"@tanstack/react-table\";\nimport * as React from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\nimport {\n Popover,\n PopoverContent,\n PopoverTrigger,\n} from \"@/components/ui/popover\";\nimport { Separator } from \"@/components/ui/separator\";\nimport { Slider } from \"@/components/ui/slider\";\nimport { cn } from \"@/lib/utils\";\nimport { PlusCircle, XCircle } from \"lucide-react\";\n\ninterface Range {\n min: number;\n max: number;\n}\n\ntype RangeValue = [number, number];\n\nfunction getIsValidRange(value: unknown): value is RangeValue {\n return (\n Array.isArray(value) &&\n value.length === 2 &&\n typeof value[0] === \"number\" &&\n typeof value[1] === \"number\"\n );\n}\n\ninterface DataTableSliderFilterProps<TData> {\n column: Column<TData, unknown>;\n title?: string;\n}\n\ninterface RangeInputProps {\n id: string;\n value: number;\n min: number;\n max: number;\n unit?: string;\n onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;\n label: string;\n}\n\nconst RangeInput = React.memo(function RangeInput({\n id,\n value,\n min,\n max,\n unit,\n onChange,\n label,\n}: RangeInputProps) {\n return (\n <>\n <Label htmlFor={id} className=\"sr-only\">\n {label}\n </Label>\n <div className=\"relative\">\n <Input\n id={id}\n type=\"number\"\n aria-valuemin={min}\n aria-valuemax={max}\n inputMode=\"numeric\"\n pattern=\"[0-9]*\"\n placeholder={min.toString()}\n min={min}\n max={max}\n value={value.toString()}\n onChange={onChange}\n className={cn(\"h-8 w-24\", unit && \"pr-8\")}\n />\n {unit && (\n <span className=\"absolute top-0 right-0 bottom-0 flex items-center rounded-r-md bg-accent px-2 text-muted-foreground text-sm\">\n {unit}\n </span>\n )}\n </div>\n </>\n );\n});\n\nexport function DataTableSliderFilter<TData>({\n column,\n title,\n}: DataTableSliderFilterProps<TData>) {\n const id = React.useId();\n\n const columnFilterValue = getIsValidRange(column.getFilterValue())\n ? (column.getFilterValue() as RangeValue)\n : undefined;\n\n const defaultRange = column.columnDef.meta?.range;\n const unit = column.columnDef.meta?.unit;\n\n const { min, max, step } = React.useMemo<Range & { step: number }>(() => {\n let minValue = 0;\n let maxValue = 100;\n\n if (defaultRange && getIsValidRange(defaultRange)) {\n [minValue, maxValue] = defaultRange;\n } else {\n const values = column.getFacetedMinMaxValues();\n if (values && Array.isArray(values) && values.length === 2) {\n const [facetMinValue, facetMaxValue] = values;\n if (\n typeof facetMinValue === \"number\" &&\n typeof facetMaxValue === \"number\"\n ) {\n minValue = facetMinValue;\n maxValue = facetMaxValue;\n }\n }\n }\n\n const rangeSize = maxValue - minValue;\n const step =\n rangeSize <= 20\n ? 1\n : rangeSize <= 100\n ? Math.ceil(rangeSize / 20)\n : Math.ceil(rangeSize / 50);\n\n return { min: minValue, max: maxValue, step };\n }, [column, defaultRange]);\n\n const range = React.useMemo((): RangeValue => {\n return columnFilterValue ?? [min, max];\n }, [columnFilterValue, min, max]);\n\n const formatValue = React.useCallback((value: number) => {\n return value.toLocaleString(undefined, { maximumFractionDigits: 0 });\n }, []);\n\n const onFromInputChange = React.useCallback(\n (event: React.ChangeEvent<HTMLInputElement>) => {\n const numValue = Number(event.target.value);\n if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {\n column.setFilterValue([numValue, range[1]]);\n }\n },\n [column, min, range],\n );\n\n const onToInputChange = React.useCallback(\n (event: React.ChangeEvent<HTMLInputElement>) => {\n const numValue = Number(event.target.value);\n if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {\n column.setFilterValue([range[0], numValue]);\n }\n },\n [column, max, range],\n );\n\n const onSliderValueChange = React.useCallback(\n (value: RangeValue) => {\n if (Array.isArray(value) && value.length === 2) {\n column.setFilterValue(value);\n }\n },\n [column],\n );\n\n const onReset = React.useCallback(\n (event?: React.MouseEvent) => {\n event?.stopPropagation();\n column.setFilterValue(undefined);\n },\n [column],\n );\n\n const FilterButton = React.useMemo(\n () => (\n <Button variant=\"outline\" size=\"sm\" className=\"border-dashed\">\n {columnFilterValue ? (\n <div\n role=\"button\"\n aria-label={`Clear ${title} filter`}\n tabIndex={0}\n className=\"rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring\"\n onClick={onReset}\n >\n <XCircle />\n </div>\n ) : (\n <PlusCircle />\n )}\n <span>{title}</span>\n {columnFilterValue && (\n <>\n <Separator\n orientation=\"vertical\"\n className=\"mx-0.5 data-[orientation=vertical]:h-4\"\n />\n {formatValue(columnFilterValue[0])} -{\" \"}\n {formatValue(columnFilterValue[1])}\n {unit && ` ${unit}`}\n </>\n )}\n </Button>\n ),\n [columnFilterValue, title, unit, onReset, formatValue],\n );\n\n return (\n <Popover>\n <PopoverTrigger asChild>{FilterButton}</PopoverTrigger>\n <PopoverContent align=\"start\" className=\"flex w-auto flex-col gap-4\">\n <div className=\"flex flex-col gap-3\">\n <p className=\"font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\">\n {title}\n </p>\n <div className=\"flex items-center gap-4\">\n <RangeInput\n id={`${id}-from`}\n value={range[0]}\n min={min}\n max={max}\n unit={unit}\n onChange={onFromInputChange}\n label=\"From\"\n />\n <RangeInput\n id={`${id}-to`}\n value={range[1]}\n min={min}\n max={max}\n unit={unit}\n onChange={onToInputChange}\n label=\"To\"\n />\n </div>\n <Label htmlFor={`${id}-slider`} className=\"sr-only\">\n {title} slider\n </Label>\n <Slider\n id={`${id}-slider`}\n min={min}\n max={max}\n step={step}\n value={range}\n onValueChange={onSliderValueChange}\n />\n </div>\n <Button\n aria-label={`Clear ${title} filter`}\n variant=\"outline\"\n size=\"sm\"\n onClick={onReset}\n >\n Clear\n </Button>\n </PopoverContent>\n </Popover>\n );\n}\n",
0 commit comments