reui

Date Selector

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

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

src/reui/date-selector/DateSelector.vue

<script setup lang="ts">
/**
 * Порт ReUI DateSelector (registry-reui/bases/radix/reui/date-selector.tsx, MIT).
 *
 * Составной, стейтфул компонент фильтра по датам. Основная логика состояния
 * вынесена в композабл `useDateSelector` (см. useDateSelector.ts) — прямой
 * аналог одноимённого React-хука. `onChange`-колбэк реализован как
 * `defineEmits("change")` (см. docs/PORTING.md, таблица соответствий);
 * `value` не оборачивается в `defineModel`, а читается как проп и
 * пробрасывается в композабл как есть — как и оригинал, где `value`
 * (если передан) синхронизируется внутрь через `useEffect`, а
 * неуправляемый режим (`value` не передан) просто не запускает эту
 * синхронизацию (правило 4a: поведение "как есть", без блока `value ?? ...`).
 *
 * `react-day-picker` (`Calendar`) заменён на `reka-ui` `Calendar`/`RangeCalendar`
 * в DateSelectorDayPicker.vue (см. комментарий там). `Tabs`/`Button`/`Input`/
 * `ScrollArea` из `bases/radix/ui/*` инлайнятся с классами `buttonVariants`/
 * `tabsListVariants`/`cn-input`/`cn-scroll-area*` по тому же соглашению, что
 * и в `phone-input` — без выноса в отдельный общий базовый слой.
 *
 * `IconPlaceholder` (dev-обвязка сайта ReUI, без Vue-эквивалента) заменена
 * инлайновым `<svg>` (см. docs/PORTING.md, раздел 5): путь "x" из lucide,
 * тот же, что и в `react.tsx`-эталоне кейса.
 */
import { computed, provide, reactive, ref, watch } from "vue"
import { parse } from "date-fns"
import {
  DateSelectorContextKey,
  DEFAULT_DATE_SELECTOR_I18N,
  formatDateValue,
  type DateSelectorFilterType,
  type DateSelectorI18nConfig,
  type DateSelectorPeriodType,
  type DateSelectorValue,
} from "./context"
import { useDateSelector } from "./useDateSelector"
import DateSelectorFilterToggle from "./DateSelectorFilterToggle.vue"
import DateSelectorPeriodTabs from "./DateSelectorPeriodTabs.vue"
import DateSelectorDayPicker from "./DateSelectorDayPicker.vue"
import DateSelectorPeriodGrid from "./DateSelectorPeriodGrid.vue"
import DateSelectorYearList from "./DateSelectorYearList.vue"
import { cn } from "@/lib/utils"

const props = withDefaults(
  defineProps<{
    value?: DateSelectorValue
    allowRange?: boolean
    periodTypes?: DateSelectorPeriodType[]
    defaultPeriodType?: DateSelectorPeriodType
    defaultFilterType?: DateSelectorFilterType
    presetMode?: DateSelectorFilterType
    showInput?: boolean
    showTwoMonths?: boolean
    label?: string
    class?: string
    yearRange?: number
    baseYear?: number
    minYear?: number
    maxYear?: number
    i18n?: Partial<DateSelectorI18nConfig>
    inputHint?: string
    dayDateFormat?: string
    dayDateFormats?: string[]
    weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
  }>(),
  {
    allowRange: true,
    defaultPeriodType: "day",
    defaultFilterType: "is",
    showInput: true,
    showTwoMonths: true,
    yearRange: 10,
    minYear: 2015,
    maxYear: 2026,
    dayDateFormat: "MM/dd/yyyy",
  }
)

const emit = defineEmits<{ change: [value: DateSelectorValue] }>()

const mergedI18n = computed<DateSelectorI18nConfig>(() => ({
  ...DEFAULT_DATE_SELECTOR_I18N,
  ...props.i18n,
}))

const selectorOptions = reactive({
  value: computed(() => props.value),
  onChange: (value: DateSelectorValue) => emit("change", value),
  defaultPeriodType: computed(() => props.defaultPeriodType),
  defaultFilterType: computed(() => props.defaultFilterType),
  presetMode: computed(() => props.presetMode),
  allowRange: computed(() => props.allowRange),
  yearRange: computed(() => props.yearRange),
  baseYear: computed(() => props.baseYear),
  minYear: computed(() => props.minYear),
  maxYear: computed(() => props.maxYear),
  periodTypes: computed(() => props.periodTypes),
})

const selector = useDateSelector(selectorOptions)

const {
  periodType,
  filterType,
  selectedDate,
  selectedEndDate,
  calendarMonth,
  selectedYear,
  selectedMonth,
  selectedQuarter,
  selectedHalfYear,
  rangeStart,
  rangeEnd,
  years,
  currentValue,
  setPeriodType,
  setFilterType,
  setCalendarMonth,
  clearSelection,
  handleDayClick,
  handlePeriodSelect,
  handleYearSelect,
  isInRange,
  isYearInRange,
} = selector

const displayValue = computed(() =>
  formatDateValue(currentValue.value, mergedI18n.value, props.dayDateFormat)
)
const inputValue = ref(displayValue.value)
const isInputFocused = ref(false)

// Sync input value when displayValue changes (but not when user is typing)
watch(displayValue, (value) => {
  if (!isInputFocused.value) {
    inputValue.value = value
  }
})

// Compute date formats for parsing
const dateFormats = computed(() => {
  if (props.dayDateFormats && props.dayDateFormats.length > 0) {
    // Use provided formats, with dayDateFormat first if not already included
    const formats = [...props.dayDateFormats]
    if (!formats.includes(props.dayDateFormat!)) {
      formats.unshift(props.dayDateFormat!)
    }
    return formats
  }
  // Default formats: use dayDateFormat first, then common alternatives
  const defaultFormats = [
    props.dayDateFormat!,
    "dd/MM/yyyy",
    "yyyy-MM-dd",
    "MM-dd-yyyy",
    "dd-MM-yyyy",
  ]
  // Remove duplicates while preserving order
  return Array.from(new Set(defaultFormats))
})

