filters

PreviousNext
Docs
reuiui

Preview

Loading preview…
registry/default/ui/filters.tsx
'use client';

import type React from 'react';
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from '@/registry/default/ui/command';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/registry/default/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/default/ui/popover';
import { Switch } from '@/registry/default/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/registry/default/ui/tooltip';
import { cva, type VariantProps } from 'class-variance-authority';
import { AlertCircle, Check, Plus, X } from 'lucide-react';
import { cn } from '@/lib/utils';

// i18n Configuration Interface
export interface FilterI18nConfig {
  // UI Labels
  addFilter: string;
  searchFields: string;
  noFieldsFound: string;
  noResultsFound: string;
  select: string;
  true: string;
  false: string;
  min: string;
  max: string;
  to: string;
  typeAndPressEnter: string;
  selected: string;
  selectedCount: string;
  percent: string;
  defaultCurrency: string;
  defaultColor: string;
  addFilterTitle: string;

  // Operators
  operators: {
    is: string;
    isNot: string;
    isAnyOf: string;
    isNotAnyOf: string;
    includesAll: string;
    excludesAll: string;
    before: string;
    after: string;
    between: string;
    notBetween: string;
    contains: string;
    notContains: string;
    startsWith: string;
    endsWith: string;
    isExactly: string;
    equals: string;
    notEquals: string;
    greaterThan: string;
    lessThan: string;
    overlaps: string;
    includes: string;
    excludes: string;
    includesAllOf: string;
    includesAnyOf: string;
    empty: string;
    notEmpty: string;
  };

  // Placeholders
  placeholders: {
    enterField: (fieldType: string) => string;
    selectField: string;
    searchField: (fieldName: string) => string;
    enterKey: string;
    enterValue: string;
  };

  // Helper functions
  helpers: {
    formatOperator: (operator: string) => string;
  };

  // Validation
  validation: {
    invalidEmail: string;
    invalidUrl: string;
    invalidTel: string;
    invalid: string;
  };
}

// Default English i18n configuration
export const DEFAULT_I18N: FilterI18nConfig = {
  // 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',
  },
};

// Context for all Filter component props
interface FilterContextValue {
  variant: 'solid' | 'outline';
  size: 'sm' | 'md' | 'lg';
  radius: 'md' | 'full';
  i18n: FilterI18nConfig;
  cursorPointer: boolean;
  className?: string;
  showAddButton?: boolean;
  addButtonText?: string;
  addButtonIcon?: React.ReactNode;
  addButtonClassName?: string;
  addButton?: React.ReactNode;
  showSearchInput?: boolean;
  trigger?: React.ReactNode;
  allowMultiple?: boolean;
}

const FilterContext = createContext<FilterContextValue>({
  variant: 'outline',
  size: 'md',
  radius: 'md',
  i18n: DEFAULT_I18N,
  cursorPointer: true,
  className: undefined,
  showAddButton: true,
  addButtonText: undefined,
  addButtonIcon: undefined,
  addButtonClassName: undefined,
  addButton: undefined,
  showSearchInput: true,
  trigger: undefined,
  allowMultiple: true,
});

const useFilterContext = () => useContext(FilterContext);

