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)