// Parse input text to DateSelectorValue
function parseInputValue(text: string): DateSelectorValue | null {
  if (!text.trim()) return null

  const trimmed = text.trim()

  // Try parsing as year (e.g., "2025")
  const yearMatch = trimmed.match(/^\d{4}$/)
  if (yearMatch) {
    const year = parseInt(yearMatch[0])
    if (year >= 1900 && year <= 2100) {
      return {
        period: "year",
        operator: props.presetMode ?? filterType.value,
        year,
      }
    }
  }

  // Try parsing as quarter (e.g., "Q4", "Q1 2025")
  const quarterMatch = trimmed.match(/^Q([1-4])(?:\s+(\d{4}))?$/i)
  if (quarterMatch) {
    const quarter = parseInt(quarterMatch[1]!) - 1
    const year = quarterMatch[2] ? parseInt(quarterMatch[2]) : new Date().getFullYear()
    if (year >= 1900 && year <= 2100) {
      return {
        period: "quarter",
        operator: props.presetMode ?? filterType.value,
        year,
        quarter,
      }
    }
  }

  // Try parsing as date using computed formats
  for (const dateFormat of dateFormats.value) {
    try {
      const parsed = parse(trimmed, dateFormat, new Date())
      if (!isNaN(parsed.getTime())) {
        return {
          period: "day",
          operator: props.presetMode ?? filterType.value,
          startDate: parsed,
        }
      }
    } catch {
      // Continue to next format
    }
  }

  return null
}

function handleInputChange(event: Event) {
  const newValue = (event.target as HTMLInputElement).value
  inputValue.value = newValue

  // Try to parse the input
  const parsed = parseInputValue(newValue)
  if (parsed) {
    emit("change", parsed)
  }
}

function handleInputBlur() {
  isInputFocused.value = false
  // Reset to display value if parsing failed
  if (!parseInputValue(inputValue.value)) {
    inputValue.value = displayValue.value
  }
}

provide(
  DateSelectorContextKey,
  reactive({
    i18n: mergedI18n,
    variant: "outline",
    size: "default",
  })
)
</script>

<template>
  <div :class="cn('w-full space-y-4 sm:w-[470px]', props.class)">
    <div class="flex flex-wrap items-center gap-3">
      <h3 v-if="props.label" class="text-sm font-medium" data-slot="data-selector-label">
        {{ props.label }}
      </h3>
      <DateSelectorFilterToggle
        :value="filterType"
        :show-between="props.allowRange"
        :preset-mode="props.presetMode"
        @change="setFilterType"
      />
    </div>
    <div v-if="props.showInput" class="relative">
      <input
        type="text"
        data-slot="input"
        :value="props.inputHint ? inputValue : displayValue"
        :readonly="!props.inputHint"
        :placeholder="isInputFocused && props.inputHint ? props.inputHint : mergedI18n.placeholder"
        class="cn-input w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
        @focus="isInputFocused = true"
        @blur="handleInputBlur"
        @input="handleInputChange"
      />
      <button
        v-if="props.inputHint ? inputValue : displayValue"
        type="button"
        :class="
          cn(
            // Base Styles
            'style-vega:rounded-xs style-nova:rounded-xs style-maia:rounded-full style-lyra:rounded-none style-mira:rounded-xs style-luma:rounded-full style-rhea:rounded-full style-sera:rounded-none absolute end-2.5 top-1/2 size-4 -translate-y-1/2 cursor-pointer',
            // Visual States
            'opacity-70 transition-opacity hover:opacity-100',
            // Focus States
            'ring-offset-background focus:ring-ring focus:ring-2 focus:ring-offset-2 focus:outline-none'
          )
        "
        @click="clearSelection"
      >
        <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="size-4"
        >
          <path d="M18 6 6 18" />
          <path d="m6 6 12 12" />
        </svg>
      </button>
    </div>
    <DateSelectorPeriodTabs
      :value="periodType"
      :period-types="props.periodTypes"
      :calendar-month="calendarMonth"
      :show-navigation-buttons="periodType === 'day'"
      @change="setPeriodType"
      @month-change="setCalendarMonth"
    />

    <div v-if="periodType === 'day'" class="w-full pb-1">
      <DateSelectorDayPicker
        :current-month="calendarMonth"
        :selected-date="selectedDate"
        :selected-end-date="selectedEndDate"
        :is-range="filterType === 'between' && props.allowRange"
        :show-two-months="props.showTwoMonths"
        :week-starts-on="props.weekStartsOn"
        @day-click="handleDayClick"
      />
    </div>
    <div v-else class="-mr-3 w-full">
      <div
        :key="periodType"
        data-slot="scroll-area"
        class="cn-scroll-area relative h-[200px] w-full pe-3"
      >
        <div
          data-slot="scroll-area-viewport"
          class="cn-scroll-area-viewport size-full rounded-[inherit] transition-[color,box-shadow] outline-none overflow-auto"
        >
          <DateSelectorPeriodGrid
            v-if="periodType === 'month'"
            :years="years"
            :items="mergedI18n.monthsShort"
            :selected-year="selectedYear"
            :selected-value="selectedMonth"
            :range-start="rangeStart"
            :range-end="rangeEnd"
            :is-in-range="isInRange"
            :columns="3"
            @select="handlePeriodSelect"
          />

          <DateSelectorPeriodGrid
            v-if="periodType === 'quarter'"
            :years="years"
            :items="mergedI18n.quarters"
            :selected-year="selectedYear"
            :selected-value="selectedQuarter"
            :range-start="rangeStart"
            :range-end="rangeEnd"
            :is-in-range="isInRange"
            :columns="4"
            @select="handlePeriodSelect"
          />

          <DateSelectorPeriodGrid
            v-if="periodType === 'half-year'"
            :years="years"
            :items="mergedI18n.halfYears"
            :selected-year="selectedYear"
            :selected-value="selectedHalfYear"
            :range-start="rangeStart"
            :range-end="rangeEnd"
            :is-in-range="isInRange"
            :columns="2"
            @select="handlePeriodSelect"
          />

          <DateSelectorYearList
            v-if="periodType === 'year'"
            :years="years"
            :selected-year="selectedYear"
            :range-start="rangeStart"
            :range-end="rangeEnd"
            :is-year-in-range="isYearInRange"
            @select="handleYearSelect"
          />
        </div>
      </div>
    </div>
  </div>
</template>

src/reui/date-selector/DateSelectorDayPicker.vue

