reui

Filters

Filters — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).

Загрузка превью…

src/reui/filters/FilterInput.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется из index.ts, как и в
 * оригинале, где `FilterInput` объявлен без `export`). Не дженерик — см.
 * пояснение в context.ts.
 *
 * React `useState`/`useRef`/`useEffect` -> `ref`/`watch` (правило 2,
 * docs/PORTING.md). `onChange`/`onBlur`/`onKeyDown` колбэки оригинала ->
 * `update:value`/`blur`/`keydown` события.
 */
import { onMounted, ref, watch } from "vue"
import { cn } from "@/lib/utils"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useFilterContext, type FilterFieldConfig } from "./context"

const props = withDefaults(
  defineProps<{
    class?: string
    field?: FilterFieldConfig<unknown>
    type?: string
    value?: string
    placeholder?: string
    pattern?: string
    autoFocus?: boolean
  }>(),
  {
    type: "text",
  }
)

const emit = defineEmits<{
  "update:value": [value: string]
  blur: [event: FocusEvent]
  keydown: [event: KeyboardEvent]
}>()

const context = useFilterContext()
const isValid = ref(true)
const validationMessage = ref("")
const inputRef = ref<{ $el: HTMLInputElement } | null>(null)

onMounted(() => {
  if (props.autoFocus) {
    const timer = setTimeout(() => {
      inputRef.value?.$el?.focus()
    }, 300)
    watch(
      () => props.autoFocus,
      () => clearTimeout(timer),
      { once: true }
    )
  }
})

// Validation function to check if input matches pattern
function 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
function getValidationMessage(): string {
  return context.i18n.value.validation.invalid
}

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

  // Only validate if there's a value and (pattern or validation function)
  if (value && (pattern || props.field?.validation)) {
    let valid = true
    let customMessage = ""

    // If there's a custom validation function, use it
    if (props.field?.validation) {
      const result = props.field.validation(value)
      // Handle both boolean and object return types
      if (typeof result === "boolean") {
        valid = result
      } else {
        valid = result.valid
        customMessage = result.message || ""
      }
    } else if (pattern) {
      // Use pattern validation
      valid = validateInput(value, pattern)
    }

    isValid.value = valid
    validationMessage.value = valid ? "" : customMessage || getValidationMessage()
  } else {
    // Reset validation state for empty values or no validation
    isValid.value = true
    validationMessage.value = ""
  }

  emit("blur", e)
}

// Handle keydown event - hide validation error when user starts typing
function handleKeydown(e: KeyboardEvent) {
  // Hide validation error when user starts typing (any key except special keys)
  if (
    !isValid.value &&
    !["Tab", "Escape", "Enter", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
      e.key
    )
  ) {
    isValid.value = true
    validationMessage.value = ""
  }

  emit("keydown", e)
}

function handleInput(e: Event) {
  emit("update:value", (e.target as HTMLInputElement).value)
}
</script>

<template>
  <InputGroup
    :class="
      cn(
        'w-36',
        // Height follows each style's own control ladder. `default` sets no
        // height on purpose so the style's `.cn-input-group` applies (h-8 nova,
        // h-9 maia/luma, h-7 mira, h-10 sera); sm/lg step down/up from it.
        // Base covers nova/lyra/rhea/vega; only deviating styles are listed.
        context.size.value == 'sm' &&
          'h-7! style-maia:h-8! style-luma:h-8! style-mira:h-6! style-sera:h-9!',
        context.size.value == 'lg' &&
          'h-9! style-maia:h-10! style-luma:h-10! style-mira:h-8! style-sera:h-11!',
        // Sera's `.cn-input` is `px-0` (underline inputs sit flush); inside a
        // segmented chip that collides with the neighbouring segment, so give
        // the value input the same inline padding sera uses elsewhere.
        'style-sera:px-2.5',
        props.class
      )
    "
  >
    <InputGroupAddon v-if="field?.prefix">
      <InputGroupText>{{ field?.prefix }}</InputGroupText>
    </InputGroupAddon>
    <InputGroupInput
      ref="inputRef"
      :type="props.type"
      :aria-invalid="!isValid"
      :aria-describedby="!isValid && validationMessage ? `${field?.key || 'input'}-error` : undefined"
      :value="props.value"
      :placeholder="props.placeholder"
      :pattern="props.pattern"
      :class="
        cn(
          context.size.value == 'sm' && 'h-7! text-xs style-maia:h-8! style-luma:h-8! style-mira:h-6! style-sera:h-9!',
          context.size.value == 'lg' && 'h-9! style-maia:h-10! style-luma:h-10! style-mira:h-8! style-sera:h-11!'
        )
      "
      @input="handleInput"
      @blur="handleBlur"
      @keydown="handleKeydown"
    />
    <InputGroupAddon v-if="!isValid && validationMessage" align="inline-end">
      <TooltipProvider>
        <Tooltip>
          <TooltipTrigger as-child>
            <InputGroupButton size="icon-xs">
              <svg
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                stroke-width="2"
                stroke-linecap="round"
                stroke-linejoin="round"
                class="text-destructive size-3.5"
              >
                <circle cx="12" cy="12" r="10" />
                <line x1="12" y1="8" x2="12" y2="12" />
                <line x1="12" y1="16" x2="12.01" y2="16" />
              </svg>
            </InputGroupButton>
          </TooltipTrigger>
          <TooltipContent>
            <p class="text-sm">{{ validationMessage }}</p>
          </TooltipContent>
        </Tooltip>
      </TooltipProvider>
    </InputGroupAddon>

    <InputGroupAddon v-if="field?.suffix" align="inline-end">
      <InputGroupText>{{ field?.suffix }}</InputGroupText>
    </InputGroupAddon>
  </InputGroup>
</template>

src/reui/filters/FilterOperatorDropdown.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется, как и в оригинале). Иконка
 * `CheckIcon` — инлайновый `<svg>` вместо `IconPlaceholder` (тот же путь,
 * что и в `DropdownMenuCheckboxItem.vue` базового слоя).
 */
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { getOperatorsForField, useFilterContext, type FilterFieldConfig } from "./context"

const props = defineProps<{
  field: FilterFieldConfig<unknown>
  operator: string
  values: unknown[]
}>()

const emit = defineEmits<{
  change: [operator: string]
}>()

const context = useFilterContext()

const operators = computed(() =>
  getOperatorsForField(props.field, props.values, context.i18n.value)
)

const operatorLabel = computed(
  () =>
    operators.value.find((op) => op.value === props.operator)?.label ||
    context.i18n.value.helpers.formatOperator(props.operator)
)
</script>

<template>
  <DropdownMenu>
    <DropdownMenuTrigger as-child>
      <Button variant="outline" :size="context.size.value" class="text-muted-foreground hover:text-foreground">
        {{ operatorLabel }}
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="start" class="w-fit min-w-fit">
      <DropdownMenuItem
        v-for="op in operators"
        :key="op.value"
        :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground flex items-center justify-between')"
        @click="emit('change', op.value)"
      >
        <span>{{ op.label }}</span>
        <svg
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          stroke-width="2"
          stroke-linecap="round"
          stroke-linejoin="round"
          :class="cn('text-primary ms-auto', op.value === props.operator ? 'opacity-100' : 'opacity-0')"
        >
          <polyline points="20 6 9 17 4 12" />
        </svg>
      </DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</template>

src/reui/filters/FilterRemoveButton.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется, как и в оригинале). Иконка
 * `XIcon` — инлайновый `<svg>` вместо `IconPlaceholder` (см. docs/PORTING.md
 * §5 и прецедент rating/tree/ui-checkbox).
 */
import { Button } from "@/components/ui/button"
import { useFilterContext } from "./context"

const props = defineProps<{
  class?: string
}>()

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const context = useFilterContext()
</script>

<template>
  <Button
    variant="outline"
    :size="context.size.value === 'sm' ? 'icon-sm' : context.size.value === 'lg' ? 'icon-lg' : 'icon'"
    :class="props.class"
    @click="(e: MouseEvent) => emit('click', e)"
  >
    <slot>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linecap="round"
        stroke-linejoin="round"
      >
        <path d="M18 6 6 18" />
        <path d="m6 6 12 12" />
      </svg>
    </slot>
  </Button>
