filters
Filters with custom controls
Filters with custom controls
Загрузка превью…
src/filters/c-filters-6.vue
<!--
Расхождение с апстримом: `IconPlaceholder` заменена инлайновым `<svg>`
(lucide-react v0.545.0 "calendar"/"clock"/"sliders-vertical"/
"list-filter"/"funnel-x") — см. docs/PORTING.md §5.
c-filters-6.tsx ("Filters with custom controls", 755 строк) — самый
крупный паттерн тройки {4,5,6}: он не меняет `fields`/`filters`
структуру данных, а кастомизирует РЕНДЕР значения каждого фильтра через
`field.customRenderer` — render-prop, уже заложенный в примитиве
(`packages/ui/src/reui/filters/context.ts`: `customRenderer?: (props:
CustomRendererProps<T>) => VNodeChild`, вызывается в
`FilterValueSelector.vue` через `<component :is="() => field
.customRenderer!(...)" />` — Vue вызывает функцию как функциональный
компонент без пропов и использует результат как VNode, см.
docs/PORTING.md "Непокрытая гейтом площадь", filters). Пять полей типа
`"custom"` подставляют вместо стандартного `SelectOptionsPopover`/
`FilterInput` собственные виджеты (Dialog-обёрнутый DateSelector,
Popover с Calendar в режиме диапазона, Popover с пресетами, Popover с
выбором даты и времени, Popover со Slider) — вынесены в
`_shared/Custom*.vue` (деталь реализации, не экспортируются из
index.ts, тот же приём, что `CheckboxTreeItem16.vue`/`SiteAvatar*.vue`).
Фиксированное состояние (docs/PORTING.md §3): меню "Add Filter" и все
попапы/диалоги кастомных виджетов не раскрываются — `autoFocus` у
каждого виджета вычисляется как `values === lastAddedValues.value`
(сравнение ссылки на массив, как в оригинале `values === lastAddedValues`),
и на первом рендере `lastAddedValues` всегда `null`, поэтому все виджеты
стартуют закрытыми, как и в апстриме. Единственный отрендеренный
изначально фильтр — "customDateRange" (`between`, значения `[]`).
`Filters`' `@change` обёрнут в `handleFiltersChange` (перенесён из
оригинала буквально, включая логику `lastAddedValues`) — не голый
no-op, т.к. эта логика — часть демонстрируемого поведения кастомных
контролов (правило 4a), но т.к. меню "Add Filter" не раскрывается, она
никогда не срабатывает в статике. Кнопка "Clear" (видна, пока
`filters.length > 0`) остаётся рабочей — тоже перенесена буквально.
-->
<script setup lang="ts">
import { h, ref } from "vue"
import { Filters, createFilter, type Filter, type FilterFieldConfig } from "@/components/reui/filters"
import { Button } from "@/components/ui/button"
import CustomModalDateSelector from "./_shared/CustomModalDateSelector.vue"
import CustomDateRangeInput from "./_shared/CustomDateRangeInput.vue"
import CustomDateRangeWithPresetsInput from "./_shared/CustomDateRangeWithPresetsInput.vue"
import CustomDateTimeInput from "./_shared/CustomDateTimeInput.vue"
import CustomSliderRangeInput from "./_shared/CustomSliderRangeInput.vue"
function iconSvg(paths: Array<[string, Record<string, string>]>, className = "size-3.5") {
return () =>
h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
class: className,
},
paths.map(([tag, attrs]) => h(tag, attrs))
)
}
const CalendarIcon = iconSvg([
["path", { d: "M8 2v4" }],
["path", { d: "M16 2v4" }],
["rect", { width: "18", height: "18", x: "3", y: "4", rx: "2" }],
["path", { d: "M3 10h18" }],
])
const ClockIcon = iconSvg([
["path", { d: "M12 6v6l4 2" }],
["circle", { cx: "12", cy: "12", r: "10" }],
])
const SlidersVerticalIcon = iconSvg([
["path", { d: "M10 8h4" }],
["path", { d: "M12 21v-9" }],
["path", { d: "M12 8V3" }],
["path", { d: "M17 16h4" }],
["path", { d: "M19 12V3" }],
["path", { d: "M19 21v-5" }],
["path", { d: "M3 14h4" }],
["path", { d: "M5 10V3" }],
["path", { d: "M5 21v-7" }],
])
function ListFilterIcon() {
return h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
[h("path", { d: "M2 5h20" }), h("path", { d: "M6 12h12" }), h("path", { d: "M9 19h6" })]
)
}
function FunnelXIcon() {
return h(
"svg",
{
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2",
"stroke-linecap": "round",
"stroke-linejoin": "round",
},
[
h("path", {
d: "M12.531 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14v6a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341l.427-.473",
}),
h("path", { d: "m16.5 3.5 5 5" }),
h("path", { d: "m21.5 3.5-5 5" }),
]
)
}
const filters = ref<Filter<string>[]>([createFilter<string>("customDateRange", "between", [])])
const lastAddedValues = ref<unknown[] | null>(null)
const fields: FilterFieldConfig<string>[] = [
{
key: "modalDateSelector",
label: "Modal Date Selector",
icon: CalendarIcon,
type: "custom",
operators: [
{ value: "is", label: "is" },
{ value: "is_not", label: "is not" },
],
customRenderer: ({ values, onChange }) =>
h(CustomModalDateSelector, {
values,
onChange: (v: unknown[]) => onChange(v as string[]),
autoFocus: values === lastAddedValues.value,
}),
},
{
key: "customDateRange",
label: "Date Range",
icon: CalendarIcon,
type: "custom",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) =>
h(CustomDateRangeInput, {
values,
onChange: (v: unknown[]) => onChange(v as string[]),
autoFocus: values === lastAddedValues.value,
}),
},
{
key: "customDateRangePresets",
label: "Date Range Presets",
icon: CalendarIcon,
type: "custom",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) =>
h(CustomDateRangeWithPresetsInput, {
values,
onChange: (v: unknown[]) => onChange(v as string[]),
autoFocus: values === lastAddedValues.value,
}),
},
{
key: "customDateTime",
label: "Date & Time",
icon: ClockIcon,
type: "custom",
operators: [
{ value: "is", label: "is" },
{ value: "before", label: "before" },
{ value: "after", label: "after" },
],
customRenderer: ({ values, onChange }) =>
h(CustomDateTimeInput, {
values,
onChange: (v: unknown[]) => onChange(v as string[]),
autoFocus: values === lastAddedValues.value,
}),
},
{
key: "customSliderRange",
label: "Slider Range",
icon: SlidersVerticalIcon,
type: "custom",
class: "w-36",
operators: [
{ value: "between", label: "between" },
{ value: "not_between", label: "not between" },
],
customRenderer: ({ values, onChange }) =>
h(CustomSliderRangeInput, {
values,
onChange: (v: unknown[]) => onChange(v as string[]),
autoFocus: values === lastAddedValues.value,
}),
},
]
function handleFiltersChange(newFilters: Filter<string>[]) {
const added = newFilters.find((nf) => !filters.value.some((f) => f.id === nf.id))
if (added) lastAddedValues.value = added.values
filters.value = newFilters
}
</script>
<template>
<div class="flex grow content-start items-start gap-2.5 space-y-6 self-start">
<div class="flex-1">
<Filters :filters="filters" :fields="fields" @change="handleFiltersChange">
<template #trigger>
<Button variant="outline" size="icon">
<ListFilterIcon />
</Button>
</template>
</Filters>
</div>
<Button v-if="filters.length > 0" variant="outline" @click="filters = []">
<FunnelXIcon />
Clear
</Button>
</div>
</template>
src/filters/_shared/CustomModalDateSelector.vue
<!--
customRenderer для поля "modalDateSelector" в c-filters-6 (Filters with
custom controls) — DateSelector внутри Dialog вместо обычного попапа.
Порт `CustomModalDateSelector` из c-filters-6.tsx (755 строк). Деталь
реализации одного блока — не экспортируется из index.ts (тот же приём,
что и `CheckboxTreeItem16.vue`/`SiteAvatar*.vue`, см. docs/PORTING.md §9/§13).
Диалог закрыт по умолчанию (`open` стартует `false`), т.к. в статике
c-filters-6 не раскрывает попапы (docs/PORTING.md §3): `autoFocus` для
единственного отрендеренного фильтра в блоке всегда `false` (сравнение
ссылки на массив значений с `lastAddedValues`, который остаётся `null`,
пока пользователь не добавит фильтр интерактивно) — оригинал ведёт себя
так же на первом рендере.
-->
<script setup lang="ts">
import { ref, watch } from "vue"
import { DateSelector, formatDateValue, type DateSelectorValue } from "@/components/reui/date-selector"
import { Button } from "@/components/ui/button"
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
const props = defineProps<{
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}>()
const open = ref(false)
const value = () => props.values?.[0] as DateSelectorValue | undefined
const internalValue = ref<DateSelectorValue | undefined>(value())
const displayText = () => {
const v = value()
return (v ? formatDateValue(v) : "") || "Select a date"
}
watch(
() => props.autoFocus,
(autoFocus) => {
if (!autoFocus) return
setTimeout(() => (open.value = true), 400)
},
{ immediate: true }
)
watch(open, (isOpen) => {
if (isOpen) internalValue.value = value()
})
function handleApply() {
props.onChange([internalValue.value])
open.value = false
}
</script>
<template>
<Dialog v-model:open="open">
<DialogTrigger>{{ displayText() }}</DialogTrigger>
<DialogContent class="sm:max-w-lg" :show-close-button="false">
<DialogHeader>
<DialogTitle>Select Date</DialogTitle>
</DialogHeader>
<DateSelector :value="internalValue" show-input @change="(v) => (internalValue = v)" />
<DialogFooter>
<DialogClose as-child>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button @click="handleApply">Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
src/filters/_shared/CustomDateRangeInput.vue
<!--
customRenderer для поля "customDateRange" в c-filters-6 — диапазон дат
через Popover + Calendar(mode="range"). Порт `CustomDateRangeInput` из
c-filters-6.tsx. Деталь реализации, не экспортируется из index.ts.
Известный пробел примитива (docs/PORTING.md §13, `block-calendar`):
`packages/ui/src/ui/calendar/Calendar.vue` поддерживает только выбор
одной даты (`v-model="Date"`), без `mode="range"`. Оригинал выбирает
диапазон через `react-day-picker`'s `mode="range"`. Popover здесь
никогда не раскрывается в статике блока (docs/PORTING.md §3), поэтому
`Calendar` внутри `PopoverContent` не монтируется и это расхождение не
видно — но воспроизвести реальный выбор диапазона, если попап всё же
открыть, порт не может без отдельного `RangeCalendarRoot`-компонента
(вне области этой задачи, примитив не трогаем). Здесь `Calendar`
привязан к `date.from` как заведомо неполная, но компилируемая замена.
-->
<script setup lang="ts">
import { ref, watch } from "vue"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { format } from "date-fns"
interface DateRangeValue {
from?: Date
to?: Date
}
const props = defineProps<{
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}>()
const date = ref<DateRangeValue | undefined>(
props.values?.[0] && typeof props.values[0] === "string"
? {
from: new Date(props.values[0] as string),
to:
props.values[1] && typeof props.values[1] === "string"
? new Date(props.values[1] as string)
: undefined,
}
: undefined
)
const isOpen = ref(false)
watch(
() => props.autoFocus,
(autoFocus) => {
if (!autoFocus) return
setTimeout(() => (isOpen.value = true), 400)
},
{ immediate: true }
)
function handleApply() {
if (date.value?.from) {
const fromStr = date.value.from.toISOString().split("T")[0]
const toStr = date.value.to ? date.value.to.toISOString().split("T")[0] : fromStr
props.onChange([fromStr, toStr])
}
isOpen.value = false
}
function handleCancel() {
isOpen.value = false
}
// Calendar (mode="single", default here) only ever emits a single
// `Date | undefined` - the wider union is the primitive's general
// (multiple/range) signature.
function onSelect(selected: Date | Date[] | { from?: Date; to?: Date } | undefined) {
date.value = { from: selected instanceof Date ? selected : undefined, to: date.value?.to }
}
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger>
<template v-if="date?.from">
<template v-if="date.to">{{ `${format(date.from, "LLL dd, y")} - ${format(date.to, "LLL dd, y")}` }}</template>
<template v-else>{{ format(date.from, "LLL dd, y") }}</template>
</template>
<span v-else>Pick a date range</span>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="start" :side-offset="8">
<Calendar :model-value="date?.from" @update:model-value="onSelect" />
<div class="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" @click="handleCancel">Cancel</Button>
<Button @click="handleApply">Apply</Button>
</div>
</PopoverContent>
</Popover>
</template>
src/filters/_shared/CustomDateRangeWithPresetsInput.vue
<!--
customRenderer для поля "customDateRangePresets" в c-filters-6 — то же,
что CustomDateRangeInput.vue, плюс список готовых пресетов слева от
календаря. Порт `CustomDateRangeWithPresetsInput` из c-filters-6.tsx.
Деталь реализации, не экспортируется из index.ts. Тот же пробел
примитива, что в CustomDateRangeInput.vue (`Calendar` без `mode="range"`,
см. комментарий там) — Popover здесь тоже никогда не раскрывается в
статике блока, расхождение не проявляется.
-->
<script setup lang="ts">
import { ref, watch, computed } from "vue"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import {
endOfMonth,
endOfYear,
format,
isEqual,
startOfDay,
startOfMonth,
startOfYear,
subDays,
subMonths,
subYears,
} from "date-fns"
interface DateRangeValue {
from?: Date
to?: Date
}
const props = defineProps<{
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}>()
// ponytail: `today` вычисляется один раз при создании компонента (как
// `useMemo(() => new Date(), [])` в оригинале), а не при каждом рендере —
// Popover всё равно никогда не раскрывается в статике блока.
const today = new Date()
const presets = [
{ label: "Today", range: { from: today, to: today } },
{ label: "Yesterday", range: { from: subDays(today, 1), to: subDays(today, 1) } },
{ label: "Last 7 days", range: { from: subDays(today, 6), to: today } },
{ label: "Last 30 days", range: { from: subDays(today, 29), to: today } },
{ label: "Month to date", range: { from: startOfMonth(today), to: today } },
{
label: "Last month",
range: { from: startOfMonth(subMonths(today, 1)), to: endOfMonth(subMonths(today, 1)) },
},
{ label: "Year to date", range: { from: startOfYear(today), to: today } },
{
label: "Last year",
range: { from: startOfYear(subYears(today, 1)), to: endOfYear(subYears(today, 1)) },
},
]
const date = ref<DateRangeValue | undefined>(
props.values?.[0] && typeof props.values[0] === "string"
? {
from: new Date(props.values[0] as string),
to:
props.values[1] && typeof props.values[1] === "string"
? new Date(props.values[1] as string)
: undefined,
}
: undefined
)
const isOpen = ref(false)
const selectedPreset = computed(() => {
const matched = presets.find(
(preset) =>
isEqual(startOfDay(preset.range.from), startOfDay(date.value?.from || new Date(0))) &&
isEqual(startOfDay(preset.range.to), startOfDay(date.value?.to || new Date(0)))
)
return matched?.label ?? null
})
watch(
() => props.autoFocus,
(autoFocus) => {
if (!autoFocus) return
setTimeout(() => (isOpen.value = true), 400)
},
{ immediate: true }
)
function handleApply() {
if (date.value?.from) {
const fromStr = date.value.from.toISOString().split("T")[0]
const toStr = date.value.to ? date.value.to.toISOString().split("T")[0] : fromStr
props.onChange([fromStr, toStr])
}
isOpen.value = false
}
function handleCancel() {
isOpen.value = false
}
// Calendar (mode="single", default here) only ever emits a single
// `Date | undefined` - the wider union is the primitive's general
// (multiple/range) signature.
function onSelect(selected: Date | Date[] | { from?: Date; to?: Date } | undefined) {
date.value = { from: selected instanceof Date ? selected : undefined, to: date.value?.to }
}
function handlePresetSelect(preset: (typeof presets)[number]) {
date.value = preset.range
}
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger>
<template v-if="date?.from">{{ `${format(date.from, "LLL dd, y")}${date.to ? ` - ${format(date.to, "LLL dd, y")}` : ""}` }}</template>
<span v-else>Pick a date range with presets</span>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" align="center" :side-offset="8">
<div class="flex max-sm:flex-col">
<div class="border-border relative max-sm:order-1 max-sm:border-t sm:w-32">
<div class="border-border h-full py-2 sm:border-e">
<div class="flex flex-col gap-[2px] px-2">
<Button
v-for="preset in presets"
:key="preset.label"
type="button"
variant="ghost"
:class="cn('h-8 w-full justify-start', selectedPreset === preset.label && 'bg-accent')"
@click="handlePresetSelect(preset)"
>
{{ preset.label }}
</Button>
</div>
</div>
</div>
<Calendar :model-value="date?.from" @update:model-value="onSelect" />
</div>
<div class="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" @click="handleCancel">Cancel</Button>
<Button @click="handleApply">Apply</Button>
</div>
</PopoverContent>
</Popover>
</template>
src/filters/_shared/CustomDateTimeInput.vue
<!--
customRenderer для поля "customDateTime" в c-filters-6 — дата + слот
времени через Popover + Calendar + сетка кнопок-таймслотов в ScrollArea.
Порт `CustomDateTimeInput` из c-filters-6.tsx. Деталь реализации, не
экспортируется из index.ts.
Известный пробел примитива: оригинал передаёт `Calendar`'ю
`disabled={[{ before: today }]}` (react-day-picker matcher, блокирует
прошлые даты). `packages/ui/src/ui/calendar/Calendar.vue` принимает
только булев `disabled` (блокирует весь календарь целиком, см. сам
файл), предикатов по диапазону не поддерживает — не перенесено (тот же
класс пробела, что и `mode="range"` в CustomDateRangeInput.vue).
Popover не раскрывается в статике блока, расхождение не проявляется.
-->
<script setup lang="ts">
import { ref, watch } from "vue"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { ScrollArea } from "@/components/ui/scroll-area"
import { format } from "date-fns"
const props = defineProps<{
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}>()
const date = ref<Date | undefined>(
props.values?.[0] && typeof props.values[0] === "string" ? new Date(props.values[0] as string) : undefined
)
const time = ref<string | undefined>(
props.values?.[0] && typeof props.values[0] === "string"
? new Date(props.values[0] as string).toTimeString().slice(0, 5)
: "10:00"
)
const isOpen = ref(false)
const timeSlots = [
{ time: "09:00", available: false },
{ time: "09:30", available: false },
{ time: "10:00", available: true },
{ time: "10:30", available: true },
{ time: "11:00", available: true },
{ time: "11:30", available: true },
{ time: "12:00", available: false },
{ time: "12:30", available: true },
{ time: "13:00", available: true },
{ time: "13:30", available: true },
{ time: "14:00", available: true },
{ time: "14:30", available: false },
{ time: "15:00", available: false },
{ time: "15:30", available: true },
{ time: "16:00", available: true },
{ time: "16:30", available: true },
{ time: "17:00", available: true },
{ time: "17:30", available: true },
{ time: "18:00", available: true },
{ time: "18:30", available: true },
{ time: "19:00", available: true },
{ time: "19:30", available: true },
{ time: "20:00", available: true },
{ time: "20:30", available: true },
{ time: "21:00", available: true },
{ time: "21:30", available: true },
{ time: "22:00", available: true },
{ time: "22:30", available: true },
{ time: "23:00", available: true },
{ time: "23:30", available: true },
]
watch(
() => props.autoFocus,
(autoFocus) => {
if (!autoFocus) return
setTimeout(() => (isOpen.value = true), 400)
},
{ immediate: true }
)
function handleApply() {
if (date.value && time.value) {
const dateTime = new Date(date.value)
const [hours, minutes] = time.value.split(":").map(Number)
dateTime.setHours(hours ?? 0, minutes ?? 0, 0, 0)
props.onChange([dateTime.toISOString()])
}
isOpen.value = false
}
function handleCancel() {
isOpen.value = false
}
// Calendar (mode="single", default here) only ever emits a single
// `Date | undefined` - the wider union is the primitive's general
// (multiple/range) signature.
function onSelect(newDate: Date | Date[] | { from?: Date; to?: Date } | undefined) {
if (newDate instanceof Date) {
date.value = newDate
time.value = undefined
}
}
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger>
<template v-if="date">{{ `${format(date, "PPP")}${time ? ` - ${time}` : ""}` }}</template>
<span v-else>Pick a date and time</span>
</PopoverTrigger>
<PopoverContent class="w-auto gap-0 p-0 pt-1" align="start">
<div class="flex max-sm:flex-col">
<Calendar :model-value="date" class="p-2 sm:pe-5" @update:model-value="onSelect" />
<div class="relative w-full max-sm:h-46 sm:w-40">
<div class="absolute inset-0 py-4 max-sm:border-t">
<ScrollArea class="h-full sm:border-s">
<div class="space-y-3">
<div class="flex h-5 shrink-0 items-center px-5">
<p class="text-sm font-medium">{{ date ? format(date, "EEEE, d") : "Pick a date" }}</p>
</div>
<div class="grid gap-1.5 px-5 max-sm:grid-cols-2">
<Button
v-for="slot in timeSlots"
:key="slot.time"
:variant="time === slot.time ? 'default' : 'outline'"
size="sm"
class="w-full"
:disabled="!slot.available"
@click="time = slot.time"
>
{{ slot.time }}
</Button>
</div>
</div>
</ScrollArea>
</div>
</div>
</div>
<div class="border-border flex items-center justify-end gap-1.5 border-t p-3">
<Button variant="outline" @click="handleCancel">Cancel</Button>
<Button @click="handleApply">Apply</Button>
</div>
</PopoverContent>
</Popover>
</template>
src/filters/_shared/CustomSliderRangeInput.vue
<!--
customRenderer для поля "customSliderRange" в c-filters-6 — диапазон
через Popover + двухбегунковый Slider. Порт `CustomSliderRangeInput` из
c-filters-6.tsx. Деталь реализации, не экспортируется из index.ts.
-->
<script setup lang="ts">
import { ref, watch } from "vue"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Slider } from "@/components/ui/slider"
const props = defineProps<{
values: unknown[]
onChange: (values: unknown[]) => void
autoFocus?: boolean
}>()
const initial = props.values?.[0]
const range = ref<number[]>(
initial && typeof initial === "object" && initial !== null && "min" in initial && "max" in initial
? [(initial as { min: number; max: number }).min, (initial as { min: number; max: number }).max]
: [0, 100]
)
const isOpen = ref(false)
watch(
() => props.autoFocus,
(autoFocus) => {
if (!autoFocus) return
setTimeout(() => (isOpen.value = true), 400)
},
{ immediate: true }
)
function handleApply() {
props.onChange([{ min: range.value[0], max: range.value[1] }])
isOpen.value = false
}
function handleCancel() {
isOpen.value = false
}
</script>
<template>
<Popover v-model:open="isOpen">
<PopoverTrigger as-child>
<span>{{ `${range[0]} - ${range[1]}` }}</span>
</PopoverTrigger>
<!--
Оригинал дополнительно передаёт `alignOffset={-8}`. Наш
`PopoverContent.vue` объявляет только `align`/`sideOffset` в
`defineProps` — непонятный проп ушёл бы фолсру на `<PopoverPortal>`
(корень шаблона), а не на сам `PopoverContent` внутри, и не сработал
бы. Не критично: попап никогда не раскрывается в статике блока.
-->
<PopoverContent class="w-auto p-4" align="start" :side-offset="8">
<div class="space-y-2.5">
<div class="space-y-4 pt-2.5">
<Slider v-model="range" :max="100" :min="0" :step="1" class="w-[200px]" />
<div class="text-muted-foreground flex justify-between ps-1.5 text-xs">
<span>0</span>
<span>100</span>
</div>
</div>
<div class="flex items-center justify-end gap-1.5">
<Button variant="ghost" size="sm" @click="handleCancel">Cancel</Button>
<Button size="sm" variant="outline" @click="handleApply">Apply</Button>
</div>
</div>
</PopoverContent>
</Popover>
</template>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-filters-6.jsonЗависимости реестра
npm-зависимости
- date-fns
Источник: порт из ReUI (Keenthemes, MIT)