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)