</template>

src/reui/filters/FilterSubmenuContent.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется, как и в оригинале) —
 * содержимое `DropdownMenuSubContent` подменю поля с опциями в меню
 * "Add Filter". Не дженерик, см. context.ts.
 */
import { computed, h, nextTick, ref, useId, watch, type VNodeChild } from "vue"
import { cn } from "@/lib/utils"
import { DropdownMenuCheckboxItem, DropdownMenuGroup, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  DEFAULT_I18N,
  useFieldOptions,
  type FilterFieldConfig,
  type FilterI18nConfig,
  type FilterOption,
} from "./context"

const props = defineProps<{
  field: FilterFieldConfig<unknown>
  currentValues: unknown[]
  isMultiSelect: boolean
  i18n: FilterI18nConfig
  isActive?: boolean
}>()

const emit = defineEmits<{
  toggle: [value: unknown, isSelected: boolean]
  active: []
  back: []
  close: []
}>()

const searchInput = ref("")
const highlightedIndex = ref(-1)
const baseId = useId()

const alwaysEnabled = computed(() => true)
const { isAsync, options: resolvedOptionsRef, loading, error, resolveSelected } = useFieldOptions(
  props.field,
  searchInput,
  alwaysEnabled
)

watch(searchInput, () => {
  highlightedIndex.value = -1
})

watch(highlightedIndex, () => {
  if (highlightedIndex.value >= 0 && props.isActive) {
    nextTick(() => {
      document
        .getElementById(`${baseId}-item-${highlightedIndex.value}`)
        ?.scrollIntoView({ block: "nearest" })
    })
  }
})

const filteredOptions = computed<FilterOption<unknown>[]>(() => {
  // Async fields: keep selected values first (resolved from cache so they
  // stay labelled), then the loader's already-query-filtered results.
  if (isAsync) {
    const selectedSet = new Set(props.currentValues)
    return [
      ...resolveSelected(props.currentValues),
      ...resolvedOptionsRef.value.filter((option) => !selectedSet.has(option.value)),
    ]
  }
  return (
    props.field.options?.filter((option) => {
      const isSelected = props.currentValues.includes(option.value)
      if (isSelected) return true
      if (!searchInput.value) return true
      return option.label.toLowerCase().includes(searchInput.value.toLowerCase())
    }) || []
  )
})

watch(
  () => [props.isActive, filteredOptions.value.length] as const,
  ([isActive, length]) => {
    if (isActive && length > 0) {
      highlightedIndex.value = 0
    }
  }
)

function isOptionSelected(option: FilterOption<unknown>): boolean {
  return props.currentValues.includes(option.value)
}

// Render-prop callback passed to `field.renderOptionList`, mirrors the
// `<DropdownMenuCheckboxItem>` markup below (see SelectOptionsPopover.vue
// for the twin implementation).
function renderOption(option: FilterOption<unknown>, index: number): VNodeChild {
  const isSelected = isOptionSelected(option)
  const isHighlighted = highlightedIndex.value === index
  return h(
    DropdownMenuCheckboxItem,
    {
      key: String(option.value),
      id: `${baseId}-item-${index}`,
      role: "option",
      "aria-selected": isHighlighted,
      "data-highlighted": isHighlighted || undefined,
      modelValue: isSelected,
      class: cn(
        "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
        option.class
      ),
      onMouseenter: () => (highlightedIndex.value = index),
      onSelect: (e: Event) => {
        if (props.isMultiSelect) e.preventDefault()
      },
      "onUpdate:modelValue": () => emit("toggle", option.value, isSelected),
    },
    {
      default: () => [
        option.icon ? option.icon() : null,
        h("span", { class: "truncate" }, option.label),
      ],
    }
  )
}

function onMouseEnterRoot() {
  emit("active")
}

function handleNav(e: KeyboardEvent, isSearchInput: boolean) {
  if (!isSearchInput && props.field.searchable !== false) return

  if (e.key === "ArrowDown") {
    e.preventDefault()
    if (filteredOptions.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value < filteredOptions.value.length - 1 ? highlightedIndex.value + 1 : 0
    }
  } else if (e.key === "ArrowUp") {
    e.preventDefault()
    if (filteredOptions.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value > 0 ? highlightedIndex.value - 1 : filteredOptions.value.length - 1
    }
  } else if (e.key === "ArrowLeft") {
    e.preventDefault()
    emit("back")
  } else if (e.key === "Enter" && highlightedIndex.value >= 0) {
    e.preventDefault()
    const option = filteredOptions.value[highlightedIndex.value]
    if (option) {
      emit("toggle", option.value, props.currentValues.includes(option.value))
      if (!props.isMultiSelect) {
        emit("back")
      }
    }
  } else if (e.key === "Escape") {
    e.preventDefault()
    emit("close")
  }
  e.stopPropagation()
}
</script>

<template>
  <div class="flex flex-col" @mouseenter="onMouseEnterRoot">
    <template v-if="field.searchable !== false">
      <Input
        role="combobox"
        aria-autocomplete="list"
        :aria-expanded="true"
        aria-haspopup="listbox"
        :aria-controls="`${baseId}-listbox`"
        :aria-activedescendant="highlightedIndex >= 0 ? `${baseId}-item-${highlightedIndex}` : undefined"
        :placeholder="i18n.placeholders.searchField(field.label || '')"
        class="h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
        :value="searchInput"
        @input="(e: Event) => (searchInput = (e.target as HTMLInputElement).value)"
        @click.stop
        @keydown="(e: KeyboardEvent) => handleNav(e, true)"
      />
      <DropdownMenuSeparator />
    </template>
    <div class="relative flex max-h-full">
      <div
        class="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain outline-hidden"
        role="listbox"
        :id="`${baseId}-listbox`"
        :tabindex="field.searchable === false ? 0 : -1"
        @keydown="(e: KeyboardEvent) => handleNav(e, field.searchable === false)"
      >
        <div v-if="isAsync && loading && filteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
          {{ i18n.loadingOptions ?? DEFAULT_I18N.loadingOptions }}
        </div>
        <div v-else-if="isAsync && error" class="text-muted-foreground py-2 text-center text-sm">
          {{ i18n.errorLoadingOptions ?? DEFAULT_I18N.errorLoadingOptions }}
        </div>
        <div v-else-if="filteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
          {{ i18n.noResultsFound }}
        </div>
        <component
          v-else-if="field.renderOptionList"
          :is="() => field.renderOptionList!({ options: filteredOptions, highlightedIndex, renderOption })"
        />
        <ScrollArea
          v-else
          class="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain"
        >
          <DropdownMenuGroup>
            <DropdownMenuCheckboxItem
              v-for="(option, index) in filteredOptions"
              :key="String(option.value)"
              :id="`${baseId}-item-${index}`"
              role="option"
              :aria-selected="highlightedIndex === index"
              :data-highlighted="highlightedIndex === index || undefined"
              :model-value="isOptionSelected(option)"
              :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground', option.class)"
              @mouseenter="highlightedIndex = index"
              @select="(e: Event) => isMultiSelect && e.preventDefault()"
              @update:model-value="() => emit('toggle', option.value, isOptionSelected(option))"
            >
              <component :is="option.icon" v-if="option.icon" />
              <span class="truncate">{{ option.label }}</span>
            </DropdownMenuCheckboxItem>
          </DropdownMenuGroup>
        </ScrollArea>
      </div>
    </div>
  </div>
</template>

src/reui/filters/FilterValueSelector.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется, как и в оригинале).
 * `field.customRenderer` — render-prop; рендерится через `:is="() => ..."`,
 * тот же приём, что и `field.renderOptionList`/`field.customValueRenderer`
 * в SelectOptionsPopover.vue (function-as-component — Vue вызывает функцию
 * без аргументов и использует результат как VNode).
 */
import { cn } from "@/lib/utils"
import { ButtonGroupText } from "@/components/ui/button-group"
import FilterInput from "./FilterInput.vue"
import SelectOptionsPopover from "./SelectOptionsPopover.vue"
import type { FilterFieldConfig } from "./context"

const props = defineProps<{
  field: FilterFieldConfig<unknown>
  values: unknown[]
  operator: string
  autoFocus?: boolean
}>()

