feat(redis): add local expiration modes

This commit is contained in:
onenewcode authored and GitHub committed 2026-07-26 23:33:56 +08:00
1 parent 3a2dc4225a
commit 51fa058edb
35 files changed
+2918 -98

No files matched your search

@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { CalendarDateTime } from "@internationalized/date";
import { createApp, defineComponent, h, nextTick, ref } from "vue";
import { createI18n } from "vue-i18n";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import DateTimePicker from "./DateTimePicker.vue";
const mountedApps: Array<{ unmount: () => void; host: HTMLElement }> = [];
afterEach(() => {
for (const { unmount, host } of mountedApps.splice(0)) {
unmount();
host.remove();
}
});
function mountPickerInDialog() {
const host = document.createElement("div");
document.body.append(host);
const update = vi.fn();
const app = createApp(
defineComponent({
setup() {
const dialogOpen = ref(true);
return () =>
h(
Dialog,
{
open: dialogOpen.value,
"onUpdate:open": (open: boolean) => {
dialogOpen.value = open;
},
},
{
default: () =>
h(
DialogContent,
{ showCloseButton: false },
{
default: () => [
h(DialogTitle, null, { default: () => "Create key" }),
h("button", { type: "button", "data-dialog-outside": true }, "Dialog action"),
h(DateTimePicker, {
modelValue: new CalendarDateTime(2024, 2, 28, 8, 9, 10),
"onUpdate:modelValue": update,
}),
],
},
),
},
);
},
}),
);
app.use(createI18n({ legacy: false, locale: "en", messages: { en: {} }, missingWarn: false, fallbackWarn: false }));
app.mount(host);
mountedApps.push({ unmount: () => app.unmount(), host });
return { update };
}
async function waitForPopover() {
await nextTick();
await new Promise((resolve) => setTimeout(resolve));
await nextTick();
}
describe("DateTimePicker in a dialog", () => {
it("opens its teleported picker when the trigger is clicked", async () => {
mountPickerInDialog();
await nextTick();
const trigger = document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]");
expect(trigger).not.toBeNull();
trigger!.click();
await waitForPopover();
const content = document.querySelector<HTMLElement>("[data-date-time-picker-content]");
expect(content).not.toBeNull();
expect(content?.getAttribute("data-state")).toBe("open");
});
it("uses a roving date grid that supports arrow-key navigation", async () => {
mountPickerInDialog();
await nextTick();
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
await waitForPopover();
const grid = document.querySelector<HTMLElement>("[data-date-time-picker-content] [role='grid']");
const currentDay = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-02-28']");
const nextDay = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-02-29']");
expect(grid).not.toBeNull();
expect(Array.from(grid!.children).filter((child) => child.getAttribute("role") === "row").length).toBeGreaterThan(1);
expect(currentDay?.getAttribute("role")).toBeNull();
expect(currentDay?.parentElement?.getAttribute("role")).toBe("gridcell");
expect(currentDay?.tabIndex).toBe(0);
currentDay!.focus();
currentDay!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
expect(document.activeElement).toBe(nextDay);
expect(nextDay?.parentElement?.getAttribute("aria-selected")).toBe("true");
nextDay!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }));
await nextTick();
await nextTick();
const nextWeek = document.querySelector<HTMLButtonElement>("[data-date-time-picker-day='2024-03-07']");
expect(document.activeElement).toBe(nextWeek);
expect(nextWeek?.parentElement?.getAttribute("aria-selected")).toBe("true");
});
it("cancels its draft on a dialog-internal outside click without closing the dialog", async () => {
const { update } = mountPickerInDialog();
await nextTick();
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
await waitForPopover();
const input = document.querySelector<HTMLInputElement>("[data-date-time-picker-input]")!;
input.value = "2024-03-01 11:12:13";
input.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(input.value).toBe("2024-03-01 11:12:13");
const dialogAction = document.querySelector<HTMLButtonElement>("[data-dialog-outside]")!;
dialogAction.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
dialogAction.click();
await waitForPopover();
expect(update).not.toHaveBeenCalled();
expect(document.querySelector("[data-slot='dialog-content']")).not.toBeNull();
expect(document.querySelector("[data-date-time-picker-content]")).toBeNull();
document.querySelector<HTMLButtonElement>("[data-date-time-picker-trigger]")!.click();
await waitForPopover();
expect(document.querySelector<HTMLInputElement>("[data-date-time-picker-input]")?.value).toBe("2024-02-28 08:09:10");
});
});
@@ -0,0 +1,150 @@
// @vitest-environment happy-dom
import { CalendarDateTime } from "@internationalized/date";
import { nextTick } from "vue";
import { describe, expect, it, vi } from "vitest";
import { dispatch, findOne, hostText, mountComponent } from "@/components/grid/__tests__/vueHostHarness";
vi.mock("vue-i18n", async () => {
const { ref } = await import("vue");
return {
useI18n: () => ({ locale: ref("en"), t: (key: string) => key }),
};
});
vi.mock("@lucide/vue", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
const icon = createPassthroughStub("Icon", "i");
return { CalendarClock: icon, ChevronLeft: icon, ChevronRight: icon };
});
vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") }));
vi.mock("@/components/ui/popover", async () => {
const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
return {
Popover: createPassthroughStub("Popover"),
PopoverContent: createPassthroughStub("PopoverContent"),
PopoverTrigger: createPassthroughStub("PopoverTrigger"),
};
});
import DateTimePicker from "./DateTimePicker.vue";
function picker(options: Record<string, unknown> = {}) {
return mountComponent(DateTimePicker, {
open: true,
modelValue: new CalendarDateTime(2024, 2, 28, 8, 9, 10),
...options,
});
}
function inputFor(mounted: ReturnType<typeof picker>) {
return findOne(mounted.root, (node) => node.props["data-date-time-picker-input"] !== undefined);
}
function buttonWithText(mounted: ReturnType<typeof picker>, text: string) {
return findOne(mounted.root, (node) => node.type === "button" && hostText(node).trim() === text);
}
describe("DateTimePicker", () => {
it("keeps typed and calendar changes in the draft until Apply", async () => {
const update = vi.fn();
const mounted = picker({ "onUpdate:modelValue": update });
dispatch(inputFor(mounted), "input", { target: { value: "2024-02-29 11:12:13" } });
await nextTick();
expect(update).not.toHaveBeenCalled();
dispatch(
findOne(mounted.root, (node) => node.props["data-date-time-picker-day"] === "2024-02-29"),
"click",
);
dispatch(buttonWithText(mounted, "Apply"), "click");
expect(update).toHaveBeenCalledTimes(1);
expect(update.mock.calls[0][0]).toMatchObject({ year: 2024, month: 2, day: 29, hour: 11, minute: 12, second: 13 });
});
it("updates second-level time segments without committing early", async () => {
const update = vi.fn();
const mounted = picker({ "onUpdate:modelValue": update });
const hour = findOne(mounted.root, (node) => node.props["data-date-time-picker-time-part"] === "hour");
dispatch(hour, "input", { target: { value: "7" } });
await nextTick();
expect(update).not.toHaveBeenCalled();
dispatch(hour, "keydown", { key: "Enter" });
expect(update).not.toHaveBeenCalled();
dispatch(buttonWithText(mounted, "Apply"), "click");
expect(update.mock.calls[0][0]).toMatchObject({ hour: 7, minute: 9, second: 10 });
});
it("discards the draft on Cancel, Escape, and popover outside-close", async () => {
const cancel = vi.fn();
const mounted = picker({ onCancel: cancel });
dispatch(inputFor(mounted), "input", { target: { value: "2024-03-01 11:12:13" } });
dispatch(buttonWithText(mounted, "Cancel"), "click");
await nextTick();
expect(cancel).toHaveBeenCalledTimes(1);
expect(inputFor(mounted).props.value).toBe("2024-02-28 08:09:10");
dispatch(inputFor(mounted), "keydown", { key: "Escape" });
expect(cancel).toHaveBeenCalledTimes(2);
await nextTick();
const popover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover");
popover.props["onUpdate:open"](false);
expect(cancel).toHaveBeenCalledTimes(3);
});
it("prevents invalid and disabled picker drafts from applying", async () => {
const update = vi.fn();
const mounted = picker({ min: new CalendarDateTime(2024, 3, 1, 0, 0, 0), "onUpdate:modelValue": update });
expect(buttonWithText(mounted, "Apply").props.disabled).toBe(true);
dispatch(buttonWithText(mounted, "Apply"), "click");
expect(update).not.toHaveBeenCalled();
await mounted.setProps({ disabled: true });
expect(buttonWithText(mounted, "Apply").props.disabled).toBe(true);
});
it("leaves popover autofocus enabled for keyboard access to teleported content", () => {
const mounted = picker();
const content = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
expect(content.props.onOpenAutoFocus).toBeUndefined();
expect(content.props.onCloseAutoFocus).toBeUndefined();
});
it("keeps the popover within the available viewport height", () => {
const mounted = picker();
const content = findOne(mounted.root, (node) => node.props["data-stub"] === "PopoverContent");
expect(content.props["collision-padding"] ?? content.props.collisionPadding).toBe(8);
expect(content.props.class).toContain("max-h-[var(--reka-popover-content-available-height)]");
expect(content.props.class).toContain("overflow-y-auto");
expect(content.props.class).toContain("max-w-[calc(100vw-1rem)]");
});
it("can fill the available width when used as a form control", () => {
const mounted = picker({ fullWidth: true });
const control = findOne(mounted.root, (node) => node.props["data-date-time-picker"] !== undefined);
const trigger = findOne(mounted.root, (node) => node.props["data-date-time-picker-trigger"] !== undefined);
expect(control.props.class).toContain("w-full");
expect(trigger.props.class).toContain("w-full");
});
it("keeps selectable adjacent-month dates at muted semantic contrast", () => {
const mounted = picker();
const adjacentDay = findOne(mounted.root, (node) => node.props["data-date-time-picker-day"] === "2024-01-28");
expect(adjacentDay.props.class).toContain("text-muted-foreground");
expect(adjacentDay.props.class).not.toContain("text-muted-foreground/60");
});
});
@@ -0,0 +1,480 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, shallowRef, useId, watch } from "vue";
import type { CalendarDateTime } from "@internationalized/date";
import { endOfMonth, startOfMonth, startOfWeek } from "@internationalized/date";
import { CalendarClock, ChevronLeft, ChevronRight } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { calendarDateTimeFromFields, currentLocalDateTime, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, type CalendarDateTimeLike } from "./dateTimePicker";
type TimePart = "hour" | "minute" | "second";
type DateTimePickerLabel = "open" | "inputLabel" | "inputPlaceholder" | "previousMonth" | "nextMonth" | "calendarLabel" | "timeLabel" | "hour" | "minute" | "second" | "cancel" | "apply" | "invalidDateTime" | "outOfRange";
const props = withDefaults(
defineProps<{
modelValue?: CalendarDateTimeLike | null;
open?: boolean;
defaultOpen?: boolean;
disabled?: boolean;
compact?: boolean;
fullWidth?: boolean;
locale?: string;
min?: CalendarDateTimeLike | null;
max?: CalendarDateTimeLike | null;
placeholder?: string;
labels?: Partial<Record<DateTimePickerLabel, string>>;
}>(),
{
// Keep the picker uncontrolled when callers omit `open`.
open: undefined,
defaultOpen: false,
disabled: false,
compact: false,
fullWidth: false,
placeholder: undefined,
labels: () => ({}),
},
);
const emit = defineEmits<{
"update:modelValue": [value: CalendarDateTime];
"update:open": [value: boolean];
apply: [value: CalendarDateTime];
cancel: [];
}>();
const { t, locale: appLocale } = useI18n();
const localOpen = ref(props.defaultOpen);
const inputId = useId();
const draft = shallowRef<CalendarDateTime | null>(null);
const localInputText = ref("");
const visibleMonth = shallowRef<CalendarDateTime>(currentLocalDateTime());
const calendarGridRef = ref<HTMLElement>();
const calendarFocus = shallowRef<CalendarDateTime | null>(null);
const closeReason = ref<"apply" | "cancel" | null>(null);
const timeInputs = reactive<Record<TimePart, string>>({ hour: "00", minute: "00", second: "00" });
const invalidTimeInputs = reactive<Record<TimePart, boolean>>({ hour: false, minute: false, second: false });
const isOpen = computed(() => props.open ?? localOpen.value);
const resolvedLocale = computed(() => props.locale || appLocale.value || "en");
const parsedInput = computed(() => parseLocalDateTime(localInputText.value));
const hasInvalidTimeInput = computed(() => Object.values(invalidTimeInputs).some(Boolean));
const validationMessage = computed(() => {
if (hasInvalidTimeInput.value || !parsedInput.value) return label("invalidDateTime", "Enter a valid local date and time.");
if (!isValidLocalDateTime(parsedInput.value)) return label("invalidDateTime", "Enter a valid local date and time.");
if (!isWithinRange(parsedInput.value)) return label("outOfRange", "Choose a date and time within the allowed range.");
return null;
});
const canApply = computed(() => !props.disabled && !validationMessage.value && !!parsedInput.value);
const triggerText = computed(() => (props.modelValue ? formatLocalDateTime(props.modelValue) : props.placeholder || label("inputPlaceholder", "YYYY-MM-DD HH:mm:ss")));
const monthHeading = computed(() => formatMonth(visibleMonth.value, resolvedLocale.value));
const weekDays = computed(() => calendarWeekDays(resolvedLocale.value));
const calendarWeeks = computed(() => buildCalendarWeeks(visibleMonth.value, resolvedLocale.value));
const today = computed(() => currentLocalDateTime());
watch(
() => props.modelValue,
() => {
if (!isOpen.value) resetDraft();
},
);
watch(
() => props.open,
(open, previous) => {
if (open === previous) return;
if (open) beginDraft();
else resetDraft();
},
);
watch(
() => props.disabled,
(disabled) => {
if (disabled && isOpen.value) requestClose("cancel");
},
);
function label(key: DateTimePickerLabel, fallback: string): string {
const override = props.labels[key];
if (override) return override;
const translationKey = `dateTimePicker.${key}`;
const translated = t(translationKey);
return translated === translationKey ? fallback : translated;
}
function beginDraft() {
const value = props.modelValue ? calendarDateTimeFromFields(props.modelValue) : currentLocalDateTime();
draft.value = value;
localInputText.value = formatLocalDateTime(value);
visibleMonth.value = startOfMonth(value);
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, value);
syncTimeInputs(value);
clearTimeInputErrors();
}
function resetDraft() {
const value = props.modelValue ? calendarDateTimeFromFields(props.modelValue) : currentLocalDateTime();
draft.value = value;
localInputText.value = formatLocalDateTime(value);
visibleMonth.value = startOfMonth(value);
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, value);
syncTimeInputs(value);
clearTimeInputErrors();
}
function onPopoverOpenChange(open: boolean) {
if (open) {
if (props.disabled || isOpen.value) return;
beginDraft();
if (props.open === undefined) localOpen.value = true;
emit("update:open", true);
return;
}
if (closeReason.value) return;
requestClose("cancel");
}
function requestClose(reason: "apply" | "cancel") {
if (closeReason.value || !isOpen.value) return;
closeReason.value = reason;
if (reason === "cancel") {
resetDraft();
emit("cancel");
}
if (props.open === undefined) localOpen.value = false;
emit("update:open", false);
void nextTick(() => {
closeReason.value = null;
});
}
function applyDraft() {
const value = parsedInput.value;
if (!value || !canApply.value) return;
emit("update:modelValue", value);
emit("apply", value);
requestClose("apply");
}
function handleEscape(event: Event) {
event.preventDefault();
event.stopPropagation();
requestClose("cancel");
}
function updateTextInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
localInputText.value = value;
const parsed = parseLocalDateTime(value);
if (!parsed) return;
draft.value = parsed;
visibleMonth.value = startOfMonth(parsed);
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, parsed);
syncTimeInputs(parsed);
clearTimeInputErrors();
}
function normalizeTextInput() {
const parsed = parsedInput.value;
if (!parsed) return;
setDraft(parsed);
}
function selectDate(value: CalendarDateTime) {
if (props.disabled || !isCalendarDateSelectable(value)) return;
const base = draft.value ?? currentLocalDateTime();
const next = base.set({ year: value.year, month: value.month, day: value.day });
setDraft(next);
visibleMonth.value = startOfMonth(next);
calendarFocus.value = calendarFocusForMonth(visibleMonth.value, next);
}
function showPreviousMonth() {
visibleMonth.value = startOfMonth(visibleMonth.value.subtract({ months: 1 }));
calendarFocus.value = calendarFocusForMonth(visibleMonth.value);
}
function showNextMonth() {
visibleMonth.value = startOfMonth(visibleMonth.value.add({ months: 1 }));
calendarFocus.value = calendarFocusForMonth(visibleMonth.value);
}
function onCalendarDayKeydown(day: CalendarDateTime, event: KeyboardEvent) {
const offset = event.key === "ArrowLeft" ? -1 : event.key === "ArrowRight" ? 1 : event.key === "ArrowUp" ? -7 : event.key === "ArrowDown" ? 7 : null;
if (offset === null) return;
event.preventDefault();
const next = day.add({ days: offset });
if (!isCalendarDateSelectable(next)) return;
selectDate(next);
void nextTick(() => {
const selector = `[data-date-time-picker-day="${calendarDayValue(next)}"]`;
const button = calendarGridRef.value?.querySelector?.(selector) as HTMLButtonElement | undefined;
button?.focus();
});
}
function calendarFocusForMonth(month: CalendarDateTime, preferred = calendarFocus.value ?? draft.value ?? month): CalendarDateTime | null {
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
const firstDay = month.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 });
const lastDay = endOfMonth(month).set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
if ((minimum && lastDay.compare(minimum) < 0) || (maximum && firstDay.compare(maximum) > 0)) return null;
const candidate = preferred.set({ year: month.year, month: month.month, day: Math.min(preferred.day, lastDay.day) });
if (isCalendarDateSelectable(candidate)) return candidate;
if (minimum && firstDay.compare(minimum) < 0) return minimum;
if (maximum && lastDay.compare(maximum) > 0) return maximum;
return null;
}
function calendarDayValue(value: CalendarDateTimeLike): string {
return formatLocalDateTime(value).slice(0, 10);
}
function updateTimeInput(part: TimePart, event: Event) {
const maximum = part === "hour" ? 23 : 59;
const raw = (event.target as HTMLInputElement).value.replace(/\D/g, "").slice(0, 2);
timeInputs[part] = raw;
const value = Number(raw);
invalidTimeInputs[part] = raw.length === 0 || !Number.isInteger(value) || value > maximum;
if (invalidTimeInputs[part]) return;
const base = draft.value ?? currentLocalDateTime();
const next = base.set({ [part]: value });
draft.value = next;
localInputText.value = formatLocalDateTime(next);
}
function normalizeTimeInput(part: TimePart) {
const value = draft.value ?? currentLocalDateTime();
timeInputs[part] = twoDigits(value[part]);
invalidTimeInputs[part] = false;
}
function onTimeInputKeydown(part: TimePart, event: KeyboardEvent) {
if (event.key === "Escape") {
handleEscape(event);
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
normalizeTimeInput(part);
}
function setDraft(value: CalendarDateTime) {
draft.value = value;
localInputText.value = formatLocalDateTime(value);
syncTimeInputs(value);
clearTimeInputErrors();
}
function syncTimeInputs(value: CalendarDateTime) {
timeInputs.hour = twoDigits(value.hour);
timeInputs.minute = twoDigits(value.minute);
timeInputs.second = twoDigits(value.second);
}
function clearTimeInputErrors() {
invalidTimeInputs.hour = false;
invalidTimeInputs.minute = false;
invalidTimeInputs.second = false;
}
function isWithinRange(value: CalendarDateTime): boolean {
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
return (!minimum || value.compare(minimum) >= 0) && (!maximum || value.compare(maximum) <= 0);
}
function isCalendarDateSelectable(value: CalendarDateTime): boolean {
const firstSecond = value.set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
const lastSecond = value.set({ hour: 23, minute: 59, second: 59, millisecond: 0 });
const minimum = props.min ? calendarDateTimeFromFields(props.min) : null;
const maximum = props.max ? calendarDateTimeFromFields(props.max) : null;
return (!minimum || lastSecond.compare(minimum) >= 0) && (!maximum || firstSecond.compare(maximum) <= 0);
}
function isSameDay(left: CalendarDateTimeLike, right: CalendarDateTimeLike | null | undefined): boolean {
return !!right && left.year === right.year && left.month === right.month && left.day === right.day;
}
function isOutsideVisibleMonth(value: CalendarDateTime): boolean {
return value.year !== visibleMonth.value.year || value.month !== visibleMonth.value.month;
}
function formatMonth(value: CalendarDateTime, locale: string): string {
return new Intl.DateTimeFormat(locale, { calendar: "gregory", month: "long", year: "numeric", timeZone: "UTC" }).format(value.toDate("UTC"));
}
function calendarWeekDays(locale: string): string[] {
const firstSunday = parseLocalDateTime("2023-01-01 00:00:00")!;
const firstDay = startOfWeek(firstSunday, locale);
const formatter = new Intl.DateTimeFormat(locale, { calendar: "gregory", weekday: "short", timeZone: "UTC" });
return Array.from({ length: 7 }, (_, index) => formatter.format(firstDay.add({ days: index }).toDate("UTC")));
}
function buildCalendarWeeks(month: CalendarDateTime, locale: string): CalendarDateTime[][] {
const firstDay = startOfWeek(startOfMonth(month), locale);
const lastDay = endOfMonth(month);
const numberOfWeeks = Math.ceil((daysBetween(firstDay, lastDay) + 1) / 7);
const weeks = Math.max(4, Math.min(6, numberOfWeeks));
return Array.from({ length: weeks }, (_, week) => Array.from({ length: 7 }, (_, day) => firstDay.add({ days: week * 7 + day })));
}
function daysBetween(first: CalendarDateTime, last: CalendarDateTime): number {
// CalendarDateTime has no duration-between helper; dates in this picker are Gregorian.
return Math.round((last.toDate("UTC").getTime() - first.toDate("UTC").getTime()) / 86_400_000);
}
function twoDigits(value: number): string {
return String(value).padStart(2, "0");
}
if (isOpen.value) beginDraft();
</script>
<template>
<Popover :open="isOpen" :modal="false" @update:open="onPopoverOpenChange">
<div data-date-time-picker class="max-w-full min-w-0" :class="fullWidth ? 'w-full' : 'inline-flex'">
<PopoverTrigger as-child>
<slot name="trigger" :open="isOpen" :value="modelValue ?? null">
<button
type="button"
data-date-time-picker-trigger
class="flex min-w-0 items-center gap-1.5 rounded-md border border-input bg-background text-left tabular-nums outline-none transition-colors hover:bg-muted/50 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
:class="[compact ? 'h-6 px-1.5 text-xs' : 'h-8 px-2.5 text-sm', fullWidth ? 'w-full' : '']"
:disabled="disabled"
:aria-label="label('open', 'Open date and time picker')"
>
<CalendarClock class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="truncate" :class="modelValue ? 'text-foreground' : 'text-muted-foreground'">{{ triggerText }}</span>
</button>
</slot>
</PopoverTrigger>
</div>
<PopoverContent data-date-time-picker-content align="start" side="bottom" :collision-padding="8" class="w-[19.5rem] max-h-[var(--reka-popover-content-available-height)] max-w-[calc(100vw-1rem)] gap-3 overflow-y-auto rounded-lg p-3" @escape-key-down="handleEscape">
<div class="space-y-1.5">
<label class="text-xs font-medium text-foreground" :for="inputId">{{ label("inputLabel", "Local date and time") }}</label>
<input
:id="inputId"
:value="localInputText"
type="text"
inputmode="text"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-date-time-picker-input
class="h-8 w-full rounded-md border border-input bg-background px-2.5 font-mono text-xs tabular-nums outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20"
:placeholder="label('inputPlaceholder', 'YYYY-MM-DD HH:mm:ss')"
:disabled="disabled"
:aria-invalid="!!validationMessage"
:aria-describedby="validationMessage ? `${inputId}-validation` : undefined"
@input="updateTextInput"
@blur="normalizeTextInput"
@keydown.enter.prevent
@keydown.escape="handleEscape"
/>
</div>
<section class="space-y-2" :aria-label="label('calendarLabel', 'Calendar')">
<div class="flex items-center justify-between gap-2">
<Button type="button" variant="ghost" size="icon-sm" :disabled="disabled" :aria-label="label('previousMonth', 'Previous month')" @click="showPreviousMonth">
<ChevronLeft class="h-4 w-4" />
</Button>
<div class="text-sm font-medium" aria-live="polite">{{ monthHeading }}</div>
<Button type="button" variant="ghost" size="icon-sm" :disabled="disabled" :aria-label="label('nextMonth', 'Next month')" @click="showNextMonth">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
<div ref="calendarGridRef" class="space-y-0.5 text-center text-[11px]" role="grid" :aria-label="label('calendarLabel', 'Calendar')">
<div role="row" class="grid grid-cols-7 gap-0.5">
<div v-for="weekDay in weekDays" :key="weekDay" role="columnheader" class="py-0.5 font-medium text-muted-foreground">{{ weekDay }}</div>
</div>
<div v-for="(week, weekIndex) in calendarWeeks" :key="weekIndex" role="row" class="grid grid-cols-7 gap-0.5">
<div v-for="day in week" :key="day.toString()" role="gridcell" :aria-selected="isSameDay(day, draft)">
<button
type="button"
:data-date-time-picker-day="calendarDayValue(day)"
class="flex h-8 w-full items-center justify-center rounded-md text-xs tabular-nums outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/60"
:class="[isOutsideVisibleMonth(day) ? 'text-muted-foreground' : 'text-foreground', isSameDay(day, draft) ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'hover:bg-muted', isSameDay(day, today) && !isSameDay(day, draft) ? 'ring-1 ring-primary/50' : '']"
:disabled="disabled || !isCalendarDateSelectable(day)"
:tabindex="isSameDay(day, calendarFocus) ? 0 : -1"
:aria-current="isSameDay(day, today) ? 'date' : undefined"
:aria-label="formatLocalDateTime(day)"
@click="selectDate(day)"
@keydown="onCalendarDayKeydown(day, $event)"
>
{{ day.day }}
</button>
</div>
</div>
</div>
</section>
<section class="space-y-1.5" :aria-label="label('timeLabel', 'Time')">
<div class="text-xs font-medium text-foreground">{{ label("timeLabel", "Time") }}</div>
<div class="grid grid-cols-[1fr_auto_1fr_auto_1fr] items-center gap-1">
<input
:value="timeInputs.hour"
type="text"
inputmode="numeric"
maxlength="2"
data-date-time-picker-time-part="hour"
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
:disabled="disabled"
:aria-label="label('hour', 'Hour')"
:aria-invalid="invalidTimeInputs.hour"
@input="updateTimeInput('hour', $event)"
@blur="normalizeTimeInput('hour')"
@keydown="onTimeInputKeydown('hour', $event)"
/>
<span class="text-muted-foreground">:</span>
<input
:value="timeInputs.minute"
type="text"
inputmode="numeric"
maxlength="2"
data-date-time-picker-time-part="minute"
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
:disabled="disabled"
:aria-label="label('minute', 'Minute')"
:aria-invalid="invalidTimeInputs.minute"
@input="updateTimeInput('minute', $event)"
@blur="normalizeTimeInput('minute')"
@keydown="onTimeInputKeydown('minute', $event)"
/>
<span class="text-muted-foreground">:</span>
<input
:value="timeInputs.second"
type="text"
inputmode="numeric"
maxlength="2"
data-date-time-picker-time-part="second"
class="h-8 min-w-0 rounded-md border border-input bg-background px-1 text-center font-mono text-sm tabular-nums outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 aria-invalid:border-destructive"
:disabled="disabled"
:aria-label="label('second', 'Second')"
:aria-invalid="invalidTimeInputs.second"
@input="updateTimeInput('second', $event)"
@blur="normalizeTimeInput('second')"
@keydown="onTimeInputKeydown('second', $event)"
/>
</div>
</section>
<p v-if="validationMessage" :id="`${inputId}-validation`" class="text-xs text-destructive" role="alert">{{ validationMessage }}</p>
<div class="flex items-center justify-end gap-2 border-t pt-2.5">
<Button type="button" variant="outline" size="sm" :disabled="disabled" @click="requestClose('cancel')">{{ label("cancel", "Cancel") }}</Button>
<Button type="button" size="sm" :disabled="!canApply" @click="applyDraft">{{ label("apply", "Apply") }}</Button>
</div>
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,89 @@
import { CalendarDateTime, fromDate, getLocalTimeZone, parseDateTime, toCalendarDateTime } from "@internationalized/date";
const LOCAL_DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/;
/**
* The fields shared by CalendarDateTime and Vue's unwrapped representation of
* it. Date values are normalized before calling methods that use private state.
*/
export interface CalendarDateTimeLike {
readonly year: number;
readonly month: number;
readonly day: number;
readonly hour: number;
readonly minute: number;
readonly second: number;
readonly millisecond: number;
}
/**
* Parses a local, timezone-free ISO date-time. Both `T` and a space are accepted
* between the date and time, and an omitted seconds segment defaults to zero.
*/
export function parseLocalDateTime(value: string): CalendarDateTime | null {
const match = LOCAL_DATE_TIME_PATTERN.exec(value.trim());
if (!match) return null;
const [, yearText, monthText, dayText, hourText, minuteText, secondText = "00"] = match;
const year = Number(yearText);
const month = Number(monthText);
const day = Number(dayText);
const hour = Number(hourText);
const minute = Number(minuteText);
const second = Number(secondText);
if (year < 1 || month < 1 || month > 12 || day < 1 || hour > 23 || minute > 59 || second > 59) return null;
try {
return parseDateTime(`${yearText}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}`);
} catch {
return null;
}
}
/** Formats a timezone-free value as the local ISO representation used by the picker. */
export function formatLocalDateTime(value: CalendarDateTimeLike): string {
return [pad(value.year, 4), pad(value.month), pad(value.day)].join("-") + ` ${pad(value.hour)}:${pad(value.minute)}:${pad(value.second)}`;
}
/**
* Interprets the timezone-free wall-clock value in the DBX device's system time
* zone before converting it to Unix seconds.
*/
export function calendarDateTimeToUnixSeconds(value: CalendarDateTimeLike): number {
// DST gaps and overlaps do not identify exactly one local instant. Reject them
// instead of silently changing the wall-clock time chosen by the user.
return Math.floor(calendarDateTimeFromFields(value).toDate(getLocalTimeZone(), "reject").getTime() / 1_000);
}
/** True when a local wall-clock value identifies exactly one system-timezone instant. */
export function isValidLocalDateTime(value: CalendarDateTimeLike): boolean {
try {
calendarDateTimeToUnixSeconds(value);
return true;
} catch {
return false;
}
}
/** Converts Unix seconds to a timezone-free value in the DBX device's system time zone. */
export function unixSecondsToCalendarDateTime(seconds: number): CalendarDateTime {
if (!Number.isFinite(seconds)) throw new RangeError("Unix seconds must be a finite number");
const date = new Date(Math.trunc(seconds) * 1_000);
if (Number.isNaN(date.getTime())) throw new RangeError("Unix seconds are outside the Date range");
const local = toCalendarDateTime(fromDate(date, getLocalTimeZone()));
return local.set({ millisecond: 0 });
}
export function currentLocalDateTime(): CalendarDateTime {
return unixSecondsToCalendarDateTime(Math.floor(Date.now() / 1_000));
}
export function calendarDateTimeFromFields(value: CalendarDateTimeLike): CalendarDateTime {
return new CalendarDateTime(value.year, value.month, value.day, value.hour, value.minute, value.second, value.millisecond);
}
function pad(value: number, width = 2): string {
return String(value).padStart(width, "0");
}
@@ -0,0 +1,44 @@
import { CalendarDateTime, resetLocalTimeZone, setLocalTimeZone } from "@internationalized/date";
import { afterEach, describe, expect, it } from "vitest";
import { calendarDateTimeToUnixSeconds, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime } from "./dateTimePicker";
afterEach(() => {
resetLocalTimeZone();
});
describe("local date-time helpers", () => {
it("parses local ISO text with a space or T separator at second precision", () => {
const space = parseLocalDateTime("2024-02-29 13:45:06");
const t = parseLocalDateTime("2024-02-29T13:45");
expect(space && formatLocalDateTime(space)).toBe("2024-02-29 13:45:06");
expect(t && formatLocalDateTime(t)).toBe("2024-02-29 13:45:00");
});
it("rejects empty, malformed, and impossible local date-times", () => {
expect(parseLocalDateTime("")).toBeNull();
expect(parseLocalDateTime("2024-02-30 12:00:00")).toBeNull();
expect(parseLocalDateTime("2024-01-01T24:00:00")).toBeNull();
expect(parseLocalDateTime("2024-01-01 12:00:00Z")).toBeNull();
});
it("converts through the local timezone rather than treating the wall-clock value as UTC", () => {
setLocalTimeZone("America/Los_Angeles");
const local = new CalendarDateTime(2024, 1, 15, 12, 34, 56);
const seconds = calendarDateTimeToUnixSeconds(local);
expect(new Date(seconds * 1_000).toISOString()).toBe("2024-01-15T20:34:56.000Z");
expect(formatLocalDateTime(unixSecondsToCalendarDateTime(seconds))).toBe("2024-01-15 12:34:56");
});
it("rejects local times that are skipped or repeated by daylight-saving transitions", () => {
setLocalTimeZone("America/Los_Angeles");
const skipped = new CalendarDateTime(2024, 3, 10, 2, 30, 0);
const repeated = new CalendarDateTime(2024, 11, 3, 1, 30, 0);
expect(isValidLocalDateTime(skipped)).toBe(false);
expect(isValidLocalDateTime(repeated)).toBe(false);
expect(() => calendarDateTimeToUnixSeconds(skipped)).toThrow();
expect(() => calendarDateTimeToUnixSeconds(repeated)).toThrow();
});
});
@@ -0,0 +1,2 @@
export { default as DateTimePicker } from "./DateTimePicker.vue";
export { calendarDateTimeFromFields, calendarDateTimeToUnixSeconds, currentLocalDateTime, formatLocalDateTime, isValidLocalDateTime, parseLocalDateTime, unixSecondsToCalendarDateTime, type CalendarDateTimeLike } from "./dateTimePicker";