// Reusable input variant component for consistent styling
const filterInputVariants = cva(
  [
    'transition shrink-0 outline-none text-foreground relative flex items-center',
    'has-[[data-slot=filters-input]:focus-visible]:ring-ring/30',
    'has-[[data-slot=filters-input]:focus-visible]:border-ring',
    'has-[[data-slot=filters-input]:focus-visible]:outline-none',
    'has-[[data-slot=filters-input]:focus-visible]:ring-[3px]',
    'has-[[data-slot=filters-input]:focus-visible]:z-1',
    'has-[[data-slot=filters-input]:[aria-invalid=true]]:border',
    'has-[[data-slot=filters-input]:[aria-invalid=true]]:border-solid',
    'has-[[data-slot=filters-input]:[aria-invalid=true]]:border-destructive/60',
    'has-[[data-slot=filters-input]:[aria-invalid=true]]:ring-destructive/10',
    'dark:has-[[data-slot=filters-input]:[aria-invalid=true]]:border-destructive',
    'dark:has-[[data-slot=filters-input]:[aria-invalid=true]]:ring-destructive/20',
  ],
  {
    variants: {
      variant: {
        solid: 'border-0 bg-secondary',
        outline: 'bg-background border border-border',
      },
      size: {
        lg: 'h-10 text-sm px-2.5 has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0',
        md: 'h-9 text-sm px-2 has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0',
        sm: 'h-8 text-xs px-1.5 has-[[data-slot=filters-prefix]]:ps-0 has-[[data-slot=filters-suffix]]:pe-0',
      },
      cursorPointer: {
        true: 'cursor-pointer',
        false: '',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
      cursorPointer: true,
    },
  },
);

// Reusable remove button variant component
const filterRemoveButtonVariants = cva(
  ['inline-flex items-center shrink-0 justify-center transition shrink-0 text-muted-foreground hover:text-foreground'],
  {
    variants: {
      variant: {
        solid: 'bg-secondary',
        outline: 'border border-border border-s-0 hover:bg-secondary',
      },
      size: {
        lg: 'h-10 w-10 [&_svg:not([class*=size-])]:size-4',
        md: 'h-9 w-9 [&_svg:not([class*=size-])]:size-3.5',
        sm: 'h-8 w-8 [&_svg:not([class*=size-])]:size-3',
      },
      cursorPointer: {
        true: 'cursor-pointer',
        false: '',
      },
      radius: {
        md: 'rounded-e-md',
        full: 'rounded-e-full',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
      radius: 'md',
      cursorPointer: true,
    },
  },
);

const filterAddButtonVariants = cva(
  [
    'inline-flex items-center shrink-0 justify-center transition shrink-0 text-foreground shadow-xs shadow-black/5',
    '[&_svg:not([role=img]):not([class*=text-]):not([class*=opacity-])]:opacity-60',
  ],
  {
    variants: {
      variant: {
        solid: 'border border-input hover:bg-secondary/60',
        outline: 'border border-border hover:bg-secondary',
      },
      size: {
        lg: 'h-10 px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
        md: 'h-9 px-3 gap-1.5 text-sm [&_svg:not([class*=size-])]:size-4',
        sm: 'h-8 px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
      },
      radius: {
        md: 'rounded-md',
        full: 'rounded-full',
      },
      cursorPointer: {
        true: 'cursor-pointer',
        false: '',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
      cursorPointer: true,
    },
  },
);

const filterOperatorVariants = cva(
  [
    'transition text-muted-foreground hover:text-foreground data-[state=open]:text-foreground shrink-0 flex items-center relative focus-visible:z-1',
  ],
  {
    variants: {
      variant: {
        solid: 'bg-secondary',
        outline:
          'bg-background border border-border border-e-0 hover:bg-secondary data-[state=open]:bg-secondary [&+[data-slot=filters-remove]]:border-s',
      },
      size: {
        lg: 'h-10 px-4 text-sm gap-1.5',
        md: 'h-9 px-3 text-sm gap-1.25',
        sm: 'h-8 px-2.5 text-xs gap-1',
      },
      cursorPointer: {
        true: 'cursor-pointer',
        false: '',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
      cursorPointer: true,
    },
  },
);

const filterFieldLabelVariants = cva(
  [
    'flex gap-1.5 shrink-0 px-1.5 py-1 items-center text-foreground',
    '[&_svg:not([class*=size-])]:size-3.5 [&_svg:not([class*=opacity-])]:opacity-60',
  ],
  {
    variants: {
      variant: {
        solid: 'bg-secondary',
        outline: 'border border-border border-e-0',
      },
      size: {
        lg: 'h-10 px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
        md: 'h-9 px-3 gap-1.5 text-sm [&_svg:not([class*=size-])]:size-4',
        sm: 'h-8 px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
      },
      radius: {
        md: 'rounded-s-md',
        full: 'rounded-s-full',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
    },
  },
);

const filterFieldValueVariants = cva(
  'text-foreground transition shrink-0 flex items-center gap-1 relative focus-visible:z-1',
  {
    variants: {
      variant: {
        solid: 'bg-secondary',
        outline: 'bg-background border border-border hover:bg-secondary has-[[data-slot=switch]]:hover:bg-transparent',
      },
      size: {
        lg: 'h-10 px-4 text-sm gap-1.5 [&_svg:not([class*=size-])]:size-4',
        md: 'h-9 px-3 gap-1.5 text-sm [&_svg:not([class*=size-])]:size-4',
        sm: 'h-8 px-2.5 gap-1.25 text-xs [&_svg:not([class*=size-])]:size-3.5',
      },
      cursorPointer: {
        true: 'cursor-pointer has-[[data-slot=switch]]:cursor-default',
        false: '',
      },
    },
    defaultVariants: {
      variant: 'outline',
      size: 'md',
      cursorPointer: true,
    },
  },
);

const filterFieldAddonVariants = cva('text-foreground shrink-0 flex items-center justify-center', {
  variants: {
    variant: {
      solid: '',
      outline: '',
    },
    size: {
      lg: 'h-10 px-4 text-sm',
      md: 'h-9 px-3 text-sm',
      sm: 'h-8 px-2.5 text-xs',
    },
  },
  defaultVariants: {
    variant: 'outline',
    size: 'md',
  },
});

const filterFieldBetweenVariants = cva('text-muted-foreground shrink-0 flex items-center', {
  variants: {
    variant: {
      solid: 'bg-secondary',
      outline: 'bg-background border border-border border-x-0',
    },
    size: {
      lg: 'h-10 px-4 text-sm',
      md: 'h-9 px-3 text-sm',
      sm: 'h-8 px-2.5 text-xs',
    },
  },
  defaultVariants: {
    variant: 'outline',
    size: 'md',
  },
});

const filtersContainerVariants = cva('flex flex-wrap items-center', {
  variants: {
    variant: {
      solid: 'gap-2',
      outline: '',
    },
    size: {
      sm: 'gap-1.5',
      md: 'gap-2.5',
      lg: 'gap-3.5',
    },
  },
  defaultVariants: {
    variant: 'outline',
    size: 'md',
  },
});

const filterItemVariants = cva('flex items-center shadow-xs shadow-black/5', {
  variants: {
    variant: {
      solid: 'gap-px',
      outline: '',
    },
  },
  defaultVariants: {
    variant: 'outline',
  },
});

function FilterInput<T = unknown>({
  field,
  onChange,
  onBlur,
  onKeyDown,
  onInputChange,
  className,
  ...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
  className?: string;
  field?: FilterFieldConfig<T>;
  onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
  const context = useFilterContext();
  const [isValid, setIsValid] = useState(true);
  const [validationMessage, setValidationMessage] = useState('');

  // Validation function to check if input matches pattern
  const validateInput = (value: string, pattern?: string): boolean => {
    if (!pattern || !value) return true;
    const regex = new RegExp(pattern);
    return regex.test(value);
  };

  // Get validation message for field type
  const getValidationMessage = (fieldType: string, hasCustomPattern: boolean = false): string => {
    // If it's a text or number field with a custom pattern, use the generic invalid message
    if ((fieldType === 'text' || fieldType === 'number') && hasCustomPattern) {
      return context.i18n.validation.invalid;
    }

    switch (fieldType) {
      case 'email':
        return context.i18n.validation.invalidEmail;
      case 'url':
        return context.i18n.validation.invalidUrl;
      case 'tel':
        return context.i18n.validation.invalidTel;
      default:
        return context.i18n.validation.invalid;
    }
  };

  // Handle input change - allow typing without validation
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // Always allow typing, just call the original onChange
    onChange?.(e);
  };

  // Handle blur event - validate when user leaves input
  const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
    const value = e.target.value;
    const pattern = field?.pattern || props.pattern;

    // Only validate if there's a value and pattern
    if (value && pattern) {
      let valid = true;

      // If there's a custom validation function, use it
      if (field?.validation) {
        valid = field.validation(value);
      } else {
        // Use pattern validation
        valid = validateInput(value, pattern);
      }

      setIsValid(valid);
      const hasCustomPattern = !!(field?.pattern || props.pattern);
      setValidationMessage(valid ? '' : getValidationMessage(field?.type || '', hasCustomPattern));
    } else {
      // Reset validation state for empty values or no pattern
      setIsValid(true);
      setValidationMessage('');
    }

    // Call onInputChange if provided (for blur-based filter updates)
    if (onInputChange) {
      onInputChange(e as React.ChangeEvent<HTMLInputElement>);
    }

    // Call the original onBlur if provided
    onBlur?.(e);
  };

  // Handle keydown event - hide validation error when user starts typing
  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    // Hide validation error when user starts typing (any key except special keys)
    if (!isValid && !['Tab', 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
      setIsValid(true);
      setValidationMessage('');
    }

    // Handle Enter key for immediate filter updates
    if (e.key === 'Enter' && onInputChange) {
      // Create a synthetic change event for Enter key
      const syntheticEvent = {
        ...e,
        target: e.target as HTMLInputElement,
        currentTarget: e.currentTarget as HTMLInputElement,
      } as React.ChangeEvent<HTMLInputElement>;
      onInputChange(syntheticEvent);
    }

    // Call the original onKeyDown if provided
    onKeyDown?.(e);
  };

  return (
    <div
      className={cn('w-36', filterInputVariants({ variant: context.variant, size: context.size }), className)}
      data-slot="filters-input-wrapper"
    >
      {field?.prefix && (
        <div
          data-slot="filters-prefix"
          className={filterFieldAddonVariants({ variant: context.variant, size: context.size })}
        >
          {field.prefix}
        </div>
      )}

      <div className="w-full flex items-stretch">
        <input
          className="w-full outline-none"
          aria-invalid={!isValid}
          aria-describedby={!isValid && validationMessage ? `${field?.key || 'input'}-error` : undefined}
          onChange={handleChange}
          onBlur={handleBlur}
          onKeyDown={handleKeyDown}
          data-slot="filters-input"
          {...props}
        />
        {!isValid && validationMessage && (
          <Tooltip>
            <TooltipTrigger asChild>
              <div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center">
                <AlertCircle className="size-3.5 text-destructive" />
              </div>
            </TooltipTrigger>
            <TooltipContent>
              <p className="text-sm">{validationMessage}</p>
            </TooltipContent>
          </Tooltip>
        )}
      </div>

      {field?.suffix && (
        <div
          data-slot="filters-suffix"
          className={cn(filterFieldAddonVariants({ variant: context.variant, size: context.size }))}
        >
          {field.suffix}
        </div>
      )}
    </div>
  );
}

interface FilterRemoveButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof filterRemoveButtonVariants> {
  icon?: React.ReactNode;
}

function FilterRemoveButton({ className, icon = <X />, ...props }: FilterRemoveButtonProps) {
  const context = useFilterContext();

  return (
    <button
      data-slot="filters-remove"
      className={cn(
        filterRemoveButtonVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
          radius: context.radius,
        }),
        className,
      )}
      {...props}
    >
      {icon}
    </button>
  );
}

// Generic types for flexible filter system
export interface FilterOption<T = unknown> {
  value: T;
  label: string;
  icon?: React.ReactNode;
  metadata?: Record<string, unknown>;
}

export interface FilterOperator {
  value: string;
  label: string;
  supportsMultiple?: boolean;
}

// Custom renderer props interface
export interface CustomRendererProps<T = unknown> {
  field: FilterFieldConfig<T>;
  values: T[];
  onChange: (values: T[]) => void;
  operator: string;
}

// Grouped field configuration interface
export interface FilterFieldGroup<T = unknown> {
  group?: string;
  fields: FilterFieldConfig<T>[];
}

// Union type for both flat and grouped field configurations
export type FilterFieldsConfig<T = unknown> = FilterFieldConfig<T>[] | FilterFieldGroup<T>[];

export interface FilterFieldConfig<T = unknown> {
  key?: string;
  label?: string;
  icon?: React.ReactNode;
  type?:
    | 'select'
    | 'multiselect'
    | 'date'
    | 'daterange'
    | 'text'
    | 'number'
    | 'numberrange'
    | 'boolean'
    | 'email'
    | 'url'
    | 'tel'
    | 'time'
    | 'datetime'
    | 'custom'
    | 'separator';
  // Group-level configuration
  group?: string;
  fields?: FilterFieldConfig<T>[];
  // Field-specific options
  options?: FilterOption<T>[];
  operators?: FilterOperator[];
  customRenderer?: (props: CustomRendererProps<T>) => React.ReactNode;
  customValueRenderer?: (values: T[], options: FilterOption<T>[]) => React.ReactNode;
  placeholder?: string;
  searchable?: boolean;
  maxSelections?: number;
  min?: number;
  max?: number;
  step?: number;
  prefix?: string | React.ReactNode;
  suffix?: string | React.ReactNode;
  pattern?: string;
  validation?: (value: unknown) => boolean;
  allowCustomValues?: boolean;
  className?: string;
  popoverContentClassName?: string;
  selectedOptionsClassName?: string;
  // Grouping options (legacy support)
  groupLabel?: string;
  // Boolean field options
  onLabel?: string;
  offLabel?: string;
  // Input event handlers
  onInputChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
  // Default operator to use when creating a filter for this field
  defaultOperator?: string;
  // Controlled values support for this field
  value?: T[];
  onValueChange?: (values: T[]) => void;
}

// Helper functions to handle both flat and grouped field configurations
const isFieldGroup = <T = unknown,>(item: FilterFieldConfig<T> | FilterFieldGroup<T>): item is FilterFieldGroup<T> => {
  return 'fields' in item && Array.isArray(item.fields);
};

// Helper function to check if a FilterFieldConfig is a group-level configuration
const isGroupLevelField = <T = unknown,>(field: FilterFieldConfig<T>): boolean => {
  return Boolean(field.group && field.fields);
};

const flattenFields = <T = unknown,>(fields: FilterFieldsConfig<T>): FilterFieldConfig<T>[] => {
  return fields.reduce<FilterFieldConfig<T>[]>((acc, item) => {
    if (isFieldGroup(item)) {
      return [...acc, ...item.fields];
    }
    // Handle group-level fields (new structure)
    if (isGroupLevelField(item)) {
      return [...acc, ...item.fields!];
    }
    return [...acc, item];
  }, []);
};

const getFieldsMap = <T = unknown,>(fields: FilterFieldsConfig<T>): Record<string, FilterFieldConfig<T>> => {
  const flatFields = flattenFields(fields);
  return flatFields.reduce(
    (acc, field) => {
      // Only add fields that have a key (skip group-level configurations)
      if (field.key) {
        acc[field.key] = field;
      }
      return acc;
    },
    {} as Record<string, FilterFieldConfig<T>>,
  );
};

// Helper function to create operators from i18n config
const createOperatorsFromI18n = (i18n: FilterI18nConfig): Record<string, FilterOperator[]> => ({
  select: [
    { value: 'is', label: i18n.operators.is },
    { value: 'is_not', label: i18n.operators.isNot },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  multiselect: [
    { value: 'is_any_of', label: i18n.operators.isAnyOf },
    { value: 'is_not_any_of', label: i18n.operators.isNotAnyOf },
    { value: 'includes_all', label: i18n.operators.includesAll },
    { value: 'excludes_all', label: i18n.operators.excludesAll },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  date: [
    { value: 'before', label: i18n.operators.before },
    { value: 'after', label: i18n.operators.after },
    { value: 'is', label: i18n.operators.is },
    { value: 'is_not', label: i18n.operators.isNot },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  daterange: [
    { value: 'between', label: i18n.operators.between },
    { value: 'not_between', label: i18n.operators.notBetween },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  text: [
    { value: 'contains', label: i18n.operators.contains },
    { value: 'not_contains', label: i18n.operators.notContains },
    { value: 'starts_with', label: i18n.operators.startsWith },
    { value: 'ends_with', label: i18n.operators.endsWith },
    { value: 'is', label: i18n.operators.isExactly },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  number: [
    { value: 'equals', label: i18n.operators.equals },
    { value: 'not_equals', label: i18n.operators.notEquals },
    { value: 'greater_than', label: i18n.operators.greaterThan },
    { value: 'less_than', label: i18n.operators.lessThan },
    { value: 'between', label: i18n.operators.between },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  numberrange: [
    { value: 'between', label: i18n.operators.between },
    { value: 'overlaps', label: i18n.operators.overlaps },
    { value: 'contains', label: i18n.operators.contains },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  boolean: [
    { value: 'is', label: i18n.operators.is },
    { value: 'is_not', label: i18n.operators.isNot },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  email: [
    { value: 'contains', label: i18n.operators.contains },
    { value: 'not_contains', label: i18n.operators.notContains },
    { value: 'starts_with', label: i18n.operators.startsWith },
    { value: 'ends_with', label: i18n.operators.endsWith },
    { value: 'is', label: i18n.operators.isExactly },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  url: [
    { value: 'contains', label: i18n.operators.contains },
    { value: 'not_contains', label: i18n.operators.notContains },
    { value: 'starts_with', label: i18n.operators.startsWith },
    { value: 'ends_with', label: i18n.operators.endsWith },
    { value: 'is', label: i18n.operators.isExactly },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  tel: [
    { value: 'contains', label: i18n.operators.contains },
    { value: 'not_contains', label: i18n.operators.notContains },
    { value: 'starts_with', label: i18n.operators.startsWith },
    { value: 'ends_with', label: i18n.operators.endsWith },
    { value: 'is', label: i18n.operators.isExactly },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  time: [
    { value: 'before', label: i18n.operators.before },
    { value: 'after', label: i18n.operators.after },
    { value: 'is', label: i18n.operators.is },
    { value: 'between', label: i18n.operators.between },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
  datetime: [
    { value: 'before', label: i18n.operators.before },
    { value: 'after', label: i18n.operators.after },
    { value: 'is', label: i18n.operators.is },
    { value: 'between', label: i18n.operators.between },
    { value: 'empty', label: i18n.operators.empty },
    { value: 'not_empty', label: i18n.operators.notEmpty },
  ],
});

// Default operators for different field types (using default i18n)
export const DEFAULT_OPERATORS: Record<string, FilterOperator[]> = createOperatorsFromI18n(DEFAULT_I18N);

// Helper function to get operators for a field
const getOperatorsForField = <T = unknown,>(
  field: FilterFieldConfig<T>,
  values: T[],
  i18n: FilterI18nConfig,
): FilterOperator[] => {
  if (field.operators) return field.operators;

  const operators = createOperatorsFromI18n(i18n);

  // Determine field type for operator selection
  let fieldType = field.type || 'select';

  // If it's a select field but has multiple values, treat as multiselect
  if (fieldType === 'select' && values.length > 1) {
    fieldType = 'multiselect';
  }

  // If it's a multiselect field or has multiselect operators, use multiselect operators
  if (fieldType === 'multiselect' || field.type === 'multiselect') {
    return operators.multiselect;
  }

  return operators[fieldType] || operators.select;
};

interface FilterOperatorDropdownProps<T = unknown> {
  field: FilterFieldConfig<T>;
  operator: string;
  values: T[];
  onChange: (operator: string) => void;
}

function FilterOperatorDropdown<T = unknown>({ field, operator, values, onChange }: FilterOperatorDropdownProps<T>) {
  const context = useFilterContext();
  const operators = getOperatorsForField(field, values, context.i18n);

  // Find the operator label, with fallback to formatted operator name
  const operatorLabel =
    operators.find((op) => op.value === operator)?.label || context.i18n.helpers.formatOperator(operator);

  // Debug logging to help identify the issue
  if (!operators.find((op) => op.value === operator)) {
    console.warn(
      `Operator "${operator}" not found in operators for field "${field.key}" (type: ${field.type}). Available operators:`,
      operators.map((op) => op.value),
    );
  }

  return (
    <DropdownMenu>
      <DropdownMenuTrigger className={filterOperatorVariants({ variant: context.variant, size: context.size })}>
        {operatorLabel}
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-fit min-w-fit">
        {operators.map((op) => (
          <DropdownMenuItem
            key={op.value}
            onClick={() => onChange(op.value)}
            className="flex items-center justify-between"
          >
            <span>{op.label}</span>
            <Check className={`text-primary ms-auto ${op.value === operator ? 'opacity-100' : 'opacity-0'}`} />
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

interface FilterValueSelectorProps<T = unknown> {
  field: FilterFieldConfig<T>;
  values: T[];
  onChange: (values: T[]) => void;
  operator: string;
}

interface SelectOptionsPopoverProps<T = unknown> {
  field: FilterFieldConfig<T>;
  values: T[];
  onChange: (values: T[]) => void;
  onClose?: () => void;
  showBackButton?: boolean;
  onBack?: () => void;
  inline?: boolean;
}

function SelectOptionsPopover<T = unknown>({
  field,
  values,
  onChange,
  onClose,
  inline = false,
}: SelectOptionsPopoverProps<T>) {
  const [open, setOpen] = useState(false);
  const [searchInput, setSearchInput] = useState('');
  const context = useFilterContext();

  const isMultiSelect = field.type === 'multiselect' || values.length > 1;
  const effectiveValues = (field.value !== undefined ? (field.value as T[]) : values) || [];
  const selectedOptions = field.options?.filter((opt) => effectiveValues.includes(opt.value)) || [];
  const unselectedOptions = field.options?.filter((opt) => !effectiveValues.includes(opt.value)) || [];

  const handleClose = () => {
    setOpen(false);
    onClose?.();
  };

  // If inline mode, render the content directly without popover
  if (inline) {
    return (
      <div className="w-full">
        <Command>
          {field.searchable !== false && (
            <CommandInput
              placeholder={context.i18n.placeholders.searchField(field.label || '')}
              className="h-8.5 text-sm"
              value={searchInput}
              onValueChange={setSearchInput}
            />
          )}
          <CommandList>
            <CommandEmpty>{context.i18n.noResultsFound}</CommandEmpty>

            {/* Selected items */}
            {selectedOptions.length > 0 && (
              <CommandGroup heading={field.label || 'Selected'}>
                {selectedOptions.map((option) => (
                  <CommandItem
                    key={String(option.value)}
                    className="group flex gap-2 items-center"
                    onSelect={() => {
                      if (isMultiSelect) {
                        const next = effectiveValues.filter((v) => v !== option.value) as T[];
                        if (field.onValueChange) {
                          field.onValueChange(next);
                        } else {
                          onChange(next);
                        }
                      } else {
                        if (field.onValueChange) {
                          field.onValueChange([] as T[]);
                        } else {
                          onChange([] as T[]);
                        }
                      }
                    }}
                  >
                    {option.icon && option.icon}
                    <span className="text-accent-foreground truncate">{option.label}</span>
                    <Check className="text-primary ms-auto" />
                  </CommandItem>
                ))}
              </CommandGroup>
            )}

            {/* Available items */}
            {unselectedOptions.length > 0 && (
              <>
                {selectedOptions.length > 0 && <CommandSeparator />}
                <CommandGroup>
                  {unselectedOptions.map((option) => (
                    <CommandItem
                      key={String(option.value)}
                      className="group flex gap-2 items-center"
                      value={option.label}
                      onSelect={() => {
                        if (isMultiSelect) {
                          const newValues = [...effectiveValues, option.value] as T[];
                          if (field.maxSelections && newValues.length > field.maxSelections) {
                            return; // Don't exceed max selections
                          }
                          if (field.onValueChange) {
                            field.onValueChange(newValues);
                          } else {
                            onChange(newValues);
                          }
                          // For multiselect, don't close the popover to allow multiple selections
                        } else {
                          if (field.onValueChange) {
                            field.onValueChange([option.value] as T[]);
                          } else {
                            onChange([option.value] as T[]);
                          }
                          onClose?.();
                        }
                      }}
                    >
                      {option.icon && option.icon}
                      <span className="text-accent-foreground truncate">{option.label}</span>
                      <Check className="text-primary ms-auto opacity-0" />
                    </CommandItem>
                  ))}
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </div>
    );
  }

  return (
    <Popover
      open={open}
      onOpenChange={(open) => {
        setOpen(open);
        if (!open) {
          setTimeout(() => setSearchInput(''), 200);
        }
      }}
    >
      <PopoverTrigger
        className={filterFieldValueVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
        })}
      >
        <div className="flex gap-1.5 items-center">
          {field.customValueRenderer ? (
            field.customValueRenderer(values, field.options || [])
          ) : (
            <>
              {selectedOptions.length > 0 && (
                <div className={cn('-space-x-1.5 flex items-center', field.selectedOptionsClassName)}>
                  {selectedOptions.slice(0, 3).map((option) => (
                    <div key={String(option.value)}>{option.icon}</div>
                  ))}
                </div>
              )}
              {selectedOptions.length === 1
                ? selectedOptions[0].label
                : selectedOptions.length > 1
                  ? `${selectedOptions.length} ${context.i18n.selectedCount}`
                  : context.i18n.select}
            </>
          )}
        </div>
      </PopoverTrigger>
      <PopoverContent align="start" className={cn('w-[200px] p-0', field.className)}>
        <Command>
          {field.searchable !== false && (
            <CommandInput
              placeholder={context.i18n.placeholders.searchField(field.label || '')}
              className="h-9 text-sm"
              value={searchInput}
              onValueChange={setSearchInput}
            />
          )}
          <CommandList>
            <CommandEmpty>{context.i18n.noResultsFound}</CommandEmpty>

            {/* Selected items */}
            {selectedOptions.length > 0 && (
              <CommandGroup>
                {selectedOptions.map((option) => (
                  <CommandItem
                    key={String(option.value)}
                    className="group flex gap-2 items-center"
                    onSelect={() => {
                      if (isMultiSelect) {
                        onChange(values.filter((v) => v !== option.value) as T[]);
                      } else {
                        onChange([] as T[]);
                      }
                      if (!isMultiSelect) {
                        setOpen(false);
                        handleClose();
                      }
                    }}
                  >
                    {option.icon && option.icon}
                    <span className="text-accent-foreground truncate">{option.label}</span>
                    <Check className="text-primary ms-auto" />
                  </CommandItem>
                ))}
              </CommandGroup>
            )}

            {/* Available items */}
            {unselectedOptions.length > 0 && (
              <>
                {selectedOptions.length > 0 && <CommandSeparator />}
                <CommandGroup>
                  {unselectedOptions.map((option) => (
                    <CommandItem
                      key={String(option.value)}
                      className="group flex gap-2 items-center"
                      value={option.label}
                      onSelect={() => {
                        if (isMultiSelect) {
                          const newValues = [...values, option.value] as T[];
                          if (field.maxSelections && newValues.length > field.maxSelections) {
                            return; // Don't exceed max selections
                          }
                          onChange(newValues);
                        } else {
                          onChange([option.value] as T[]);
                          setOpen(false);
                          handleClose();
                        }
                      }}
                    >
                      {option.icon && option.icon}
                      <span className="text-accent-foreground truncate">{option.label}</span>
                      <Check className="text-primary ms-auto opacity-0" />
                    </CommandItem>
                  ))}
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

function FilterValueSelector<T = unknown>({ field, values, onChange, operator }: FilterValueSelectorProps<T>) {
  const [open, setOpen] = useState(false);
  const [searchInput, setSearchInput] = useState('');
  const context = useFilterContext();

  // Hide value input for empty/not empty operators
  if (operator === 'empty' || operator === 'not_empty') {
    return null;
  }

  // Use custom renderer if provided
  if (field.customRenderer) {
    return (
      <div
        className={filterFieldValueVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
        })}
      >
        {field.customRenderer({ field, values, onChange, operator })}
      </div>
    );
  }

  if (field.type === 'boolean') {
    const isChecked = values[0] === true;

    // Use custom labels if provided, otherwise fall back to i18n defaults
    const onLabel = field.onLabel || context.i18n.true;
    const offLabel = field.offLabel || context.i18n.false;

    return (
      <div
        className={filterFieldValueVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
        })}
      >
        <div className="flex items-center gap-2">
          <Switch checked={isChecked} onCheckedChange={(checked) => onChange([checked as T])} size="sm" />
          {field.onLabel && field.offLabel && (
            <span className="text-xs text-muted-foreground">{isChecked ? onLabel : offLabel}</span>
          )}
        </div>
      </div>
    );
  }

  if (field.type === 'time') {
    if (operator === 'between') {
      const startTime = (values[0] as string) || '';
      const endTime = (values[1] as string) || '';

      return (
        <div className="flex items-center" data-slot="filters-item">
          <FilterInput
            type="time"
            value={startTime}
            onChange={(e) => onChange([e.target.value, endTime] as T[])}
            onInputChange={field.onInputChange}
            className={field.className}
            field={field}
          />
          <div
            data-slot="filters-between"
            className={filterFieldBetweenVariants({ variant: context.variant, size: context.size })}
          >
            {context.i18n.to}
          </div>
          <FilterInput
            type="time"
            value={endTime}
            onChange={(e) => onChange([startTime, e.target.value] as T[])}
            onInputChange={field.onInputChange}
            className={field.className}
            field={field}
          />
        </div>
      );
    }

    return (
      <FilterInput
        type="time"
        value={(values[0] as string) || ''}
        onChange={(e) => onChange([e.target.value] as T[])}
        onInputChange={field.onInputChange}
        field={field}
        className={field.className}
      />
    );
  }

  if (field.type === 'datetime') {
    if (operator === 'between') {
      const startDateTime = (values[0] as string) || '';
      const endDateTime = (values[1] as string) || '';

      return (
        <div className="flex items-center" data-slot="filters-item">
          <FilterInput
            type="datetime-local"
            value={startDateTime}
            onChange={(e) => onChange([e.target.value, endDateTime] as T[])}
            onInputChange={field.onInputChange}
            className={cn('w-36', field.className)}
            field={field}
          />
          <div
            data-slot="filters-between"
            className={filterFieldBetweenVariants({ variant: context.variant, size: context.size })}
          >
            {context.i18n.to}
          </div>
          <FilterInput
            type="datetime-local"
            value={endDateTime}
            onChange={(e) => onChange([startDateTime, e.target.value] as T[])}
            onInputChange={field.onInputChange}
            className={cn('w-36', field.className)}
            field={field}
          />
        </div>
      );
    }

    return (
      <FilterInput
        type="datetime-local"
        value={(values[0] as string) || ''}
        onChange={(e) => onChange([e.target.value] as T[])}
        onInputChange={field.onInputChange}
        className={cn('w-36', field.className)}
        field={field}
      />
    );
  }

  if (['email', 'url', 'tel'].includes(field.type || '')) {
    const getInputType = () => {
      switch (field.type) {
        case 'email':
          return 'email';
        case 'url':
          return 'url';
        case 'tel':
          return 'tel';
        default:
          return 'text';
      }
    };

    const getPattern = () => {
      switch (field.type) {
        case 'email':
          return '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$';
        case 'url':
          return '^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$';
        case 'tel':
          return '^[\\+]?[1-9][\\d]{0,15}$';
        default:
          return undefined;
      }
    };

    return (
      <FilterInput
        type={getInputType()}
        value={(values[0] as string) || ''}
        onChange={(e) => onChange([e.target.value] as T[])}
        onInputChange={field.onInputChange}
        placeholder={field.placeholder || context.i18n.placeholders.enterField(field.type || 'text')}
        pattern={field.pattern || getPattern()}
        className={field.className}
        field={field}
      />
    );
  }

  if (field.type === 'daterange') {
    const startDate = (values[0] as string) || '';
    const endDate = (values[1] as string) || '';

    return (
      <div
        className={filterFieldValueVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
        })}
      >
        <FilterInput
          type="date"
          value={startDate}
          onChange={(e) => onChange([e.target.value, endDate] as T[])}
          onInputChange={field.onInputChange}
          className={cn('w-24', field.className)}
          field={field}
        />
        <div
          data-slot="filters-between"
          className={filterFieldBetweenVariants({ variant: context.variant, size: context.size })}
        >
          {context.i18n.to}
        </div>
        <FilterInput
          type="date"
          value={endDate}
          onChange={(e) => onChange([startDate, e.target.value] as T[])}
          onInputChange={field.onInputChange}
          className={cn('w-24', field.className)}
          field={field}
        />
      </div>
    );
  }

  // Handle different field types
  if (field.type === 'text' || field.type === 'number') {
    if (field.type === 'number' && operator === 'between') {
      const minVal = (values[0] as string) || '';
      const maxVal = (values[1] as string) || '';

      return (
        <div className="flex items-center" data-slot="filters-item">
          <FilterInput
            type="number"
            value={minVal}
            onChange={(e) => onChange([e.target.value, maxVal] as T[])}
            onInputChange={field.onInputChange}
            placeholder={context.i18n.min}
            className={cn('w-16', field.className)}
            min={field.min}
            max={field.max}
            step={field.step}
            pattern={field.pattern}
            field={field}
          />
          <div
            data-slot="filters-between"
            className={filterFieldBetweenVariants({ variant: context.variant, size: context.size })}
          >
            {context.i18n.to}
          </div>
          <FilterInput
            type="number"
            value={maxVal}
            onChange={(e) => onChange([minVal, e.target.value] as T[])}
            onInputChange={field.onInputChange}
            placeholder={context.i18n.max}
            className={cn('w-16', field.className)}
            min={field.min}
            max={field.max}
            step={field.step}
            pattern={field.pattern}
            field={field}
          />
        </div>
      );
    }

    return (
      <div className="flex items-center" data-slot="filters-item">
        <FilterInput
          type={field.type === 'number' ? 'number' : 'text'}
          value={(values[0] as string) || ''}
          onChange={(e) => onChange([e.target.value] as T[])}
          onInputChange={field.onInputChange}
          placeholder={field.placeholder}
          min={field.type === 'number' ? field.min : undefined}
          max={field.type === 'number' ? field.max : undefined}
          step={field.type === 'number' ? field.step : undefined}
          pattern={field.pattern}
          field={field}
          className={cn('w-36', field.className)}
        />
      </div>
    );
  }

  if (field.type === 'date') {
    return (
      <FilterInput
        type="date"
        value={(values[0] as string) || ''}
        onChange={(e) => onChange([e.target.value] as T[])}
        onInputChange={field.onInputChange}
        field={field}
        className={cn('w-16', field.className)}
      />
    );
  }

  // For select and multiselect types, use the SelectOptionsPopover component
  if (field.type === 'select' || field.type === 'multiselect') {
    return <SelectOptionsPopover field={field} values={values} onChange={onChange} />;
  }

  const isMultiSelect = values.length > 1;
  const selectedOptions = field.options?.filter((opt) => values.includes(opt.value)) || [];
  const unselectedOptions = field.options?.filter((opt) => !values.includes(opt.value)) || [];

  return (
    <Popover
      open={open}
      onOpenChange={(open) => {
        setOpen(open);
        if (!open) {
          setTimeout(() => setSearchInput(''), 200);
        }
      }}
    >
      <PopoverTrigger
        className={filterFieldValueVariants({
          variant: context.variant,
          size: context.size,
          cursorPointer: context.cursorPointer,
        })}
      >
        <div className="flex gap-1.5 items-center">
          {field.customValueRenderer ? (
            field.customValueRenderer(values, field.options || [])
          ) : (
            <>
              {selectedOptions.length > 0 && (
                <div className="flex items-center -space-x-1.5">
                  {selectedOptions.slice(0, 3).map((option) => (
                    <div key={String(option.value)}>{option.icon}</div>
                  ))}
                </div>
              )}
              {selectedOptions.length === 1
                ? selectedOptions[0].label
                : selectedOptions.length > 1
                  ? `${selectedOptions.length} ${context.i18n.selectedCount}`
                  : context.i18n.select}
            </>
          )}
        </div>
      </PopoverTrigger>
      <PopoverContent className={cn('w-36 p-0', field.popoverContentClassName)}>
        <Command>
          {field.searchable !== false && (
            <CommandInput
              placeholder={context.i18n.placeholders.searchField(field.label || '')}
              className="h-9 text-sm"
              value={searchInput}
              onValueChange={setSearchInput}
            />
          )}
          <CommandList>
            <CommandEmpty>{context.i18n.noResultsFound}</CommandEmpty>

            {/* Selected items */}
            {selectedOptions.length > 0 && (
              <CommandGroup>
                {selectedOptions.map((option) => (
                  <CommandItem
                    key={String(option.value)}
                    className="group flex gap-2 items-center"
                    onSelect={() => {
                      if (isMultiSelect) {
                        onChange(values.filter((v) => v !== option.value) as T[]);
                      } else {
                        onChange([] as T[]);
                      }
                      if (!isMultiSelect) setOpen(false);
                    }}
                  >
                    {option.icon && option.icon}
                    <span className="text-accent-foreground truncate">{option.label}</span>
                    <Check className="text-primary ms-auto" />
                  </CommandItem>
                ))}
              </CommandGroup>
            )}

            {/* Available items */}
            {unselectedOptions.length > 0 && (
              <>
                {selectedOptions.length > 0 && <CommandSeparator />}
                <CommandGroup>
                  {unselectedOptions.map((option) => (
                    <CommandItem
                      key={String(option.value)}
                      className="group flex gap-2 items-center"
                      value={option.label}
                      onSelect={() => {
                        if (isMultiSelect) {
                          const newValues = [...values, option.value] as T[];
                          if (field.maxSelections && newValues.length > field.maxSelections) {
                            return; // Don't exceed max selections
                          }
                          onChange(newValues);
                        } else {
                          onChange([option.value] as T[]);
                          setOpen(false);
                        }
                      }}
                    >
                      {option.icon && option.icon}
                      <span className="text-accent-foreground truncate">{option.label}</span>
                      <Check className="text-primary ms-auto opacity-0" />
                    </CommandItem>
                  ))}
                </CommandGroup>
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}

export interface Filter<T = unknown> {
  id: string;
  field: string;
  operator: string;
  values: T[];
}

export interface FilterGroup<T = unknown> {
  id: string;
  label?: string;
  filters: Filter<T>[];
  fields: FilterFieldConfig<T>[];
}

// FiltersContent component for the filter panel content
interface FiltersContentProps<T = unknown> {
  filters: Filter<T>[];
  fields: FilterFieldsConfig<T>;
  onChange: (filters: Filter<T>[]) => void;
}

export const FiltersContent = <T = unknown,>({ filters, fields, onChange }: FiltersContentProps<T>) => {
  const context = useFilterContext();
  const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]);

  const updateFilter = useCallback(
    (filterId: string, updates: Partial<Filter<T>>) => {
      onChange(
        filters.map((filter) => {
          if (filter.id === filterId) {
            const updatedFilter = { ...filter, ...updates };
            // Clear values for empty/not empty operators
            if (updates.operator === 'empty' || updates.operator === 'not_empty') {
              updatedFilter.values = [] as T[];
            }
            return updatedFilter;
          }
          return filter;
        }),
      );
    },
    [filters, onChange],
  );

  const removeFilter = useCallback(
    (filterId: string) => {
      onChange(filters.filter((filter) => filter.id !== filterId));
    },
    [filters, onChange],
  );

  return (
    <div className={cn(filtersContainerVariants({ variant: context.variant, size: context.size }), context.className)}>
      {filters.map((filter) => {
        const field = fieldsMap[filter.field];
        if (!field) return null;

        return (
          <div key={filter.id} className={filterItemVariants({ variant: context.variant })} data-slot="filter-item">
            {/* Field Label */}
            <div
              className={filterFieldLabelVariants({
                variant: context.variant,
                size: context.size,
                radius: context.radius,
              })}
            >
              {field.icon}
              {field.label}
            </div>

            {/* Operator Dropdown */}
            <FilterOperatorDropdown<T>
              field={field}
              operator={filter.operator}
              values={filter.values}
              onChange={(operator) => updateFilter(filter.id, { operator })}
            />

            {/* Value Selector */}
            <FilterValueSelector<T>
              field={field}
              values={filter.values}
              onChange={(values) => updateFilter(filter.id, { values })}
              operator={filter.operator}
            />

            {/* Remove Button */}
            <FilterRemoveButton onClick={() => removeFilter(filter.id)} />
          </div>
        );
      })}
    </div>
  );
};

interface FiltersProps<T = unknown> {
  filters: Filter<T>[];
  fields: FilterFieldsConfig<T>;
  onChange: (filters: Filter<T>[]) => void;
  className?: string;
  showAddButton?: boolean;
  addButtonText?: string;
  addButtonIcon?: React.ReactNode;
  addButtonClassName?: string;
  addButton?: React.ReactNode;
  variant?: 'solid' | 'outline';
  size?: 'sm' | 'md' | 'lg';
  radius?: 'md' | 'full';
  i18n?: Partial<FilterI18nConfig>;
  showSearchInput?: boolean;
  cursorPointer?: boolean;
  trigger?: React.ReactNode;
  allowMultiple?: boolean;
  popoverContentClassName?: string;
}

export function Filters<T = unknown>({
  filters,
  fields,
  onChange,
  className,
  showAddButton = true,
  addButtonText,
  addButtonIcon,
  addButtonClassName,
  addButton,
  variant = 'outline',
  size = 'md',
  radius = 'md',
  i18n,
  showSearchInput = true,
  cursorPointer = true,
  trigger,
  allowMultiple = true,
  popoverContentClassName,
}: FiltersProps<T>) {
  const [addFilterOpen, setAddFilterOpen] = useState(false);
  const [selectedFieldForOptions, setSelectedFieldForOptions] = useState<FilterFieldConfig<T> | null>(null);
  const [tempSelectedValues, setTempSelectedValues] = useState<unknown[]>([]);

  // Merge provided i18n with defaults
  const mergedI18n: FilterI18nConfig = {
    ...DEFAULT_I18N,
    ...i18n,
    operators: {
      ...DEFAULT_I18N.operators,
      ...i18n?.operators,
    },
    placeholders: {
      ...DEFAULT_I18N.placeholders,
      ...i18n?.placeholders,
    },
    validation: {
      ...DEFAULT_I18N.validation,
      ...i18n?.validation,
    },
  };

  const fieldsMap = useMemo(() => getFieldsMap(fields), [fields]);

  const updateFilter = useCallback(
    (filterId: string, updates: Partial<Filter<T>>) => {
      onChange(
        filters.map((filter) => {
          if (filter.id === filterId) {
            const updatedFilter = { ...filter, ...updates };
            // Clear values for empty/not empty operators
            if (updates.operator === 'empty' || updates.operator === 'not_empty') {
              updatedFilter.values = [] as T[];
            }
            return updatedFilter;
          }
          return filter;
        }),
      );
    },
    [filters, onChange],
  );

  const removeFilter = useCallback(
    (filterId: string) => {
      onChange(filters.filter((filter) => filter.id !== filterId));
    },
    [filters, onChange],
  );

  const addFilter = useCallback(
    (fieldKey: string) => {
      const field = fieldsMap[fieldKey];
      if (field && field.key) {
        // For select and multiselect types, show options directly
        if (field.type === 'select' || field.type === 'multiselect') {
          setSelectedFieldForOptions(field);
          // For multiselect, check if there's already a filter and use its values
          const existingFilter = filters.find((f) => f.field === fieldKey);
          const initialValues = field.type === 'multiselect' && existingFilter ? existingFilter.values : [];
          setTempSelectedValues(initialValues);
          return;
        }

        // For other types, add filter directly
        const defaultOperator =
          field.defaultOperator ||
          (field.type === 'daterange'
            ? 'between'
            : field.type === 'numberrange'
              ? 'between'
              : field.type === 'boolean'
                ? 'is'
                : 'is');
        let defaultValues: unknown[] = [];

        if (['text', 'number', 'date', 'email', 'url', 'tel', 'time', 'datetime'].includes(field.type || '')) {
          defaultValues = [''] as unknown[];
        } else if (field.type === 'daterange') {
          defaultValues = ['', ''] as unknown[];
        } else if (field.type === 'numberrange') {
          defaultValues = [field.min || 0, field.max || 100] as unknown[];
        } else if (field.type === 'boolean') {
          defaultValues = [false] as unknown[];
        } else if (field.type === 'time') {
          defaultValues = [''] as unknown[];
        } else if (field.type === 'datetime') {
          defaultValues = [''] as unknown[];
        }

        const newFilter = createFilter<T>(fieldKey, defaultOperator, defaultValues as T[]);
        const newFilters = [...filters, newFilter];
        onChange(newFilters);
        setAddFilterOpen(false);
      }
    },
    [fieldsMap, filters, onChange],
  );

  const addFilterWithOption = useCallback(
    (field: FilterFieldConfig<T>, values: unknown[], closePopover: boolean = true) => {
      if (!field.key) return;

      const defaultOperator = field.defaultOperator || (field.type === 'multiselect' ? 'is_any_of' : 'is');

      // Check if there's already a filter for this field
      const existingFilterIndex = filters.findIndex((f) => f.field === field.key);

      if (existingFilterIndex >= 0) {
        // Update existing filter
        const updatedFilters = [...filters];
        updatedFilters[existingFilterIndex] = {
          ...updatedFilters[existingFilterIndex],
          values: values as T[],
        };
        onChange(updatedFilters);
      } else {
        // Create new filter
        const newFilter = createFilter<T>(field.key, defaultOperator, values as T[]);
        const newFilters = [...filters, newFilter];
        onChange(newFilters);
      }

      if (closePopover) {
        setAddFilterOpen(false);
        setSelectedFieldForOptions(null);
        setTempSelectedValues([]);
      } else {
        // For multiselect, keep popover open but update temp values
        setTempSelectedValues(values as unknown[]);
      }
    },
    [filters, onChange],
  );

  const selectableFields = useMemo(() => {
    const flatFields = flattenFields(fields);
    return flatFields.filter((field) => {
      // Only include actual filterable fields (must have key and type)
      if (!field.key || field.type === 'separator') {
        return false;
      }
      // If allowMultiple is true, don't filter out fields that already have filters
      if (allowMultiple) {
        return true;
      }
      // Filter out fields that already have filters (default behavior)
      return !filters.some((filter) => filter.field === field.key);
    });
  }, [fields, filters, allowMultiple]);

  return (
    <FilterContext.Provider
      value={{
        variant,
        size,
        radius,
        i18n: mergedI18n,
        cursorPointer,
        className,
        showAddButton,
        addButtonText,
        addButtonIcon,
        addButtonClassName,
        addButton,
        showSearchInput,
        trigger,
        allowMultiple,
      }}
    >
      <div className={cn(filtersContainerVariants({ variant, size }), className)}>
        {showAddButton && selectableFields.length > 0 && (
          <Popover
            open={addFilterOpen}
            onOpenChange={(open) => {
              setAddFilterOpen(open);
              if (!open) {
                setSelectedFieldForOptions(null);
                setTempSelectedValues([]);
              }
            }}
          >
            <PopoverTrigger asChild>
              {addButton ? (
                addButton
              ) : (
                <button
                  className={cn(
                    filterAddButtonVariants({
                      variant: variant,
                      size: size,
                      cursorPointer: cursorPointer,
                      radius: radius,
                    }),
                    addButtonClassName,
                  )}
                  title={mergedI18n.addFilterTitle}
                >
                  {addButtonIcon || <Plus />}
                  {addButtonText || mergedI18n.addFilter}
                </button>
              )}
            </PopoverTrigger>
            <PopoverContent className={cn('w-[200px] p-0', popoverContentClassName)} align="start">
              <Command>
                {selectedFieldForOptions ? (
                  // Show original select/multiselect rendering without back button
                  <SelectOptionsPopover<T>
                    field={selectedFieldForOptions}
                    values={tempSelectedValues as T[]}
                    onChange={(values) => {
                      // For multiselect, create filter immediately but keep popover open
                      // For single select, create filter and close popover
                      const shouldClosePopover = selectedFieldForOptions.type === 'select';
                      addFilterWithOption(selectedFieldForOptions, values as unknown[], shouldClosePopover);
                    }}
                    onClose={() => setAddFilterOpen(false)}
                    inline={true}
                  />
                ) : (
                  // Show field selection
                  <>
                    {showSearchInput && <CommandInput placeholder={mergedI18n.searchFields} className="h-9" />}
                    <CommandList>
                      <CommandEmpty>{mergedI18n.noFieldsFound}</CommandEmpty>
                      {fields.map((item, index) => {
                        // Handle grouped fields (FilterFieldGroup structure)
                        if (isFieldGroup(item)) {
                          const groupFields = item.fields.filter((field) => {
                            // Include separators and labels for display
                            if (field.type === 'separator') {
                              return true;
                            }
                            // If allowMultiple is true, don't filter out fields that already have filters
                            if (allowMultiple) {
                              return true;
                            }
                            // Filter out fields that already have filters (default behavior)
                            return !filters.some((filter) => filter.field === field.key);
                          });

                          if (groupFields.length === 0) return null;

                          return (
                            <CommandGroup key={`group-${index}`} heading={item.group || 'Fields'}>
                              {groupFields.map((field, fieldIndex) => {
                                // Handle separator
                                if (field.type === 'separator') {
                                  return <CommandSeparator key={`separator-${fieldIndex}`} />;
                                }

                                // Regular field
                                return (
                                  <CommandItem key={field.key} onSelect={() => field.key && addFilter(field.key)}>
                                    {field.icon}
                                    <span>{field.label}</span>
                                  </CommandItem>
                                );
                              })}
                            </CommandGroup>
                          );
                        }

                        // Handle group-level fields (new FilterFieldConfig structure with group property)
                        if (isGroupLevelField(item)) {
                          const groupFields = item.fields!.filter((field) => {
                            // Include separators and labels for display
                            if (field.type === 'separator') {
                              return true;
                            }
                            // If allowMultiple is true, don't filter out fields that already have filters
                            if (allowMultiple) {
                              return true;
                            }
                            // Filter out fields that already have filters (default behavior)
                            return !filters.some((filter) => filter.field === field.key);
                          });

                          if (groupFields.length === 0) return null;

                          return (
                            <CommandGroup key={`group-${index}`} heading={item.group || 'Fields'}>
                              {groupFields.map((field, fieldIndex) => {
                                // Handle separator
                                if (field.type === 'separator') {
                                  return <CommandSeparator key={`separator-${fieldIndex}`} />;
                                }

                                // Regular field
                                return (
                                  <CommandItem key={field.key} onSelect={() => field.key && addFilter(field.key)}>
                                    {field.icon}
                                    <span>{field.label}</span>
                                  </CommandItem>
                                );
                              })}
                            </CommandGroup>
                          );
                        }

                        // Handle flat field configuration (backward compatibility)
                        const field = item as FilterFieldConfig<T>;

                        // Handle separator
                        if (field.type === 'separator') {
                          return <CommandSeparator key={`separator-${index}`} />;
                        }

                        // Regular field
                        return (
                          <CommandItem key={field.key} onSelect={() => field.key && addFilter(field.key)}>
                            {field.icon}
                            <span>{field.label}</span>
                          </CommandItem>
                        );
                      })}
                    </CommandList>
                  </>
                )}
              </Command>
            </PopoverContent>
          </Popover>
        )}

        {filters.map((filter) => {
          const field = fieldsMap[filter.field];
          if (!field) return null;

          return (
            <div key={filter.id} className={filterItemVariants({ variant })} data-slot="filter-item">
              {/* Field Label */}
              <div className={filterFieldLabelVariants({ variant: variant, size: size, radius: radius })}>
                {field.icon}
                {field.label}
              </div>

              {/* Operator Dropdown */}
              <FilterOperatorDropdown<T>
                field={field}
                operator={filter.operator}
                values={filter.values}
                onChange={(operator) => updateFilter(filter.id, { operator })}
              />

              {/* Value Selector */}
              <FilterValueSelector<T>
                field={field}
                values={filter.values}
                onChange={(values) => updateFilter(filter.id, { values })}
                operator={filter.operator}
              />

              {/* Remove Button */}
              <FilterRemoveButton onClick={() => removeFilter(filter.id)} />
            </div>
          );
        })}
      </div>
    </FilterContext.Provider>
  );
}

export const createFilter = <T = unknown,>(field: string, operator?: string, values: T[] = []): Filter<T> => ({
  id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
  field,
  operator: operator || 'is',
  values,
});

export const createFilterGroup = <T = unknown,>(
  id: string,
  label: string,
  fields: FilterFieldConfig<T>[],
  initialFilters: Filter<T>[] = [],
): FilterGroup<T> => ({
  id,
  label,
  filters: initialFilters,
  fields,
});

Installation

npx shadcn@latest add @reui/filters

Usage

import { Filters } from "@/components/ui/filters"
<Filters />