const emit = defineEmits<{
  change: [values: unknown[]]
}>()
</script>

<template>
  <template v-if="operator === 'empty' || operator === 'not_empty'" />
  <ButtonGroupText
    v-else-if="field.customRenderer"
    class="hover:bg-accent aria-expanded:bg-accent bg-background dark:bg-input/30 text-start whitespace-nowrap outline-hidden"
  >
    <component
      :is="() => field.customRenderer!({ field, values: props.values, onChange: (v: unknown[]) => emit('change', v), operator: props.operator })"
    />
  </ButtonGroupText>
  <FilterInput
    v-else-if="field.type === 'text'"
    type="text"
    :value="(values[0] as string) || ''"
    @update:value="(v: string) => emit('change', [v])"
    :placeholder="field.placeholder"
    :pattern="field.pattern"
    :field="field"
    :class="cn('w-36', field.class)"
    :auto-focus="props.autoFocus"
  />
  <SelectOptionsPopover v-else :field="field" :values="props.values" @change="(v) => emit('change', v)" />
</template>

src/reui/filters/Filters.vue

<script setup lang="ts" generic="T">
/**
 * Порт ReUI Filters — главный экспортируемый компонент
 * (registry-reui/bases/radix/reui/filters.tsx, MIT). Дженерик `<T>`, как в
 * оригинале (`Filters<T>`); внутренние подкомпоненты не дженерики, см.
 * пояснение в context.ts.
 *
 * React `useState` -> `ref`, `useEffect` -> `watch`/`onMounted`,
 * `useMemo` -> `computed`, `useCallback` -> обычная функция (правило 2,
 * docs/PORTING.md). Иконка `PlusIcon` (кнопка "Add filter") — инлайновый
 * `<svg>` вместо `IconPlaceholder` (docs/PORTING.md §5).
 *
 * `trigger` (React: `React.ReactNode`, кастомный триггер кнопки "Add
 * filter") реализован именованным слотом `#trigger` — тот же приём, что и
 * `indicators` в Stepper (см. docs/PORTING.md §7): у Vue нет дешёвого
 * аналога пропа с готовой нодой.
 */
import { computed, onBeforeUnmount, onMounted, provide, ref, useId, watch } from "vue"
import { cn } from "@/lib/utils"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Kbd } from "@/components/ui/kbd"
import { ScrollArea } from "@/components/ui/scroll-area"
import FilterOperatorDropdown from "./FilterOperatorDropdown.vue"
import FilterRemoveButton from "./FilterRemoveButton.vue"
import FilterSubmenuContent from "./FilterSubmenuContent.vue"
import FilterValueSelector from "./FilterValueSelector.vue"
import { filtersContainerVariants } from "./variants"
import {
  createFilter,
  DEFAULT_I18N,
  fieldHasOptions,
  FilterContextKey,
  flattenFields,
  getFieldsMap,
  type Filter,
  type FilterFieldConfig,
  type FilterFieldsConfig,
  type FilterI18nConfig,
} from "./context"

const props = withDefaults(
  defineProps<{
    filters: Filter<T>[]
    fields: FilterFieldsConfig<T>
    class?: string
    variant?: "solid" | "default"
    size?: "sm" | "default" | "lg"
    radius?: "default" | "full"
    i18n?: Partial<FilterI18nConfig>
    showSearchInput?: boolean
    allowMultiple?: boolean
    menuPopupClass?: string
    enableShortcut?: boolean
    shortcutKey?: string
    shortcutLabel?: string
  }>(),
  {
    variant: "default",
    size: "default",
    radius: "default",
    showSearchInput: true,
    allowMultiple: true,
    enableShortcut: false,
    shortcutKey: "f",
    shortcutLabel: "F",
  }
)

const emit = defineEmits<{
  change: [filters: Filter<T>[]]
}>()

const addFilterOpen = ref(false)
const menuSearchInput = ref("")
const activeMenu = ref("root")
const openSubMenu = ref<string | null>(null)
const highlightedIndex = ref(-1)
const lastAddedFilterId = ref<string | null>(null)
const rootId = useId()

// Track which filter instance is being built in the current Add Filter menu
// session. Maps fieldKey -> unique filterId created during this open session.
const sessionFilterIds = ref<Record<string, string>>({})

function handleGlobalShortcut(e: KeyboardEvent) {
  if (!props.enableShortcut) return
  if (
    e.key.toLowerCase() === props.shortcutKey.toLowerCase() &&
    !addFilterOpen.value &&
    !(
      document.activeElement instanceof HTMLInputElement ||
      document.activeElement instanceof HTMLTextAreaElement
    )
  ) {
    e.preventDefault()
    addFilterOpen.value = true
  }
}

onMounted(() => window.addEventListener("keydown", handleGlobalShortcut))
onBeforeUnmount(() => window.removeEventListener("keydown", handleGlobalShortcut))

watch(menuSearchInput, () => {
  highlightedIndex.value = -1
})

watch(highlightedIndex, () => {
  if (highlightedIndex.value >= 0 && addFilterOpen.value) {
    document.getElementById(`${rootId}-item-${highlightedIndex.value}`)?.scrollIntoView({
      block: "nearest",
    })
  }
})

watch(addFilterOpen, (open) => {
  if (!open) {
    openSubMenu.value = null
  }
})

let lastAddedTimer: ReturnType<typeof setTimeout> | undefined
watch(lastAddedFilterId, (id) => {
  if (lastAddedTimer) clearTimeout(lastAddedTimer)
  if (id) {
    lastAddedTimer = setTimeout(() => {
      lastAddedFilterId.value = null
    }, 1000)
  }
})

const mergedI18n = computed<FilterI18nConfig>(() => ({
  ...DEFAULT_I18N,
  ...props.i18n,
  operators: { ...DEFAULT_I18N.operators, ...props.i18n?.operators },
  placeholders: { ...DEFAULT_I18N.placeholders, ...props.i18n?.placeholders },
  validation: { ...DEFAULT_I18N.validation, ...props.i18n?.validation },
}))

const fieldsMap = computed(() =>
  getFieldsMap(props.fields as unknown as FilterFieldsConfig<unknown>)
)

function updateFilter(filterId: string, updates: Partial<Filter<T>>) {
  emit(
    "change",
    props.filters.map((filter) => {
      if (filter.id === filterId) {
        const updatedFilter = { ...filter, ...updates }
        if (updates.operator === "empty" || updates.operator === "not_empty") {
          updatedFilter.values = [] as T[]
        }
        return updatedFilter
      }
      return filter
    })
  )
}

function removeFilter(filterId: string) {
  emit(
    "change",
    props.filters.filter((filter) => filter.id !== filterId)
  )
}

function addFilter(fieldKey: string) {
  const field = fieldsMap.value[fieldKey]
  if (field && field.key) {
    const defaultOperator =
      field.defaultOperator || (field.type === "multiselect" ? "is_any_of" : "is")
    const defaultValues: unknown[] = field.type === "text" ? [""] : []
    const newFilter = createFilter<unknown>(fieldKey, defaultOperator, defaultValues)
    lastAddedFilterId.value = newFilter.id
    emit("change", [...props.filters, newFilter as unknown as Filter<T>])
    addFilterOpen.value = false
    menuSearchInput.value = ""
  }
}

const selectableFields = computed(() => {
  const flatFields = flattenFields(props.fields as unknown as FilterFieldsConfig<unknown>)
  return flatFields.filter((field) => {
    if (!field.key || field.type === "separator") return false
    if (props.allowMultiple) return true
    return !props.filters.some((filter) => filter.field === field.key)
  })
})

const filteredFields = computed(() =>
  selectableFields.value.filter(
    (f) => !menuSearchInput.value || f.label?.toLowerCase().includes(menuSearchInput.value.toLowerCase())
  )
)

watch([addFilterOpen, () => filteredFields.value.length], ([open, length]) => {
  if (open && length > 0) {
    highlightedIndex.value = 0
  }
})

const contextValue = {
  variant: computed(() => props.variant),
  size: computed(() => props.size),
  radius: computed(() => props.radius),
  i18n: mergedI18n,
  class: computed(() => props.class),
  showSearchInput: computed(() => props.showSearchInput),
  allowMultiple: computed(() => props.allowMultiple),
}
provide(FilterContextKey, contextValue)

