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.
This commit is contained in:
2026-06-15 16:54:29 +07:00
parent 661a55719e
commit eefd7abf83
1029 changed files with 65815 additions and 9449 deletions
@@ -0,0 +1,33 @@
<script lang="ts">
import type { PopperAnchorProps } from '../../overlays/popper';
/**
* Optional custom anchor for positioning the popover against an element other
* than the trigger (e.g. a field or input group). When present, the trigger
* stops acting as the anchor and the content is positioned relative to this.
*/
export interface DatePickerAnchorProps extends PopperAnchorProps {}
</script>
<script setup lang="ts">
import { onBeforeMount, onUnmounted } from 'vue';
import { PopperAnchor } from '../../overlays/popper';
import { useDatePickerRootContext } from './context';
const props = defineProps<DatePickerAnchorProps>();
const ctx = useDatePickerRootContext();
onBeforeMount(() => {
ctx.hasCustomAnchor.value = true;
});
onUnmounted(() => {
ctx.hasCustomAnchor.value = false;
});
</script>
<template>
<PopperAnchor v-bind="props">
<slot />
</PopperAnchor>
</template>
@@ -0,0 +1,21 @@
<script lang="ts">
import type { PopperArrowProps } from '../../overlays/popper';
/**
* An optional arrow rendered inside `DatePickerContent` that points back at the
* trigger/anchor. Purely decorative; place it as a child of the content.
*/
export interface DatePickerArrowProps extends PopperArrowProps {}
</script>
<script setup lang="ts">
import { PopperArrow } from '../../overlays/popper';
const { width = 10, height = 5 } = defineProps<DatePickerArrowProps>();
</script>
<template>
<PopperArrow :width="width" :height="height">
<slot />
</PopperArrow>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A styling wrapper for the calendar grid rendered inside the popover. The
* calendar subparts (`DatePickerGrid`, `DatePickerCell`, etc.) consume the
* calendar context provided by `DatePickerRoot`; this element just groups and
* labels them with a `data-primitives-date-picker-calendar` hook.
*/
export interface DatePickerCalendarProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
const { as = 'div' } = defineProps<DatePickerCalendarProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-date-picker-calendar="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,29 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A button that closes the picker popover when clicked. Render it inside
* `DatePickerContent` (e.g. a "Done" or dismiss action).
*/
export interface DatePickerCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
const { as = 'button' } = defineProps<DatePickerCloseProps>();
const ctx = useDatePickerRootContext();
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
:data-state="ctx.open.value ? 'open' : 'closed'"
@click="ctx.onOpenChange(false)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,81 @@
<script lang="ts">
import type { DismissableLayerEmits } from '../../utilities/dismissable-layer';
import type { FocusScopeEmits } from '../../utilities/focus-scope';
import type { PopperContentProps } from '../../overlays/popper';
/**
* The popover panel that holds the calendar. Handles Popper positioning,
* presence (mount/unmount on open), focus trapping/restoration, and dismissal
* via Escape or outside interaction. Renders only while open unless `forceMount`
* is set.
*/
export interface DatePickerContentProps extends PopperContentProps {
/** Keep mounted for CSS exit animations. */
forceMount?: boolean;
}
export interface DatePickerContentEmits {
openAutoFocus: FocusScopeEmits['mountAutoFocus'];
closeAutoFocus: FocusScopeEmits['unmountAutoFocus'];
escapeKeyDown: DismissableLayerEmits['escapeKeyDown'];
pointerDownOutside: DismissableLayerEmits['pointerDownOutside'];
focusOutside: DismissableLayerEmits['focusOutside'];
interactOutside: DismissableLayerEmits['interactOutside'];
dismiss: [];
}
</script>
<script setup lang="ts">
import { DismissableLayer } from '../../utilities/dismissable-layer';
import { FocusScope } from '../../utilities/focus-scope';
import { PopperContent } from '../../overlays/popper';
import { Presence } from '../../utilities/presence';
import { useDatePickerRootContext } from './context';
const {
forceMount = false,
as = 'div',
...popperProps
} = defineProps<DatePickerContentProps>();
const emit = defineEmits<DatePickerContentEmits>();
const ctx = useDatePickerRootContext();
</script>
<template>
<Presence :present="ctx.open.value" :force-mount="forceMount">
<FocusScope
as="template"
:loop="true"
:trapped="ctx.modal.value"
@mount-auto-focus.prevent="emit('openAutoFocus', $event)"
@unmount-auto-focus="(event: Event) => {
emit('closeAutoFocus', event);
if (!event.defaultPrevented) ctx.triggerElement.value?.focus();
}"
>
<DismissableLayer
as="template"
:disable-outside-pointer-events="ctx.modal.value"
@escape-key-down="emit('escapeKeyDown', $event)"
@pointer-down-outside="emit('pointerDownOutside', $event)"
@focus-outside="emit('focusOutside', $event)"
@interact-outside="emit('interactOutside', $event)"
@dismiss="() => { ctx.onOpenChange(false); emit('dismiss'); }"
>
<PopperContent
:id="ctx.contentId.value"
:as="as"
v-bind="popperProps"
role="dialog"
:aria-labelledby="ctx.triggerId.value"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-primitives-date-picker-content="''"
>
<slot />
</PopperContent>
</DismissableLayer>
</FocusScope>
</Presence>
</template>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A text input that renders the selected date and, when `editable`, lets users
* type a date that is parsed and committed back to the picker on blur/Enter.
* Aliased as `DatePickerInput`; defaults to a read-only display of the value.
*/
export interface DatePickerFieldProps extends PrimitiveProps {
/** Allow typing into the field. @default false (read-only display) */
editable?: boolean;
/** Display format for the rendered value. */
format?: Intl.DateTimeFormatOptions;
/** Placeholder text shown when no value is selected. */
placeholderText?: string;
}
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useDatePickerRootContext } from './context';
const {
as: _as = 'input',
editable = false,
format = { year: 'numeric', month: '2-digit', day: '2-digit' },
placeholderText,
} = defineProps<DatePickerFieldProps>();
const ctx = useDatePickerRootContext();
const adapter = ctx.dateAdapter;
const displayValue = computed(() => {
if (!ctx.modelValue.value) return '';
return adapter.value.format(ctx.modelValue.value, format, ctx.locale.value);
});
const draft = ref(displayValue.value);
watch(displayValue, (v) => {
draft.value = v;
});
function commit() {
if (!editable) return;
const text = draft.value.trim();
if (!text) {
ctx.modelValue.value = undefined;
return;
}
const parsed = adapter.value.parse(text);
if (parsed)
ctx.modelValue.value = adapter.value.toDateOnly(parsed);
else
draft.value = displayValue.value;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') commit();
}
</script>
<template>
<input
:value="editable ? draft : displayValue"
:readonly="!editable"
:placeholder="placeholderText"
:data-primitives-date-picker-field="''"
@input="(e) => { if (editable) draft = (e.target as HTMLInputElement).value; }"
@blur="commit"
@keydown="handleKeydown"
>
</template>
@@ -0,0 +1,168 @@
<script lang="ts">
import type { SegmentContent, SegmentPart, SegmentValues } from './use-date-field';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A segmented date field: a `role="group"` of individually-focusable
* `role="spinbutton"` segments (`DatePickerFieldSegment`) that edit one part of
* the date each. It reads the picker's value, placeholder, locale, granularity,
* and hour cycle from `DatePickerRoot`, and commits a complete date back to the
* picker. This is the accessible, keyboard-driven alternative to the plain
* `DatePickerField` text input.
*
* The default slot receives the ordered `segments` descriptors (including
* literals) and the current `modelValue`, so the consumer renders a
* `DatePickerFieldSegment` per segment.
*/
export interface DatePickerFieldRootProps extends PrimitiveProps {}
export interface DatePickerFieldRootSlot {
default?: (props: {
segments: SegmentContent[];
modelValue: Date | undefined;
isInvalid: boolean;
}) => unknown;
}
</script>
<script setup lang="ts">
import { computed, markRaw, shallowRef, triggerRef, watch } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
import { provideDatePickerFieldContext } from './field-context';
import {
createSegmentContents,
initializeSegmentValues,
isSegmentValuesComplete,
segmentValuesToDate,
syncSegmentValues,
} from './use-date-field';
const { as = 'div' } = defineProps<DatePickerFieldRootProps>();
defineSlots<DatePickerFieldRootSlot>();
defineOptions({ inheritAttrs: false });
const ctx = useDatePickerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const adapter = ctx.dateAdapter;
const granularity = ctx.granularity;
const hourCycle = ctx.hourCycle;
const segmentValues = shallowRef<SegmentValues>(
ctx.modelValue.value
? syncSegmentValues(adapter.value, ctx.modelValue.value, granularity.value)
: initializeSegmentValues(granularity.value),
);
// Re-seed when the model or granularity changes from the outside.
watch([() => ctx.modelValue.value, granularity], ([value, gran]) => {
if (value) {
segmentValues.value = syncSegmentValues(adapter.value, value, gran);
}
else if (Object.values(segmentValues.value).every(v => v !== null)) {
// Only reset when the field was fully populated; preserve mid-edit state.
segmentValues.value = initializeSegmentValues(gran);
}
});
const segmentContents = computed<SegmentContent[]>(() => createSegmentContents(
segmentValues.value,
ctx.placeholder.value,
granularity.value,
hourCycle.value,
ctx.locale.value,
));
// Ordered registry of focusable segment elements (DOM order via querySelectorAll).
// `shallowRef` so element keys stay raw (a deep `ref` would proxy the Map and
// its entries); mutated in place, so `triggerRef` after each change.
const segmentMap = shallowRef<Map<HTMLElement, SegmentPart>>(new Map());
function registerSegment(el: HTMLElement, part: SegmentPart): () => void {
const key = markRaw(el);
segmentMap.value.set(key, part);
triggerRef(segmentMap);
return () => {
segmentMap.value.delete(key);
triggerRef(segmentMap);
};
}
function orderedSegments(): HTMLElement[] {
const root = currentElement.value;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLElement>('[data-primitives-date-picker-segment]:not([data-readonly])'),
);
}
function focusSegment(from: HTMLElement, direction: 1 | -1) {
const sign = ctx.dir.value === 'rtl' ? -direction : direction;
const els = orderedSegments();
const index = els.indexOf(from);
if (index < 0) return;
const next = els[index + sign];
next?.focus();
}
function focusNext(from: HTMLElement) {
const els = orderedSegments();
const index = els.indexOf(from);
if (index < 0) return;
els[index + 1]?.focus();
}
function commit() {
if (ctx.readonly.value || ctx.disabled.value) return;
if (!isSegmentValuesComplete(segmentValues.value, granularity.value)) return;
ctx.onDateChange(segmentValuesToDate(adapter.value, segmentValues.value, granularity.value));
}
function updateSegment(part: SegmentPart, value: number | string | null) {
// Replace wholesale so shallowRef triggers without deep tracking.
segmentValues.value = { ...segmentValues.value, [part]: value };
}
provideDatePickerFieldContext({
dateAdapter: adapter,
locale: ctx.locale,
dir: ctx.dir,
placeholder: ctx.placeholder,
disabled: ctx.disabled,
readonly: ctx.readonly,
isInvalid: ctx.isInvalid,
hourCycle,
granularity,
segmentValues,
segmentContents,
registerSegment,
focusSegment,
focusNext,
updateSegment,
commit,
});
</script>
<template>
<Primitive
:ref="forwardRef"
v-bind="$attrs"
:as="as"
role="group"
:data-primitives-date-picker-field-root="''"
:aria-disabled="ctx.disabled.value ? true : undefined"
:aria-invalid="ctx.isInvalid.value ? true : undefined"
:data-disabled="ctx.disabled.value ? '' : undefined"
:data-readonly="ctx.readonly.value ? '' : undefined"
:data-invalid="ctx.isInvalid.value ? '' : undefined"
:dir="ctx.dir.value"
>
<slot
:segments="segmentContents"
:model-value="ctx.modelValue.value"
:is-invalid="ctx.isInvalid.value"
/>
</Primitive>
</template>
@@ -0,0 +1,191 @@
<script lang="ts">
import type { DayPeriod, SegmentPart } from './use-date-field';
import type { PrimitiveProps } from '../../internal/primitive';
/**
* A single segment of a `DatePickerFieldRoot`. Editable parts (`day`, `month`,
* `year`, `hour`, `minute`, `second`, `dayPeriod`) render as a focusable
* `role="spinbutton"` with `aria-valuemin/max/now/valuetext`; `literal` parts
* render as inert separators. Supports arrow increment/decrement, numeric
* type-ahead with auto-advance, Backspace to clear, and `a`/`p` for AM/PM.
*/
export interface DatePickerFieldSegmentProps extends PrimitiveProps {
/** The date part this segment edits. */
part: SegmentPart;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted } from 'vue';
import { useForwardExpose } from '@robonen/vue';
import { Primitive } from '../../internal/primitive';
import { useDatePickerFieldContext } from './field-context';
import {
applySegmentKeydown,
isEditableSegmentPart,
resolveHourCycle,
} from './use-date-field';
const { part, as = 'div' } = defineProps<DatePickerFieldSegmentProps>();
const ctx = useDatePickerFieldContext();
const adapter = ctx.dateAdapter;
const { forwardRef, currentElement } = useForwardExpose();
const isLiteral = computed(() => part === 'literal');
const isEditable = computed(() => isEditableSegmentPart(part));
const displayValue = computed(() => {
// Find this part's current content. For repeated literals we just show value.
const match = ctx.segmentContents.value.find(s => s.part === part);
return match?.value ?? '';
});
const isEmpty = computed(() => {
if (!isEditable.value || part === 'dayPeriod') return false;
const v = (ctx.segmentValues.value as Record<string, unknown>)[part];
return v === null || v === undefined;
});
// Type-ahead state lives per segment instance.
const typeState = { hasLeftFocus: true, lastKeyZero: false };
const ariaValues = computed(() => {
const values = ctx.segmentValues.value;
switch (part) {
case 'day': {
// Use the placeholder's real year so `aria-valuemax` matches the editing
// cap in `applySegmentKeydown` (February differs across leap/non-leap years).
const year = adapter.value.getParts(ctx.placeholder.value).year;
const monthDays = values.month
? adapter.value.getDaysInMonth(adapter.value.fromParts({ year, month: values.month, day: 1 }))
: 31;
return { min: 1, max: monthDays, now: values.day ?? undefined, label: 'day' };
}
case 'month':
return { min: 1, max: 12, now: values.month ?? undefined, label: 'month' };
case 'year':
return { min: 1, max: 9999, now: values.year ?? undefined, label: 'year' };
case 'hour': {
const is12 = resolveHourCycle(ctx.hourCycle.value, ctx.locale.value) === 12;
return {
min: is12 ? 1 : 0,
max: is12 ? 12 : 23,
now: values.hour ?? undefined,
label: 'hour',
};
}
case 'minute':
return { min: 0, max: 59, now: values.minute ?? undefined, label: 'minute' };
case 'second':
return { min: 0, max: 59, now: values.second ?? undefined, label: 'second' };
case 'dayPeriod':
return { min: 0, max: 12, now: (values.hour ?? 0) % 12, label: 'AM/PM' };
default:
return { min: undefined, max: undefined, now: undefined, label: undefined };
}
});
const ariaValueText = computed(() => {
if (isEmpty.value) return 'Empty';
if (part === 'dayPeriod') return ctx.segmentValues.value.dayPeriod ?? 'AM';
return displayValue.value;
});
let cleanup: (() => void) | undefined;
onMounted(() => {
if (isEditable.value && !ctx.readonly.value && currentElement.value)
cleanup = ctx.registerSegment(currentElement.value, part);
});
onBeforeUnmount(() => cleanup?.());
const disabled = computed(() => ctx.disabled.value);
const readonly = computed(() => ctx.readonly.value);
function handleKeydown(e: KeyboardEvent) {
if (!isEditable.value) return;
if (disabled.value || readonly.value) return;
if (e.key === 'ArrowLeft') {
e.preventDefault();
ctx.focusSegment(currentElement.value!, -1);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
ctx.focusSegment(currentElement.value!, 1);
return;
}
if (e.key === 'Tab' || e.key === 'Shift')
return;
e.preventDefault();
typeState.hasLeftFocus = false;
const result = applySegmentKeydown(e, {
adapter: adapter.value,
part: part as Exclude<SegmentPart, 'literal'>,
values: ctx.segmentValues.value,
placeholder: ctx.placeholder.value,
granularity: ctx.granularity.value,
hourCycle: ctx.hourCycle.value,
locale: ctx.locale.value,
state: typeState,
focusNext: () => ctx.focusNext(currentElement.value!),
});
if (!result) return;
ctx.updateSegment(result.part, result.value);
if ('dayPeriod' in result && result.dayPeriod !== undefined)
ctx.updateSegment('dayPeriod', result.dayPeriod as DayPeriod);
if ('hour' in result && typeof (result as { hour?: number }).hour === 'number')
ctx.updateSegment('hour', (result as { hour: number }).hour);
ctx.commit();
}
function handleFocusOut() {
typeState.hasLeftFocus = true;
}
</script>
<template>
<Primitive
v-if="isLiteral"
:as="as"
aria-hidden="true"
:data-primitives-date-picker-segment="part"
data-readonly=""
>
<slot :value="displayValue">{{ displayValue }}</slot>
</Primitive>
<Primitive
v-else
:ref="forwardRef"
:as="as"
role="spinbutton"
:contenteditable="false"
:tabindex="disabled ? undefined : 0"
:aria-label="ariaValues.label"
:aria-valuemin="ariaValues.min"
:aria-valuemax="ariaValues.max"
:aria-valuenow="ariaValues.now"
:aria-valuetext="ariaValueText"
:aria-disabled="disabled ? true : undefined"
:aria-readonly="readonly ? true : undefined"
:aria-invalid="ctx.isInvalid.value ? true : undefined"
:data-primitives-date-picker-segment="part"
:data-placeholder="isEmpty ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="ctx.isInvalid.value ? '' : undefined"
spellcheck="false"
autocorrect="off"
inputmode="numeric"
@keydown="handleKeydown"
@focusout="handleFocusOut"
>
<slot :value="displayValue">{{ displayValue }}</slot>
</Primitive>
</template>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { TeleportPrimitiveProps } from '../../utilities/teleport';
/**
* Teleports the popover content into a different part of the DOM (the body by
* default) so it escapes overflow/stacking-context clipping. Wrap
* `DatePickerContent` with it when the picker lives inside a scrolled or
* transformed container.
*/
export interface DatePickerPortalProps extends TeleportPrimitiveProps {}
</script>
<script setup lang="ts">
import PortalPrimitive from '../../utilities/teleport/Teleport.vue';
const props = defineProps<DatePickerPortalProps>();
</script>
<template>
<PortalPrimitive v-bind="props">
<slot />
</PortalPrimitive>
</template>
@@ -0,0 +1,479 @@
<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>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { PrimitiveProps } from '../../internal/primitive';
/**
* The button that toggles the picker popover open and closed. Acts as the
* Popper anchor (unless a custom `DatePickerAnchor` is present) and carries the
* dialog-related ARIA wiring (`aria-haspopup`, `aria-expanded`, `aria-controls`).
*/
export interface DatePickerTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, onMounted } from 'vue';
import { PopperAnchor } from '../../overlays/popper';
import { Primitive } from '../../internal/primitive';
import { useDatePickerRootContext } from './context';
const { as = 'button' } = defineProps<DatePickerTriggerProps>();
const ctx = useDatePickerRootContext();
const { forwardRef, currentElement } = useForwardExpose();
const disabled = computed(() => ctx.disabled.value);
function onClick() {
if (disabled.value) return;
ctx.onOpenToggle();
}
onMounted(() => {
ctx.triggerElement.value = currentElement.value;
});
</script>
<template>
<component :is="ctx.hasCustomAnchor.value ? Primitive : PopperAnchor" as="template">
<Primitive
:id="ctx.triggerId.value"
:ref="forwardRef"
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="ctx.open.value"
:aria-controls="ctx.contentId.value"
:disabled="as === 'button' && disabled ? true : undefined"
:aria-disabled="disabled ? true : undefined"
:data-state="ctx.open.value ? 'open' : 'closed'"
:data-disabled="disabled ? '' : undefined"
:data-primitives-date-picker-trigger="''"
@click="onClick"
>
<slot />
</Primitive>
</component>
</template>
@@ -0,0 +1,406 @@
import type { SegmentContent } from '../use-date-field';
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it } from 'vitest';
import { defineComponent, h, nextTick } from 'vue';
import {
DatePickerCalendar,
DatePickerCell,
DatePickerCellTrigger,
DatePickerContent,
DatePickerFieldRoot,
DatePickerFieldSegment,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridRow,
DatePickerRoot,
DatePickerTrigger,
} from '../index';
function press(el: Element, key: string) {
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
}
function mountField(rootProps: Record<string, unknown> = {}, options: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => h(DatePickerFieldRoot, null, {
default: ({ segments }: { segments: SegmentContent[] }) =>
segments.map((seg, i) => h(DatePickerFieldSegment, { key: i, part: seg.part }, {
default: () => seg.value,
})),
}),
}),
}), { attachTo: document.body, ...options });
}
function segments(wrapper: ReturnType<typeof mount>, part?: string) {
const sel = part
? `[data-primitives-date-picker-segment="${part}"]`
: '[role="spinbutton"]';
return Array.from(wrapper.element.querySelectorAll<HTMLElement>(sel));
}
describe('DatePicker field ARIA skeleton', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders a role=group with role=spinbutton segments', () => {
w = mountField();
expect(w.element.querySelector('[role="group"]')).toBeTruthy();
const spin = segments(w);
// day/month/year for default day granularity
expect(spin.length).toBe(3);
for (const s of spin) {
expect(s.getAttribute('role')).toBe('spinbutton');
expect(s.getAttribute('tabindex')).toBe('0');
}
});
it('marks empty segments with data-placeholder and aria-valuetext=Empty', () => {
w = mountField();
const day = segments(w, 'day')[0]!;
expect(day.getAttribute('data-placeholder')).toBe('');
expect(day.getAttribute('aria-valuetext')).toBe('Empty');
expect(day.getAttribute('aria-valuemin')).toBe('1');
});
it('reflects the controlled value into segment aria-valuenow', async () => {
w = mountField({ modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
const month = segments(w, 'month')[0]!;
const year = segments(w, 'year')[0]!;
expect(day.getAttribute('aria-valuenow')).toBe('15');
expect(month.getAttribute('aria-valuenow')).toBe('3');
expect(year.getAttribute('aria-valuenow')).toBe('2024');
});
});
describe('DatePicker segment keyboard editing', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('ArrowUp increments a segment, ArrowDown decrements', async () => {
w = mountField({ modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('16');
press(segments(w, 'day')[0]!, 'ArrowDown');
press(segments(w, 'day')[0]!, 'ArrowDown');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('14');
});
it('ArrowUp on an empty segment seeds a sensible value', async () => {
w = mountField();
await nextTick();
const month = segments(w, 'month')[0]!;
press(month, 'ArrowUp');
await nextTick();
expect(segments(w, 'month')[0]!.getAttribute('aria-valuenow')).toBe('1');
});
it('numeric type-ahead fills a segment and auto-advances to the next', async () => {
w = mountField();
await nextTick();
const order = segments(w);
const monthSeg = segments(w, 'month')[0]!;
monthSeg.focus();
// typing 2 for month — month max 12, maxStart=1, so 2 > 1 → completes and advances
press(monthSeg, '2');
await nextTick();
expect(segments(w, 'month')[0]!.getAttribute('aria-valuenow')).toBe('2');
// focus advanced to the next focusable segment
expect(document.activeElement).not.toBe(monthSeg);
expect(order.length).toBeGreaterThan(1);
});
it('typing two digits builds a two-digit value before advancing', async () => {
w = mountField();
await nextTick();
const year = segments(w, 'year')[0]!;
year.focus();
press(year, '2');
press(segments(w, 'year')[0]!, '0');
press(segments(w, 'year')[0]!, '2');
press(segments(w, 'year')[0]!, '4');
await nextTick();
expect(segments(w, 'year')[0]!.getAttribute('aria-valuenow')).toBe('2024');
});
it('Backspace clears a digit / empties the segment', async () => {
w = mountField({ modelValue: new Date(2024, 2, 5) });
await nextTick();
const day = segments(w, 'day')[0]!;
// day=5 single digit → backspace empties it
press(day, 'Backspace');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuetext')).toBe('Empty');
});
it('commits a full date once all segments are filled', async () => {
w = mountField();
await nextTick();
const month = segments(w, 'month')[0]!;
const day = segments(w, 'day')[0]!;
const year = segments(w, 'year')[0]!;
// Fill in any order; commit fires when complete.
press(month, '5');
press(day, '6');
press(year, '2');
press(segments(w, 'year')[0]!, '0');
press(segments(w, 'year')[0]!, '2');
press(segments(w, 'year')[0]!, '0');
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
expect(emitted).toBeTruthy();
const last = emitted!.at(-1)![0] as Date;
expect(last.getFullYear()).toBe(2020);
expect(last.getMonth()).toBe(4);
expect(last.getDate()).toBe(6);
});
});
describe('DatePicker RTL-aware segment navigation', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('ArrowRight moves to the next segment in LTR', async () => {
w = mountField({ locale: 'en-US' });
await nextTick();
const order = segments(w);
const first = order[0]!;
first.focus();
press(first, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(order[1]);
});
it('ArrowRight moves to the previous segment in RTL', async () => {
w = mountField({ locale: 'en-US', dir: 'rtl' });
await nextTick();
const order = segments(w);
const second = order[1]!;
second.focus();
press(second, 'ArrowRight');
await nextTick();
expect(document.activeElement).toBe(order[0]);
});
});
describe('DatePicker time granularity', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders hour/minute segments for granularity=minute', async () => {
w = mountField({ granularity: 'minute', hourCycle: 24, modelValue: new Date(2024, 0, 1, 13, 30) });
await nextTick();
expect(segments(w, 'hour').length).toBe(1);
expect(segments(w, 'minute').length).toBe(1);
expect(segments(w, 'hour')[0]!.getAttribute('aria-valuenow')).toBe('13');
expect(segments(w, 'minute')[0]!.getAttribute('aria-valuenow')).toBe('30');
});
it('renders a dayPeriod segment for a 12-hour cycle and toggles with a/p', async () => {
w = mountField({ granularity: 'minute', hourCycle: 12, modelValue: new Date(2024, 0, 1, 9, 0) });
await nextTick();
const period = segments(w, 'dayPeriod')[0]!;
expect(period).toBeTruthy();
expect(period.getAttribute('aria-valuetext')).toBe('AM');
press(period, 'p');
await nextTick();
expect(segments(w, 'dayPeriod')[0]!.getAttribute('aria-valuetext')).toBe('PM');
expect(segments(w, 'hour')[0]!.getAttribute('aria-valuenow')).toBe('21');
});
it('preserves time-of-day when picking a calendar day', async () => {
w = mountField({ granularity: 'minute', hourCycle: 24, modelValue: new Date(2024, 0, 10, 8, 45) });
await nextTick();
// bump the hour segment then ensure minute kept
const hour = segments(w, 'hour')[0]!;
press(hour, 'ArrowUp');
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
const last = emitted!.at(-1)![0] as Date;
expect(last.getHours()).toBe(9);
expect(last.getMinutes()).toBe(45);
});
});
describe('DatePicker disabled / readonly guards', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('disabled blocks segment mutation and marks aria-disabled', async () => {
w = mountField({ disabled: true, modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
expect(day.getAttribute('aria-disabled')).toBe('true');
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('15');
});
it('readonly blocks segment mutation', async () => {
w = mountField({ readonly: true, modelValue: new Date(2024, 2, 15) });
await nextTick();
const day = segments(w, 'day')[0]!;
press(day, 'ArrowUp');
await nextTick();
expect(segments(w, 'day')[0]!.getAttribute('aria-valuenow')).toBe('15');
});
});
describe('DatePicker trigger honors disabled', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
function mountTrigger(rootProps: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => [
h(DatePickerTrigger, null, { default: () => 'open' }),
h(DatePickerContent, null, { default: () => h(DatePickerCalendar) }),
],
}),
}), { attachTo: document.body });
}
it('does not open when disabled', async () => {
w = mountTrigger({ disabled: true });
await nextTick();
const trigger = w.element.querySelector<HTMLElement>('[data-primitives-date-picker-trigger]')!;
expect(trigger.getAttribute('data-disabled')).toBe('');
expect((trigger as HTMLButtonElement).disabled).toBe(true);
trigger.click();
await nextTick();
expect(w.findComponent(DatePickerRoot).emitted('update:open')).toBeFalsy();
});
it('opens normally when enabled', async () => {
w = mountTrigger();
await nextTick();
const trigger = w.element.querySelector<HTMLElement>('[data-primitives-date-picker-trigger]')!;
trigger.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:open');
expect(emitted).toBeTruthy();
expect(emitted!.at(-1)![0]).toBe(true);
});
});
describe('DatePicker preventDeselect', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
function findCell(wrapper: ReturnType<typeof mount>, date: Date): HTMLElement {
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const triggers = Array.from(
wrapper.element.querySelectorAll<HTMLElement>('[data-primitives-calendar-cell-trigger][data-value]'),
);
const match = triggers.find(t => t.getAttribute('data-value') === iso);
return match ?? triggers[0]!;
}
function mountWithCalendar(rootProps: Record<string, unknown> = {}) {
return mount(defineComponent({
setup: () => () => h(DatePickerRoot, rootProps, {
default: () => h(DatePickerCalendar, null, {
default: () => h(DatePickerGrid, { month: new Date(2024, 2, 1) }, {
default: () => h(DatePickerGridBody, null, {
default: () => h(DatePickerGridRow, null, {
default: () => h(DatePickerCell, { date: new Date(2024, 2, 15) }, {
default: () => h(DatePickerCellTrigger, {
day: new Date(2024, 2, 15),
month: new Date(2024, 2, 1),
}),
}),
}),
}),
}),
}),
}),
}), { attachTo: document.body });
}
it('re-selecting the same date clears it by default', async () => {
w = mountWithCalendar({ modelValue: new Date(2024, 2, 15), closeOnSelect: false });
await nextTick();
const cell = findCell(w, new Date(2024, 2, 15));
cell.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
expect(emitted).toBeTruthy();
expect(emitted!.at(-1)![0]).toBeUndefined();
});
it('keeps the date selected when preventDeselect is set', async () => {
w = mountWithCalendar({
modelValue: new Date(2024, 2, 15),
preventDeselect: true,
closeOnSelect: false,
});
await nextTick();
const cell = findCell(w, new Date(2024, 2, 15));
cell.click();
await nextTick();
const emitted = w.findComponent(DatePickerRoot).emitted('update:modelValue');
if (emitted) {
const last = emitted.at(-1)![0] as Date | undefined;
expect(last).toBeInstanceOf(Date);
}
});
});
describe('DatePicker form validation input', () => {
let w: ReturnType<typeof mount> | undefined;
afterEach(() => {
w?.unmount();
w = undefined;
});
it('renders a focusable native input when required', async () => {
w = mountField({ required: true, name: 'date' });
await nextTick();
const input = w.element.querySelector<HTMLInputElement>('input[type="date"]');
expect(input).toBeTruthy();
expect(input!.required).toBe(true);
});
it('uses datetime-local type for time granularity with min/max', async () => {
w = mountField({
granularity: 'minute',
hourCycle: 24,
minValue: new Date(2024, 0, 1, 8, 0),
maxValue: new Date(2024, 11, 31, 18, 0),
});
await nextTick();
const input = w.element.querySelector<HTMLInputElement>('input[type="datetime-local"]');
expect(input).toBeTruthy();
expect(input!.min).toBe('2024-01-01T08:00');
expect(input!.max).toBe('2024-12-31T18:00');
});
});
@@ -0,0 +1,38 @@
import type { ComputedRef, Ref } from 'vue';
import type { DateAdapter } from '../../utilities/config-provider';
import type { Granularity, HourCycle } from './use-date-field';
import { useContextFactory } from '@robonen/vue';
export interface DatePickerRootContext {
/** Resolved date backend (root `dateAdapter` prop or the global `ConfigProvider`). */
dateAdapter: ComputedRef<DateAdapter<Date>>;
open: Ref<boolean>;
modal: Ref<boolean>;
name: Ref<string | undefined>;
modelValue: Ref<Date | undefined>;
placeholder: Ref<Date>;
locale: Ref<string>;
dir: Ref<'ltr' | 'rtl'>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
required: Ref<boolean>;
isInvalid: ComputedRef<boolean>;
granularity: ComputedRef<Granularity>;
hourCycle: Ref<HourCycle>;
minValue: Ref<Date | undefined>;
maxValue: Ref<Date | undefined>;
triggerId: ComputedRef<string>;
contentId: ComputedRef<string>;
fieldId: ComputedRef<string>;
triggerElement: Ref<HTMLElement | undefined>;
hasCustomAnchor: Ref<boolean>;
/** Commit a date from any source (calendar cell or field), honoring readonly/granularity/preventDeselect. */
onDateChange: (date: Date | undefined) => void;
onPlaceholderChange: (date: Date) => void;
onOpenChange: (value: boolean) => void;
onOpenToggle: () => void;
}
const ctx = useContextFactory<DatePickerRootContext>('DatePickerRoot');
export const provideDatePickerRootContext = ctx.provide;
export const useDatePickerRootContext = ctx.inject;
@@ -0,0 +1,158 @@
<script lang="ts">
import { defineComponent, h } from 'vue';
import {
DatePickerCell,
DatePickerCellTrigger,
DatePickerGrid,
DatePickerGridBody,
DatePickerGridHead,
DatePickerGridRow,
DatePickerHeadCell,
useCalendarRootContext,
} from '@robonen/primitives';
// Reads the calendar context provided by DatePickerRoot. Defined as a child so
// the injection resolves (the demo's own <script setup> is the Root's parent).
const CalendarBody = defineComponent({
name: 'CalendarBody',
setup() {
const ctx = useCalendarRootContext();
return () => ctx.grid.value.map(month => h(
DatePickerGrid,
{ key: month.value.toString(), month: month.value, class: 'w-full border-collapse select-none' },
() => [
h(DatePickerGridHead, null, () => h(
DatePickerGridRow,
{ class: 'mb-1 flex' },
() => ctx.weekDays.value.map((weekday, i) => h(
DatePickerHeadCell,
{ key: weekday + i, class: 'w-9 text-center text-xs font-medium text-fg-subtle' },
() => weekday,
)),
)),
h(DatePickerGridBody, null, () => month.weeks.map((week, w) => h(
DatePickerGridRow,
{ key: w, class: 'flex w-full' },
() => week.map(day => h(
DatePickerCell,
{ key: day.toString(), date: day, class: 'p-0.5' },
() => h(
DatePickerCellTrigger,
{
day,
month: month.value,
class: `flex size-8 items-center justify-center rounded-lg text-sm tabular-nums transition outline-none cursor-pointer
focus-visible:ring-2 focus-visible:ring-ring
hover:bg-bg-inset
data-[selected]:bg-accent data-[selected]:font-semibold data-[selected]:text-accent-fg data-[selected]:hover:bg-accent-hover
data-[outside-view]:text-fg-subtle data-[outside-view]:opacity-50
data-[disabled]:cursor-not-allowed data-[disabled]:opacity-30`,
},
),
)),
))),
],
));
},
});
export default CalendarBody;
</script>
<script setup lang="ts">
import {
DatePickerCalendar,
DatePickerClose,
DatePickerContent,
DatePickerField,
DatePickerHeading,
DatePickerNext,
DatePickerPrev,
DatePickerRoot,
DatePickerTrigger,
} from '@robonen/primitives';
import { ref } from 'vue';
const value = ref<Date>();
</script>
<template>
<div class="flex w-full max-w-xs flex-col gap-2">
<span class="text-xs font-medium text-fg-muted">Departure date</span>
<DatePickerRoot v-slot="{ open }" v-model="value" :close-on-select="true">
<div class="flex items-stretch gap-1.5">
<DatePickerField
:format="{ weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }"
placeholder-text="Select a date"
class="min-w-0 flex-1 rounded-lg border border-border bg-bg px-3 py-2 text-sm text-fg outline-none placeholder:text-fg-subtle focus-visible:ring-2 focus-visible:ring-ring"
/>
<DatePickerTrigger
aria-label="Open calendar"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer data-[state=open]:bg-bg-inset data-[state=open]:text-fg"
>
<svg
class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
</DatePickerTrigger>
</div>
<DatePickerContent
:side-offset="6"
class="demo-card z-50 p-3 text-fg shadow-lg data-[state=closed]:opacity-0"
>
<DatePickerCalendar>
<div class="mb-3 flex items-center justify-between gap-2">
<DatePickerPrev
aria-label="Previous month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerPrev>
<DatePickerHeading class="text-sm font-semibold tracking-tight" />
<DatePickerNext
aria-label="Next month"
class="inline-flex size-8 items-center justify-center rounded-lg border border-border bg-bg text-fg-muted transition hover:bg-bg-inset hover:text-fg active:scale-95 cursor-pointer disabled:cursor-not-allowed disabled:opacity-40"
>
</DatePickerNext>
</div>
<CalendarBody />
</DatePickerCalendar>
<div class="mt-3 flex items-center justify-between border-t border-border pt-3">
<button
type="button"
class="rounded-md px-2 py-1 text-xs font-medium text-fg-muted transition hover:bg-bg-inset hover:text-fg cursor-pointer"
@click="value = undefined"
>
Clear
</button>
<DatePickerClose
class="rounded-md bg-accent px-3 py-1 text-xs font-medium text-accent-fg transition hover:bg-accent-hover active:scale-95 cursor-pointer"
>
Done
</DatePickerClose>
</div>
</DatePickerContent>
<p v-if="false">{{ open }}</p>
</DatePickerRoot>
<p class="text-xs text-fg-subtle">
<template v-if="value">
Selected
<span class="font-medium text-fg-muted">{{ value.toLocaleDateString('en', { dateStyle: 'medium' }) }}</span>
</template>
<template v-else>
No date selected yet
</template>
</p>
</div>
</template>
@@ -0,0 +1,34 @@
import type { ComputedRef, Ref } from 'vue';
import type { DateAdapter } from '../../utilities/config-provider';
import type { Granularity, HourCycle, SegmentContent, SegmentPart, SegmentValues } from './use-date-field';
import { useContextFactory } from '@robonen/vue';
export interface DatePickerFieldContext {
/** Resolved date backend, inherited from `DatePickerRoot`. */
dateAdapter: ComputedRef<DateAdapter<Date>>;
locale: Ref<string>;
dir: Ref<'ltr' | 'rtl'>;
placeholder: Ref<Date>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
isInvalid: Ref<boolean>;
hourCycle: Ref<HourCycle>;
granularity: ComputedRef<Granularity>;
/** Live per-part numeric/string values (null when empty). */
segmentValues: Ref<SegmentValues>;
/** Ordered, formatted segment descriptors (incl. literals) for rendering. */
segmentContents: ComputedRef<SegmentContent[]>;
/** Registered focusable segment elements in DOM order. */
registerSegment: (el: HTMLElement, part: SegmentPart) => () => void;
/** Move focus to the next/previous focusable segment (RTL-aware). */
focusSegment: (from: HTMLElement, direction: 1 | -1) => void;
focusNext: (from: HTMLElement) => void;
/** Mutate a single part value and recompute the committed model value. */
updateSegment: (part: SegmentPart, value: number | string | null) => void;
/** Commit current segment values into the picker model if complete. */
commit: () => void;
}
const ctx = useContextFactory<DatePickerFieldContext>('date-picker-field');
export const provideDatePickerFieldContext = ctx.provide;
export const useDatePickerFieldContext = ctx.inject;
@@ -0,0 +1,54 @@
export { default as DatePickerRoot } from './DatePickerRoot.vue';
export { default as DatePickerTrigger } from './DatePickerTrigger.vue';
export { default as DatePickerAnchor } from './DatePickerAnchor.vue';
export { default as DatePickerPortal } from './DatePickerPortal.vue';
export { default as DatePickerContent } from './DatePickerContent.vue';
export { default as DatePickerArrow } from './DatePickerArrow.vue';
export { default as DatePickerClose } from './DatePickerClose.vue';
export { default as DatePickerCalendar } from './DatePickerCalendar.vue';
export { default as DatePickerField } from './DatePickerField.vue';
export { default as DatePickerInput } from './DatePickerField.vue';
export { default as DatePickerFieldRoot } from './DatePickerFieldRoot.vue';
export { default as DatePickerFieldSegment } from './DatePickerFieldSegment.vue';
// Calendar subparts re-exported as DatePicker* aliases (share CalendarRootContext provided by DatePickerRoot).
export { default as DatePickerHeader } from '../calendar/CalendarHeader.vue';
export { default as DatePickerHeading } from '../calendar/CalendarHeading.vue';
export { default as DatePickerPrev } from '../calendar/CalendarPrev.vue';
export { default as DatePickerNext } from '../calendar/CalendarNext.vue';
export { default as DatePickerGrid } from '../calendar/CalendarGrid.vue';
export { default as DatePickerGridHead } from '../calendar/CalendarGridHead.vue';
export { default as DatePickerGridBody } from '../calendar/CalendarGridBody.vue';
export { default as DatePickerGridRow } from '../calendar/CalendarGridRow.vue';
export { default as DatePickerHeadCell } from '../calendar/CalendarHeadCell.vue';
export { default as DatePickerCell } from '../calendar/CalendarCell.vue';
export { default as DatePickerCellTrigger } from '../calendar/CalendarCellTrigger.vue';
export { provideDatePickerRootContext, useDatePickerRootContext } from './context';
export type { DatePickerRootContext } from './context';
export { provideDatePickerFieldContext, useDatePickerFieldContext } from './field-context';
export type { DatePickerFieldContext } from './field-context';
export type {
DateSegmentPart,
EditableSegmentPart,
Granularity,
HourCycle,
SegmentContent,
SegmentPart,
SegmentValues,
TimeSegmentPart,
} from './use-date-field';
export type { DatePickerRootEmits, DatePickerRootProps } from './DatePickerRoot.vue';
export type { DatePickerTriggerProps } from './DatePickerTrigger.vue';
export type { DatePickerAnchorProps } from './DatePickerAnchor.vue';
export type { DatePickerPortalProps } from './DatePickerPortal.vue';
export type { DatePickerContentEmits, DatePickerContentProps } from './DatePickerContent.vue';
export type { DatePickerArrowProps } from './DatePickerArrow.vue';
export type { DatePickerCloseProps } from './DatePickerClose.vue';
export type { DatePickerCalendarProps } from './DatePickerCalendar.vue';
export type { DatePickerFieldProps } from './DatePickerField.vue';
export type { DatePickerFieldRootProps, DatePickerFieldRootSlot } from './DatePickerFieldRoot.vue';
export type { DatePickerFieldSegmentProps } from './DatePickerFieldSegment.vue';
@@ -0,0 +1,510 @@
import type { DateAdapter } from '../../utilities/config-provider';
export type Granularity = 'day' | 'hour' | 'minute' | 'second';
export type HourCycle = 12 | 24 | undefined;
export type DateSegmentPart = 'day' | 'month' | 'year';
export type TimeSegmentPart = 'hour' | 'minute' | 'second' | 'dayPeriod';
export type EditableSegmentPart = DateSegmentPart | TimeSegmentPart;
export type SegmentPart = EditableSegmentPart | 'literal';
export type DayPeriod = 'AM' | 'PM';
export interface SegmentValues {
day: number | null;
month: number | null;
year: number | null;
hour?: number | null;
minute?: number | null;
second?: number | null;
dayPeriod?: DayPeriod;
}
export interface SegmentContent {
part: SegmentPart;
value: string;
}
export const DATE_SEGMENT_PARTS: DateSegmentPart[] = ['day', 'month', 'year'];
export const TIME_SEGMENT_PARTS: TimeSegmentPart[] = ['hour', 'minute', 'second', 'dayPeriod'];
export const EDITABLE_SEGMENT_PARTS: EditableSegmentPart[] = [...DATE_SEGMENT_PARTS, ...TIME_SEGMENT_PARTS];
export function isEditableSegmentPart(part: string): part is EditableSegmentPart {
return (EDITABLE_SEGMENT_PARTS as string[]).includes(part);
}
export function hasTimeGranularity(granularity: Granularity): boolean {
return granularity === 'hour' || granularity === 'minute' || granularity === 'second';
}
export function isSegmentNavigationKey(key: string): boolean {
return key === 'ArrowLeft' || key === 'ArrowRight';
}
export function isNumberKey(key: string): boolean {
return key.length === 1 && key >= '0' && key <= '9';
}
export function isAcceptableSegmentKey(key: string): boolean {
if (isNumberKey(key))
return true;
switch (key) {
case 'Enter':
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
case 'Backspace':
case 'Delete':
case ' ':
case 'a':
case 'A':
case 'p':
case 'P':
return true;
default:
return false;
}
}
/** Empty value set for the requested granularity. */
export function initializeSegmentValues(granularity: Granularity): SegmentValues {
const base: SegmentValues = { day: null, month: null, year: null };
if (!hasTimeGranularity(granularity))
return base;
base.hour = null;
base.dayPeriod = 'AM';
if (granularity === 'minute' || granularity === 'second')
base.minute = null;
if (granularity === 'second')
base.second = null;
return base;
}
/** Extract segment values from a concrete date for the requested granularity. */
export function syncSegmentValues(
adapter: DateAdapter<Date>,
date: Date,
granularity: Granularity,
): SegmentValues {
const parts = adapter.getParts(date);
const values: SegmentValues = {
day: parts.day,
month: parts.month,
year: parts.year,
};
if (hasTimeGranularity(granularity)) {
const h = parts.hour;
values.hour = h;
values.dayPeriod = h >= 12 ? 'PM' : 'AM';
if (granularity === 'minute' || granularity === 'second')
values.minute = parts.minute;
if (granularity === 'second')
values.second = parts.second;
}
return values;
}
/** True when every editable part for the granularity has a value. */
export function isSegmentValuesComplete(values: SegmentValues, granularity: Granularity): boolean {
if (values.day === null || values.month === null || values.year === null)
return false;
if (!hasTimeGranularity(granularity))
return true;
if (values.hour === null || values.hour === undefined)
return false;
if ((granularity === 'minute' || granularity === 'second') && (values.minute === null || values.minute === undefined))
return false;
if (granularity === 'second' && (values.second === null || values.second === undefined))
return false;
return true;
}
/** Build a date from complete segment values (caller guarantees completeness). */
export function segmentValuesToDate(
adapter: DateAdapter<Date>,
values: SegmentValues,
granularity: Granularity,
): Date {
const year = values.year as number;
const month = values.month as number;
const day = values.day as number;
if (!hasTimeGranularity(granularity))
return adapter.fromParts({ year, month, day });
const hour = (values.hour as number) ?? 0;
const minute = (granularity === 'minute' || granularity === 'second') ? ((values.minute as number) ?? 0) : 0;
const second = granularity === 'second' ? ((values.second as number) ?? 0) : 0;
return adapter.fromParts({ year, month, day, hour, minute, second });
}
interface FormatPartOptions {
hourCycle: HourCycle;
locale: string;
}
/** Render the live string for a single segment, falling back to a placeholder. */
export function formatSegment(
part: SegmentPart,
values: SegmentValues,
placeholder: Date,
opts: FormatPartOptions,
): string {
switch (part) {
case 'day':
return values.day === null ? 'dd' : String(values.day).padStart(2, '0');
case 'month':
return values.month === null ? 'mm' : String(values.month).padStart(2, '0');
case 'year':
return values.year === null ? 'yyyy' : String(values.year).padStart(4, '0');
case 'hour': {
if (values.hour === null || values.hour === undefined)
return 'hh';
const is12 = resolveHourCycle(opts.hourCycle, opts.locale) === 12;
if (!is12)
return String(values.hour).padStart(2, '0');
const h = values.hour % 12 === 0 ? 12 : values.hour % 12;
return String(h).padStart(2, '0');
}
case 'minute':
return values.minute === null || values.minute === undefined ? 'mm' : String(values.minute).padStart(2, '0');
case 'second':
return values.second === null || values.second === undefined ? 'ss' : String(values.second).padStart(2, '0');
case 'dayPeriod':
return values.dayPeriod ?? 'AM';
default:
return '';
}
}
let cachedLocale: string | undefined;
let cachedHourCycleIs12: boolean | undefined;
/** Resolve the effective hour cycle: explicit prop wins, else infer from locale. */
export function resolveHourCycle(hourCycle: HourCycle, locale: string): 12 | 24 {
if (hourCycle === 12 || hourCycle === 24)
return hourCycle;
if (cachedLocale !== locale) {
cachedLocale = locale;
try {
const resolved = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions().hourCycle;
cachedHourCycleIs12 = resolved === 'h11' || resolved === 'h12';
}
catch {
cachedHourCycleIs12 = false;
}
}
return cachedHourCycleIs12 ? 12 : 24;
}
/**
* Ordered list of segment descriptors (incl. literals) honoring locale order
* for the date parts and a fixed time order, mirroring native formatting.
*/
export function createSegmentContents(
values: SegmentValues,
placeholder: Date,
granularity: Granularity,
hourCycle: HourCycle,
locale: string,
): SegmentContent[] {
const dateOrder = resolveDatePartOrder(locale);
const dateLiteral = resolveDateLiteral(locale);
const out: SegmentContent[] = [];
dateOrder.forEach((part, index) => {
if (index > 0)
out.push({ part: 'literal', value: dateLiteral });
out.push({ part, value: formatSegment(part, values, placeholder, { hourCycle, locale }) });
});
if (hasTimeGranularity(granularity)) {
out.push({ part: 'literal', value: ', ' });
out.push({ part: 'hour', value: formatSegment('hour', values, placeholder, { hourCycle, locale }) });
if (granularity === 'minute' || granularity === 'second') {
out.push({ part: 'literal', value: ':' });
out.push({ part: 'minute', value: formatSegment('minute', values, placeholder, { hourCycle, locale }) });
}
if (granularity === 'second') {
out.push({ part: 'literal', value: ':' });
out.push({ part: 'second', value: formatSegment('second', values, placeholder, { hourCycle, locale }) });
}
if (resolveHourCycle(hourCycle, locale) === 12) {
out.push({ part: 'literal', value: ' ' });
out.push({ part: 'dayPeriod', value: formatSegment('dayPeriod', values, placeholder, { hourCycle, locale }) });
}
}
return out;
}
const datePartOrderCache = new Map<string, DateSegmentPart[]>();
/** Derive `[day, month, year]` order from the locale's numeric format. */
export function resolveDatePartOrder(locale: string): DateSegmentPart[] {
const cached = datePartOrderCache.get(locale);
if (cached)
return cached;
let order: DateSegmentPart[] = ['month', 'day', 'year'];
try {
const parts = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
.formatToParts(new Date(2000, 0, 2));
const derived = parts
.map(p => p.type)
.filter((t): t is DateSegmentPart => t === 'day' || t === 'month' || t === 'year');
if (derived.length === 3)
order = derived;
}
catch {
// keep default
}
datePartOrderCache.set(locale, order);
return order;
}
function resolveDateLiteral(locale: string): string {
try {
const parts = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
.formatToParts(new Date(2000, 0, 2));
const literal = parts.find(p => p.type === 'literal');
if (literal && literal.value.trim().length <= 1)
return literal.value;
}
catch {
// keep default
}
return '/';
}
interface TypeAheadState {
hasLeftFocus: boolean;
lastKeyZero: boolean;
}
interface UpdateResult {
value: number | null;
moveToNext: boolean;
}
/** Numeric type-ahead for capped two-digit fields (day/month/hour/minute/second). */
function updateCappedField(
max: number,
num: number,
prev: number | null,
state: TypeAheadState,
allowZeroValue: boolean,
): UpdateResult {
const maxStart = Math.floor(max / 10);
if (state.hasLeftFocus) {
state.hasLeftFocus = false;
state.lastKeyZero = false;
prev = null;
}
if (prev === null || prev === undefined) {
if (num === 0) {
state.lastKeyZero = true;
return { value: allowZeroValue ? 0 : null, moveToNext: false };
}
const moveToNext = state.lastKeyZero || num > maxStart;
state.lastKeyZero = false;
return { value: num, moveToNext };
}
const digits = prev.toString().length;
const total = Number.parseInt(prev.toString() + num.toString(), 10);
if (digits === 2 || total > max) {
const moveToNext = num > maxStart || total > max;
return { value: num, moveToNext };
}
return { value: total, moveToNext: true };
}
function updateYear(num: number, prev: number | null, state: TypeAheadState): UpdateResult {
if (state.hasLeftFocus) {
state.hasLeftFocus = false;
prev = null;
}
if (prev === null || prev === undefined)
return { value: num === 0 ? 1 : num, moveToNext: false };
const str = prev.toString() + num.toString();
if (str.length > 4)
return { value: num === 0 ? 1 : num, moveToNext: false };
return { value: Number.parseInt(str, 10), moveToNext: str.length === 4 };
}
function cycle(value: number | null, delta: number, min: number, max: number, fallback: number): number {
if (value === null || value === undefined)
return delta > 0 ? min : fallback;
let next = value + delta;
const range = max - min + 1;
if (next > max)
next = min + ((next - min) % range);
if (next < min)
next = max - ((min - next - 1) % range);
return next;
}
export interface SegmentKeydownContext {
adapter: DateAdapter<Date>;
part: EditableSegmentPart;
values: SegmentValues;
placeholder: Date;
granularity: Granularity;
hourCycle: HourCycle;
locale: string;
state: TypeAheadState;
focusNext: () => void;
}
/**
* Apply a keydown to a single segment, returning the new value for that part
* (and, for hour edits, the synchronized day-period). Returns `null` value to
* clear. The caller writes the result into `segmentValues` and commits.
*/
export function applySegmentKeydown(
e: KeyboardEvent,
ctx: SegmentKeydownContext,
): { part: EditableSegmentPart; value: number | string | null; dayPeriod?: DayPeriod } | undefined {
const { adapter, part, values, placeholder, state } = ctx;
const key = e.key;
if (!isAcceptableSegmentKey(key) || isSegmentNavigationKey(key))
return undefined;
if (key === 'Backspace' || key === 'Delete') {
state.hasLeftFocus = false;
return { part, value: deleteDigit(values[part] as number | string | null) };
}
if (part === 'dayPeriod')
return applyDayPeriod(e, values);
const isArrow = key === 'ArrowUp' || key === 'ArrowDown';
const delta = key === 'ArrowUp' ? 1 : -1;
switch (part) {
case 'day': {
const monthDays = values.month
? adapter.getDaysInMonth(adapter.fromParts({ year: adapter.getParts(placeholder).year, month: values.month, day: 1 }))
: 31;
if (isArrow)
return { part, value: cycle(values.day, delta, 1, monthDays, monthDays) };
if (isNumberKey(key)) {
const r = updateCappedField(monthDays, Number.parseInt(key, 10), values.day, state, false);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'month': {
if (isArrow)
return { part, value: cycle(values.month, delta, 1, 12, 12) };
if (isNumberKey(key)) {
const r = updateCappedField(12, Number.parseInt(key, 10), values.month, state, false);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'year': {
if (isArrow)
return { part, value: values.year === null ? placeholder.getFullYear() : Math.max(1, values.year + delta) };
if (isNumberKey(key)) {
const r = updateYear(Number.parseInt(key, 10), values.year, state);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'hour': {
const is12 = resolveHourCycle(ctx.hourCycle, ctx.locale) === 12;
if (isArrow) {
const next = cycle(values.hour ?? null, delta, 0, 23, 23);
return { part, value: next, dayPeriod: next >= 12 ? 'PM' : 'AM' };
}
if (isNumberKey(key)) {
const displayMax = is12 ? 12 : 23;
let displayPrev = values.hour ?? null;
if (is12 && displayPrev !== null)
displayPrev = displayPrev % 12 === 0 ? 0 : (displayPrev > 12 ? displayPrev - 12 : displayPrev);
const r = updateCappedField(displayMax, Number.parseInt(key, 10), displayPrev, state, true);
let internal = r.value;
if (is12 && internal !== null) {
const period = values.dayPeriod ?? 'AM';
internal = internal === 12
? (period === 'AM' ? 0 : 12)
: (period === 'PM' ? internal + 12 : internal);
}
if (r.moveToNext)
ctx.focusNext();
return { part, value: internal, dayPeriod: internal === null ? undefined : (internal >= 12 ? 'PM' : 'AM') };
}
return undefined;
}
case 'minute': {
if (isArrow)
return { part, value: cycle(values.minute ?? null, delta, 0, 59, 59) };
if (isNumberKey(key)) {
const r = updateCappedField(59, Number.parseInt(key, 10), values.minute ?? null, state, true);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
case 'second': {
if (isArrow)
return { part, value: cycle(values.second ?? null, delta, 0, 59, 59) };
if (isNumberKey(key)) {
const r = updateCappedField(59, Number.parseInt(key, 10), values.second ?? null, state, true);
if (r.moveToNext)
ctx.focusNext();
return { part, value: r.value };
}
return undefined;
}
default:
return undefined;
}
}
function applyDayPeriod(
e: KeyboardEvent,
values: SegmentValues,
): { part: 'dayPeriod'; value: DayPeriod; hour?: number } | undefined {
const key = e.key;
const current = values.dayPeriod ?? 'AM';
const hour = values.hour ?? null;
const setPeriod = (period: DayPeriod): { part: 'dayPeriod'; value: DayPeriod; hour?: number } => {
if (hour === null)
return { part: 'dayPeriod', value: period };
if (period === 'PM' && hour < 12)
return { part: 'dayPeriod', value: period, hour: hour + 12 };
if (period === 'AM' && hour >= 12)
return { part: 'dayPeriod', value: period, hour: hour - 12 };
return { part: 'dayPeriod', value: period };
};
if (key === 'ArrowUp' || key === 'ArrowDown')
return setPeriod(current === 'AM' ? 'PM' : 'AM');
if (key === 'a' || key === 'A')
return setPeriod('AM');
if (key === 'p' || key === 'P')
return setPeriod('PM');
return undefined;
}
function deleteDigit(prev: number | string | null): number | null {
if (prev === null || prev === undefined)
return null;
const str = prev.toString();
if (str.length <= 1)
return null;
return Number.parseInt(str.slice(0, -1), 10);
}