<script setup lang="ts">
/**
 * Порт внутренней функции `DateSelectorDayPicker` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT). Не экспортируется
 * из `index.ts`, как и в оригинале.
 *
 * ЗАМЕНА ПРИМИТИВА (см. docs/PORTING.md): `react-day-picker` не имеет
 * Vue-порта. Заменён на `CalendarRoot`/`RangeCalendarRoot` из `reka-ui` +
 * `@internationalized/date`, как предписано заданием. Это меняет DOM/API
 * сильнее, чем обычная замена Radix-примитива на Reka UI-примитив:
 *
 * - react-day-picker строит `<table>` с одной шапкой недели и рядами по
 *   месяцу через собственный движок разметки (react-day-picker v9,
 *   `classNames`-конфиг). Reka UI `Calendar*` — набор headless-примитивов
 *   (`CalendarGrid`/`GridHead`/`GridBody`/`GridRow`/`Cell`/`CellTrigger`),
 *   которые здесь собраны вручную в похожую, но не идентичную структуру
 *   (нет `<table>`, разметка на `div`).
 * - `react-day-picker` работает с нативным `Date`; Reka UI/`@internationalized/date`
 *   используют `DateValue`/`CalendarDate` (календарная дата без времени и
 *   часового пояса). Конвертация — по локальным полям года/месяца/дня,
 *   без обращения к часовому поясу, что соответствует тому, как оригинал
 *   использует `Date` (только календарные поля, время не участвует).
 * - `numberOfMonths`, `weekStartsOn`, `showOutsideDays`-подобное поведение
 *   и мультимесячная раскладка — по сути те же понятия и есть у обоих
 *   примитивов, поэтому это переносится почти буквально.
 * - Всё, что в оригинале завязано на `formatters.formatWeekdayName` /
 *   `formatMonthCaption`, реализовано вручную (Reka UI такого API не
 *   даёт) — заголовки недель и месяцев строятся из `i18n` напрямую.
 * - Наведение (`onDayHover`/`hoverDate`) в оригинале — самодельная логика
 *   поверх кастомного `DayButton`. Для диапазона реку-юай `RangeCalendarCellTrigger`
 *   уже даёт `highlighted`/`highlightedStart`/`highlightedEnd` из коробки
 *   (наведение на ещё не завершённый диапазон), поэтому здесь используется
 *   встроенное поведение примитива вместо ручного дублирования — это
 *   осознанное расхождение от буквального переноса `hoverDate`, вынужденное
 *   различием движков (см. docs/PORTING.md, "известная сложность").
 *
 * Честно предупреждаем (см. финальный отчёт задачи): раскрытая сетка
 * дневного календаря почти наверняка НЕ совпадёт попиксельно с
 * react-day-picker из-за разной DOM-структуры сетки.
 */