function fieldHasSubMenu(field: FilterFieldConfig<unknown>): boolean {
  return (field.type === "select" || field.type === "multiselect") && fieldHasOptions(field)
}

function onRootSearchKeydown(e: KeyboardEvent) {
  if (e.key === "ArrowDown") {
    e.preventDefault()
    if (filteredFields.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value < filteredFields.value.length - 1 ? highlightedIndex.value + 1 : 0
    }
  } else if (e.key === "ArrowUp") {
    e.preventDefault()
    if (filteredFields.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value > 0 ? highlightedIndex.value - 1 : filteredFields.value.length - 1
    }
  } else if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && highlightedIndex.value >= 0) {
    const field = filteredFields.value[highlightedIndex.value]
    const hasSubMenu = field && fieldHasSubMenu(field)

    if (e.key === "ArrowRight" && hasSubMenu) {
      e.preventDefault()
      openSubMenu.value = field.key || null
      activeMenu.value = field.key || "root"
    } else if (e.key === "ArrowLeft") {
      e.preventDefault()
      if (openSubMenu.value) {
        openSubMenu.value = null
        activeMenu.value = "root"
      }
    }
  } else if (e.key === "Enter" && highlightedIndex.value >= 0) {
    e.preventDefault()
    const field = filteredFields.value[highlightedIndex.value]
    if (field?.key) {
      const hasSubMenu = fieldHasSubMenu(field)
      if (!hasSubMenu) {
        addFilter(field.key)
      } else {
        if (openSubMenu.value === field.key) {
          openSubMenu.value = null
          activeMenu.value = "root"
        } else {
          openSubMenu.value = field.key
          activeMenu.value = field.key
        }
      }
    }
  } else if (e.key === "Escape") {
    addFilterOpen.value = false
  }
  e.stopPropagation()
}

function onAddFilterOpenChange(open: boolean) {
  addFilterOpen.value = open
  if (!open) {
    menuSearchInput.value = ""
    sessionFilterIds.value = {}
  } else {
    activeMenu.value = "root"
  }
}

function onSubMenuOpenChange(fieldKey: string, open: boolean) {
  if (open) {
    if (openSubMenu.value !== fieldKey) openSubMenu.value = fieldKey
  } else if (openSubMenu.value === fieldKey) {
    openSubMenu.value = null
    activeMenu.value = "root"
  }
}

function sessionFilterFor(fieldKey: string): Filter<unknown> | null {
  const sessionFilterId = sessionFilterIds.value[fieldKey]
  if (!sessionFilterId) return null
  return (props.filters as unknown as Filter<unknown>[]).find((f) => f.id === sessionFilterId) || null
}

function onSubmenuToggle(field: FilterFieldConfig<unknown>, isMultiSelect: boolean, value: unknown, isSelected: boolean) {
  const fieldKey = field.key as string

  if (isMultiSelect) {
    const sessionFilter = sessionFilterFor(fieldKey)
    const currentValues = sessionFilter?.values || []
    const nextValues = isSelected
      ? currentValues.filter((v) => v !== value)
      : [...currentValues, value]

    if (sessionFilter) {
      if (nextValues.length === 0) {
        emit(
          "change",
          props.filters.filter((f) => f.id !== sessionFilter.id)
        )
        sessionFilterIds.value = { ...sessionFilterIds.value, [fieldKey]: "" }
      } else {
        emit(
          "change",
          props.filters.map((f) =>
            f.id === sessionFilter.id ? ({ ...f, values: nextValues } as Filter<T>) : f
          )
        )
      }
    } else {
      const newFilter = createFilter<unknown>(fieldKey, field.defaultOperator || "is_any_of", nextValues)
      emit("change", [...props.filters, newFilter as unknown as Filter<T>])
      sessionFilterIds.value = { ...sessionFilterIds.value, [fieldKey]: newFilter.id }
    }
  } else {
    const newFilter = createFilter<unknown>(fieldKey, field.defaultOperator || "is", [value])
    lastAddedFilterId.value = newFilter.id
    emit("change", [...props.filters, newFilter as unknown as Filter<T>])
    addFilterOpen.value = false
  }
}
</script>

<template>
  <div :class="cn(filtersContainerVariants({ variant: props.variant, size: props.size }), props.class)">
    <DropdownMenu
      v-if="selectableFields.length > 0"
      :open="addFilterOpen"
      @update:open="onAddFilterOpenChange"
    >
      <DropdownMenuTrigger as-child>
        <slot name="trigger">
          <Button variant="outline">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              stroke-width="2"
              stroke-linecap="round"
              stroke-linejoin="round"
            >
              <path d="M5 12h14" />
              <path d="M12 5v14" />
            </svg>
            {{ mergedI18n.addFilter }}
          </Button>
        </slot>
      </DropdownMenuTrigger>
      <DropdownMenuContent :class="cn('w-[220px]', props.menuPopupClass)" align="start">
        <template v-if="props.showSearchInput">
          <div class="relative">
            <Input
              role="combobox"
              :aria-controls="`${rootId}-listbox`"
              :aria-activedescendant="highlightedIndex >= 0 ? `${rootId}-item-${highlightedIndex}` : undefined"
              :placeholder="mergedI18n.searchFields"
              class="h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
              :value="menuSearchInput"
              @input="(e: Event) => (menuSearchInput = (e.target as HTMLInputElement).value)"
              @click.stop
              @keydown="onRootSearchKeydown"
            />
            <Kbd v-if="props.enableShortcut && props.shortcutLabel" class="bg-background absolute top-1/2 right-2 -translate-y-1/2 border">
              {{ props.shortcutLabel }}
            </Kbd>
          </div>
          <DropdownMenuSeparator />
        </template>

        <div class="relative flex max-h-full">
          <div
            class="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain"
            role="listbox"
            :id="`${rootId}-listbox`"
          >
            <ScrollArea class="**:data-[slot=scroll-area-scrollbar]:m-0">
              <div v-if="filteredFields.length === 0" class="text-muted-foreground py-2 text-center text-sm">
                {{ mergedI18n.noFieldsFound }}
              </div>
              <template v-else>
                <template v-for="(field, index) in filteredFields" :key="field.key">
                  <DropdownMenuSub
                    v-if="fieldHasSubMenu(field)"
                    :open="openSubMenu === field.key"
                    @update:open="(open: boolean) => onSubMenuOpenChange(field.key as string, open)"
                  >
                    <DropdownMenuSubTrigger
                      :id="`${rootId}-item-${index}`"
                      role="option"
                      :aria-selected="highlightedIndex === index"
                      :data-highlighted="highlightedIndex === index || undefined"
                      class="data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-highlighted:bg-accent data-highlighted:text-accent-foreground"
                      @mouseenter="highlightedIndex = index"
                    >
                      <component :is="field.icon" v-if="field.icon" />
                      <span>{{ field.label }}</span>
                    </DropdownMenuSubTrigger>
                    <DropdownMenuSubContent class="w-[200px]">
                      <FilterSubmenuContent
                        :field="field"
                        :current-values="sessionFilterFor(field.key as string)?.values || []"
                        :is-multi-select="field.type === 'multiselect'"
                        :i18n="mergedI18n"
                        :is-active="activeMenu === field.key"
                        @active="() => { if (field.searchable !== false) activeMenu = field.key as string }"
                        @back="() => { openSubMenu = null; activeMenu = 'root' }"
                        @close="addFilterOpen = false"
                        @toggle="(value, isSelected) => onSubmenuToggle(field, field.type === 'multiselect', value, isSelected)"
                      />
                    </DropdownMenuSubContent>
                  </DropdownMenuSub>

                  <DropdownMenuItem
                    v-else
                    :id="`${rootId}-item-${index}`"
                    role="option"
                    :aria-selected="highlightedIndex === index"
                    :data-highlighted="highlightedIndex === index || undefined"
                    class="data-highlighted:bg-accent data-highlighted:text-accent-foreground"
                    @mouseenter="highlightedIndex = index"
                    @click="() => field.key && addFilter(field.key)"
                  >
                    <component :is="field.icon" v-if="field.icon" />
                    <span>{{ field.label }}</span>
                  </DropdownMenuItem>
                </template>
              </template>
            </ScrollArea>
          </div>
        </div>
      </DropdownMenuContent>
    </DropdownMenu>

    <template v-for="filter in props.filters" :key="filter.id">
      <ButtonGroup
        v-if="fieldsMap[filter.field]"
        class="style-sera:*:border-transparent style-sera:*:border-b-input style-sera:*:rounded-none"
      >
        <ButtonGroupText class="bg-background dark:bg-input/30">
          <component :is="fieldsMap[filter.field]!.icon" v-if="fieldsMap[filter.field]!.icon" />
          {{ fieldsMap[filter.field]!.label }}
        </ButtonGroupText>
        <FilterOperatorDropdown
          :field="fieldsMap[filter.field]!"
          :operator="filter.operator"
          :values="filter.values"
          @change="(operator: string) => updateFilter(filter.id, { operator })"
        />
        <FilterValueSelector
          :field="fieldsMap[filter.field]!"
          :values="filter.values"
          :operator="filter.operator"
          :auto-focus="filter.id === lastAddedFilterId"
          @change="(values: unknown[]) => updateFilter(filter.id, { values: values as T[] })"
        />
        <FilterRemoveButton @click="removeFilter(filter.id)" />
      </ButtonGroup>
    </template>
  </div>
