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.
@@ -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');
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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);
|
||||
}
|
||||