Files
tools/vue/primitives/src/display/date-picker/DatePickerRoot.vue
T
robonen eefd7abf83 feat(primitives): media-editor components, category reorg, perf + type cleanup
Reorganize components into category folders (forms/canvas/overlays/etc.); add the
media-editor headless family (timeline, curve-editor, waveform, crop, color
picker, etc.); apply perf fixes (O(1) collection lookups, plain-object drag
state, gesture-leak teardown, shallowRef color state, rect caching) and replace
source `any` with proper types.
2026-06-15 16:54:29 +07:00

480 lines
15 KiB
Vue

<script lang="ts">
import type { CalendarMonth, CalendarRootProps, WeekDayFormat } from '../calendar';
import type { PrimitiveProps } from '../../internal/primitive';
import type { Granularity, HourCycle } from './use-date-field';
/**
* A single-date picker that pairs a popover-anchored calendar with an optional
* trigger, field, and hidden form input. Owns the selected date, placeholder
* month, and open state, and provides both date-picker and calendar context to
* its parts. Use it when you need a compact, accessible "pick one date" control
* (e.g. a form field) rather than an always-visible `Calendar`.
*/
export interface DatePickerRootProps extends PrimitiveProps,
Omit<CalendarRootProps, 'as' | 'asChild'> {
/** Uncontrolled initial open state. */
defaultOpen?: boolean;
/** Modal popover (traps focus + blocks outside pointer). @default false */
modal?: boolean;
/** Hidden form input name for submission. */
name?: string;
/** Id forwarded to the focusable form control / first segment. */
id?: string;
/** Marks the form control as required for native constraint validation. @default false */
required?: boolean;
/** Format used to serialize the hidden input value. @default 'iso' */
valueFormat?: 'iso' | ((d: Date) => string);
/** Close popover on selection. @default true */
closeOnSelect?: boolean;
/**
* Keep the current value selected when the already-selected date is picked
* again (otherwise re-selecting clears it). @default false
*/
preventDeselect?: boolean;
/**
* Smallest unit the field edits. `'day'` is date-only; `'hour'`/`'minute'`/
* `'second'` add time segments and preserve the time-of-day. @default 'day'
*/
granularity?: Granularity;
/** Hour cycle for the time segments (12 or 24). Inferred from locale if omitted. */
hourCycle?: HourCycle;
}
export interface DatePickerRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
'update:open': [open: boolean];
}
</script>
<script setup lang="ts">
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { computed, onMounted, ref, shallowRef, toRef, watch } from 'vue';
import { provideCalendarRootContext } from '../calendar';
import { useDateAdapter, useId } from '../../utilities/config-provider';
import { PopperRoot } from '../../overlays/popper';
import { Primitive } from '../../internal/primitive';
import { VisuallyHidden } from '../../utilities/visually-hidden';
import { provideDatePickerRootContext } from './context';
import { hasTimeGranularity } from './use-date-field';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultOpen = false,
modal = false,
name,
id,
required = false,
valueFormat = 'iso',
closeOnSelect = true,
preventDeselect = false,
granularity: propsGranularity = 'day',
hourCycle: propsHourCycle,
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disableDaysOutsideCurrentView = false,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
dateAdapter,
} = defineProps<DatePickerRootProps>();
defineEmits<DatePickerRootEmits>();
const { forwardRef, currentElement: parentElement } = useForwardExpose();
// Resolve the effective date backend: per-instance prop wins over the global
// `ConfigProvider` adapter, falling back to the native `Date` adapter.
const adapter = useDateAdapter(() => dateAdapter);
const localOpen = ref<boolean>(defaultOpen);
const open = defineModel<boolean>('open', {
default: undefined,
get: v => v ?? localOpen.value,
set: (v) => {
localOpen.value = v;
return v;
},
});
const localValue = ref<Date | undefined>(defaultValue);
const modelValue = defineModel<Date | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localPlaceholder = ref<Date>(
adapter.value.toDateOnly(defaultPlaceholder ?? modelValue.value ?? adapter.value.now()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = adapter.value.toDateOnly(v);
return localPlaceholder.value;
},
});
const triggerId = useId(undefined, 'date-picker-trigger');
const contentId = useId(undefined, 'date-picker-content');
const generatedFieldId = useId(undefined, 'date-picker-field');
const fieldId = computed(() => id ?? generatedFieldId.value);
const triggerElement = shallowRef<HTMLElement>();
const hasCustomAnchor = ref(false);
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
const modalRef = toRef(() => modal);
const nameRef = toRef(() => name);
const weekStartsOnRef = toRef(() => weekStartsOn);
const weekdayFormatRef = toRef(() => weekdayFormat as WeekDayFormat);
const fixedWeeksRef = toRef(() => fixedWeeks);
const numberOfMonthsRef = toRef(() => numberOfMonths);
const disabledRef = toRef(() => disabled);
const readonlyRef = toRef(() => readonly);
const pagedNavigationRef = toRef(() => pagedNavigation);
const minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const requiredRef = toRef(() => required);
const granularityRef = computed<Granularity>(() => propsGranularity);
const hourCycleRef = toRef(() => propsHourCycle);
const preventDeselectRef = toRef(() => preventDeselect);
const multipleRef = toRef(() => false);
const disableDaysOutsideCurrentViewRef = toRef(() => disableDaysOutsideCurrentView);
/** Strip time for `day` granularity; preserve full time-of-day otherwise. */
function normalizeValue(date: Date): Date {
return hasTimeGranularity(propsGranularity)
? adapter.value.clone(date)
: adapter.value.toDateOnly(date);
}
const grid = computed<CalendarMonth[]>(() => adapter.value.createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => adapter.value.getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return adapter.value.formatMonthYear(months[0]!.value, locale);
const first = adapter.value.formatMonthYear(months[0]!.value, locale);
const last = adapter.value.formatMonthYear(months[months.length - 1]!.value, locale);
return `${first} - ${last}`;
});
const fullCalendarLabel = computed(() => `${calendarLabel}, ${headingValue.value}`);
function isDateDisabled(date: Date): boolean {
if (disabled) return true;
if (propsIsDateDisabled?.(date)) return true;
if (minValue && adapter.value.isBefore(date, minValue)) return true;
if (maxValue && adapter.value.isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return adapter.value.isDateUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? adapter.value.isSameDay(modelValue.value, date) : false;
}
const hasSelectedDate = computed(() => modelValue.value !== undefined);
const firstFocusableDate = computed(() =>
adapter.value.findFirstFocusableDate(grid.value, isDateDisabled, isDateUnavailableLocal),
);
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => adapter.value.isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
/**
* Unified commit path for any selection source. Honors readonly/disabled,
* disabled-date guards, and `preventDeselect` toggle-off. When `keepTime` is set
* (calendar day pick under a time granularity) the existing time-of-day is
* carried onto the picked day; the segmented field passes a full datetime and
* commits it verbatim.
*/
function onDateChange(date: Date | undefined, options?: { keepTime?: boolean }) {
if (readonly || disabled) return;
if (!date) {
modelValue.value = undefined;
return;
}
if (isDateDisabled(date) || isDateUnavailableLocal(date)) return;
let next = date;
if (options?.keepTime && hasTimeGranularity(propsGranularity) && modelValue.value) {
const day = adapter.value.getParts(date);
const time = adapter.value.getParts(modelValue.value);
next = adapter.value.fromParts({
year: day.year,
month: day.month,
day: day.day,
hour: time.hour,
minute: time.minute,
second: time.second,
});
}
else if (!hasTimeGranularity(propsGranularity)) {
next = normalizeValue(next);
}
if (!preventDeselect && modelValue.value
&& adapter.value.compare(modelValue.value, next) === 0) {
modelValue.value = undefined;
return;
}
modelValue.value = next;
if (closeOnSelect) open.value = false;
}
function setDate(date: Date | undefined) {
onDateChange(date, { keepTime: true });
}
function onPlaceholderChange(date: Date) {
placeholder.value = date;
}
function setPlaceholder(date: Date) {
placeholder.value = adapter.value.clamp(date, minValue, maxValue);
}
function pageStep(): number {
return pagedNavigation ? numberOfMonths : 1;
}
function nextPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsNextPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = adapter.value.addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.value = adapter.value.addYears(placeholder.value, -1);
}
function isNextButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!maxValue) return false;
const lastMonth = grid.value[grid.value.length - 1]?.value;
if (!lastMonth) return false;
const fnToUse = fn ?? propsNextPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(lastMonth, 1);
return adapter.value.isAfter(probe, maxValue);
}
function isPrevButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!minValue) return false;
const firstMonth = grid.value[0]?.value;
if (!firstMonth) return false;
const fnToUse = fn ?? propsPrevPage;
const probe = fnToUse
? adapter.value.toDateOnly(fnToUse(placeholder.value))
: adapter.value.addMonths(firstMonth, -1);
return adapter.value.isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !adapter.value.isSameMonth(v, placeholder.value))
placeholder.value = adapter.value.toDateOnly(v);
});
onMounted(() => {
if (!initialFocus || !open.value || !parentElement.value) return;
const target = parentElement.value.querySelector<HTMLElement>(
'[data-primitives-calendar-cell-trigger][data-selected]'
+ ',[data-primitives-calendar-cell-trigger][data-today]'
+ ',[data-primitives-calendar-cell-trigger]:not([data-outside-view]):not([data-disabled])',
);
target?.focus();
});
useEventListener(parentElement, 'focusout', (e) => {
if (!parentElement.value?.contains(e.relatedTarget as Node | null))
focusedDate.value = undefined;
});
const hiddenValue = computed(() => {
if (!modelValue.value) return '';
if (typeof valueFormat === 'function') return valueFormat(modelValue.value);
return adapter.value.toISO(modelValue.value).slice(0, 10);
});
const hasTime = computed(() => hasTimeGranularity(propsGranularity));
const nativeInputType = computed(() => hasTime.value ? 'datetime-local' : 'date');
/** Local (not UTC) value string for the native validation input. */
function toNativeInputValue(d: Date | undefined): string {
if (!d) return '';
const pad = (n: number, len = 2) => String(n).padStart(len, '0');
const p = adapter.value.getParts(d);
const date = `${pad(p.year, 4)}-${pad(p.month)}-${pad(p.day)}`;
if (!hasTime.value) return date;
const time = propsGranularity === 'second'
? `${pad(p.hour)}:${pad(p.minute)}:${pad(p.second)}`
: `${pad(p.hour)}:${pad(p.minute)}`;
return `${date}T${time}`;
}
const nativeValue = computed(() => toNativeInputValue(modelValue.value));
const nativeMin = computed(() => minValue ? toNativeInputValue(minValue) : undefined);
const nativeMax = computed(() => maxValue ? toNativeInputValue(maxValue) : undefined);
function focusFirstSegment() {
if (disabled || readonly) return;
const first = parentElement.value?.querySelector<HTMLElement>('[data-primitives-date-picker-segment]:not([data-readonly])');
first?.focus();
}
provideDatePickerRootContext({
dateAdapter: adapter,
open,
modal: modalRef,
name: nameRef,
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
disabled: disabledRef,
readonly: readonlyRef,
required: requiredRef,
isInvalid,
granularity: granularityRef,
hourCycle: hourCycleRef,
minValue: minValueRef,
maxValue: maxValueRef,
triggerId,
contentId,
fieldId,
triggerElement,
hasCustomAnchor,
onDateChange,
onPlaceholderChange,
onOpenChange: (v) => { open.value = v; },
onOpenToggle: () => { open.value = !open.value; },
});
provideCalendarRootContext({
dateAdapter: adapter,
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
multiple: multipleRef,
preventDeselect: preventDeselectRef,
disableDaysOutsideCurrentView: disableDaysOutsideCurrentViewRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
hasSelectedDate,
firstFocusableDate,
parentElement,
focusedDate,
setDate,
setPlaceholder,
nextPage,
prevPage,
nextYear,
prevYear,
isNextButtonDisabled,
isPrevButtonDisabled,
});
</script>
<template>
<PopperRoot>
<Primitive
:ref="forwardRef"
:as="as"
:data-primitives-date-picker-root="''"
:data-state="open ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
>
<slot :open="open" :model-value="modelValue" />
<input
v-if="name"
type="hidden"
:name="name"
:value="hiddenValue"
:disabled="disabled"
aria-hidden="true"
tabindex="-1"
style="display: none"
>
<VisuallyHidden
v-if="required || minValue || maxValue"
:id="fieldId"
as="input"
feature="focusable"
tabindex="-1"
:type="nativeInputType"
:value="nativeValue"
:required="required"
:min="nativeMin"
:max="nativeMax"
:disabled="disabled"
@focus="focusFirstSegment"
/>
</Primitive>
</PopperRoot>
</template>