</template>

src/reui/filters/FiltersContent.vue

<script setup lang="ts" generic="T">
/**
 * Порт `FiltersContent` (без меню "Add Filter" — только рендер уже
 * существующих фильтров). Дженерик `<T>`, как и оригинал (`FiltersContent<T>`,
 * React.FC). См. context.ts про генерики между файлами.
 */
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group"
import FilterOperatorDropdown from "./FilterOperatorDropdown.vue"
import FilterRemoveButton from "./FilterRemoveButton.vue"
import FilterValueSelector from "./FilterValueSelector.vue"
import { filtersContainerVariants } from "./variants"
import { getFieldsMap, useFilterContext, type Filter, type FilterFieldsConfig } from "./context"

const props = defineProps<{
  filters: Filter<T>[]
  fields: FilterFieldsConfig<T>
}>()

const emit = defineEmits<{
  change: [filters: Filter<T>[]]
}>()

const context = useFilterContext()

const fieldsMap = computed(() =>
  getFieldsMap(props.fields as unknown as FilterFieldsConfig<unknown>)
)

function updateFilter(filterId: string, updates: Partial<Filter<T>>) {
  emit(
    "change",
    props.filters.map((filter) => {
      if (filter.id === filterId) {
        const updatedFilter = { ...filter, ...updates }
        if (updates.operator === "empty" || updates.operator === "not_empty") {
          updatedFilter.values = [] as T[]
        }
        return updatedFilter
      }
      return filter
    })
  )
}

function removeFilter(filterId: string) {
  emit(
    "change",
    props.filters.filter((filter) => filter.id !== filterId)
  )
}
</script>

<template>
  <div :class="cn(filtersContainerVariants({ variant: context.variant.value, size: context.size.value }), context.class.value)">
    <template v-for="filter in filters" :key="filter.id">
      <ButtonGroup
        v-if="fieldsMap[filter.field]"
        class="style-sera:*:border-transparent style-sera:*:border-b-input style-sera:*:rounded-none"
      >
        <ButtonGroupText>
          <component :is="fieldsMap[filter.field]!.icon" v-if="fieldsMap[filter.field]!.icon" />
          {{ fieldsMap[filter.field]!.label }}
        </ButtonGroupText>

        <FilterOperatorDropdown
          :field="fieldsMap[filter.field]!"
          :operator="filter.operator"
          :values="filter.values"
          @change="(operator: string) => updateFilter(filter.id, { operator })"
        />

        <FilterValueSelector
          :field="fieldsMap[filter.field]!"
          :values="filter.values"
          :operator="filter.operator"
          :auto-focus="false"
          @change="(values: unknown[]) => updateFilter(filter.id, { values: values as T[] })"
        />

        <FilterRemoveButton @click="removeFilter(filter.id)" />
      </ButtonGroup>
    </template>
  </div>
</template>

src/reui/filters/SelectOptionsPopover.vue

<script setup lang="ts">
/**
 * Внутренний подкомпонент (не экспортируется, как и в оригинале). Иконки
 * (`CheckIcon` не используется здесь — только `DropdownMenuCheckboxItem`
 * индикатор из базового слоя) — см. соседние подкомпоненты для деталей
 * замены `IconPlaceholder`.
 *
 * React `useState` -> `ref`, `useEffect` -> `watch`, `useMemo` -> `computed`
 * (правило 2, docs/PORTING.md).
 */
import { computed, h, nextTick, ref, useId, watch, type VNodeChild } from "vue"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
  DEFAULT_I18N,
  useFieldOptions,
  useFilterContext,
  type FilterFieldConfig,
  type FilterOption,
} from "./context"

const props = withDefaults(
  defineProps<{
    field: FilterFieldConfig<unknown>
    values: unknown[]
    inline?: boolean
  }>(),
  {
    inline: false,
  }
)

const emit = defineEmits<{
  change: [values: unknown[]]
  close: []
}>()

const open = ref(false)
const searchInput = ref("")
const highlightedIndex = ref(-1)
const context = useFilterContext()
const baseId = useId()

watch([searchInput, open], () => {
  highlightedIndex.value = -1
})

watch(highlightedIndex, () => {
  if (highlightedIndex.value >= 0 && open.value) {
    nextTick(() => {
      document
        .getElementById(`${baseId}-item-${highlightedIndex.value}`)
        ?.scrollIntoView({ block: "nearest" })
    })
  }
})

const fetchEnabled = computed(() => props.inline || open.value)
const { isAsync, options: resolvedOptionsRef, loading, error, resolveSelected } =
  useFieldOptions(props.field, searchInput, fetchEnabled)

const isMultiSelect = computed(
  () => props.field.type === "multiselect" || props.values.length > 1
)
const effectiveValues = computed<unknown[]>(
  () => (props.field.value !== undefined ? (props.field.value as unknown[]) : props.values) || []
)

// Static fields read their list verbatim (unchanged legacy behavior). Async
// fields resolve selected values from the value->label cache and take the
// loader's (already query-filtered) result as the unselected list.
const selectedOptions = computed<FilterOption<unknown>[]>(() =>
  isAsync
    ? resolveSelected(effectiveValues.value)
    : props.field.options?.filter((opt) => effectiveValues.value.includes(opt.value)) || []
)
const unselectedOptions = computed<FilterOption<unknown>[]>(() =>
  isAsync
    ? resolvedOptionsRef.value.filter((opt) => !effectiveValues.value.includes(opt.value))
    : props.field.options?.filter((opt) => !effectiveValues.value.includes(opt.value)) || []
)

// Filter options based on search input (client-side for static lists; async
// loaders have already filtered by the query).
const filteredSelectedOptions = computed(() => selectedOptions.value) // Keep all selected visible
const filteredUnselectedOptions = computed(() =>
  isAsync
    ? unselectedOptions.value
    : unselectedOptions.value.filter((opt) =>
        opt.label.toLowerCase().includes(searchInput.value.toLowerCase())
      )
)

const allFilteredOptions = computed(() => [
  ...filteredSelectedOptions.value,
  ...filteredUnselectedOptions.value,
])

function handleClose() {
  open.value = false
  emit("close")
}

// Toggle a single option, shared by the plain and custom (renderOptionList)
// renderers so both behave identically.
function toggleOption(option: FilterOption<unknown>) {
  const isSelected = effectiveValues.value.includes(option.value)
  const next = isSelected
    ? effectiveValues.value.filter((v) => v !== option.value)
    : isMultiSelect.value
      ? [...effectiveValues.value, option.value]
      : [option.value]

  if (
    !isSelected &&
    isMultiSelect.value &&
    props.field.maxSelections &&
    next.length > props.field.maxSelections
  ) {
    return
  }

  if (props.field.onValueChange) {
    props.field.onValueChange(next)
  } else {
    emit("change", next)
  }
  if (!isMultiSelect.value) handleClose()
}

function isOptionSelected(option: FilterOption<unknown>): boolean {
  return effectiveValues.value.includes(option.value)
}