import { computed } from "vue"
import { useMediaQuery } from "@vueuse/core"
import { CalendarDate, type DateValue } from "@internationalized/date"
import {
  CalendarCell,
  CalendarCellTrigger,
  CalendarGrid,
  CalendarGridBody,
  CalendarGridHead,
  CalendarGridRow,
  CalendarHeadCell,
  CalendarRoot,
  RangeCalendarCell,
  RangeCalendarCellTrigger,
  RangeCalendarGrid,
  RangeCalendarGridBody,
  RangeCalendarGridHead,
  RangeCalendarGridRow,
  RangeCalendarHeadCell,
  RangeCalendarRoot,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { useDateSelectorContext } from "./context"

const props = withDefaults(
  defineProps<{
    currentMonth: Date
    selectedDate?: Date
    selectedEndDate?: Date
    isRange: boolean
    showTwoMonths?: boolean
    weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
    class?: string
  }>(),
  {
    showTwoMonths: true,
  }
)

const emit = defineEmits<{ "day-click": [day: Date] }>()

const { i18n } = useDateSelectorContext()

// MOBILE_BREAKPOINT = 768 в оригинальном `useIsMobile`: мобильный режим —
// `window.innerWidth < 768`, т.е. `(max-width: 767px)`.
const isMobile = useMediaQuery("(max-width: 767px)")

const numberOfMonths = computed(() => (isMobile.value ? 1 : props.showTwoMonths ? 2 : 1))

function toCalendarDate(date: Date): CalendarDate {
  return new CalendarDate(date.getFullYear(), date.getMonth() + 1, date.getDate())
}

function toJsDate(date: DateValue): Date {
  return new Date(date.year, date.month - 1, date.day)
}

const placeholderValue = computed(() => toCalendarDate(props.currentMonth))

const singleValue = computed<DateValue | undefined>(() =>
  props.selectedDate ? toCalendarDate(props.selectedDate) : undefined
)

function onUpdateSingle(date: DateValue | undefined) {
  if (date) {
    emit("day-click", toJsDate(date))
  }
}

const rangeValue = computed(() => ({
  start: props.selectedDate ? toCalendarDate(props.selectedDate) : undefined,
  end: props.selectedEndDate ? toCalendarDate(props.selectedEndDate) : undefined,
}))

function onUpdateRange(range: { start?: DateValue; end?: DateValue } | null) {
  if (!range) return
  if (range.start && !range.end) {
    emit("day-click", toJsDate(range.start))
  } else if (range.start && range.end) {
    emit("day-click", toJsDate(range.end))
  }
}

function weekdayLabels(weekStartsOn: number): string[] {
  return Array.from({ length: 7 }, (_, i) => {
    const dayIndex = (weekStartsOn + i) % 7
    return i18n.weekdaysShort[dayIndex] || i18n.weekdays[dayIndex] || ""
  })
}

function monthCaption(value: DateValue): string {
  return `${i18n.months[value.month - 1]} ${value.year}`
}
</script>

<template>
  <div :class="cn('flex w-full items-center justify-between', props.class)">
    <RangeCalendarRoot
      v-if="props.isRange"
      data-slot="calendar"
      class="cn-calendar group/calendar w-full shrink-0 p-0"
      :model-value="rangeValue"
      :placeholder="placeholderValue"
      :number-of-months="numberOfMonths"
      :week-starts-on="props.weekStartsOn ?? 0"
      @update:model-value="onUpdateRange"
    >
      <template #default="{ grid, weekStartsOn }">
        <div class="flex flex-wrap items-start justify-between gap-5 w-full">
          <div
            v-for="month in grid"
            :key="`${month.value.year}-${month.value.month}`"
            class="flex flex-col items-center min-w-0 flex-1"
          >
            <div class="cn-calendar-caption text-sm font-medium select-none">
              {{ monthCaption(month.value) }}
            </div>
            <RangeCalendarGrid class="w-full border-collapse mt-2">
              <RangeCalendarGridHead>
                <RangeCalendarGridRow class="flex">
                  <RangeCalendarHeadCell
                    v-for="(label, i) in weekdayLabels(weekStartsOn)"
                    :key="i"
                    class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none"
                  >
                    {{ label }}
                  </RangeCalendarHeadCell>
                </RangeCalendarGridRow>
              </RangeCalendarGridHead>
              <RangeCalendarGridBody>
                <RangeCalendarGridRow
                  v-for="(week, wi) in month.rows"
                  :key="wi"
                  class="mt-2 flex w-full"
                >
                  <RangeCalendarCell
                    v-for="day in week"
                    :key="day.toString()"
                    :date="day"
                    class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none"
                  >
                    <RangeCalendarCellTrigger
                      :day="day"
                      :month="month.value"
                      class="cn-calendar-day-button relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 font-normal leading-none data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground"
                    />
                  </RangeCalendarCell>
                </RangeCalendarGridRow>
              </RangeCalendarGridBody>
            </RangeCalendarGrid>
          </div>
        </div>
      </template>
    </RangeCalendarRoot>
    <CalendarRoot
      v-else
      data-slot="calendar"
      class="cn-calendar group/calendar w-full shrink-0 p-0"
      :model-value="singleValue"
      :placeholder="placeholderValue"
      :number-of-months="numberOfMonths"
      :week-starts-on="props.weekStartsOn ?? 0"
      @update:model-value="onUpdateSingle"
    >
      <template #default="{ grid, weekStartsOn }">
        <div class="flex flex-wrap items-start justify-between gap-5 w-full">
          <div
            v-for="month in grid"
            :key="`${month.value.year}-${month.value.month}`"
            class="flex flex-col items-center min-w-0 flex-1"
          >
            <div class="cn-calendar-caption text-sm font-medium select-none">
              {{ monthCaption(month.value) }}
            </div>
            <CalendarGrid class="w-full border-collapse mt-2">
              <CalendarGridHead>
                <CalendarGridRow class="flex">
                  <CalendarHeadCell
                    v-for="(label, i) in weekdayLabels(weekStartsOn)"
                    :key="i"
                    class="flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none"
                  >
                    {{ label }}
                  </CalendarHeadCell>
                </CalendarGridRow>
              </CalendarGridHead>
              <CalendarGridBody>
                <CalendarGridRow
                  v-for="(week, wi) in month.rows"
                  :key="wi"
                  class="mt-2 flex w-full"
                >
                  <CalendarCell
                    v-for="day in week"
                    :key="day.toString()"
                    :date="day"
                    class="group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none"
                  >
                    <CalendarCellTrigger
                      :day="day"
                      :month="month.value"
                      class="cn-calendar-day-button relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 font-normal leading-none data-[selected]:bg-primary data-[selected]:text-primary-foreground"
                    />
                  </CalendarCell>
                </CalendarGridRow>
              </CalendarGridBody>
            </CalendarGrid>
          </div>
        </div>
      </template>
    </CalendarRoot>
  </div>
</template>

src/reui/date-selector/DateSelectorFilterToggle.vue

<script setup lang="ts">
/**
 * Порт внутренней функции `DateSelectorFilterToggle` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT). В оригинале
 * это модульно-приватная функция (не экспортируется из файла), здесь —
 * такой же внутренний компонент папки `date-selector/`, не экспортированный
 * из `index.ts`.
 *
 * Radix `Tabs`/`TabsList`/`TabsTrigger` (bases/radix/ui/tabs.tsx) заменены
 * на тот же примитив в Reka UI (`TabsRoot`/`TabsList`/`TabsTrigger`) — само
 * поведение (role=tab, aria-selected, keyboard roving focus) реализовано
 * идентично. Один нюанс: у Reka UI активное состояние выражено атрибутом
 * `data-state="active"|"inactive"`, тогда как вендоренный Tailwind-слой
 * проекта (`cn-tabs-trigger`) написан под булев атрибут `data-active`
 * (см. style-*.css). Поэтому здесь рядом со стандартным data-state
 * проставляется ещё и `data-active`, вычисленный локально — это не меняет
 * визуальный результат, а лишь мостит разницу двух конвенций разметки.
 */
import { TabsList, TabsRoot, TabsTrigger } from "reka-ui"
import { cn } from "@/lib/utils"
import { useDateSelectorContext, type DateSelectorFilterType } from "./context"

const props = withDefaults(
  defineProps<{
    value: DateSelectorFilterType
    showBetween?: boolean
    showIs?: boolean
    presetMode?: DateSelectorFilterType
    class?: string
  }>(),
  {
    showBetween: true,
    showIs: true,
  }
)

const emit = defineEmits<{ change: [value: DateSelectorFilterType] }>()

const { i18n } = useDateSelectorContext()
const isDisabled = props.presetMode !== undefined

function handleUpdate(newValue: string | number) {
  if (!isDisabled && newValue) {
    emit("change", newValue as DateSelectorFilterType)
  }
}
</script>

<template>
  <TabsRoot
    data-slot="tabs"
    data-orientation="horizontal"
    :model-value="props.value"
    :class="cn('cn-tabs group/tabs flex data-horizontal:flex-col', props.class)"
    @update:model-value="handleUpdate"
  >
    <TabsList
      data-slot="tabs-list"
      data-variant="default"
      :class="
        cn(
          'cn-tabs-list group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col cn-tabs-list-variant-default bg-muted',
          isDisabled && 'pointer-events-none opacity-50',
          props.class
        )
      "
    >
      <TabsTrigger
        v-if="props.showIs"
        value="is"
        data-slot="tabs-trigger"
        :data-active="props.value === 'is' ? true : undefined"
        :aria-label="i18n.filterTypes.is"
        class="cn-tabs-trigger relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 py-1 font-normal"
      >
        {{ i18n.filterTypes.is }}
      </TabsTrigger>
      <TabsTrigger
        value="before"
        data-slot="tabs-trigger"
        :data-active="props.value === 'before' ? true : undefined"
        :aria-label="i18n.filterTypes.before"
        class="cn-tabs-trigger relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 py-1 font-normal"
      >
        {{ i18n.filterTypes.before }}
      </TabsTrigger>
      <TabsTrigger
        value="after"
        data-slot="tabs-trigger"
        :data-active="props.value === 'after' ? true : undefined"
        :aria-label="i18n.filterTypes.after"
        class="cn-tabs-trigger relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 py-1 font-normal"
      >
        {{ i18n.filterTypes.after }}
      </TabsTrigger>
      <TabsTrigger
        v-if="props.showBetween"
        value="between"
        data-slot="tabs-trigger"
        :data-active="props.value === 'between' ? true : undefined"
        :aria-label="i18n.filterTypes.between"
        class="cn-tabs-trigger relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 py-1 font-normal"
      >
        {{ i18n.filterTypes.between }}
      </TabsTrigger>
    </TabsList>
  </TabsRoot>
</template>

src/reui/date-selector/DateSelectorPeriodGrid.vue

<script setup lang="ts">
/**
 * Порт внутренней функции `DateSelectorPeriodGrid` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT). Как и в
 * оригинале, не экспортируется из `index.ts`.
 *
 * `Button` (bases/radix/ui/button.tsx) инлайнится по тому же соглашению,
 * что и в `phone-input`/`DateSelectorPeriodTabs.vue` — классы это буквально
 * `buttonVariants({variant, size:"sm"})`.
 */
import { cn } from "@/lib/utils"

const props = defineProps<{
  years: number[]
  items: string[]
  selectedYear?: number
  selectedValue?: number
  rangeStart?: { year: number; value: number }
  rangeEnd?: { year: number; value: number }
  isInRange: (year: number, value: number) => boolean
  columns: number
  class?: string
}>()

const emit = defineEmits<{ select: [year: number, value: number] }>()

const buttonBase =
  "cn-button group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cn-button-size-sm"

function variantClass(isSelected: boolean, isRangeStart: boolean, isRangeEnd: boolean) {
  return isSelected || isRangeStart || isRangeEnd
    ? "cn-button-variant-default"
    : "cn-button-variant-outline"
}
</script>

<template>
  <div :class="cn('w-full space-y-6', props.class)">
    <div v-for="year in props.years" :key="year">
      <div class="text-muted-foreground mb-3 text-sm font-medium">{{ year }}</div>
      <div
        class="grid gap-2"
        :style="{ gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))` }"
      >
        <button
          v-for="(item, index) in props.items"
          :key="item"
          type="button"
          data-slot="button"
          :data-variant="
            (props.selectedYear === year && props.selectedValue === index) ||
            (props.rangeStart?.year === year && props.rangeStart?.value === index) ||
            (props.rangeEnd?.year === year && props.rangeEnd?.value === index)
              ? 'default'
              : 'outline'
          "
          data-size="sm"
          :class="
            cn(
              buttonBase,
              variantClass(
                props.selectedYear === year && props.selectedValue === index,
                props.rangeStart?.year === year && props.rangeStart?.value === index,
                props.rangeEnd?.year === year && props.rangeEnd?.value === index
              ),
              props.isInRange(year, index) &&
                !(props.selectedYear === year && props.selectedValue === index) &&
                !(props.rangeStart?.year === year && props.rangeStart?.value === index) &&
                !(props.rangeEnd?.year === year && props.rangeEnd?.value === index) &&
                'bg-accent dark:bg-accent/60'
            )
          "
          @click="emit('select', year, index)"
        >
          {{ item }}
        </button>
      </div>
    </div>
  </div>
</template>

src/reui/date-selector/DateSelectorPeriodTabs.vue

<script setup lang="ts">
/**
 * Порт внутренней функции `DateSelectorPeriodTabs` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT). Как и в
 * оригинале, не экспортируется из `index.ts` — это деталь реализации
 * `DateSelector.vue`.
 *
 * `IconPlaceholder` (dev-обвязка сайта ReUI на выбор библиотеки иконок,
 * localStorage + lazy/Suspense, без Vue-эквивалента) заменена инлайновым
 * `<svg>` — тот же путь используется и в `react.tsx`-эталоне кейса
 * `date-selector` (см. docs/PORTING.md, раздел 5, и rating/tree).
 *
 * `Button` (bases/radix/ui/button.tsx, cva `buttonVariants`) не выделен в
 * отдельный общий примитив проекта — по аналогии с `phone-input`
 * (PhoneInputCountrySelect.vue), где Button-разметка инлайнится в
 * потребляющий компонент вместо развёртывания в целый базовый слой.
 * Классы здесь — это буквально `buttonVariants({variant:"ghost"})` с
 * добавленным `size-8.5`, как в оригинале.
 */
import { computed } from "vue"
import { isSameMonth, addMonths, subMonths } from "date-fns"
import { TabsList, TabsRoot, TabsTrigger } from "reka-ui"
import { cn } from "@/lib/utils"
import { useDateSelectorContext, type DateSelectorPeriodType } from "./context"

const props = defineProps<{
  value: DateSelectorPeriodType
  periodTypes?: DateSelectorPeriodType[]
  class?: string
  calendarMonth?: Date
  showNavigationButtons?: boolean
}>()

const emit = defineEmits<{
  change: [value: DateSelectorPeriodType]
  "month-change": [date: Date]
}>()

const { i18n } = useDateSelectorContext()

const tabs = computed<{ value: DateSelectorPeriodType; label: string }[]>(() => [
  { value: "day", label: i18n.periodTypes.day },
  { value: "month", label: i18n.periodTypes.month },
  { value: "quarter", label: i18n.periodTypes.quarter },
  { value: "half-year", label: i18n.periodTypes.halfYear },
  { value: "year", label: i18n.periodTypes.year },
])

const filteredTabs = computed(() =>
  props.periodTypes ? tabs.value.filter((tab) => props.periodTypes!.includes(tab.value)) : tabs.value
)

function handleUpdate(newValue: string | number) {
  if (newValue) {
    emit("change", newValue as DateSelectorPeriodType)
  }
}

const today = new Date()
const isCurrentMonth = computed(() =>
  props.calendarMonth ? isSameMonth(props.calendarMonth, today) : true
)
const isFuture = computed(() => (props.calendarMonth ? props.calendarMonth > today : false))
const showTodayButton = computed(
  () =>
    props.showNavigationButtons &&
    props.value === "day" &&
    !!props.calendarMonth &&
    !isCurrentMonth.value
)
const showNav = computed(() => props.showNavigationButtons && props.value === "day" && !!props.calendarMonth)

const buttonGhostClass =
  "cn-button group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cn-button-variant-ghost cn-button-size-default size-8.5"
</script>

<template>
  <div :class="cn('flex flex-wrap items-center justify-between gap-3', props.class)">
    <TabsRoot
      data-slot="tabs"
      data-orientation="horizontal"
      :model-value="props.value"
      class="cn-tabs group/tabs flex data-horizontal:flex-col"
      @update:model-value="handleUpdate"
    >
      <TabsList
        data-slot="tabs-list"
        data-variant="default"
        class="cn-tabs-list group/tabs-list inline-flex w-fit items-center justify-center text-muted-foreground group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col cn-tabs-list-variant-default bg-muted"
      >
        <TabsTrigger
          v-for="tab in filteredTabs"
          :key="tab.value"
          :value="tab.value"
          data-slot="tabs-trigger"
          :data-active="props.value === tab.value ? true : undefined"
          :aria-label="tab.label"
          class="cn-tabs-trigger relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100 px-1 py-1 font-normal sm:px-2.5"
        >
          {{ tab.label }}
        </TabsTrigger>
      </TabsList>
    </TabsRoot>
    <div v-if="showNav" class="flex items-center">
      <button
        v-if="showTodayButton"
        type="button"
        data-slot="button"
        data-variant="ghost"
        data-size="default"
        :class="buttonGhostClass"
        :title="i18n.today"
        @click="emit('month-change', new Date())"
      >
        <svg
          v-if="isFuture"
          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"
        >
          <path d="M9 14 4 9l5-5" />
          <path d="M20 20v-7a4 4 0 0 0-4-4H4" />
        </svg>
        <svg
          v-else
          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"
        >
          <path d="m15 14 5-5-5-5" />
          <path d="M4 20v-7a4 4 0 0 1 4-4h12" />
        </svg>
      </button>
      <button
        type="button"
        data-slot="button"
        data-variant="ghost"
        data-size="default"
        :class="buttonGhostClass"
        @click="emit('month-change', subMonths(props.calendarMonth!, 1))"
      >
        <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="size-4"
        >
          <path d="m15 18-6-6 6-6" />
        </svg>
      </button>
      <button
        type="button"
        data-slot="button"
        data-variant="ghost"
        data-size="default"
        :class="buttonGhostClass"
        @click="emit('month-change', addMonths(props.calendarMonth!, 1))"
      >
        <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="size-4"
        >
          <path d="m9 18 6-6-6-6" />
        </svg>
      </button>
    </div>
  </div>
</template>

src/reui/date-selector/DateSelectorYearList.vue

<script setup lang="ts">
/**
 * Порт внутренней функции `DateSelectorYearList` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT). Как и в
 * оригинале, не экспортируется из `index.ts`. Button инлайнится по тому
 * же соглашению, что и `DateSelectorPeriodGrid.vue`.
 */
import { cn } from "@/lib/utils"

const props = defineProps<{
  years: number[]
  selectedYear?: number
  rangeStart?: { year: number; value: number }
  rangeEnd?: { year: number; value: number }
  isYearInRange: (year: number) => boolean
  class?: string
}>()

const emit = defineEmits<{ select: [year: number] }>()

const buttonBase =
  "cn-button group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 cn-button-size-sm"
</script>

<template>
  <div :class="cn('grid grid-cols-2 gap-2', props.class)">
    <button
      v-for="year in props.years"
      :key="year"
      type="button"
      data-slot="button"
      :data-variant="
        (props.selectedYear === year && !props.rangeStart && !props.rangeEnd) ||
        props.rangeStart?.year === year ||
        props.rangeEnd?.year === year
          ? 'default'
          : 'outline'
      "
      data-size="sm"
      :class="
        cn(
          buttonBase,
          (props.selectedYear === year && !props.rangeStart && !props.rangeEnd) ||
            props.rangeStart?.year === year ||
            props.rangeEnd?.year === year
            ? 'cn-button-variant-default'
            : 'cn-button-variant-outline',
          props.isYearInRange(year) &&
            !(props.selectedYear === year && !props.rangeStart && !props.rangeEnd) &&
            props.rangeStart?.year !== year &&
            props.rangeEnd?.year !== year &&
            'bg-accent dark:bg-accent/60'
        )
      "
      @click="emit('select', year)"
    >
      {{ year }}
    </button>
  </div>
</template>

src/reui/date-selector/context.ts

import type { InjectionKey } from "vue"
import { inject } from "vue"
import { format } from "date-fns"

/**
 * Порт типов/i18n/контекста из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT).
 *
 * Соответствие React `createContext`/`useContext`: один provide/inject-ключ
 * `DateSelectorContextKey` с композаблом `useDateSelectorContext()`,
 * который в оригинале возвращает значение по умолчанию, если контекст не
 * найден (React `createContext(defaultValue)` всегда что-то отдаёт — здесь
 * это воспроизведено через `inject(key, default)`, а не через throw, как
 * сделано в Stepper для контекстов без дефолта).
 */
export interface DateSelectorI18nConfig {
  // Labels
  selectDate: string
  apply: string
  cancel: string
  clear: string
  today: string
  // Filter types
  filterTypes: {
    is: string
    before: string
    after: string
    between: string
  }
  // Period types
  periodTypes: {
    day: string
    month: string
    quarter: string
    halfYear: string
    year: string
  }
  // Months
  months: string[]
  monthsShort: string[]
  // Quarters
  quarters: string[]
  // Half years
  halfYears: string[]
  // Weekdays
  weekdays: string[]
  weekdaysShort: string[]
  // Placeholders
  placeholder: string
  rangePlaceholder: string
}

export const DEFAULT_DATE_SELECTOR_I18N: DateSelectorI18nConfig = {
  selectDate: "Select date",
  apply: "Apply",
  cancel: "Cancel",
  clear: "Clear",
  today: "Today",
  filterTypes: {
    is: "is",
    before: "before",
    after: "after",
    between: "between",
  },
  periodTypes: {
    day: "Day",
    month: "Month",
    quarter: "Quarter",
    halfYear: "Half-year",
    year: "Year",
  },
  months: [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
  ],
  monthsShort: [
    "Jan",
    "Feb",
    "Mar",
    "Apr",
    "May",
    "Jun",
    "Jul",
    "Aug",
    "Sep",
    "Oct",
    "Nov",
    "Dec",
  ],
  quarters: ["Q1", "Q2", "Q3", "Q4"],
  halfYears: ["H1", "H2"],
  weekdays: [
    "Sunday",
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
  ],
  weekdaysShort: ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"],
  placeholder: "Select date...",
  rangePlaceholder: "Select date range...",
}

export type DateSelectorPeriodType =
  | "day"
  | "month"
  | "quarter"
  | "half-year"
  | "year"
export type DateSelectorFilterType = "is" | "before" | "after" | "between"

export interface DateSelectorValue {
  period: DateSelectorPeriodType
  operator: DateSelectorFilterType
  startDate?: Date
  endDate?: Date
  year?: number
  month?: number
  quarter?: number
  halfYear?: number
  rangeStart?: { year: number; value: number }
  rangeEnd?: { year: number; value: number }
}

export interface DateSelectorContextValue {
  i18n: DateSelectorI18nConfig
  variant: "outline" | "default"
  size: "sm" | "default" | "lg"
}

export const DateSelectorContextKey: InjectionKey<DateSelectorContextValue> =
  Symbol("DateSelectorContext")

const defaultDateSelectorContext: DateSelectorContextValue = {
  i18n: DEFAULT_DATE_SELECTOR_I18N,
  variant: "outline",
  size: "default",
}

export const useDateSelectorContext = (): DateSelectorContextValue =>
  inject(DateSelectorContextKey, defaultDateSelectorContext)

export function formatDateValue(
  value: DateSelectorValue,
  i18n: DateSelectorI18nConfig = DEFAULT_DATE_SELECTOR_I18N,
  dayDateFormat: string = "MM/dd/yyyy"
): string {
  const {
    period,
    startDate,
    endDate,
    year,
    month,
    quarter,
    halfYear,
    rangeStart,
    rangeEnd,
  } = value

  if (period === "day") {
    if (startDate && endDate) {
      return `${format(startDate, dayDateFormat)} - ${format(endDate, dayDateFormat)}`
    }
    if (startDate) {
      return format(startDate, dayDateFormat)
    }
    return ""
  }

  if (period === "month") {
    if (rangeStart && rangeEnd) {
      return `${i18n.monthsShort[rangeStart.value]} ${rangeStart.year} - ${i18n.monthsShort[rangeEnd.value]} ${rangeEnd.year}`
    }
    if (year !== undefined && month !== undefined) {
      return `${i18n.monthsShort[month]} ${year}`
    }
    return ""
  }

  if (period === "quarter") {
    if (rangeStart && rangeEnd) {
      return `${i18n.quarters[rangeStart.value]} ${rangeStart.year} - ${i18n.quarters[rangeEnd.value]} ${rangeEnd.year}`
    }
    if (year !== undefined && quarter !== undefined) {
      return `${i18n.quarters[quarter]} ${year}`
    }
    return ""
  }

  if (period === "half-year") {
    if (rangeStart && rangeEnd) {
      return `${i18n.halfYears[rangeStart.value]} ${rangeStart.year} - ${i18n.halfYears[rangeEnd.value]} ${rangeEnd.year}`
    }
    if (year !== undefined && halfYear !== undefined) {
      return `${i18n.halfYears[halfYear]} ${year}`
    }
    return ""
  }

  if (period === "year") {
    if (rangeStart && rangeEnd) {
      return `${rangeStart.year} - ${rangeEnd.year}`
    }
    if (year !== undefined) {
      return `${year}`
    }
    return ""
  }

  return ""
}

src/reui/date-selector/index.ts

export { default as DateSelector } from "./DateSelector.vue"
export { useDateSelector, type UseDateSelectorOptions } from "./useDateSelector"
export {
  useDateSelectorContext,
  formatDateValue,
  DateSelectorContextKey,
  DEFAULT_DATE_SELECTOR_I18N,
  type DateSelectorContextValue,
  type DateSelectorFilterType,
  type DateSelectorI18nConfig,
  type DateSelectorPeriodType,
  type DateSelectorValue,
} from "./context"

src/reui/date-selector/useDateSelector.ts

import { computed, ref, watch } from "vue"
import { isBefore } from "date-fns"
import type { DateSelectorFilterType, DateSelectorPeriodType, DateSelectorValue } from "./context"

/**
 * Порт хука `useDateSelector` из ReUI DateSelector
 * (registry-reui/bases/radix/reui/date-selector.tsx, MIT).
 *
 * `useState` -> `ref`, `useMemo` -> `computed`, `useCallback` -> обычная
 * функция. `useEffect`, синхронизирующий внутреннее состояние с внешним
 * `value` и вызывающий `onChange`, перенесён как `watch` с теми же
 * зависимостями (буквально: `value`, `presetMode` и т.д.) — включая
 * ранний выход `if (value)`, который в оригинале означает, что для
 * неуправляемого режима (`value` не передан) эта синхронизация не
 * срабатывает вовсе.
 *
 * `options` обязан быть реактивным объектом (реактивный `props` из
 * `defineProps`, либо `reactive({...})`) — как и React-хук, читающий
 * пропсы на каждый рендер, композабл читает поля `options` внутри
 * `computed`/`watch`, и без реактивности источника изменения снаружи
 * (например, `props.value`) не будут подхвачены.
 */
export interface UseDateSelectorOptions {
  value?: DateSelectorValue
  onChange?: (value: DateSelectorValue) => void
  defaultPeriodType?: DateSelectorPeriodType
  defaultFilterType?: DateSelectorFilterType
  presetMode?: DateSelectorFilterType
  allowRange?: boolean
  yearRange?: number
  baseYear?: number
  minYear?: number
  maxYear?: number
  periodTypes?: DateSelectorPeriodType[]
}

export function useDateSelector(options: UseDateSelectorOptions) {
  const defaultPeriodType = computed(() => options.defaultPeriodType ?? "day")
  const defaultFilterType = computed(() => options.defaultFilterType ?? "is")
  const allowRange = computed(() => options.allowRange ?? true)
  const yearRange = computed(() => options.yearRange ?? 11)

  const currentYear = computed(() => options.baseYear ?? new Date().getFullYear())

  const validDefaultPeriodType = computed<DateSelectorPeriodType>(() => {
    const periodTypes = options.periodTypes
    if (!periodTypes || periodTypes.length === 0) return defaultPeriodType.value
    if (periodTypes.includes(defaultPeriodType.value)) return defaultPeriodType.value
    return periodTypes[0]!
  })

  const effectiveFilterType = computed<DateSelectorFilterType>(
    () => options.presetMode ?? options.value?.operator ?? defaultFilterType.value
  )

  const periodType = ref<DateSelectorPeriodType>(
    options.value?.period || validDefaultPeriodType.value
  )
  const filterType = ref<DateSelectorFilterType>(effectiveFilterType.value)
  const selectedDate = ref<Date | undefined>(options.value?.startDate)
  const selectedEndDate = ref<Date | undefined>(options.value?.endDate)
  const calendarMonth = ref<Date>(options.value?.startDate || new Date())
  const selectedYear = ref<number | undefined>(options.value?.year)
  const selectedMonth = ref<number | undefined>(options.value?.month)
  const selectedQuarter = ref<number | undefined>(options.value?.quarter)
  const selectedHalfYear = ref<number | undefined>(options.value?.halfYear)
  const rangeStart = ref<{ year: number; value: number } | undefined>(options.value?.rangeStart)
  const rangeEnd = ref<{ year: number; value: number } | undefined>(options.value?.rangeEnd)
  const hoverDate = ref<Date | undefined>(undefined)

  const years = computed(() => {
    const minYear = options.minYear
    const maxYear = options.maxYear
    if (minYear !== undefined && maxYear !== undefined) {
      return Array.from({ length: maxYear - minYear + 1 }, (_, i) => minYear + i)
    }
    return Array.from(
      { length: yearRange.value },
      (_, i) => currentYear.value - Math.floor(yearRange.value / 2) + i
    )
  })

  const currentValue = computed<DateSelectorValue>(() => ({
    period: periodType.value,
    operator: options.presetMode ?? filterType.value,
    startDate: selectedDate.value,
    endDate: selectedEndDate.value,
    year: selectedYear.value,
    month: selectedMonth.value,
    quarter: selectedQuarter.value,
    halfYear: selectedHalfYear.value,
    rangeStart: rangeStart.value,
    rangeEnd: rangeEnd.value,
  }))

  function clearSelection() {
    selectedDate.value = undefined
    selectedEndDate.value = undefined
    selectedYear.value = undefined
    selectedMonth.value = undefined
    selectedQuarter.value = undefined
    selectedHalfYear.value = undefined
    rangeStart.value = undefined
    rangeEnd.value = undefined
  }

  function handleDayClick(day: Date) {
    if (filterType.value === "between" && allowRange.value) {
      if (!selectedDate.value || (selectedDate.value && selectedEndDate.value)) {
        selectedDate.value = day
        selectedEndDate.value = undefined
      } else {
        if (isBefore(day, selectedDate.value)) {
          selectedEndDate.value = selectedDate.value
          selectedDate.value = day
        } else {
          selectedEndDate.value = day
        }
      }
    } else {
      selectedDate.value = day
      selectedEndDate.value = undefined
    }
  }

  function handlePeriodSelect(year: number, value: number) {
    if (filterType.value === "between" && allowRange.value) {
      if (!rangeStart.value || (rangeStart.value && rangeEnd.value)) {
        rangeStart.value = { year, value }
        rangeEnd.value = undefined
        selectedYear.value = year
        if (periodType.value === "month") selectedMonth.value = value
        if (periodType.value === "quarter") selectedQuarter.value = value
        if (periodType.value === "half-year") selectedHalfYear.value = value
      } else {
        const startKey = rangeStart.value.year * 100 + rangeStart.value.value
        const endKey = year * 100 + value
        if (endKey < startKey) {
          rangeEnd.value = rangeStart.value
          rangeStart.value = { year, value }
        } else {
          rangeEnd.value = { year, value }
        }
      }
    } else {
      selectedYear.value = year
      if (periodType.value === "month") selectedMonth.value = value
      if (periodType.value === "quarter") selectedQuarter.value = value
      if (periodType.value === "half-year") selectedHalfYear.value = value
      rangeStart.value = undefined
      rangeEnd.value = undefined
    }
  }

  function handleYearSelect(year: number) {
    if (filterType.value === "between" && allowRange.value) {
      if (!rangeStart.value || (rangeStart.value && rangeEnd.value)) {
        rangeStart.value = { year, value: 0 }
        rangeEnd.value = undefined
        selectedYear.value = year
      } else {
        if (year < rangeStart.value.year) {
          rangeEnd.value = rangeStart.value
          rangeStart.value = { year, value: 0 }
        } else {
          rangeEnd.value = { year, value: 0 }
        }
      }
    } else {
      selectedYear.value = year
      rangeStart.value = undefined
      rangeEnd.value = undefined
    }
  }

  function handlePeriodTypeChange(type: DateSelectorPeriodType) {
    periodType.value = type
    clearSelection()
  }

  function handleFilterTypeChange(type: DateSelectorFilterType) {
    // Don't allow changes if presetMode is set
    if (options.presetMode !== undefined) return
    filterType.value = type
    clearSelection()
  }

  function isInRange(year: number, value: number) {
    if (!rangeStart.value || !rangeEnd.value) return false
    const key = year * 100 + value
    const startKey = rangeStart.value.year * 100 + rangeStart.value.value
    const endKey = rangeEnd.value.year * 100 + rangeEnd.value.value
    return key >= startKey && key <= endKey
  }

  function isYearInRange(year: number) {
    if (!rangeStart.value || !rangeEnd.value) return false
    return year >= rangeStart.value.year && year <= rangeEnd.value.year
  }

  function setCalendarMonth(date: Date) {
    calendarMonth.value = date
  }

  function setHoverDate(date: Date | undefined) {
    hoverDate.value = date
  }

  function setSelectedDate(date: Date | undefined) {
    selectedDate.value = date
  }

  function setSelectedEndDate(date: Date | undefined) {
    selectedEndDate.value = date
  }

  watch(
    () => options.value,
    (value) => {
      if (value) {
        periodType.value = value.period || validDefaultPeriodType.value
        // Use presetMode if provided, otherwise use value's operator or default
        filterType.value = options.presetMode ?? value.operator ?? defaultFilterType.value
        selectedDate.value = value.startDate
        selectedEndDate.value = value.endDate
        selectedYear.value = value.year
        selectedMonth.value = value.month
        selectedQuarter.value = value.quarter
        selectedHalfYear.value = value.halfYear
        rangeStart.value = value.rangeStart
        rangeEnd.value = value.rangeEnd
      }
    },
    { deep: true }
  )

  // Sync filterType when presetMode changes
  watch(
    () => options.presetMode,
    (presetMode) => {
      if (presetMode !== undefined) {
        filterType.value = presetMode
      }
    }
  )

  watch(
    currentValue,
    (value) => {
      options.onChange?.(value)
    },
    { deep: true }
  )

  return {
    // State
    periodType,
    filterType,
    selectedDate,
    selectedEndDate,
    calendarMonth,
    selectedYear,
    selectedMonth,
    selectedQuarter,
    selectedHalfYear,
    rangeStart,
    rangeEnd,
    hoverDate,
    years,
    currentValue,
    allowRange,

    // Setters
    setPeriodType: handlePeriodTypeChange,
    setFilterType: handleFilterTypeChange,
    setSelectedDate,
    setSelectedEndDate,
    setCalendarMonth,
    setHoverDate,

    // Actions
    clearSelection,
    handleDayClick,
    handlePeriodSelect,
    handleYearSelect,
    isInRange,
    isYearInRange,
  }
}

Установка

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

Зависимости реестра

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

  • @internationalized/date
  • @vueuse/core
  • date-fns
  • reka-ui

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