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)