// Render-prop callback passed to `field.renderOptionList` (bring-your-own
// list rendering, e.g. virtualization). Reproduces one `renderOptionItem`
// row using `h()` since a render-prop callback in Vue must build VNodes
// directly rather than through a `<template>`.
function renderOption(option: FilterOption<unknown>, overallIndex: number): VNodeChild {
  const isSelected = isOptionSelected(option)
  const isHighlighted = highlightedIndex.value === overallIndex
  return h(
    DropdownMenuCheckboxItem,
    {
      key: String(option.value),
      id: `${baseId}-item-${overallIndex}`,
      role: "option",
      "aria-selected": isHighlighted,
      "data-highlighted": isHighlighted || undefined,
      modelValue: isSelected,
      class: cn(
        "data-highlighted:bg-accent data-highlighted:text-accent-foreground",
        option.class
      ),
      onMouseenter: () => (highlightedIndex.value = overallIndex),
      onSelect: (e: Event) => {
        if (isMultiSelect.value) e.preventDefault()
      },
      "onUpdate:modelValue": () => toggleOption(option),
    },
    {
      default: () => [
        option.icon ? option.icon() : null,
        h("span", { class: "truncate" }, option.label),
      ],
    }
  )
}

function onRootKeydown(e: KeyboardEvent) {
  if (e.key === "ArrowDown") {
    e.preventDefault()
    if (allFilteredOptions.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value < allFilteredOptions.value.length - 1
          ? highlightedIndex.value + 1
          : 0
    }
  } else if (e.key === "ArrowUp") {
    e.preventDefault()
    if (allFilteredOptions.value.length > 0) {
      highlightedIndex.value =
        highlightedIndex.value > 0
          ? highlightedIndex.value - 1
          : allFilteredOptions.value.length - 1
    }
  } else if (e.key === "ArrowLeft") {
    e.preventDefault()
    open.value = false
  } else if (e.key === "Enter" && highlightedIndex.value >= 0) {
    e.preventDefault()
    const option = allFilteredOptions.value[highlightedIndex.value]
    if (option) {
      toggleOption(option)
    }
  }
  e.stopPropagation()
}

function onOpenChange(next: boolean) {
  open.value = next
  if (!next) {
    setTimeout(() => {
      searchInput.value = ""
    }, 200)
  }
}
</script>

<template>
  <div v-if="props.inline" class="w-full">
    <template v-if="field.searchable !== false">
      <Input
        role="combobox"
        aria-autocomplete="list"
        :aria-expanded="true"
        aria-haspopup="listbox"
        :aria-controls="`${baseId}-listbox`"
        :aria-activedescendant="highlightedIndex >= 0 ? `${baseId}-item-${highlightedIndex}` : undefined"
        :placeholder="context.i18n.value.placeholders.searchField(field.label || '')"
        class="border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
        :value="searchInput"
        @input="(e: Event) => (searchInput = (e.target as HTMLInputElement).value)"
        @click.stop
        @keydown="onRootKeydown"
      />
      <DropdownMenuSeparator />
    </template>
    <div class="relative flex max-h-full">
      <div
        class="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain"
        role="listbox"
        :id="`${baseId}-listbox`"
      >
        <div v-if="isAsync && loading && allFilteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
          {{ context.i18n.value.loadingOptions ?? DEFAULT_I18N.loadingOptions }}
        </div>
        <div v-else-if="isAsync && error" class="text-muted-foreground py-2 text-center text-sm">
          {{ context.i18n.value.errorLoadingOptions ?? DEFAULT_I18N.errorLoadingOptions }}
        </div>
        <div v-else-if="allFilteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
          {{ context.i18n.value.noResultsFound }}
        </div>
        <component
          v-else-if="field.renderOptionList"
          :is="() => field.renderOptionList!({ options: allFilteredOptions, highlightedIndex, renderOption })"
        />
        <ScrollArea
          v-else
          class="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain"
        >
          <DropdownMenuGroup v-if="filteredSelectedOptions.length > 0" class="px-1">
            <DropdownMenuCheckboxItem
              v-for="(option, index) in filteredSelectedOptions"
              :key="String(option.value)"
              :id="`${baseId}-item-${index}`"
              role="option"
              :aria-selected="highlightedIndex === index"
              :data-highlighted="highlightedIndex === index || undefined"
              :model-value="isOptionSelected(option)"
              :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground', option.class)"
              @mouseenter="highlightedIndex = index"
              @select="(e: Event) => isMultiSelect && e.preventDefault()"
              @update:model-value="() => toggleOption(option)"
            >
              <component :is="option.icon" v-if="option.icon" />
              <span class="truncate">{{ option.label }}</span>
            </DropdownMenuCheckboxItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator v-if="filteredSelectedOptions.length > 0 && filteredUnselectedOptions.length > 0" class="mx-0" />
          <DropdownMenuGroup v-if="filteredUnselectedOptions.length > 0" class="px-1">
            <DropdownMenuCheckboxItem
              v-for="(option, index) in filteredUnselectedOptions"
              :key="String(option.value)"
              :id="`${baseId}-item-${index + filteredSelectedOptions.length}`"
              role="option"
              :aria-selected="highlightedIndex === index + filteredSelectedOptions.length"
              :data-highlighted="highlightedIndex === index + filteredSelectedOptions.length || undefined"
              :model-value="isOptionSelected(option)"
              :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground', option.class)"
              @mouseenter="highlightedIndex = index + filteredSelectedOptions.length"
              @select="(e: Event) => isMultiSelect && e.preventDefault()"
              @update:model-value="() => toggleOption(option)"
            >
              <component :is="option.icon" v-if="option.icon" />
              <span class="truncate">{{ option.label }}</span>
            </DropdownMenuCheckboxItem>
          </DropdownMenuGroup>
        </ScrollArea>
      </div>
    </div>
  </div>

  <DropdownMenu v-else :open="open" @update:open="onOpenChange">
    <DropdownMenuTrigger as-child>
      <Button variant="outline" :size="context.size.value">
        <div class="flex items-center gap-1.5">
          <template v-if="field.customValueRenderer">
            <component
              :is="() => field.customValueRenderer!(props.values, isAsync ? resolveSelected(props.values) : field.options || [])"
            />
          </template>
          <template v-else>
            <div v-if="selectedOptions.length > 0" class="flex items-center -space-x-1.5">
              <div v-for="option in selectedOptions.slice(0, 3)" :key="String(option.value)">
                <component :is="option.icon" v-if="option.icon" />
              </div>
            </div>
            {{
              selectedOptions.length === 1
                ? selectedOptions[0]!.label
                : selectedOptions.length > 1
                  ? `${selectedOptions.length} ${context.i18n.value.selectedCount}`
                  : context.i18n.value.select
            }}
          </template>
        </div>
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="start" :class="cn('w-[200px] px-0', field.class)">
      <template v-if="field.searchable !== false">
        <Input
          role="combobox"
          aria-autocomplete="list"
          :aria-expanded="true"
          aria-haspopup="listbox"
          :aria-controls="`${baseId}-listbox`"
          :aria-activedescendant="highlightedIndex >= 0 ? `${baseId}-item-${highlightedIndex}` : undefined"
          :placeholder="context.i18n.value.placeholders.searchField(field.label || '')"
          class="border-input h-8 rounded-none border-0 bg-transparent! px-2 text-sm shadow-none focus-visible:border-border focus-visible:ring-0 focus-visible:ring-offset-0"
          :value="searchInput"
          @input="(e: Event) => (searchInput = (e.target as HTMLInputElement).value)"
          @click.stop
          @keydown="onRootKeydown"
        />
        <DropdownMenuSeparator />
      </template>
      <div class="relative flex max-h-full">
        <div
          class="flex max-h-[min(var(--radix-dropdown-menu-content-available-height),24rem)] w-full scroll-pt-2 scroll-pb-2 flex-col overscroll-contain"
          role="listbox"
          :id="`${baseId}-listbox`"
        >
          <div v-if="isAsync && loading && allFilteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
            {{ context.i18n.value.loadingOptions ?? DEFAULT_I18N.loadingOptions }}
          </div>
          <div v-else-if="isAsync && error" class="text-muted-foreground py-2 text-center text-sm">
            {{ context.i18n.value.errorLoadingOptions ?? DEFAULT_I18N.errorLoadingOptions }}
          </div>
          <div v-else-if="allFilteredOptions.length === 0" class="text-muted-foreground py-2 text-center text-sm">
            {{ context.i18n.value.noResultsFound }}
          </div>
          <component
          v-else-if="field.renderOptionList"
          :is="() => field.renderOptionList!({ options: allFilteredOptions, highlightedIndex, renderOption })"
        />
          <ScrollArea
            v-else
            class="size-full min-h-0 **:data-[slot=scroll-area-scrollbar]:m-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain"
          >
            <DropdownMenuGroup v-if="filteredSelectedOptions.length > 0" class="px-1">
              <DropdownMenuCheckboxItem
                v-for="(option, index) in filteredSelectedOptions"
                :key="String(option.value)"
                :id="`${baseId}-item-${index}`"
                role="option"
                :aria-selected="highlightedIndex === index"
                :data-highlighted="highlightedIndex === index || undefined"
                :model-value="isOptionSelected(option)"
                :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground', option.class)"
                @mouseenter="highlightedIndex = index"
                @select="(e: Event) => isMultiSelect && e.preventDefault()"
                @update:model-value="() => toggleOption(option)"
              >
                <component :is="option.icon" v-if="option.icon" />
                <span class="truncate">{{ option.label }}</span>
              </DropdownMenuCheckboxItem>
            </DropdownMenuGroup>
            <DropdownMenuSeparator v-if="filteredSelectedOptions.length > 0 && filteredUnselectedOptions.length > 0" class="mx-0" />
            <DropdownMenuGroup v-if="filteredUnselectedOptions.length > 0" class="px-1">
              <DropdownMenuCheckboxItem
                v-for="(option, index) in filteredUnselectedOptions"
                :key="String(option.value)"
                :id="`${baseId}-item-${index + filteredSelectedOptions.length}`"
                role="option"
                :aria-selected="highlightedIndex === index + filteredSelectedOptions.length"
                :data-highlighted="highlightedIndex === index + filteredSelectedOptions.length || undefined"
                :model-value="isOptionSelected(option)"
                :class="cn('data-highlighted:bg-accent data-highlighted:text-accent-foreground', option.class)"
                @mouseenter="highlightedIndex = index + filteredSelectedOptions.length"
                @select="(e: Event) => isMultiSelect && e.preventDefault()"
                @update:model-value="() => toggleOption(option)"
              >
                <component :is="option.icon" v-if="option.icon" />
                <span class="truncate">{{ option.label }}</span>
              </DropdownMenuCheckboxItem>
            </DropdownMenuGroup>
          </ScrollArea>
        </div>
      </div>
    </DropdownMenuContent>
  </DropdownMenu>
