Custom Shadcn Filters for React and Tailwind CSS. A comprehensive filtering system with multiple filter types, operators, and visual indicators for data organization.
Browse 9 production-ready Shadcn Filters components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 9 Shadcn Filters components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low", "medium"]),
])
const fields: FilterFieldConfig[] = [
{
key: "priority",
label: "Priority",
type: "multiselect",
options: [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
],
},
]
return <Filters filters={filters} fields={fields} onChange={setFilters} />The main component for displaying and managing a collection of active filters.
Configuration for an individual filterable field.
Structure for options in select and multiselect fields.
The structure of an active filter object.
Configuration for internationalization and custom labels.
"use client"
import { useCallback, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { AtSignIcon, CreditCardIcon, GlobeIcon, LinkIcon, ListFilterIcon, PhoneIcon, UserIcon } from 'lucide-react'
// Zod validation helper - wraps a Zod schema to return validation result with message
function zodValidator<T extends z.ZodType>(schema: T) {
return (value: unknown): { valid: boolean; message?: string } => {
const result = schema.safeParse(value)
if (result.success) {
return { valid: true }
}
// Get the first error message from Zod using format()
const formatted = result.error.format()
const message =
formatted._errors?.[0] || result.error.message || "Invalid value"
return { valid: false, message }
}
}
// Define Zod schemas for different field types
const emailSchema = z
.string()
.min(1, { message: "Email is required" })
.pipe(z.email({ message: "Please enter a valid email address" }))
const urlSchema = z
.string()
.pipe(
z.url({ message: "Please enter a valid URL (e.g., https://example.com)" })
)
const phoneSchema = z
.string()
.regex(/^\+?[1-9]\d{1,14}$/, { message: "Please enter a valid phone number" })
const usernameSchema = z
.string()
.min(3, { message: "Username must be at least 3 characters" })
.max(20, { message: "Username must be at most 20 characters" })
.regex(/^[a-zA-Z0-9_]+$/, {
message: "Username can only contain letters, numbers, and underscores",
})
const creditCardSchema = z.string().regex(/^\d{13,19}$/, {
message: "Please enter a valid credit card number (13-19 digits)",
})
export function Pattern() {
const fields: FilterFieldConfig[] = [
{
key: "email",
label: "Email",
icon: (
<AtSignIcon className="size-3.5" />
),
type: "text",
placeholder: "user@example.com",
// Use Zod validator for email validation
validation: zodValidator(emailSchema),
},
{
key: "website",
label: "Website",
icon: (
<GlobeIcon className="size-3.5" />
),
type: "text",
placeholder: "https://example.com",
// Use Zod validator for URL validation
validation: zodValidator(urlSchema),
},
{
key: "phone",
label: "Phone",
icon: (
<PhoneIcon className="size-3.5" />
),
type: "text",
placeholder: "+1234567890",
// Use Zod validator for phone validation
validation: zodValidator(phoneSchema),
},
{
key: "username",
label: "Username",
icon: (
<UserIcon className="size-3.5" />
),
type: "text",
className: "w-44",
placeholder: "john_doe",
// Use Zod validator for username validation
validation: zodValidator(usernameSchema),
},
{
key: "cardNumber",
label: "Card Number",
icon: (
<CreditCardIcon className="size-3.5" />
),
type: "text",
placeholder: "4111111111111111",
// Use Zod validator for credit card validation
validation: zodValidator(creditCardSchema),
},
{
key: "customUrl",
label: "Custom URL",
icon: (
<LinkIcon className="size-3.5" />
),
type: "text",
placeholder: "https://...",
// Custom validation function without Zod (for comparison)
validation: (value) => {
const urlPattern = /^https?:\/\/.+\..+/
if (!urlPattern.test(value as string)) {
return {
valid: false,
message: "URL must start with http:// or https://",
}
}
return { valid: true }
},
},
]
const [filters, setFilters] = useState<Filter[]>([
createFilter("email", "contains", [""]),
])
const handleFiltersChange = useCallback((filters: Filter[]) => {
setFilters(filters)
}, [])
return (
<div className="flex grow content-start items-start self-start">
<Filters
filters={filters}
fields={fields}
trigger={
<Button variant="outline" size="icon">
<ListFilterIcon />
</Button>
}
onChange={handleFiltersChange}
/>
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { CircleAlertIcon, FunnelXIcon, GlobeIcon, ListFilterIcon, MailIcon, StarIcon, TagIcon, UserIcon, UserRoundXIcon } from 'lucide-react'
// Priority icon component
const PriorityIcon = ({ priority }: { priority: string }) => {
const colors = {
low: "text-green-500",
medium: "text-yellow-500",
high: "text-orange-500",
urgent: "text-red-500",
}
return (
<StarIcon className="colors[priority as keyof typeof colors]" />
)
}
export function Pattern() {
// Basic filter fields for outline variant demo
const fields: FilterFieldConfig[] = [
{
key: "text",
label: "Text",
icon: (
<TagIcon className="size-3.5" />
),
type: "text",
className: "w-36",
placeholder: "Search text...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "user@example.com",
},
{
key: "website",
label: "Website",
icon: (
<GlobeIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "https://example.com",
},
{
key: "assignee",
label: "Assignee",
icon: (
<UserIcon className="size-3.5" />
),
type: "multiselect",
className: "w-[200px]",
options: [
{
value: "john",
label: "John Doe",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/1.jpg"
alt="John Doe"
/>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
),
},
{
value: "jane",
label: "Jane Smith",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/2.jpg"
alt="Jane Smith"
/>
<AvatarFallback>JS</AvatarFallback>
</Avatar>
),
},
{
value: "bob",
label: "Bob Johnson",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/3.jpg"
alt="Bob Johnson"
/>
<AvatarFallback>BJ</AvatarFallback>
</Avatar>
),
},
{
value: "alice",
label: "Alice Brown",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/4.jpg"
alt="Alice Brown"
/>
<AvatarFallback>AB</AvatarFallback>
</Avatar>
),
},
{
value: "nick",
label: "Nick Bold",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/4.jpg"
alt="Nick Bold"
/>
<AvatarFallback>NB</AvatarFallback>
</Avatar>
),
},
{
value: "unassigned",
label: "Unassigned",
icon: (
<Avatar className="size-5">
<AvatarFallback>
<UserRoundXIcon />
</AvatarFallback>
</Avatar>
),
},
],
},
{
key: "priority",
label: "Priority",
icon: (
<CircleAlertIcon className="size-3.5" />
),
type: "multiselect",
className: "w-[180px]",
options: [
{ value: "low", label: "Low", icon: <PriorityIcon priority="low" /> },
{
value: "medium",
label: "Medium",
icon: <PriorityIcon priority="medium" />,
},
{
value: "high",
label: "High",
icon: <PriorityIcon priority="high" />,
},
{
value: "urgent",
label: "Urgent",
icon: <PriorityIcon priority="urgent" />,
},
],
},
]
const [filters, setFilters] = useState<Filter[]>([
createFilter("assignee", "is_any_of", ["john", "nick", "alice"]),
])
const handleFiltersChange = useCallback((filters: Filter[]) => {
setFilters(filters)
}, [])
return (
<div className="flex grow content-start items-start gap-2.5 self-start">
<div className="flex-1">
<Filters
filters={filters}
fields={fields}
trigger={
<Button variant="outline" size="icon">
<ListFilterIcon />
</Button>
}
onChange={handleFiltersChange}
/>
</div>
{filters.length > 0 && (
<Button variant="outline" onClick={() => setFilters([])}>
<FunnelXIcon />
Clear
</Button>
)}
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import { Button } from "@/components/ui/button"
import { BanIcon, CircleAlertIcon, CircleCheckIcon, CircleIcon, ClockIcon, GlobeIcon, ListFilterIcon, MailIcon, StarIcon, TagIcon } from 'lucide-react'
const StatusIcon = ({ status }: { status: string }) => {
switch (status) {
case "todo":
return (
<ClockIcon className="text-primary" />
)
case "in-progress":
return (
<CircleAlertIcon className="text-yellow-500" />
)
case "done":
return (
<CircleCheckIcon className="text-green-500" />
)
case "cancelled":
return (
<BanIcon className="text-destructive" />
)
default:
return (
<CircleIcon className="text-muted-foreground" />
)
}
}
// Priority icon component
const PriorityIcon = ({ priority }: { priority: string }) => {
const colors = {
low: "text-green-500",
medium: "text-yellow-500",
high: "text-orange-500",
urgent: "text-red-500",
}
return (
<StarIcon className="colors[priority as keyof typeof colors]" />
)
}
export function Pattern() {
// Basic filter fields for size variant demo
const fields: FilterFieldConfig[] = [
{
key: "text",
label: "Text",
icon: (
<TagIcon className="size-3.5" />
),
type: "text",
className: "w-36",
placeholder: "Search text...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-48",
placeholder: "user@example.com",
},
{
key: "website",
label: "Website",
icon: (
<GlobeIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "https://example.com",
},
{
key: "status",
label: "Status",
icon: (
<ClockIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[200px]",
options: [
{ value: "todo", label: "To Do", icon: <StatusIcon status="todo" /> },
{
value: "in-progress",
label: "In Progress",
icon: <StatusIcon status="in-progress" />,
},
{ value: "done", label: "Done", icon: <StatusIcon status="done" /> },
{
value: "cancelled",
label: "Cancelled",
icon: <StatusIcon status="cancelled" />,
},
],
},
{
key: "priority",
label: "Priority",
icon: (
<CircleAlertIcon className="size-3.5" />
),
type: "multiselect",
className: "w-[180px]",
options: [
{ value: "low", label: "Low", icon: <PriorityIcon priority="low" /> },
{
value: "medium",
label: "Medium",
icon: <PriorityIcon priority="medium" />,
},
{
value: "high",
label: "High",
icon: <PriorityIcon priority="high" />,
},
{
value: "urgent",
label: "Urgent",
icon: <PriorityIcon priority="urgent" />,
},
],
},
]
const [smallFilters, setSmallFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["high", "urgent"]),
])
const [mediumFilters, setMediumFilters] = useState<Filter[]>([
createFilter("status", "is", ["todo"]),
])
const [largeFilters, setLargeFilters] = useState<Filter[]>([
createFilter("email", "contains", ["example@example.com"]),
])
const handleSmallFiltersChange = useCallback((filters: Filter[]) => {
setSmallFilters(filters)
}, [])
const handleMediumFiltersChange = useCallback((filters: Filter[]) => {
setMediumFilters(filters)
}, [])
const handleLargeFiltersChange = useCallback((filters: Filter[]) => {
setLargeFilters(filters)
}, [])
return (
<div className="flex grow flex-col content-start items-start gap-2.5 space-y-6 self-start">
<Filters
size="sm"
filters={smallFilters}
fields={fields}
onChange={handleSmallFiltersChange}
trigger={
<Button variant="outline" size="icon-sm">
<ListFilterIcon />
</Button>
}
/>
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import { Button } from "@/components/ui/button"
import { BanIcon, CircleAlertIcon, CircleCheckIcon, CircleIcon, ClockIcon, GlobeIcon, ListFilterIcon, MailIcon, StarIcon, TagIcon } from 'lucide-react'
const StatusIcon = ({ status }: { status: string }) => {
switch (status) {
case "todo":
return (
<ClockIcon className="text-primary" />
)
case "in-progress":
return (
<CircleAlertIcon className="text-yellow-500" />
)
case "done":
return (
<CircleCheckIcon className="text-green-500" />
)
case "cancelled":
return (
<BanIcon className="text-destructive" />
)
default:
return (
<CircleIcon className="text-muted-foreground" />
)
}
}
// Priority icon component
const PriorityIcon = ({ priority }: { priority: string }) => {
const colors = {
low: "text-green-500",
medium: "text-yellow-500",
high: "text-orange-500",
urgent: "text-red-500",
}
return (
<StarIcon className="colors[priority as keyof typeof colors]" />
)
}
export function Pattern() {
// Basic filter fields for size variant demo
const fields: FilterFieldConfig[] = [
{
key: "text",
label: "Text",
icon: (
<TagIcon className="size-3.5" />
),
type: "text",
className: "w-36",
placeholder: "Search text...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-48",
placeholder: "user@example.com",
},
{
key: "website",
label: "Website",
icon: (
<GlobeIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "https://example.com",
},
{
key: "status",
label: "Status",
icon: (
<ClockIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[200px]",
options: [
{ value: "todo", label: "To Do", icon: <StatusIcon status="todo" /> },
{
value: "in-progress",
label: "In Progress",
icon: <StatusIcon status="in-progress" />,
},
{ value: "done", label: "Done", icon: <StatusIcon status="done" /> },
{
value: "cancelled",
label: "Cancelled",
icon: <StatusIcon status="cancelled" />,
},
],
},
{
key: "priority",
label: "Priority",
icon: (
<CircleAlertIcon className="size-3.5" />
),
type: "multiselect",
className: "w-[180px]",
options: [
{ value: "low", label: "Low", icon: <PriorityIcon priority="low" /> },
{
value: "medium",
label: "Medium",
icon: <PriorityIcon priority="medium" />,
},
{
value: "high",
label: "High",
icon: <PriorityIcon priority="high" />,
},
{
value: "urgent",
label: "Urgent",
icon: <PriorityIcon priority="urgent" />,
},
],
},
]
const [smallFilters, setSmallFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["high", "urgent"]),
])
const [mediumFilters, setMediumFilters] = useState<Filter[]>([
createFilter("status", "is", ["todo"]),
])
const [largeFilters, setLargeFilters] = useState<Filter[]>([
createFilter("email", "contains", ["example@example.com"]),
])
const handleSmallFiltersChange = useCallback((filters: Filter[]) => {
setSmallFilters(filters)
}, [])
const handleMediumFiltersChange = useCallback((filters: Filter[]) => {
setMediumFilters(filters)
}, [])
const handleLargeFiltersChange = useCallback((filters: Filter[]) => {
setLargeFilters(filters)
}, [])
return (
<div className="flex grow flex-col content-start items-start gap-2.5 space-y-6 self-start">
<Filters
size="lg"
filters={smallFilters}
fields={fields}
onChange={handleSmallFiltersChange}
trigger={
<Button variant="outline" size="icon-sm">
<ListFilterIcon />
</Button>
}
/>
</div>
)
}
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
DateSelector,
formatDateValue,
type DateSelectorValue,
} from "@/components/reui/date-selector"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
endOfMonth,
endOfYear,
format,
isEqual,
startOfDay,
startOfMonth,
startOfYear,
subDays,
subMonths,
subYears,
} from "date-fns"
import { DateRange } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Slider } from "@/components/ui/slider"
import { CalendarIcon, ClockIcon, FunnelXIcon, ListFilterIcon, SlidersVerticalIcon } from 'lucide-react'
// Type for custom renderer props
type CustomRendererProps = {
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}
// Modal-based Date Selector Component
function CustomModalDateSelector({
values,
onChange,
autoFocus,
}: CustomRendererProps) {
const value = values?.[0] as DateSelectorValue | undefined
const [open, setOpen] = useState(false)
const [internalValue, setInternalValue] = useState<
DateSelectorValue | undefined
>(value)
const formattedValue = value ? formatDateValue(value) : ""
const displayText = formattedValue || "Select a date"
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => setOpen(true), 400)
return () => clearTimeout(timer)
}
}, [autoFocus])
useEffect(() => {
if (open) {
setInternalValue(value)
}
}, [open, value])
const handleApply = () => {
onChange([internalValue])
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{displayText}</DialogTrigger>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Select Date</DialogTitle>
</DialogHeader>
<DateSelector
value={internalValue}
onChange={setInternalValue}
showInput={true}
/>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button onClick={handleApply}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Custom Date Range Input Component
function CustomDateRangeInput({
values,
onChange,
autoFocus,
}: CustomRendererProps) {
const [date, setDate] = useState<DateRange | undefined>(
values?.[0] && typeof values[0] === "string"
? {
from: new Date(values[0] as string),
to:
values[1] && typeof values[1] === "string"
? new Date(values[1] as string)
: undefined,
}
: undefined
)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => setIsOpen(true), 400)
return () => clearTimeout(timer)
}
}, [autoFocus])
const handleApply = () => {
if (date?.from) {
const fromStr = date.from.toISOString().split("T")[0]
const toStr = date.to ? date.to.toISOString().split("T")[0] : fromStr
onChange([fromStr, toStr])
}
setIsOpen(false)
}
const handleCancel = () => {
setIsOpen(false)
}
const handleSelect = (selected: DateRange | undefined) => {
setDate(selected)
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger>
{date?.from ? (
date.to ? (
<>
{format(date.from, "LLL dd, y")} - {format(date.to, "LLL dd, y")}
</>
) : (
format(date.from, "LLL dd, y")
)
) : (
<span>Pick a date range</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start" sideOffset={8}>
<Calendar
autoFocus
mode="range"
defaultMonth={date?.from}
showOutsideDays={false}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
<div className="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleApply}>Apply</Button>
</div>
</PopoverContent>
</Popover>
)
}
// Custom Date Range with Presets Input Component
function CustomDateRangeWithPresetsInput({
values,
onChange,
autoFocus,
}: CustomRendererProps) {
const today = useMemo(() => new Date(), [])
const presets = useMemo(
() => [
{ label: "Today", range: { from: today, to: today } },
{
label: "Yesterday",
range: { from: subDays(today, 1), to: subDays(today, 1) },
},
{ label: "Last 7 days", range: { from: subDays(today, 6), to: today } },
{ label: "Last 30 days", range: { from: subDays(today, 29), to: today } },
{
label: "Month to date",
range: { from: startOfMonth(today), to: today },
},
{
label: "Last month",
range: {
from: startOfMonth(subMonths(today, 1)),
to: endOfMonth(subMonths(today, 1)),
},
},
{ label: "Year to date", range: { from: startOfYear(today), to: today } },
{
label: "Last year",
range: {
from: startOfYear(subYears(today, 1)),
to: endOfYear(subYears(today, 1)),
},
},
],
[today]
)
const [month, setMonth] = useState(today)
const [date, setDate] = useState<DateRange | undefined>(
values?.[0] && typeof values[0] === "string"
? {
from: new Date(values[0] as string),
to:
values[1] && typeof values[1] === "string"
? new Date(values[1] as string)
: undefined,
}
: undefined
)
const [selectedPreset, setSelectedPreset] = useState<string | null>(null)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => setIsOpen(true), 400)
return () => clearTimeout(timer)
}
}, [autoFocus])
useEffect(() => {
const matchedPreset = presets.find(
(preset) =>
isEqual(
startOfDay(preset.range.from),
startOfDay(date?.from || new Date(0))
) &&
isEqual(
startOfDay(preset.range.to),
startOfDay(date?.to || new Date(0))
)
)
setSelectedPreset(matchedPreset?.label || null)
}, [date, presets])
const handleApply = () => {
if (date?.from) {
const fromStr = date.from.toISOString().split("T")[0]
const toStr = date.to ? date.to.toISOString().split("T")[0] : fromStr
onChange([fromStr, toStr])
}
setIsOpen(false)
}
const handleCancel = () => {
setIsOpen(false)
}
const handleSelect = (selected: DateRange | undefined) => {
setDate({
from: selected?.from || undefined,
to: selected?.to || undefined,
})
setSelectedPreset(null)
}
const handlePresetSelect = (preset: (typeof presets)[0]) => {
setDate(preset.range)
setMonth(preset.range.from || today)
setSelectedPreset(preset.label)
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger>
{date?.from ? (
format(date.from, "LLL dd, y") +
(date.to ? ` - ${format(date.to, "LLL dd, y")}` : "")
) : (
<span>Pick a date range with presets</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="center" sideOffset={8}>
<div className="flex max-sm:flex-col">
<div className="border-border relative max-sm:order-1 max-sm:border-t sm:w-32">
<div className="border-border h-full py-2 sm:border-e">
<div className="flex flex-col gap-[2px] px-2">
{presets.map((preset, index) => (
<Button
key={index}
type="button"
variant="ghost"
className={cn(
"h-8 w-full justify-start",
selectedPreset === preset.label && "bg-accent"
)}
onClick={() => handlePresetSelect(preset)}
>
{preset.label}
</Button>
))}
</div>
</div>
</div>
<Calendar
autoFocus
mode="range"
month={month}
onMonthChange={setMonth}
showOutsideDays={false}
selected={date}
onSelect={handleSelect}
numberOfMonths={2}
/>
</div>
<div className="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleApply}>Apply</Button>
</div>
</PopoverContent>
</Popover>
)
}
// Custom DateTime Input Component
function CustomDateTimeInput({
values,
onChange,
autoFocus,
}: CustomRendererProps) {
const today = new Date()
const [date, setDate] = useState<Date | undefined>(
values?.[0] && typeof values[0] === "string"
? new Date(values[0] as string)
: undefined
)
const [time, setTime] = useState<string | undefined>(
values?.[0] && typeof values[0] === "string"
? new Date(values[0] as string).toTimeString().slice(0, 5)
: "10:00"
)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => setIsOpen(true), 400)
return () => clearTimeout(timer)
}
}, [autoFocus])
const timeSlots = [
{ time: "09:00", available: false },
{ time: "09:30", available: false },
{ time: "10:00", available: true },
{ time: "10:30", available: true },
{ time: "11:00", available: true },
{ time: "11:30", available: true },
{ time: "12:00", available: false },
{ time: "12:30", available: true },
{ time: "13:00", available: true },
{ time: "13:30", available: true },
{ time: "14:00", available: true },
{ time: "14:30", available: false },
{ time: "15:00", available: false },
{ time: "15:30", available: true },
{ time: "16:00", available: true },
{ time: "16:30", available: true },
{ time: "17:00", available: true },
{ time: "17:30", available: true },
{ time: "18:00", available: true },
{ time: "18:30", available: true },
{ time: "19:00", available: true },
{ time: "19:30", available: true },
{ time: "20:00", available: true },
{ time: "20:30", available: true },
{ time: "21:00", available: true },
{ time: "21:30", available: true },
{ time: "22:00", available: true },
{ time: "22:30", available: true },
{ time: "23:00", available: true },
{ time: "23:30", available: true },
]
const handleApply = () => {
if (date && time) {
const dateTime = new Date(date)
const [hours, minutes] = time.split(":").map(Number)
dateTime.setHours(hours, minutes, 0, 0)
onChange([dateTime.toISOString()])
}
setIsOpen(false)
}
const handleCancel = () => {
setIsOpen(false)
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger>
{date ? (
format(date, "PPP") + (time ? ` - ${time}` : "")
) : (
<span>Pick a date and time</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto gap-0 p-0 pt-1" align="start">
<div className="flex max-sm:flex-col">
<Calendar
mode="single"
selected={date}
onSelect={(newDate: Date | undefined) => {
if (newDate) {
setDate(newDate)
setTime(undefined)
}
}}
className="p-2 sm:pe-5"
disabled={[{ before: today }]}
/>
<div className="relative w-full max-sm:h-46 sm:w-40">
<div className="absolute inset-0 py-4 max-sm:border-t">
<ScrollArea className="h-full sm:border-s">
<div className="space-y-3">
<div className="flex h-5 shrink-0 items-center px-5">
<p className="text-sm font-medium">
{date ? format(date, "EEEE, d") : "Pick a date"}
</p>
</div>
<div className="grid gap-1.5 px-5 max-sm:grid-cols-2">
{timeSlots.map(({ time: timeSlot, available }) => (
<Button
key={timeSlot}
variant={time === timeSlot ? "default" : "outline"}
size="sm"
className="w-full"
onClick={() => setTime(timeSlot)}
disabled={!available}
>
{timeSlot}
</Button>
))}
</div>
</div>
</ScrollArea>
</div>
</div>
</div>
<div className="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleApply}>Apply</Button>
</div>
</PopoverContent>
</Popover>
)
}
// Custom Slider Range Input Component
function CustomSliderRangeInput({
values,
onChange,
autoFocus,
}: CustomRendererProps) {
const [range, setRange] = useState<number[]>(
values?.[0] &&
typeof values[0] === "object" &&
values[0] !== null &&
"min" in values[0] &&
"max" in values[0]
? [
(values[0] as { min: number; max: number }).min,
(values[0] as { min: number; max: number }).max,
]
: [0, 100]
)
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => setIsOpen(true), 400)
return () => clearTimeout(timer)
}
}, [autoFocus])
const handleApply = () => {
onChange([{ min: range[0], max: range[1] }])
setIsOpen(false)
}
const handleCancel = () => {
setIsOpen(false)
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger render={<span />}>
{range[0]} - {range[1]}
</PopoverTrigger>
<PopoverContent
className="w-auto p-4"
align="start"
sideOffset={8}
alignOffset={-8}
>
<div className="space-y-2.5">
<div className="space-y-4 pt-2.5">
<Slider
value={range}
onValueChange={(value) => setRange(value as number[])}
max={100}
min={0}
step={1}
className="w-[200px]"
/>
<div className="text-muted-foreground flex justify-between ps-1.5 text-xs">
<span>0</span>
<span>100</span>
</div>
</div>
<div className="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" variant="outline" onClick={handleApply}>
Apply
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export function Pattern() {
const [filters, setFilters] = useState<Filter[]>([
createFilter("customDateRange", "between", []),
])
const [lastAddedValues, setLastAddedValues] = useState<unknown[] | null>(null)
const fields: FilterFieldConfig[] = [
{
key: "modalDateSelector",
label: "Modal Date Selector",
icon: (
<CalendarIcon className="size-3.5" />
),
type: "custom",
operators: [
{ value: "is", label: "is" },
{ value: "is_not", label: "is not" },
],
customRenderer: ({ values, onChange }) => (
<CustomModalDateSelector
values={values}
onChange={onChange}
autoFocus={values === lastAddedValues}
/>
),
},
{
key: "customDateRange",
label: "Date Range",
icon: (
<CalendarIcon className="size-3.5" />
),
type: "custom",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) => (
<CustomDateRangeInput
values={values}
onChange={onChange}
autoFocus={values === lastAddedValues}
/>
),
},
{
key: "customDateRangePresets",
label: "Date Range Presets",
icon: (
<CalendarIcon className="size-3.5" />
),
type: "custom",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) => (
<CustomDateRangeWithPresetsInput
values={values}
onChange={onChange}
autoFocus={values === lastAddedValues}
/>
),
},
{
key: "customDateTime",
label: "Date & Time",
icon: (
<ClockIcon className="size-3.5" />
),
type: "custom",
operators: [
{ value: "is", label: "is" },
{ value: "before", label: "before" },
{ value: "after", label: "after" },
],
customRenderer: ({ values, onChange }) => (
<CustomDateTimeInput
values={values}
onChange={onChange}
autoFocus={values === lastAddedValues}
/>
),
},
{
key: "customSliderRange",
label: "Slider Range",
icon: (
<SlidersVerticalIcon className="size-3.5" />
),
type: "custom",
className: "w-36",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) => (
<CustomSliderRangeInput
values={values}
onChange={onChange}
autoFocus={values === lastAddedValues}
/>
),
},
]
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
// Check if a filter was added by comparing IDs
const added = newFilters.find(
(nf) => !filters.some((f) => f.id === nf.id)
)
if (added) {
setLastAddedValues(added.values)
}
setFilters(newFilters)
},
[filters]
)
return (
<div className="flex grow content-start items-start gap-2.5 space-y-6 self-start">
<div className="flex-1">
<Filters
filters={filters}
fields={fields}
onChange={handleFiltersChange}
trigger={
<Button variant="outline" size="icon">
<ListFilterIcon />
</Button>
}
/>
</div>
{filters.length > 0 && (
<Button variant="outline" onClick={() => setFilters([])}>
<FunnelXIcon />
Clear
</Button>
)}
</div>
)
}
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Alert, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { BuildingIcon, CircleAlertIcon, FunnelXIcon, ListFilterIcon, MailIcon, MapPinIcon, UserIcon } from 'lucide-react'
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2024",
location: "San Francisco, USA",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Mar, 2023",
location: "London, UK",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Jun, 2022",
location: "Toronto, Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Sep, 2024",
location: "Sydney, Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "Nov, 2023",
location: "Berlin, Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Feb, 2022",
location: "Kuala Lumpur, MY",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Aug, 2024",
location: "Barcelona, Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Dec, 2023",
location: "Tokyo, Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Apr, 2022",
location: "Paris, France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Jul, 2024",
location: "Milan, Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "May, 2023",
location: "Rio de Janeiro, Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Oct, 2024",
location: "Mumbai, India",
balance: 4521.67,
},
]
// Availability status component
const AvailabilityStatus = ({ availability }: { availability: string }) => {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500"
case "away":
return "bg-yellow-500"
case "busy":
return "bg-red-500"
case "offline":
return "bg-gray-400"
default:
return "bg-gray-400"
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case "online":
return "Online"
case "away":
return "Away"
case "busy":
return "Busy"
case "offline":
return "Offline"
default:
return "Unknown"
}
}
return (
<div className="flex items-center gap-1.5">
<div className={`size-2 rounded-full ${getStatusColor(availability)}`} />
<span className="text-muted-foreground text-sm">
{getStatusLabel(availability)}
</span>
</div>
)
}
// Helper to check if a filter has meaningful values
const getActiveFilters = (filters: Filter[]) => {
return filters.filter((filter) => {
const { values } = filter
// Check if filter has meaningful values
if (!values || values.length === 0) return false
// For text/string values, check if they're not empty strings
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
// For number values, check if they're not null/undefined
if (values.every((value) => value === null || value === undefined))
return false
// For arrays, check if they're not empty
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
])
const [filters, setFilters] = useState<Filter[]>([
createFilter("status", "is", ["active"]),
])
// Async state management
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<IData[]>(demoData)
const isInitialLoad = useRef(true)
// Filter field configurations
const fields: FilterFieldConfig[] = [
{
key: "name",
label: "Name",
icon: (
<UserIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "Search names...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-48",
placeholder: "user@example.com",
},
{
key: "company",
label: "Company",
icon: (
<BuildingIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[180px]",
options: [
{ value: "Apple", label: "Apple" },
{ value: "OpenAI", label: "OpenAI" },
{ value: "Meta", label: "Meta" },
{ value: "Tesla", label: "Tesla" },
{ value: "SAP", label: "SAP" },
{ value: "Keenthemes", label: "Keenthemes" },
{ value: "BBVA", label: "BBVA" },
{ value: "Sony", label: "Sony" },
{ value: "LVMH", label: "LVMH" },
{ value: "ENI", label: "ENI" },
{ value: "Vale", label: "Vale" },
{ value: "Tata", label: "Tata" },
],
},
{
key: "role",
label: "Role",
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[160px]",
options: [
{ value: "CEO", label: "CEO" },
{ value: "CTO", label: "CTO" },
{ value: "Designer", label: "Designer" },
{ value: "Developer", label: "Developer" },
{ value: "Lawyer", label: "Lawyer" },
{ value: "Director", label: "Director" },
{ value: "Product Manager", label: "Product Manager" },
{ value: "Marketing Lead", label: "Marketing Lead" },
{ value: "Data Scientist", label: "Data Scientist" },
{ value: "Engineer", label: "Engineer" },
{ value: "Software Engineer", label: "Software Engineer" },
{ value: "Sales Manager", label: "Sales Manager" },
],
},
{
key: "status",
label: "Status",
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[140px]",
options: [
{
value: "active",
label: "Active",
icon: <div className="size-2 rounded-full bg-green-500"></div>,
},
{
value: "inactive",
label: "Inactive",
icon: <div className="bg-destructive size-2 rounded-full"></div>,
},
{
value: "archived",
label: "Archived",
icon: <div className="size-2 rounded-full bg-zinc-400"></div>,
},
],
},
{
key: "availability",
label: "Availability",
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: [
{
value: "online",
label: "Online",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-green-500" />
<span>Online</span>
</div>
),
},
{
value: "away",
label: "Away",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-yellow-500" />
<span>Away</span>
</div>
),
},
{
value: "busy",
label: "Busy",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-red-500" />
<span>Busy</span>
</div>
),
},
{
value: "offline",
label: "Offline",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-gray-400" />
<span>Offline</span>
</div>
),
},
],
},
{
key: "location",
label: "Location",
icon: (
<MapPinIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "Search locations...",
},
]
// Apply filters to data (shared function)
const applyFiltersToData = useCallback((newFilters: Filter[]) => {
let filtered = [...demoData]
// Filter out empty filters before applying
const activeFilters = getActiveFilters(newFilters)
activeFilters.forEach((filter) => {
const { field, operator, values } = filter
filtered = filtered.filter((item) => {
const fieldValue = item[field as keyof IData]
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "contains":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "not_contains":
return !values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "before":
return new Date(String(fieldValue)) < new Date(String(values[0]))
case "after":
return new Date(String(fieldValue)) > new Date(String(values[0]))
default:
return true
}
})
})
return filtered
}, [])
// Simulate async data filtering
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true) // Show loading on current data
// Simulate API call delay
await new Promise((resolve) =>
setTimeout(resolve, 800 + Math.random() * 1200)
)
// Apply filters and update data after timeout
const filtered = applyFiltersToData(newFilters)
setFilteredData(filtered)
setIsLoading(false)
},
[applyFiltersToData]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
const oldActive = getActiveFilters(filters)
const newActive = getActiveFilters(newFilters)
setFilters(newFilters)
// Compare active filters to decide if we need to trigger async search
// Use stringify for simple deep comparison of filter objects
if (JSON.stringify(oldActive) === JSON.stringify(newActive)) {
return
}
// Reset pagination when filters change
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
// Trigger async filtering
simulateAsyncFiltering(newFilters)
},
[filters, simulateAsyncFiltering]
)
// Initial data load - only run once on mount
useEffect(() => {
if (isInitialLoad.current) {
// Apply initial filter without loading state
const initialFiltered = applyFiltersToData(filters)
setFilteredData(initialFiltered)
isInitialLoad.current = false
}
}, [filters, applyFiltersToData])
const columns = useMemo<ColumnDef<IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader title="Staff" column={column} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="space-y-px">
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground truncate text-xs">
{row.original.email}
</div>
</div>
</div>
)
},
size: 200,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: (
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
</div>
),
},
},
{
accessorKey: "company",
id: "company",
header: ({ column }) => (
<DataGridColumnHeader title="Company" column={column} />
),
cell: (info) => <span>{info.getValue() as string}</span>,
size: 150,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: <Skeleton className="h-4 w-20" />,
},
},
{
accessorKey: "role",
id: "role",
header: ({ column }) => (
<DataGridColumnHeader title="Occupation" column={column} />
),
cell: (info) => <span>{info.getValue() as string}</span>,
size: 125,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
{
accessorKey: "status",
id: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
if (status == "active") {
return <Badge variant="success-outline">Active</Badge>
} else if (status == "inactive") {
return <Badge variant="destructive-outline">Inactive</Badge>
} else if (status == "archived") {
return <Badge variant="warning-outline">Archived</Badge>
}
},
size: 100,
meta: {
skeleton: <Skeleton className="h-4 w-16 rounded-full" />,
},
},
{
accessorKey: "availability",
id: "availability",
header: "Availability",
cell: ({ row }) => (
<AvailabilityStatus availability={row.original.availability} />
),
size: 120,
enableSorting: true,
meta: {
skeleton: (
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-3.5 w-12" />
</div>
),
},
},
{
accessorKey: "location",
id: "location",
header: ({ column }) => (
<DataGridColumnHeader title="Location" column={column} />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<span>{row.original.location}</span>
</div>
),
size: 180,
enableSorting: true,
meta: {
skeleton: (
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-3.5 w-24" />
</div>
),
},
},
{
accessorKey: "balance",
id: "balance",
header: ({ column }) => (
<DataGridColumnHeader title="Balance" column={column} />
),
cell: ({ row }) => (
<span className="font-medium">
${row.original.balance.toLocaleString()}
</span>
),
size: 120,
enableSorting: true,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
],
[]
)
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((column) => column.id as string)
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil((filteredData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting,
columnOrder,
},
onColumnOrderChange: setColumnOrder,
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<div className="w-full self-start">
{/* Filters Section */}
<div className="mb-3.5 flex items-start gap-2.5">
<div className="flex-1">
<Filters
filters={filters}
fields={fields}
onChange={handleFiltersChange}
size="sm"
trigger={
<Button variant="outline" size="icon-sm">
<ListFilterIcon />
</Button>
}
/>
</div>
{filters.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
setFilters([])
simulateAsyncFiltering([])
}}
disabled={isLoading}
>
<FunnelXIcon />
Clear
</Button>
)}
</div>
{/* Data Grid */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData?.length || 0}
tableLayout={{
dense: true,
columnsMovable: true,
}}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
{/* Async Info Alert */}
<Alert variant="success" className="mt-5">
<CircleAlertIcon />
<AlertTitle>
Async Mode: Simulated API Delay of <strong>800-2000ms</strong>
</AlertTitle>
</Alert>
</div>
)
}
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Alert, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import {
DataGrid,
DataGridContainer,
} from "@/components/reui/data-grid/data-grid"
import { DataGridColumnHeader } from "@/components/reui/data-grid/data-grid-column-header"
import { DataGridPagination } from "@/components/reui/data-grid/data-grid-pagination"
import { DataGridTable } from "@/components/reui/data-grid/data-grid-table"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import {
ColumnDef,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
SortingState,
useReactTable,
} from "@tanstack/react-table"
import {
createParser,
parseAsBoolean,
parseAsJson,
useQueryState,
useQueryStates,
} from "nuqs"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
import { Skeleton } from "@/components/ui/skeleton"
import { BuildingIcon, CircleAlertIcon, FunnelXIcon, ListFilterIcon, MailIcon, MapPinIcon, UserIcon } from 'lucide-react'
interface IData {
id: string
name: string
availability: "online" | "away" | "busy" | "offline"
avatar: string
status: "active" | "inactive"
flag: string // Emoji flags
email: string
company: string
role: string
joined: string
location: string
balance: number
}
const demoData: IData[] = [
{
id: "1",
name: "Alex Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "us",
email: "alex@apple.com",
company: "Apple",
role: "CEO",
joined: "Jan, 2024",
location: "San Francisco, USA",
balance: 5143.03,
},
{
id: "2",
name: "Sarah Chen",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "gb",
email: "sarah@openai.com",
company: "OpenAI",
role: "CTO",
joined: "Mar, 2023",
location: "London, UK",
balance: 4321.87,
},
{
id: "3",
name: "Michael Rodriguez",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "ca",
email: "michael@meta.com",
company: "Meta",
role: "Designer",
joined: "Jun, 2022",
location: "Toronto, Canada",
balance: 7654.98,
},
{
id: "4",
name: "Emma Wilson",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "au",
email: "emma@tesla.com",
company: "Tesla",
role: "Developer",
joined: "Sep, 2024",
location: "Sydney, Australia",
balance: 3456.45,
},
{
id: "5",
name: "David Kim",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1607990281513-2c110a25bd8c?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "de",
email: "david@sap.com",
company: "SAP",
role: "Lawyer",
joined: "Nov, 2023",
location: "Berlin, Germany",
balance: 9876.54,
},
{
id: "6",
name: "Aron Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "my",
email: "aron@keenthemes.com",
company: "Keenthemes",
role: "Director",
joined: "Feb, 2022",
location: "Kuala Lumpur, MY",
balance: 6214.22,
},
{
id: "7",
name: "James Brown",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "es",
email: "james@bbva.es",
company: "BBVA",
role: "Product Manager",
joined: "Aug, 2024",
location: "Barcelona, Spain",
balance: 5321.77,
},
{
id: "8",
name: "Maria Garcia",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1620075225255-8c2051b6c015?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "jp",
email: "maria@sony.jp",
company: "Sony",
role: "Marketing Lead",
joined: "Dec, 2023",
location: "Tokyo, Japan",
balance: 8452.39,
},
{
id: "9",
name: "Nick Johnson",
availability: "online",
avatar:
"https://images.unsplash.com/photo-1485206412256-701ccc5b93ca?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "fr",
email: "nick@lvmh.fr",
company: "LVMH",
role: "Data Scientist",
joined: "Apr, 2022",
location: "Paris, France",
balance: 7345.1,
},
{
id: "10",
name: "Liam Thompson",
availability: "away",
avatar:
"https://images.unsplash.com/photo-1542595913-85d69b0edbaf?w=96&h=96&dpr=2&q=80",
status: "inactive",
flag: "it",
email: "liam@eni.it",
company: "ENI",
role: "Engineer",
joined: "Jul, 2024",
location: "Milan, Italy",
balance: 5214.88,
},
{
id: "11",
name: "Alex Johnson",
availability: "busy",
avatar:
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "br",
email: "alex@vale.br",
company: "Vale",
role: "Software Engineer",
joined: "May, 2023",
location: "Rio de Janeiro, Brazil",
balance: 9421.5,
},
{
id: "12",
name: "Sarah Chen",
availability: "offline",
avatar:
"https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
status: "active",
flag: "in",
email: "sarah@tata.in",
company: "Tata",
role: "Sales Manager",
joined: "Oct, 2024",
location: "Mumbai, India",
balance: 4521.67,
},
]
// Availability status component
const AvailabilityStatus = ({ availability }: { availability: string }) => {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500"
case "away":
return "bg-yellow-500"
case "busy":
return "bg-red-500"
case "offline":
return "bg-gray-400"
default:
return "bg-gray-400"
}
}
const getStatusLabel = (status: string) => {
switch (status) {
case "online":
return "Online"
case "away":
return "Away"
case "busy":
return "Busy"
case "offline":
return "Offline"
default:
return "Unknown"
}
}
return (
<div className="flex items-center gap-1.5">
<div className={`size-2 rounded-full ${getStatusColor(availability)}`} />
<span className="text-muted-foreground text-sm">
{getStatusLabel(availability)}
</span>
</div>
)
}
// Helper to check if a filter has meaningful values
const getActiveFilters = (filters: Filter[]) => {
return filters.filter((filter) => {
const { values } = filter
// Check if filter has meaningful values
if (!values || values.length === 0) return false
// For text/string values, check if they're not empty strings
if (
values.every((value) => typeof value === "string" && value.trim() === "")
)
return false
// For number values, check if they're not null/undefined
if (values.every((value) => value === null || value === undefined))
return false
// For arrays, check if they're not empty
if (values.every((value) => Array.isArray(value) && value.length === 0))
return false
return true
})
}
type FilterState = { operator: string; values: string[] }
// Custom parser for "operator:value1|value2" format
const parseAsFilterValue = createParser<FilterState>({
parse: (queryValue: string) => {
if (!queryValue) return null
const separatorIndex = queryValue.indexOf(":")
if (separatorIndex === -1) {
return { operator: "is", values: queryValue.split("|").filter(Boolean) }
}
const operator = queryValue.slice(0, separatorIndex)
const values = queryValue
.slice(separatorIndex + 1)
.split("|")
.filter(Boolean)
return { operator, values }
},
serialize: (filter: FilterState) => {
if (!filter.values?.length) return ""
return `${filter.operator}:${filter.values.join("|")}`
},
})
export function Pattern() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 5,
})
// Sorting state synced with URL
const [sorting, setSorting] = useQueryState(
"sort",
parseAsJson<SortingState>((v) => v as SortingState).withDefault([
{ id: "name", desc: false },
])
)
// Individual filter states synced with URL
const [filterStates, setFilterStates] = useQueryStates(
{
filters: parseAsBoolean.withDefault(false),
name: parseAsFilterValue,
email: parseAsFilterValue,
company: parseAsFilterValue,
role: parseAsFilterValue,
status: parseAsFilterValue,
availability: parseAsFilterValue,
location: parseAsFilterValue,
},
{ history: "replace", shallow: true }
)
// Derived filters array for the Filters component
const filters = useMemo(() => {
return Object.entries(filterStates)
.filter(
([key, state]) =>
key !== "filters" && state !== null && typeof state !== "boolean"
)
.map(([key, state]) => {
const filterState = state as FilterState
return createFilter(key, filterState.operator, filterState.values)
})
}, [filterStates])
// Async state management
const [isLoading, setIsLoading] = useState(false)
const [filteredData, setFilteredData] = useState<IData[]>(demoData)
const isInitialLoad = useRef(true)
const filterTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// Filter field configurations
const fields: FilterFieldConfig[] = [
{
key: "name",
label: "Name",
icon: (
<UserIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "Search names...",
},
{
key: "email",
label: "Email",
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-48",
placeholder: "user@example.com",
},
{
key: "company",
label: "Company",
icon: (
<BuildingIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[180px]",
options: [
{ value: "Apple", label: "Apple" },
{ value: "OpenAI", label: "OpenAI" },
{ value: "Meta", label: "Meta" },
{ value: "Tesla", label: "Tesla" },
{ value: "SAP", label: "SAP" },
{ value: "Keenthemes", label: "Keenthemes" },
{ value: "BBVA", label: "BBVA" },
{ value: "Sony", label: "Sony" },
{ value: "LVMH", label: "LVMH" },
{ value: "ENI", label: "ENI" },
{ value: "Vale", label: "Vale" },
{ value: "Tata", label: "Tata" },
],
},
{
key: "role",
label: "Role",
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[160px]",
options: [
{ value: "CEO", label: "CEO" },
{ value: "CTO", label: "CTO" },
{ value: "Designer", label: "Designer" },
{ value: "Developer", label: "Developer" },
{ value: "Lawyer", label: "Lawyer" },
{ value: "Director", label: "Director" },
{ value: "Product Manager", label: "Product Manager" },
{ value: "Marketing Lead", label: "Marketing Lead" },
{ value: "Data Scientist", label: "Data Scientist" },
{ value: "Engineer", label: "Engineer" },
{ value: "Software Engineer", label: "Software Engineer" },
{ value: "Sales Manager", label: "Sales Manager" },
],
},
{
key: "status",
label: "Status",
icon: (
<UserIcon className="size-3.5" />
),
type: "multiselect",
searchable: false,
className: "w-[140px]",
options: [
{
value: "active",
label: "Active",
icon: <div className="size-2 rounded-full bg-green-500"></div>,
},
{
value: "inactive",
label: "Inactive",
icon: <div className="bg-destructive size-2 rounded-full"></div>,
},
{
value: "archived",
label: "Archived",
icon: <div className="size-2 rounded-full bg-zinc-400"></div>,
},
],
},
{
key: "availability",
label: "Availability",
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[160px]",
options: [
{
value: "online",
label: "Online",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-green-500" />
<span>Online</span>
</div>
),
},
{
value: "away",
label: "Away",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-yellow-500" />
<span>Away</span>
</div>
),
},
{
value: "busy",
label: "Busy",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-red-500" />
<span>Busy</span>
</div>
),
},
{
value: "offline",
label: "Offline",
icon: (
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-gray-400" />
<span>Offline</span>
</div>
),
},
],
},
{
key: "location",
label: "Location",
icon: (
<MapPinIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder: "Search locations...",
},
]
// Apply filters to data (shared function)
const applyFiltersToData = useCallback((newFilters: Filter[]) => {
let filtered = [...demoData]
// Filter out empty filters before applying
const activeFilters = getActiveFilters(newFilters)
activeFilters.forEach((filter) => {
const { field, operator, values } = filter
filtered = filtered.filter((item) => {
const fieldValue = item[field as keyof IData]
switch (operator) {
case "is":
return values.includes(fieldValue)
case "is_not":
return !values.includes(fieldValue)
case "contains":
return values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "not_contains":
return !values.some((value) =>
String(fieldValue)
.toLowerCase()
.includes(String(value).toLowerCase())
)
case "equals":
return fieldValue === values[0]
case "not_equals":
return fieldValue !== values[0]
case "greater_than":
return Number(fieldValue) > Number(values[0])
case "less_than":
return Number(fieldValue) < Number(values[0])
case "greater_than_or_equal":
return Number(fieldValue) >= Number(values[0])
case "less_than_or_equal":
return Number(fieldValue) <= Number(values[0])
case "between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) >= min && Number(fieldValue) <= max
}
return true
case "not_between":
if (values.length >= 2) {
const min = Number(values[0])
const max = Number(values[1])
return Number(fieldValue) < min || Number(fieldValue) > max
}
return true
case "before":
return new Date(String(fieldValue)) < new Date(String(values[0]))
case "after":
return new Date(String(fieldValue)) > new Date(String(values[0]))
default:
return true
}
})
})
return filtered
}, [])
// Simulate async data filtering
const simulateAsyncFiltering = useCallback(
async (newFilters: Filter[]) => {
setIsLoading(true) // Show loading on current data
// Simulate API call delay
await new Promise((resolve) =>
setTimeout(resolve, 800 + Math.random() * 1200)
)
// Apply filters and update data after timeout
const filtered = applyFiltersToData(newFilters)
setFilteredData(filtered)
setIsLoading(false)
},
[applyFiltersToData]
)
const handleFiltersChange = useCallback(
(newFilters: Filter[]) => {
const oldActive = getActiveFilters(filters)
const newActive = getActiveFilters(newFilters)
// Convert Filter[] back to individual query states
const nextStates: Record<string, FilterState | boolean | null> = {}
// Reset all tracked fields first
Object.keys(filterStates).forEach((key) => {
nextStates[key] = null
})
// Set only active ones
newFilters.forEach((f) => {
if (f.values.length > 0) {
nextStates[f.field] = {
operator: f.operator,
values: f.values as string[],
}
}
})
// Set the filters marker if any filters exist
nextStates.filters = newFilters.length > 0 ? true : null
setFilterStates(nextStates)
if (JSON.stringify(oldActive) === JSON.stringify(newActive)) {
return
}
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
// Clear any pending timeout
if (filterTimeoutRef.current) {
clearTimeout(filterTimeoutRef.current)
}
// Add a small debounce before starting the async simulation
filterTimeoutRef.current = setTimeout(() => {
simulateAsyncFiltering(newFilters)
}, 300)
},
[filters, filterStates, setFilterStates, simulateAsyncFiltering]
)
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (filterTimeoutRef.current) {
clearTimeout(filterTimeoutRef.current)
}
}
}, [])
// Initial data load - only run once on mount
useEffect(() => {
if (isInitialLoad.current) {
// Apply initial filter without loading state
const initialFiltered = applyFiltersToData(filters || [])
setFilteredData(initialFiltered)
isInitialLoad.current = false
}
}, [filters, applyFiltersToData])
const columns = useMemo<ColumnDef<IData>[]>(
() => [
{
accessorKey: "name",
id: "name",
header: ({ column }) => (
<DataGridColumnHeader title="Staff" column={column} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar className="size-8">
<AvatarImage
src={row.original.avatar}
alt={row.original.name}
/>
<AvatarFallback>
{row.original.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="space-y-px">
<div className="text-foreground font-medium">
{row.original.name}
</div>
<div className="text-muted-foreground truncate text-xs">
{row.original.email}
</div>
</div>
</div>
)
},
size: 200,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: (
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
</div>
</div>
),
},
},
{
accessorKey: "company",
id: "company",
header: ({ column }) => (
<DataGridColumnHeader title="Company" column={column} />
),
cell: (info) => <span>{info.getValue() as string}</span>,
size: 150,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: <Skeleton className="h-4 w-20" />,
},
},
{
accessorKey: "role",
id: "role",
header: ({ column }) => (
<DataGridColumnHeader title="Occupation" column={column} />
),
cell: (info) => <span>{info.getValue() as string}</span>,
size: 125,
enableSorting: true,
enableHiding: false,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
{
accessorKey: "status",
id: "status",
header: "Status",
cell: ({ row }) => {
const status = row.original.status
if (status == "active") {
return <Badge variant="success-outline">Active</Badge>
} else if (status == "inactive") {
return <Badge variant="destructive-outline">Inactive</Badge>
} else if (status == "archived") {
return <Badge variant="warning-outline">Archived</Badge>
}
},
size: 100,
meta: {
skeleton: <Skeleton className="h-4 w-16 rounded-full" />,
},
},
{
accessorKey: "availability",
id: "availability",
header: "Availability",
cell: ({ row }) => (
<AvailabilityStatus availability={row.original.availability} />
),
size: 120,
enableSorting: true,
meta: {
skeleton: (
<div className="flex items-center gap-1.5">
<Skeleton className="size-4 rounded-full" />
<Skeleton className="h-3.5 w-12" />
</div>
),
},
},
{
accessorKey: "location",
id: "location",
header: ({ column }) => (
<DataGridColumnHeader title="Location" column={column} />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<img
src={`https://flagcdn.com/${row.original.flag.toLowerCase()}.svg`}
alt={row.original.flag}
className="size-4 rounded-full object-cover"
/>
<span>{row.original.location}</span>
</div>
),
size: 180,
enableSorting: true,
meta: {
skeleton: (
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded" />
<Skeleton className="h-3.5 w-24" />
</div>
),
},
},
{
accessorKey: "balance",
id: "balance",
header: ({ column }) => (
<DataGridColumnHeader title="Balance" column={column} />
),
cell: ({ row }) => (
<span className="font-medium">
${row.original.balance.toLocaleString()}
</span>
),
size: 120,
enableSorting: true,
meta: {
skeleton: <Skeleton className="h-4 w-16" />,
},
},
],
[]
)
const [columnOrder, setColumnOrder] = useState<string[]>(
columns.map((column) => column.id as string)
)
const table = useReactTable({
columns,
data: filteredData,
pageCount: Math.ceil((filteredData?.length || 0) / pagination.pageSize),
getRowId: (row: IData) => row.id,
state: {
pagination,
sorting: sorting || [],
columnOrder,
},
onColumnOrderChange: setColumnOrder,
onPaginationChange: setPagination,
onSortingChange: (updater) => {
const nextSorting =
typeof updater === "function" ? updater(sorting || []) : updater
setSorting(nextSorting, { history: "replace", shallow: true })
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<div className="w-full self-start">
{/* Filters Section */}
<div className="mb-3.5 flex items-start gap-2.5">
<div className="flex-1">
<Filters
filters={filters}
fields={fields}
onChange={handleFiltersChange}
size="sm"
trigger={
<Button variant="outline" size="icon-sm">
<ListFilterIcon />
</Button>
}
/>
</div>
{filters.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (filterTimeoutRef.current) {
clearTimeout(filterTimeoutRef.current)
}
// Clear all filters in the URL
const clearedStates: Record<string, null> = {}
Object.keys(filterStates).forEach(
(key) => (clearedStates[key] = null)
)
clearedStates.filters = null
setFilterStates(clearedStates)
simulateAsyncFiltering([])
}}
disabled={isLoading}
>
<FunnelXIcon />
Clear
</Button>
)}
</div>
{/* Data Grid */}
<DataGrid
table={table}
isLoading={isLoading}
loadingMode="skeleton"
recordCount={filteredData?.length || 0}
tableLayout={{
dense: true,
columnsMovable: true,
}}
>
<div className="w-full space-y-2.5">
<DataGridContainer>
<ScrollArea>
<DataGridTable />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</DataGridContainer>
<DataGridPagination />
</div>
</DataGrid>
{/* Async Info Alert */}
<Alert variant="success" className="mt-5">
<CircleAlertIcon />
<AlertTitle>
Async Mode: Simulated API Delay of <strong>800-2000ms</strong>
</AlertTitle>
</Alert>
</div>
)
}
"use client"
import { useCallback, useMemo, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
type FilterI18nConfig,
} from "@/components/reui/filters"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { BuildingIcon, ChevronDownIcon, CircleCheckIcon, ListFilterIcon, MailIcon, MapPinIcon, UserIcon } from 'lucide-react'
// Internationalization configurations
const i18nConfigs: Record<string, FilterI18nConfig> = {
en: {
// UI Labels
addFilter: "Add filter",
searchFields: "Search fields...",
noFieldsFound: "No fields found.",
noResultsFound: "No results found.",
select: "Select...",
true: "True",
false: "False",
min: "Min",
max: "Max",
to: "to",
typeAndPressEnter: "Type and press Enter to add tag",
selected: "selected",
selectedCount: "selected",
percent: "%",
defaultCurrency: "$",
defaultColor: "#000000",
addFilterTitle: "Add filter",
// Operators
operators: {
is: "is",
isNot: "is not",
isAnyOf: "is any of",
isNotAnyOf: "is not any of",
includesAll: "includes all",
excludesAll: "excludes all",
before: "before",
after: "after",
between: "between",
notBetween: "not between",
contains: "contains",
notContains: "does not contain",
startsWith: "starts with",
endsWith: "ends with",
isExactly: "is exactly",
equals: "equals",
notEquals: "not equals",
greaterThan: "greater than",
lessThan: "less than",
overlaps: "overlaps",
includes: "includes",
excludes: "excludes",
includesAllOf: "includes all of",
includesAnyOf: "includes any of",
empty: "is empty",
notEmpty: "is not empty",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `Enter ${fieldType}...`,
selectField: "Select...",
searchField: (fieldName: string) =>
`Search ${fieldName.toLowerCase()}...`,
enterKey: "Enter key...",
enterValue: "Enter value...",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "Invalid email format",
invalidUrl: "Invalid URL format",
invalidTel: "Invalid phone format",
invalid: "Invalid input format",
},
},
es: {
// UI Labels
addFilter: "Agregar filtro",
searchFields: "Buscar campos...",
noFieldsFound: "No se encontraron campos.",
noResultsFound: "No se encontraron resultados.",
select: "Seleccionar...",
true: "Verdadero",
false: "Falso",
min: "Mín",
max: "Máx",
to: "a",
typeAndPressEnter: "Escriba y presione Enter para agregar etiqueta",
selected: "seleccionado",
selectedCount: "seleccionados",
percent: "%",
defaultCurrency: "€",
defaultColor: "#000000",
addFilterTitle: "Agregar filtro",
// Operators
operators: {
is: "es",
isNot: "no es",
isAnyOf: "es cualquiera de",
isNotAnyOf: "no es cualquiera de",
includesAll: "incluye todos",
excludesAll: "excluye todos",
before: "antes de",
after: "después de",
between: "entre",
notBetween: "no entre",
contains: "contiene",
notContains: "no contiene",
startsWith: "comienza con",
endsWith: "termina con",
isExactly: "es exactamente",
equals: "igual a",
notEquals: "no igual a",
greaterThan: "mayor que",
lessThan: "menor que",
overlaps: "se superpone",
includes: "incluye",
excludes: "excluye",
includesAllOf: "incluye todos de",
includesAnyOf: "incluye cualquiera de",
empty: "está vacío",
notEmpty: "no está vacío",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `Ingrese ${fieldType}...`,
selectField: "Seleccionar...",
searchField: (fieldName: string) =>
`Buscar ${fieldName.toLowerCase()}...`,
enterKey: "Ingrese clave...",
enterValue: "Ingrese valor...",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "Formato de email inválido",
invalidUrl: "Formato de URL inválido",
invalidTel: "Formato de teléfono inválido",
invalid: "Formato de entrada inválido",
},
},
fr: {
// UI Labels
addFilter: "Ajouter un filtre",
searchFields: "Rechercher des champs...",
noFieldsFound: "Aucun champ trouvé.",
noResultsFound: "Aucun résultat trouvé.",
select: "Sélectionner...",
true: "Vrai",
false: "Faux",
min: "Min",
max: "Max",
to: "à",
typeAndPressEnter: "Tapez et appuyez sur Entrée pour ajouter une étiquette",
selected: "sélectionné",
selectedCount: "sélectionnés",
percent: "%",
defaultCurrency: "€",
defaultColor: "#000000",
addFilterTitle: "Ajouter un filtre",
// Operators
operators: {
is: "est",
isNot: "n'est pas",
isAnyOf: "est l'un de",
isNotAnyOf: "n'est pas l'un de",
includesAll: "inclut tous",
excludesAll: "exclut tous",
before: "avant",
after: "après",
between: "entre",
notBetween: "pas entre",
contains: "contient",
notContains: "ne contient pas",
startsWith: "commence par",
endsWith: "se termine par",
isExactly: "est exactement",
equals: "égal à",
notEquals: "pas égal à",
greaterThan: "supérieur à",
lessThan: "inférieur à",
overlaps: "se chevauche",
includes: "inclut",
excludes: "exclut",
includesAllOf: "inclut tous de",
includesAnyOf: "inclut l'un de",
empty: "est vide",
notEmpty: "n'est pas vide",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `Entrez ${fieldType}...`,
selectField: "Sélectionner...",
searchField: (fieldName: string) =>
`Rechercher ${fieldName.toLowerCase()}...`,
enterKey: "Entrez la clé...",
enterValue: "Entrez la valeur...",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "Format d'email invalide",
invalidUrl: "Format d'URL invalide",
invalidTel: "Format de téléphone invalide",
invalid: "Format de saisie invalide",
},
},
de: {
// UI Labels
addFilter: "Filter hinzufügen",
searchFields: "Felder suchen...",
noFieldsFound: "Keine Felder gefunden.",
noResultsFound: "Keine Ergebnisse gefunden.",
select: "Auswählen...",
true: "Wahr",
false: "Falsch",
min: "Min",
max: "Max",
to: "bis",
typeAndPressEnter: "Tippen und Enter drücken, um Tag hinzuzufügen",
selected: "ausgewählt",
selectedCount: "ausgewählt",
percent: "%",
defaultCurrency: "€",
defaultColor: "#000000",
addFilterTitle: "Filter hinzufügen",
// Operators
operators: {
is: "ist",
isNot: "ist nicht",
isAnyOf: "ist eines von",
isNotAnyOf: "ist nicht eines von",
includesAll: "enthält alle",
excludesAll: "schließt alle aus",
before: "vor",
after: "nach",
between: "zwischen",
notBetween: "nicht zwischen",
contains: "enthält",
notContains: "enthält nicht",
startsWith: "beginnt mit",
endsWith: "endet mit",
isExactly: "ist genau",
equals: "gleich",
notEquals: "nicht gleich",
greaterThan: "größer als",
lessThan: "kleiner als",
overlaps: "überschneidet sich",
includes: "enthält",
excludes: "schließt aus",
includesAllOf: "enthält alle von",
includesAnyOf: "enthält eines von",
empty: "ist leer",
notEmpty: "ist nicht leer",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `${fieldType} eingeben...`,
selectField: "Auswählen...",
searchField: (fieldName: string) =>
`${fieldName.toLowerCase()} suchen...`,
enterKey: "Schlüssel eingeben...",
enterValue: "Wert eingeben...",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "Ungültiges E-Mail-Format",
invalidUrl: "Ungültiges URL-Format",
invalidTel: "Ungültiges Telefonformat",
invalid: "Ungültiges Format",
},
},
ja: {
// UI Labels
addFilter: "フィルターを追加",
searchFields: "フィールドを検索...",
noFieldsFound: "フィールドが見つかりません。",
noResultsFound: "結果が見つかりません。",
select: "選択...",
true: "真",
false: "偽",
min: "最小",
max: "最大",
to: "から",
typeAndPressEnter: "入力してEnterキーを押してタグを追加",
selected: "選択済み",
selectedCount: "選択済み",
percent: "%",
defaultCurrency: "¥",
defaultColor: "#000000",
addFilterTitle: "フィルターを追加",
// Operators
operators: {
is: "は",
isNot: "ではない",
isAnyOf: "のいずれか",
isNotAnyOf: "のいずれでもない",
includesAll: "すべて含む",
excludesAll: "すべて除外",
before: "より前",
after: "より後",
between: "の間",
notBetween: "の間ではない",
contains: "含む",
notContains: "含まない",
startsWith: "で始まる",
endsWith: "で終わる",
isExactly: "正確に",
equals: "等しい",
notEquals: "等しくない",
greaterThan: "より大きい",
lessThan: "より小さい",
overlaps: "重複する",
includes: "含む",
excludes: "除外",
includesAllOf: "すべて含む",
includesAnyOf: "いずれか含む",
empty: "空",
notEmpty: "空でない",
},
// Placeholders
placeholders: {
enterField: (fieldType: string) => `${fieldType}を入力...`,
selectField: "選択...",
searchField: (fieldName: string) => `${fieldName.toLowerCase()}を検索...`,
enterKey: "キーを入力...",
enterValue: "値を入力...",
},
// Helper functions
helpers: {
formatOperator: (operator: string) => operator.replace(/_/g, " "),
},
// Validation
validation: {
invalidEmail: "無効なメール形式",
invalidUrl: "無効なURL形式",
invalidTel: "無効な電話番号形式",
invalid: "無効な形式",
},
},
}
// Language options for the selector
const languageOptions = [
{ value: "en", label: "English", flag: "us" },
{ value: "es", label: "Español", flag: "es" },
{ value: "fr", label: "Français", flag: "fr" },
{ value: "de", label: "Deutsch", flag: "de" },
{ value: "ja", label: "日本語", flag: "jp" },
]
export function Pattern() {
const [currentLanguage, setCurrentLanguage] = useState<string>("es")
const [filters, setFilters] = useState<Filter[]>([
createFilter("status", "is", ["active"]),
])
// Get current i18n configuration
const currentI18n = useMemo(
() => i18nConfigs[currentLanguage],
[currentLanguage]
)
// Filter field configurations with localized labels
const fields: FilterFieldConfig[] = useMemo(() => {
const fieldLabels = {
en: {
name: "Name",
email: "Email",
company: "Company",
role: "Role",
status: "Status",
location: "Location",
joined: "Joined Date",
balance: "Balance",
rating: "Rating",
},
es: {
name: "Nombre",
email: "Correo electrónico",
company: "Empresa",
role: "Rol",
status: "Estado",
location: "Ubicación",
joined: "Fecha de ingreso",
balance: "Saldo",
rating: "Calificación",
},
fr: {
name: "Nom",
email: "E-mail",
company: "Entreprise",
role: "Rôle",
status: "Statut",
location: "Localisation",
joined: "Date d'adhésion",
balance: "Solde",
rating: "Note",
},
de: {
name: "Name",
email: "E-Mail",
company: "Unternehmen",
role: "Rolle",
status: "Status",
location: "Standort",
joined: "Beitrittsdatum",
balance: "Guthaben",
rating: "Bewertung",
},
ja: {
name: "名前",
email: "メール",
company: "会社",
role: "役割",
status: "ステータス",
location: "場所",
joined: "参加日",
balance: "残高",
rating: "評価",
},
}
const labels =
fieldLabels[currentLanguage as keyof typeof fieldLabels] || fieldLabels.en
return [
{
key: "name",
label: labels.name,
icon: (
<UserIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder:
currentLanguage === "en"
? "Search names..."
: currentLanguage === "es"
? "Buscar nombres..."
: currentLanguage === "fr"
? "Rechercher des noms..."
: currentLanguage === "de"
? "Namen suchen..."
: "名前を検索...",
},
{
key: "email",
label: labels.email,
icon: (
<MailIcon className="size-3.5" />
),
type: "text",
className: "w-48",
placeholder: "user@example.com",
},
{
key: "company",
label: labels.company,
icon: (
<BuildingIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[180px]",
options: [
{ value: "TechCorp", label: "TechCorp" },
{ value: "StartupCo", label: "StartupCo" },
{ value: "BigCorp", label: "BigCorp" },
{ value: "InnovateTech", label: "InnovateTech" },
{ value: "GlobalNet", label: "GlobalNet" },
],
},
{
key: "role",
label: labels.role,
icon: (
<UserIcon className="size-3.5" />
),
type: "select",
searchable: true,
className: "w-[160px]",
options: [
{ value: "Developer", label: "Developer" },
{ value: "Designer", label: "Designer" },
{ value: "Manager", label: "Manager" },
{ value: "Product Manager", label: "Product Manager" },
{ value: "Sales Rep", label: "Sales Rep" },
],
},
{
key: "status",
label: labels.status,
icon: (
<CircleCheckIcon className="size-3.5" />
),
type: "select",
searchable: false,
className: "w-[140px]",
options: [
{
value: "active",
label:
currentLanguage === "en"
? "Active"
: currentLanguage === "es"
? "Activo"
: currentLanguage === "fr"
? "Actif"
: currentLanguage === "de"
? "Aktiv"
: "アクティブ",
},
{
value: "inactive",
label:
currentLanguage === "en"
? "Inactive"
: currentLanguage === "es"
? "Inactivo"
: currentLanguage === "fr"
? "Inactif"
: currentLanguage === "de"
? "Inaktiv"
: "非アクティブ",
},
],
},
{
key: "location",
label: labels.location,
icon: (
<MapPinIcon className="size-3.5" />
),
type: "text",
className: "w-40",
placeholder:
currentLanguage === "en"
? "Search locations..."
: currentLanguage === "es"
? "Buscar ubicaciones..."
: currentLanguage === "fr"
? "Rechercher des lieux..."
: currentLanguage === "de"
? "Standorte suchen..."
: "場所を検索...",
},
]
}, [currentLanguage])
const handleFiltersChange = useCallback((newFilters: Filter[]) => {
setFilters(newFilters)
}, [])
return (
<div className="flex w-full grow items-start justify-between space-y-6 self-start">
{/* Filters Section */}
<Filters
filters={filters}
fields={fields}
onChange={handleFiltersChange}
size="sm"
trigger={
<Button variant="outline" size="icon-sm">
<ListFilterIcon />
</Button>
}
i18n={currentI18n}
/>
<div className="flex items-center gap-2">
{/* Language selection */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
{(() => {
const lang = languageOptions.find(
(l) => l.value === currentLanguage
)
return (
lang && (
<img
src={`https://flagcdn.com/${lang.flag.toLowerCase()}.svg`}
alt={lang.flag}
className="size-4 rounded-full object-cover"
/>
)
)
})()}
<span>
{
languageOptions.find(
(lang) => lang.value === currentLanguage
)?.label
}
</span>
<ChevronDownIcon className="size-4" />
</Button>
}
/>
<DropdownMenuContent align="start">
{languageOptions.map((lang) => (
<DropdownMenuItem
key={lang.value}
onClick={() => setCurrentLanguage(lang.value)}
className="flex items-center gap-2"
>
<img
src={`https://flagcdn.com/${lang.flag.toLowerCase()}.svg`}
alt={lang.flag}
className="size-4 rounded-full object-cover"
/>
<span>{lang.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}
"use client"
import { useCallback, useState } from "react"
import {
createFilter,
Filters,
type Filter,
type FilterFieldConfig,
} from "@/components/reui/filters"
import { cn } from "@/lib/utils"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { BanIcon, BellIcon, BuildingIcon, CircleAlertIcon, CircleCheckIcon, ClockIcon, FunnelXIcon, GlobeIcon, ListFilterIcon, MailIcon, PhoneIcon, StarIcon, TypeIcon, UserRoundCheckIcon, UserRoundXIcon, UsersIcon } from 'lucide-react'
// Priority icon component
const PriorityIcon = ({ priority }: { priority: string }) => {
const colors = {
low: "bg-green-500",
medium: "bg-yellow-500",
high: "bg-violet-500",
urgent: "bg-orange-500",
critical: "bg-red-500",
}
return (
<div
className={cn(
"size-2.25 shrink-0 rounded-full",
colors[priority as keyof typeof colors]
)}
/>
)
}
const countryFlags = [
{ code: "AF", name: "Afghanistan" },
{ code: "AL", name: "Albania" },
{ code: "DZ", name: "Algeria" },
{ code: "AS", name: "American Samoa" },
{ code: "AD", name: "Andorra" },
{ code: "AO", name: "Angola" },
{ code: "AI", name: "Anguilla" },
{ code: "AG", name: "Antigua and Barbuda" },
{ code: "AR", name: "Argentina" },
{ code: "AM", name: "Armenia" },
{ code: "AU", name: "Australia" },
{ code: "AT", name: "Austria" },
{ code: "AZ", name: "Azerbaijan" },
{ code: "BS", name: "Bahamas" },
{ code: "BH", name: "Bahrain" },
{ code: "BD", name: "Bangladesh" },
{ code: "BB", name: "Barbados" },
{ code: "BY", name: "Belarus" },
{ code: "BE", name: "Belgium" },
{ code: "BZ", name: "Belize" },
{ code: "BJ", name: "Benin" },
{ code: "BM", name: "Bermuda" },
{ code: "BT", name: "Bhutan" },
{ code: "BO", name: "Bolivia" },
{ code: "BA", name: "Bosnia and Herzegovina" },
{ code: "BW", name: "Botswana" },
{ code: "BR", name: "Brazil" },
{ code: "IO", name: "British Indian Ocean Territory" },
{ code: "BN", name: "Brunei Darussalam" },
{ code: "BG", name: "Bulgaria" },
{ code: "BF", name: "Burkina Faso" },
{ code: "BI", name: "Burundi" },
{ code: "KH", name: "Cambodia" },
{ code: "CM", name: "Cameroon" },
{ code: "CA", name: "Canada" },
{ code: "CV", name: "Cape Verde" },
{ code: "KY", name: "Cayman Islands" },
{ code: "CF", name: "Central African Republic" },
{ code: "TD", name: "Chad" },
{ code: "CL", name: "Chile" },
{ code: "CN", name: "China" },
{ code: "CO", name: "Colombia" },
{ code: "KM", name: "Comoros" },
{ code: "CG", name: "Congo" },
{ code: "CR", name: "Costa Rica" },
{ code: "CI", name: "Cote D'Ivoire" },
{ code: "HR", name: "Croatia" },
{ code: "CU", name: "Cuba" },
{ code: "CY", name: "Cyprus" },
{ code: "CZ", name: "Czech Republic" },
{ code: "DK", name: "Denmark" },
{ code: "DJ", name: "Djibouti" },
{ code: "DM", name: "Dominica" },
{ code: "DO", name: "Dominican Republic" },
{ code: "EC", name: "Ecuador" },
{ code: "EG", name: "Egypt" },
{ code: "SV", name: "El Salvador" },
{ code: "GQ", name: "Equatorial Guinea" },
{ code: "ER", name: "Eritrea" },
{ code: "EE", name: "Estonia" },
{ code: "SZ", name: "Eswatini" },
{ code: "ET", name: "Ethiopia" },
{ code: "FI", name: "Finland" },
{ code: "FR", name: "France" },
{ code: "GA", name: "Gabon" },
{ code: "GM", name: "Gambia" },
{ code: "GE", name: "Georgia" },
{ code: "DE", name: "Germany" },
{ code: "GH", name: "Ghana" },
{ code: "GR", name: "Greece" },
{ code: "GD", name: "Grenada" },
{ code: "GT", name: "Guatemala" },
{ code: "GN", name: "Guinea" },
{ code: "GW", name: "Guinea-Bissau" },
{ code: "GY", name: "Guyana" },
{ code: "HT", name: "Haiti" },
{ code: "HN", name: "Honduras" },
{ code: "HK", name: "Hong Kong" },
{ code: "HU", name: "Hungary" },
{ code: "IS", name: "Iceland" },
{ code: "IN", name: "India" },
{ code: "ID", name: "Indonesia" },
{ code: "IR", name: "Iran" },
{ code: "IQ", name: "Iraq" },
{ code: "IE", name: "Ireland" },
{ code: "IL", name: "Israel" },
{ code: "IT", name: "Italy" },
{ code: "JM", name: "Jamaica" },
{ code: "JP", name: "Japan" },
{ code: "JO", name: "Jordan" },
{ code: "KZ", name: "Kazakhstan" },
{ code: "KE", name: "Kenya" },
{ code: "KR", name: "South Korea" },
{ code: "KW", name: "Kuwait" },
{ code: "KG", name: "Kyrgyzstan" },
{ code: "LA", name: "Laos" },
{ code: "LV", name: "Latvia" },
{ code: "LB", name: "Lebanon" },
{ code: "LS", name: "Lesotho" },
{ code: "LR", name: "Liberia" },
{ code: "LY", name: "Libya" },
{ code: "LT", name: "Lithuania" },
{ code: "LU", name: "Luxembourg" },
{ code: "MO", name: "Macao" },
{ code: "MG", name: "Madagascar" },
{ code: "MW", name: "Malawi" },
{ code: "MY", name: "Malaysia" },
{ code: "MV", name: "Maldives" },
{ code: "ML", name: "Mali" },
{ code: "MT", name: "Malta" },
{ code: "MH", name: "Marshall Islands" },
{ code: "MR", name: "Mauritania" },
{ code: "MU", name: "Mauritius" },
{ code: "MX", name: "Mexico" },
{ code: "FM", name: "Micronesia" },
{ code: "MD", name: "Moldova" },
{ code: "MC", name: "Monaco" },
{ code: "MN", name: "Mongolia" },
{ code: "ME", name: "Montenegro" },
{ code: "MA", name: "Morocco" },
{ code: "MZ", name: "Mozambique" },
{ code: "MM", name: "Myanmar" },
{ code: "NA", name: "Namibia" },
{ code: "NP", name: "Nepal" },
{ code: "NL", name: "Netherlands" },
{ code: "NZ", name: "New Zealand" },
{ code: "NI", name: "Nicaragua" },
{ code: "NG", name: "Nigeria" },
{ code: "NO", name: "Norway" },
{ code: "OM", name: "Oman" },
{ code: "PK", name: "Pakistan" },
{ code: "PA", name: "Panama" },
{ code: "PG", name: "Papua New Guinea" },
{ code: "PY", name: "Paraguay" },
{ code: "PE", name: "Peru" },
{ code: "PH", name: "Philippines" },
{ code: "PL", name: "Poland" },
{ code: "PT", name: "Portugal" },
{ code: "QA", name: "Qatar" },
{ code: "RO", name: "Romania" },
{ code: "RU", name: "Russia" },
{ code: "RW", name: "Rwanda" },
{ code: "WS", name: "Samoa" },
{ code: "SM", name: "San Marino" },
{ code: "SA", name: "Saudi Arabia" },
{ code: "SN", name: "Senegal" },
{ code: "RS", name: "Serbia" },
{ code: "SG", name: "Singapore" },
{ code: "SK", name: "Slovakia" },
{ code: "SI", name: "Slovenia" },
{ code: "ZA", name: "South Africa" },
{ code: "ES", name: "Spain" },
{ code: "LK", name: "Sri Lanka" },
{ code: "SE", name: "Sweden" },
{ code: "CH", name: "Switzerland" },
{ code: "SY", name: "Syria" },
{ code: "TW", name: "Taiwan" },
{ code: "TJ", name: "Tajikistan" },
{ code: "TZ", name: "Tanzania" },
{ code: "TH", name: "Thailand" },
{ code: "TR", name: "Turkey" },
{ code: "UG", name: "Uganda" },
{ code: "UA", name: "Ukraine" },
{ code: "AE", name: "United Arab Emirates" },
{ code: "GB", name: "United Kingdom" },
{ code: "US", name: "United States" },
{ code: "UY", name: "Uruguay" },
{ code: "UZ", name: "Uzbekistan" },
{ code: "VN", name: "Vietnam" },
{ code: "ZM", name: "Zambia" },
{ code: "ZW", name: "Zimbabwe" },
]
export function Pattern() {
// Example: All Possible Filter Field Types with Grouping
const fields: FilterFieldConfig[] = [
{
group: "Basic",
fields: [
{
key: "text",
label: "Text",
type: "text",
icon: (
<MailIcon />
),
placeholder: "Search text...",
},
{
key: "email",
label: "Email",
type: "text",
icon: (
<TypeIcon />
),
placeholder: "user@example.com",
},
{
key: "website",
label: "Website",
icon: (
<GlobeIcon />
),
type: "text",
placeholder: "https://example.com",
},
{
key: "phone",
label: "Phone",
icon: (
<PhoneIcon />
),
type: "text",
placeholder: "+1 (123) 456-7890",
},
],
},
{
group: "Select",
fields: [
{
key: "status",
label: "Status",
icon: (
<BellIcon />
),
type: "select",
searchable: false,
className: "w-[200px]",
options: [
{
value: "todo",
label: "To Do",
icon: (
<ClockIcon className="stroke-violet-500" />
),
},
{
value: "in-progress",
label: "In Progress",
icon: (
<CircleAlertIcon className="stroke-yellow-500" />
),
},
{
value: "done",
label: "Done",
icon: (
<CircleCheckIcon className="stroke-green-500" />
),
},
{
value: "cancelled",
label: "Cancelled",
icon: (
<BanIcon className="stroke-destructive" />
),
},
],
},
{
key: "priority",
label: "Priority",
icon: (
<BanIcon />
),
type: "multiselect",
className: "w-[180px]",
options: [
{
value: "low",
label: "Low",
icon: <PriorityIcon priority="low" />,
},
{
value: "medium",
label: "Medium",
icon: <PriorityIcon priority="medium" />,
},
{
value: "high",
label: "High",
icon: <PriorityIcon priority="high" />,
},
{
value: "urgent",
label: "Urgent",
icon: <PriorityIcon priority="urgent" />,
},
{
value: "critical",
label: "Critical",
icon: <PriorityIcon priority="critical" />,
},
],
},
{
key: "assignee",
label: "Assignee",
icon: (
<UserRoundCheckIcon />
),
type: "multiselect",
maxSelections: 5,
options: [
{
value: "john",
label: "John Doe",
icon: (
<Avatar className="size-5 border">
<AvatarImage
src="https://randomuser.me/api/portraits/men/1.jpg"
alt="John Doe"
/>
<AvatarFallback>JD</AvatarFallback>
</Avatar>
),
},
{
value: "jane",
label: "Jane Smith",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/2.jpg"
alt="Jane Smith"
/>
<AvatarFallback>JS</AvatarFallback>
</Avatar>
),
},
{
value: "bob",
label: "Bob Johnson",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/3.jpg"
alt="Bob Johnson"
/>
<AvatarFallback>BJ</AvatarFallback>
</Avatar>
),
},
{
value: "alice",
label: "Alice Brown",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/4.jpg"
alt="Alice Brown"
/>
<AvatarFallback>AB</AvatarFallback>
</Avatar>
),
},
{
value: "nick",
label: "Nick Bold",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/4.jpg"
alt="Nick Bold"
/>
<AvatarFallback>NB</AvatarFallback>
</Avatar>
),
},
{
value: "sarah",
label: "Sarah Wilson",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/5.jpg"
alt="Sarah Wilson"
/>
<AvatarFallback>SW</AvatarFallback>
</Avatar>
),
},
{
value: "michael",
label: "Michael Scott",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/6.jpg"
alt="Michael Scott"
/>
<AvatarFallback>MS</AvatarFallback>
</Avatar>
),
},
{
value: "emily",
label: "Emily Blunt",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/7.jpg"
alt="Emily Blunt"
/>
<AvatarFallback>EB</AvatarFallback>
</Avatar>
),
},
{
value: "david",
label: "David Gandy",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/8.jpg"
alt="David Gandy"
/>
<AvatarFallback>DG</AvatarFallback>
</Avatar>
),
},
{
value: "laura",
label: "Laura Palmer",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/9.jpg"
alt="Laura Palmer"
/>
<AvatarFallback>LP</AvatarFallback>
</Avatar>
),
},
{
value: "kevin",
label: "Kevin Hart",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/10.jpg"
alt="Kevin Hart"
/>
<AvatarFallback>KH</AvatarFallback>
</Avatar>
),
},
{
value: "anna",
label: "Anna Kendrick",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/11.jpg"
alt="Anna Kendrick"
/>
<AvatarFallback>AK</AvatarFallback>
</Avatar>
),
},
{
value: "tom",
label: "Tom Cruise",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/12.jpg"
alt="Tom Cruise"
/>
<AvatarFallback>TC</AvatarFallback>
</Avatar>
),
},
{
value: "lisa",
label: "Lisa Kudrow",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/women/13.jpg"
alt="Lisa Kudrow"
/>
<AvatarFallback>LK</AvatarFallback>
</Avatar>
),
},
{
value: "james",
label: "James Bond",
icon: (
<Avatar className="size-5">
<AvatarImage
src="https://randomuser.me/api/portraits/men/14.jpg"
alt="James Bond"
/>
<AvatarFallback>JB</AvatarFallback>
</Avatar>
),
},
{
value: "unassigned",
label: "Unassigned",
icon: (
<Avatar className="size-5">
<AvatarFallback>
<UserRoundXIcon />
</AvatarFallback>
</Avatar>
),
},
],
},
{
key: "userType",
label: "User Type",
icon: (
<UsersIcon />
),
type: "select",
searchable: false,
className: "w-[200px]",
options: [
{
value: "premium",
label: "Premium",
icon: (
<StarIcon className="size-3 text-yellow-500" />
),
},
{
value: "standard",
label: "Standard",
icon: (
<BuildingIcon className="size-3 text-blue-500" />
),
},
{
value: "trial",
label: "Trial",
icon: (
<ClockIcon className="size-3 text-gray-500" />
),
},
],
},
{
key: "country",
label: "Country",
icon: (
<GlobeIcon />
),
type: "select",
searchable: true,
className: "w-[220px]",
options: countryFlags.map((country) => ({
value: country.code,
label: country.name,
icon: (
<img
src={`https://flagcdn.com/${country.code.toLowerCase()}.svg`}
alt={country.code}
className="size-4 rounded-full object-cover"
/>
),
})),
},
],
},
]
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low", "medium", "critical"]),
])
const handleFiltersChange = useCallback((filters: Filter[]) => {
setFilters(filters)
}, [])
return (
<div className="flex grow content-start items-start gap-2.5 self-start">
<div className="grow space-y-5">
{/* Filters Section */}
<div className="flex items-start gap-2.5">
<div className="flex-1">
<Filters
filters={filters}
fields={fields}
onChange={handleFiltersChange}
shortcutKey="f"
shortcutLabel="F"
enableShortcut={true}
trigger={
<Button variant="outline">
<ListFilterIcon />
Add Filter
</Button>
}
/>
</div>
{filters.length > 0 && (
<Button variant="outline" onClick={() => setFilters([])}>
<FunnelXIcon />
Clear
</Button>
)}
</div>
{/* Debug Block */}
<pre className="bg-muted dark:bg-muted/60 mt-2 max-h-[400px] w-full max-w-[500px] overflow-auto overflow-x-auto rounded-md border p-3 text-xs">
{JSON.stringify(filters, null, 2)}
</pre>
</div>
</div>
)
}