</template>

src/reui/filters/context.ts

/**
 * Порт ReUI Filters (registry-reui/bases/radix/reui/filters.tsx, MIT).
 *
 * Компонент дженерик (`Filters<T>`, `FilterGroup<T>`, `createFilter<T>`).
 * Во Vue корневые экспортируемые компоненты (`Filters.vue`,
 * `FiltersContent.vue`) объявляют `<script setup lang="ts" generic="T">`
 * (см. docs/PORTING.md и прецедент `packages/ui/src/reui/sortable/Sortable.vue`).
 * Внутренние подкомпоненты (FilterInput, FilterRemoveButton,
 * FilterOperatorDropdown, SelectOptionsPopover, FilterValueSelector,
 * FilterSubmenuContent) НЕ дженерики — тот же выбор, что уже сделан для
 * `SortableItem.vue` рядом с дженериком `Sortable.vue`: связывание одного
 * и того же параметра типа между независимыми `.vue`-файлами через
 * vue-tsc ненадёжно на глубину нескольких компонентов, поэтому внутренняя
 * прослойка работает с `unknown`, а типобезопасность дженерика сохраняется
 * только на публичной границе (`Filters`/`FiltersContent`/`createFilter`/
 * `createFilterGroup`).
 *
 * React `createContext`/`useContext` (`FilterContext`) перенесён в
 * provide/inject-ключ `FilterContextKey` + композабл `useFilterContext()`,
 * который (как и React-версия) возвращает объект контекста по умолчанию,
 * если использован вне `<Filters>`/`<FiltersContent>`.
 */
import type { ComputedRef, Ref, VNodeChild } from "vue"
import { computed, inject, ref, watch, type InjectionKey } from "vue"

// ---------------------------------------------------------------------------
// i18n
// ---------------------------------------------------------------------------

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
  // Async option loading states (optional; fall back to sensible defaults)
  loadingOptions?: string
  errorLoadingOptions?: 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: "Filter",
  searchFields: "Filter...",
  noFieldsFound: "No filters 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",
  loadingOptions: "Loading...",
  errorLoadingOptions: "Failed to load options.",

  // 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
// ---------------------------------------------------------------------------

/**
 * Поля — `ComputedRef`, а не голые значения: тот же приём, что и в
 * `StepperContextValue` (packages/ui/src/reui/stepper/context.ts) — Vue
 * `provide`/`inject` не переоборачивает значение реактивно само по себе,
 * поэтому чтобы изменение пропов `<Filters>` доходило до потребителей
 * контекста (как в React, где `Provider` перерендеривает детей при новом
 * `value`), сюда кладутся `computed()`, а не снятые `.value`. Читается как
 * `context.size.value`.
 */
export interface FilterContextValue {
  variant: ComputedRef<"solid" | "default">
  size: ComputedRef<"sm" | "default" | "lg">
  radius: ComputedRef<"default" | "full">
  i18n: ComputedRef<FilterI18nConfig>
  class: ComputedRef<string | undefined>
  showSearchInput: ComputedRef<boolean>
  allowMultiple: ComputedRef<boolean>
}

export const FilterContextKey: InjectionKey<FilterContextValue> =
  Symbol("FilterContext")

const DEFAULT_FILTER_CONTEXT: FilterContextValue = {
  variant: computed(() => "default"),
  size: computed(() => "default"),
  radius: computed(() => "default"),
  i18n: computed(() => DEFAULT_I18N),
  class: computed(() => undefined),
  showSearchInput: computed(() => true),
  allowMultiple: computed(() => true),
}

export function useFilterContext(): FilterContextValue {
  return inject(FilterContextKey, DEFAULT_FILTER_CONTEXT)
}

// ---------------------------------------------------------------------------
// Generic types for flexible filter system
// ---------------------------------------------------------------------------

export interface FilterOption<T = unknown> {
  value: T
  label: string
  icon?: () => VNodeChild
  metadata?: Record<string, unknown>
  class?: string
}

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
}

// Props passed to a field's `renderOptionList` slot/callback. Lets a consumer
// render the options list however they like (e.g. windowing / virtualization
// with a library of their choice) while staying bound to the primitive's
// selection and keyboard behavior.
export interface FilterOptionListRenderProps<T = unknown> {
  // Options to render: already resolved, query-filtered, and selected-first.
  options: FilterOption<T>[]
  // Index into `options` of the keyboard-highlighted row (-1 if none). A
  // virtualized implementation should scroll this row into view and keep it
  // mounted so the combobox's aria-activedescendant stays valid.
  highlightedIndex: number
  // Renders one option row with the correct id, selection state, highlight,
  // and toggle handler wired to the primitive. Call it for each row you render.
  renderOption: (option: FilterOption<T>, index: number) => VNodeChild
}

// 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?: () => VNodeChild
  type?: "select" | "multiselect" | "text" | "custom" | "separator"
  // Group-level configuration
  group?: string
  fields?: FilterFieldConfig<T>[]
  // Field-specific options
  options?: FilterOption<T>[]
  // Async / large-list options loader. Receives the current search query and
  // may return a Promise. Use it to prefetch a remote list once (ignore the
  // query) or to run server-side search (filter by the query). When both
  // `options` and `loadOptions` are provided, `options` seeds the initial
  // view and the value->label cache while `loadOptions` supplies live results.
  loadOptions?: (
    query: string
  ) => FilterOption<T>[] | Promise<FilterOption<T>[]>
  // Bring-your-own rendering for the options list (e.g. virtualization with a
  // windowing library of your choice). Return the full scrollable list, call
  // `renderOption` for each row, and scroll `highlightedIndex` into view.
  // When omitted, the options render as a plain scrollable list.
  renderOptionList?: (props: FilterOptionListRenderProps<T>) => VNodeChild
  operators?: FilterOperator[]
  customRenderer?: (props: CustomRendererProps<T>) => VNodeChild
  customValueRenderer?: (values: T[], options: FilterOption<T>[]) => VNodeChild
  placeholder?: string
  searchable?: boolean
  maxSelections?: number
  min?: number
  max?: number
  step?: number
  prefix?: string | VNodeChild
  suffix?: string | VNodeChild
  pattern?: string
  validation?: (
    value: unknown
  ) => boolean | { valid: boolean; message?: string }
  allowCustomValues?: boolean
  class?: string
  menuPopupClass?: string
  // Grouping options (legacy support)
  groupLabel?: string
  // Boolean field options
  onLabel?: string
  offLabel?: string
  // 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
// ---------------------------------------------------------------------------

export const isFieldGroup = (
  item: FilterFieldConfig<unknown> | FilterFieldGroup<unknown>
): item is FilterFieldGroup<unknown> => {
  return "fields" in item && Array.isArray(item.fields)
}

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

export const flattenFields = (
  fields: FilterFieldsConfig<unknown>
): FilterFieldConfig<unknown>[] => {
  return fields.reduce<FilterFieldConfig<unknown>[]>((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]
  }, [])
}

export const getFieldsMap = (
  fields: FilterFieldsConfig<unknown>
): Record<string, FilterFieldConfig<unknown>> => {
  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<unknown>>
  )
}

// Whether a field exposes any option source (a static list or an async
// loader). IMPORTANT: never gate on `field.options?.length` once
// `loadOptions` exists -- a function's `.length` is its arity, not an option
// count, which silently breaks the submenu gate for async fields.
export const fieldHasOptions = (field: FilterFieldConfig<unknown>): boolean =>
  (field.options?.length ?? 0) > 0 || typeof field.loadOptions === "function"

// ---------------------------------------------------------------------------
// Operators
// ---------------------------------------------------------------------------

// Helper function to create operators from i18n config
export 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 },
  ],
  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 },
  ],
  custom: [
    { value: "is", label: i18n.operators.is },
    { value: "after", label: i18n.operators.after },
    { 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
export const getOperatorsForField = (
  field: FilterFieldConfig<unknown>,
  values: unknown[],
  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 ?? []
}

// ---------------------------------------------------------------------------
// Value -> option cache
// ---------------------------------------------------------------------------

// Value->option cache shared across every component instance rendering the
// SAME field object (the Add Filter submenu and the active-filter chip both
// receive the same config reference from the fields map). Keyed by the field
// object so it is shared when fields are memoized and garbage-collected
// otherwise. This keeps a value selected in the submenu labelled in the chip.
const fieldOptionCaches = new WeakMap<object, Map<unknown, FilterOption<unknown>>>()

export const getFieldOptionCache = (
  field: FilterFieldConfig<unknown>
): Map<unknown, FilterOption<unknown>> => {
  let cache = fieldOptionCaches.get(field as object)
  if (!cache) {
    cache = new Map()
    fieldOptionCaches.set(field as object, cache)
  }
  return cache
}

export interface ResolvedFieldOptions {
  isAsync: boolean
  options: Ref<FilterOption<unknown>[]>
  loading: Ref<boolean>
  error: Ref<boolean>
  // Resolve selected values to full options using an accumulating
  // value->option cache, so async/controlled selections keep their label and
  // icon even when absent from the latest result page.
  resolveSelected: (values: unknown[]) => FilterOption<unknown>[]
}

// Resolves a field's options for a popover/submenu. Static fields return
// their list verbatim (unchanged legacy behavior). Async fields
// (`loadOptions`) debounce the query, guard against out-of-order responses,
// and expose loading/error state plus a value->label cache.
//
// React's `useFieldOptions` is a hook re-evaluated on every render with fresh
// closures; this composable is set up once per component instance and reacts
// to `searchInput`/`enabled` via `watch`, which is the equivalent behavior
// (see docs/PORTING.md: useState -> ref, useEffect -> watch).
export function useFieldOptions(
  field: FilterFieldConfig<unknown>,
  searchInput: Ref<string>,
  enabled: Ref<boolean>
): ResolvedFieldOptions {
  const isAsync = typeof field.loadOptions === "function"

  // Seed the shared cache from any static options an async field also
  // provides (static fields never read this cache, so skip the work for them).
  if (isAsync && field.options) {
    const cache = getFieldOptionCache(field)
    for (const opt of field.options) {
      cache.set(opt.value, opt)
    }
  }

  const asyncOptions = ref<FilterOption<unknown>[]>(field.options ?? [])
  const loading = ref(false)
  const error = ref(false)

  // Debounce the query for async fields to avoid a request per keystroke.
  const debouncedQuery = ref(searchInput.value)
  let debounceTimer: ReturnType<typeof setTimeout> | undefined
  if (isAsync) {
    watch(searchInput, (value) => {
      if (debounceTimer) clearTimeout(debounceTimer)
      debounceTimer = setTimeout(() => {
        debouncedQuery.value = value
      }, 250)
    })
  }

  let requestId = 0
  if (isAsync) {
    watch(
      [debouncedQuery, enabled],
      () => {
        if (!enabled.value) return
        const loader = field.loadOptions
        if (!loader) return

        const currentRequestId = ++requestId
        loading.value = true
        error.value = false

        Promise.resolve()
          .then(() => loader(debouncedQuery.value))
          .then((result) => {
            // Ignore stale responses (out-of-order guard).
            if (currentRequestId !== requestId) return
            const cache = getFieldOptionCache(field)
            for (const opt of result) cache.set(opt.value, opt)
            asyncOptions.value = result
            loading.value = false
            error.value = false
          })
          .catch(() => {
            if (currentRequestId !== requestId) return
            loading.value = false
            error.value = true
          })
      },
      { immediate: true }
    )
  }

  const resolveSelected = (values: unknown[]): FilterOption<unknown>[] => {
    const cache = getFieldOptionCache(field)
    return values.map(
      (value) => cache.get(value) ?? { value, label: String(value) }
    )
  }

  if (!isAsync) {
    return {
      isAsync: false,
      options: computed(() => field.options ?? []),
      loading: computed(() => false),
      error: computed(() => false),
      resolveSelected,
    }
  }

  return {
    isAsync: true,
    options: asyncOptions,
    loading,
    error,
    resolveSelected,
  }
}

// ---------------------------------------------------------------------------
// Filter / FilterGroup data types + factories
// ---------------------------------------------------------------------------

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>[]
}

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,
})

src/reui/filters/index.ts

export { default as Filters } from "./Filters.vue"
export { default as FiltersContent } from "./FiltersContent.vue"
export {
  createFilter,
  createFilterGroup,
  DEFAULT_I18N,
  DEFAULT_OPERATORS,
  useFilterContext,
  FilterContextKey,
  type CustomRendererProps,
  type Filter,
  type FilterContextValue,
  type FilterFieldConfig,
  type FilterFieldGroup,
  type FilterFieldsConfig,
  type FilterGroup,
  type FilterI18nConfig,
  type FilterOperator,
  type FilterOption,
  type FilterOptionListRenderProps,
} from "./context"
export { filtersContainerVariants } from "./variants"

src/reui/filters/variants.ts

import { cva } from "class-variance-authority"

/**
 * Перенесено дословно из ReUI (registry-reui/bases/radix/reui/filters.tsx, MIT).
 *
 * cva-блоки — чистые JS-объекты и переносятся между React и Vue без единой
 * правки. Вся визуальная семантика живёт здесь, а фреймворк-специфичной
 * остаётся только разметка.
 */
export const filtersContainerVariants = cva("flex flex-wrap items-center", {
  variants: {
    variant: {
      solid: "gap-2",
      default: "",
    },
    size: {
      sm: "gap-1.5",
      default: "gap-2.5",
      lg: "gap-3.5",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "default",
  },
})

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/filters.json

npm-зависимости

  • class-variance-authority

Источник: порт из ReUI (Keenthemes, MIT)