diff --git a/.changeset/gentle-cups-destroy.md b/.changeset/gentle-cups-destroy.md new file mode 100644 index 00000000..05bc59e5 --- /dev/null +++ b/.changeset/gentle-cups-destroy.md @@ -0,0 +1,5 @@ +--- +'@formwerk/core': minor +--- + +feat: implement useDateTimeField diff --git a/packages/core/package.json b/packages/core/package.json index 73f06244..8d638592 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,7 @@ "dist/*.d.ts" ], "dependencies": { + "@internationalized/date": "^3.7.0", "@standard-schema/spec": "1.0.0", "@standard-schema/utils": "^0.3.0", "klona": "^2.0.6", diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index f898aa3c..a9bd4442 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -18,6 +18,9 @@ export const FieldTypePrefixes = { CustomField: 'cf', ComboBox: 'cbx', ListBox: 'lb', + DateTimeField: 'dtf', + DateTimeSegment: 'dts', + Calendar: 'cal', } as const; export const NOOP = () => {}; diff --git a/packages/core/src/helpers/useControlButtonProps/index.ts b/packages/core/src/helpers/useControlButtonProps/index.ts new file mode 100644 index 00000000..2917a033 --- /dev/null +++ b/packages/core/src/helpers/useControlButtonProps/index.ts @@ -0,0 +1 @@ +export * from './useControlButtonProps'; diff --git a/packages/core/src/helpers/useControlButtonProps/useControlButtonProps.ts b/packages/core/src/helpers/useControlButtonProps/useControlButtonProps.ts new file mode 100644 index 00000000..61e6169c --- /dev/null +++ b/packages/core/src/helpers/useControlButtonProps/useControlButtonProps.ts @@ -0,0 +1,29 @@ +import { computed, shallowRef } from 'vue'; +import { isButtonElement, withRefCapture } from '../../utils/common'; + +interface ControlButtonProps { + [key: string]: unknown; + disabled?: boolean; +} + +export function useControlButtonProps(props: () => ControlButtonProps) { + const buttonEl = shallowRef(); + + const buttonProps = computed(() => { + const isBtn = isButtonElement(buttonEl.value); + const { disabled, ...rest } = props(); + + return withRefCapture( + { + type: isBtn ? ('button' as const) : undefined, + role: isBtn ? undefined : 'button', + [isBtn ? 'disabled' : 'aria-disabled']: disabled || undefined, + tabindex: '-1', + ...rest, + }, + buttonEl, + ); + }); + + return buttonProps; +} diff --git a/packages/core/src/i18n/getCalendar.ts b/packages/core/src/i18n/getCalendar.ts new file mode 100644 index 00000000..b4f4cbaf --- /dev/null +++ b/packages/core/src/i18n/getCalendar.ts @@ -0,0 +1,11 @@ +export function getCalendar(locale: Intl.Locale): string { + if (locale.calendar) { + return locale.calendar as string; + } + + if ('calendars' in locale) { + return (locale.calendars as string[])[0] as string; + } + + return 'gregory'; +} diff --git a/packages/core/src/i18n/getDirection.spec.ts b/packages/core/src/i18n/getDirection.spec.ts index d3531b57..228d2f04 100644 --- a/packages/core/src/i18n/getDirection.spec.ts +++ b/packages/core/src/i18n/getDirection.spec.ts @@ -2,8 +2,8 @@ import { configure } from '../config'; import { getDirection } from './getDirection'; test('gets the direction of a locale', () => { - expect(getDirection('ar-EG')).toBe('rtl'); - expect(getDirection('en-US')).toBe('ltr'); + expect(getDirection(new Intl.Locale('ar-EG'))).toBe('rtl'); + expect(getDirection(new Intl.Locale('en-US'))).toBe('ltr'); }); test('warns if the direction was not recognized', () => { @@ -15,6 +15,6 @@ test('warns if the direction was not recognized', () => { test('returns ltr if detectDirection is false', () => { configure({ detectDirection: false }); - expect(getDirection('ar-EG')).toBe('ltr'); + expect(getDirection(new Intl.Locale('ar-EG'))).toBe('ltr'); configure({ detectDirection: true }); }); diff --git a/packages/core/src/i18n/getDirection.ts b/packages/core/src/i18n/getDirection.ts index 722bd07a..3ec01065 100644 --- a/packages/core/src/i18n/getDirection.ts +++ b/packages/core/src/i18n/getDirection.ts @@ -2,19 +2,18 @@ import { Direction } from '../types'; import { isCallable, warn } from '../utils/common'; import { getConfig } from '../config'; -export function getDirection(locale: string): Direction { +export function getDirection(locale: Intl.Locale): Direction { if (!getConfig().detectDirection) { return 'ltr'; } try { - const instance = new Intl.Locale(locale); - if ('textInfo' in instance) { - return ((instance.textInfo as { direction: Direction }).direction as Direction) || 'ltr'; + if ('textInfo' in locale) { + return ((locale.textInfo as { direction: Direction }).direction as Direction) || 'ltr'; } - if ('getTextInfo' in instance && isCallable(instance.getTextInfo)) { - return (instance.getTextInfo().direction as Direction) || 'ltr'; + if ('getTextInfo' in locale && isCallable(locale.getTextInfo)) { + return (locale.getTextInfo().direction as Direction) || 'ltr'; } throw new Error(`Cannot determine direction for locale ${locale}`); diff --git a/packages/core/src/i18n/getTimezone.ts b/packages/core/src/i18n/getTimezone.ts new file mode 100644 index 00000000..daf2080a --- /dev/null +++ b/packages/core/src/i18n/getTimezone.ts @@ -0,0 +1,3 @@ +export function getTimeZone(locale: Intl.Locale) { + return new Intl.DateTimeFormat(locale).resolvedOptions().timeZone; +} diff --git a/packages/core/src/i18n/getWeekInfo.ts b/packages/core/src/i18n/getWeekInfo.ts new file mode 100644 index 00000000..4ca00482 --- /dev/null +++ b/packages/core/src/i18n/getWeekInfo.ts @@ -0,0 +1,26 @@ +import { isCallable, warn } from '../utils/common'; + +export interface WeekInfo { + firstDay: number; + weekend: number[]; +} + +export function getWeekInfo(locale: Intl.Locale): WeekInfo { + const fallbackInfo: WeekInfo = { firstDay: 7, weekend: [6, 7] }; + + try { + if ('weekInfo' in locale) { + return (locale.weekInfo as WeekInfo) || fallbackInfo; + } + + if ('getWeekInfo' in locale && isCallable(locale.getWeekInfo)) { + return (locale.getWeekInfo() as WeekInfo) || fallbackInfo; + } + + throw new Error(`Cannot determine week info for locale ${locale}`); + } catch { + warn(`Cannot determine week info for locale ${locale}`); + + return fallbackInfo; + } +} diff --git a/packages/core/src/i18n/index.ts b/packages/core/src/i18n/index.ts index 5b9704ce..1d8e238b 100644 --- a/packages/core/src/i18n/index.ts +++ b/packages/core/src/i18n/index.ts @@ -4,3 +4,4 @@ export * from './getSiteLocale'; export * from './useLocale'; export * from './useNumberParser'; export * from './checkLocaleMismatch'; +export * from './useDateFormatter'; diff --git a/packages/core/src/i18n/useDateFormatter.ts b/packages/core/src/i18n/useDateFormatter.ts new file mode 100644 index 00000000..734354fc --- /dev/null +++ b/packages/core/src/i18n/useDateFormatter.ts @@ -0,0 +1,40 @@ +import { MaybeRefOrGetter, shallowRef, toValue, watch } from 'vue'; +import { DateFormatter } from '@internationalized/date'; +import { getUserLocale } from './getUserLocale'; +import { isEqual } from '../utils/common'; + +// TODO: May memory leak in SSR +const dateFormatterCache = new Map(); + +function getFormatter(locale: string, options: Intl.DateTimeFormatOptions = {}) { + const cacheKey = locale + JSON.stringify(options); + let formatter = dateFormatterCache.get(cacheKey); + if (!formatter) { + formatter = new DateFormatter(locale, options); + dateFormatterCache.set(cacheKey, formatter); + } + + return formatter; +} + +export function useDateFormatter( + locale: MaybeRefOrGetter, + opts?: MaybeRefOrGetter, +) { + const resolvedLocale = getUserLocale(); + const formatter = shallowRef(getFormatter(toValue(locale) || resolvedLocale, toValue(opts))); + + watch( + () => ({ + locale: toValue(locale) || resolvedLocale, + opts: toValue(opts), + }), + (config, oldConfig) => { + if (!isEqual(config, oldConfig)) { + formatter.value = getFormatter(config.locale, config.opts); + } + }, + ); + + return formatter; +} diff --git a/packages/core/src/i18n/useLocale.ts b/packages/core/src/i18n/useLocale.ts index e87ac77c..5bc58b7b 100644 --- a/packages/core/src/i18n/useLocale.ts +++ b/packages/core/src/i18n/useLocale.ts @@ -1,13 +1,57 @@ -import { computed } from 'vue'; +import { computed, MaybeRefOrGetter, toValue } from 'vue'; import { getConfig } from '../config'; import { getDirection } from './getDirection'; +import { getWeekInfo } from './getWeekInfo'; +import { Maybe, Reactivify } from '../types'; +import { Calendar, GregorianCalendar } from '@internationalized/date'; +import { getTimeZone } from './getTimezone'; + +export type NumberLocaleExtension = `nu-${string}`; + +export interface LocaleExtension { + number: Maybe; + calendar: Maybe; + timeZone: Maybe; +} /** * Composable that resolves the currently configured locale and direction. */ -export function useLocale() { - const locale = computed(() => getConfig().locale); - const direction = computed(() => getDirection(locale.value)); +export function useLocale( + localeCode?: MaybeRefOrGetter>, + extensions: Partial> = {}, +) { + const localeString = computed(() => { + let code = toValue(localeCode) || getConfig().locale; + const calExt = toValue(extensions.calendar); + const numExt = toValue(extensions.number); + + // Add the base locale extension if it's not already present + if (!code.includes('-u-') && (numExt || calExt)) { + code += '-u-'; + } + + // Add the number locale extension if it's not already present + if (!code.includes('-nu-') && numExt) { + code += `-nu-${numExt}`; + } + + // Add the calendar locale extension if it's not already present + if (!code.includes('-ca-') && calExt?.identifier) { + code += `-ca-${calExt.identifier}`; + } + + code = code.replaceAll('--', '-'); + + return code; + }); + + const localeInstance = computed(() => new Intl.Locale(localeString.value)); + const direction = computed(() => getDirection(localeInstance.value)); + const weekInfo = computed(() => getWeekInfo(localeInstance.value)); + const calendar = computed(() => toValue(extensions.calendar) ?? (new GregorianCalendar() as Calendar)); + const timeZone = computed(() => toValue(extensions.timeZone) ?? getTimeZone(localeInstance.value)); + const locale = computed(() => localeInstance.value.toString()); - return { locale, direction }; + return { locale, direction, weekInfo, calendar, timeZone }; } diff --git a/packages/core/src/i18n/useNumberParser/index.spec.ts b/packages/core/src/i18n/useNumberParser.spec.ts similarity index 98% rename from packages/core/src/i18n/useNumberParser/index.spec.ts rename to packages/core/src/i18n/useNumberParser.spec.ts index 0f74cd88..0c1c55dd 100644 --- a/packages/core/src/i18n/useNumberParser/index.spec.ts +++ b/packages/core/src/i18n/useNumberParser.spec.ts @@ -1,4 +1,4 @@ -import { useNumberParser } from '.'; +import { useNumberParser } from './useNumberParser'; const enNumber = 1234567890.12; diff --git a/packages/core/src/i18n/useNumberParser/index.ts b/packages/core/src/i18n/useNumberParser.ts similarity index 94% rename from packages/core/src/i18n/useNumberParser/index.ts rename to packages/core/src/i18n/useNumberParser.ts index 2f10ca6e..e28446b3 100644 --- a/packages/core/src/i18n/useNumberParser/index.ts +++ b/packages/core/src/i18n/useNumberParser.ts @@ -1,5 +1,5 @@ -import { MaybeRefOrGetter, toValue } from 'vue'; -import { getUserLocale } from '../getUserLocale'; +import { MaybeRefOrGetter, toValue, watch } from 'vue'; +import { getUserLocale } from './getUserLocale'; /** * Stuff that are considered "literals" that's not part of the number itself and should be stripped out when parsing/validating. @@ -57,7 +57,7 @@ interface NumberSymbols { resolveNumber: (number: string) => string; } -interface NumberParser { +export interface NumberParser { formatter: Intl.NumberFormat; options: Intl.ResolvedNumberFormatOptions; locale: string; @@ -67,6 +67,7 @@ interface NumberParser { isValidNumberPart(value: string): boolean; } +// TODO: May memory leak in SSR const numberParserCache = new Map(); function getParser(locale: string, options: Intl.NumberFormatOptions) { @@ -206,6 +207,8 @@ export function defineNumberParser(locale: string, options: Intl.NumberFormatOpt }; } +export type NumberParserContext = Pick; + export function useNumberParser( locale: MaybeRefOrGetter, opts?: MaybeRefOrGetter, @@ -273,12 +276,18 @@ export function useNumberParser( return resolveParser(value).isValidNumberPart(value); } - function format(value: number): string { - const defaultParser = getParser(toValue(locale) ?? toValue(resolvedLocale), toValue(opts) || {}); + function getDefaultParser() { + return getParser(toValue(locale) ?? toValue(resolvedLocale), toValue(opts) || {}); + } - return (lastResolvedParser ?? defaultParser).format(value); + function format(value: number): string { + return (lastResolvedParser ?? getDefaultParser()).format(value); } + watch([() => toValue(locale), () => toValue(opts)], () => { + lastResolvedParser = getDefaultParser(); + }); + return { parse, format, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 711fa914..c4e71e63 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,9 @@ export * from './useSelect'; export * from './useComboBox'; export * from './useHiddenField'; export * from './useCustomField'; +export * from './useDateTimeField'; +export * from './useCalendar'; +export * from './usePicker'; export * from './types'; export * from './config'; export * from './useForm'; diff --git a/packages/core/src/useCalendar/constants.ts b/packages/core/src/useCalendar/constants.ts new file mode 100644 index 00000000..d4d44e12 --- /dev/null +++ b/packages/core/src/useCalendar/constants.ts @@ -0,0 +1,10 @@ +import { InjectionKey } from 'vue'; +import { CalendarContext } from './types'; + +export const CalendarContextKey: InjectionKey = Symbol('CalendarContext'); + +export const YEAR_CELLS_COUNT = 9; + +export const MONTHS_COLUMNS_COUNT = 3; + +export const YEARS_COLUMNS_COUNT = 3; diff --git a/packages/core/src/useCalendar/index.ts b/packages/core/src/useCalendar/index.ts new file mode 100644 index 00000000..84e2a208 --- /dev/null +++ b/packages/core/src/useCalendar/index.ts @@ -0,0 +1,4 @@ +export * from './useCalendar'; +export * from './types'; +export * from './useCalendarCell'; +export * from './useCalendarView'; diff --git a/packages/core/src/useCalendar/types.ts b/packages/core/src/useCalendar/types.ts new file mode 100644 index 00000000..6dc7082b --- /dev/null +++ b/packages/core/src/useCalendar/types.ts @@ -0,0 +1,53 @@ +import { WeekInfo } from '../i18n/getWeekInfo'; +import { Ref } from 'vue'; +import { Maybe } from '../types'; +import type { ZonedDateTime, Calendar } from '@internationalized/date'; + +export interface CalendarDayCell { + type: 'day'; + value: ZonedDateTime; + dayOfMonth: number; + label: string; + isToday: boolean; + isOutsideMonth: boolean; + selected: boolean; + disabled: boolean; + focused: boolean; +} + +export interface CalendarMonthCell { + type: 'month'; + label: string; + value: ZonedDateTime; + monthOfYear: number; + selected: boolean; + disabled: boolean; + focused: boolean; +} + +export interface CalendarYearCell { + type: 'year'; + label: string; + value: ZonedDateTime; + year: number; + selected: boolean; + disabled: boolean; + focused: boolean; +} + +export type CalendarCellProps = CalendarDayCell | CalendarMonthCell | CalendarYearCell; + +export type CalendarViewType = 'weeks' | 'months' | 'years'; + +export interface CalendarContext { + locale: Ref; + weekInfo: Ref; + calendar: Ref; + timeZone: Ref; + getSelectedDate: () => ZonedDateTime; + getMinDate: () => Maybe; + getMaxDate: () => Maybe; + getFocusedDate: () => ZonedDateTime; + setFocusedDate: (date: ZonedDateTime) => void; + setDate: (date: ZonedDateTime, view?: CalendarViewType) => void; +} diff --git a/packages/core/src/useCalendar/useCalendar.spec.ts b/packages/core/src/useCalendar/useCalendar.spec.ts new file mode 100644 index 00000000..8f0c791d --- /dev/null +++ b/packages/core/src/useCalendar/useCalendar.spec.ts @@ -0,0 +1,802 @@ +import { fireEvent, render, screen } from '@testing-library/vue'; +import { axe } from 'vitest-axe'; +import { useCalendar, CalendarCell } from './index'; +import { flush } from '@test-utils/flush'; +import { createCalendar, now } from '@internationalized/date'; + +describe('useCalendar', () => { + describe('a11y', () => { + test('calendar should not have accessibility violations', async () => { + await render({ + setup() { + const { calendarProps, gridProps, gridLabelProps, nextButtonProps, previousButtonProps } = useCalendar({ + label: 'Calendar', + }); + + return { + calendarProps, + gridProps, + gridLabelProps, + nextButtonProps, + previousButtonProps, + }; + }, + template: ` +
+
+
Month Year
+ + +
+ +
+
+
+ `, + }); + + await flush(); + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + }); + }); + + describe('date selection', () => { + test('calls onUpdateModelValue when a date is selected', async () => { + const currentDate = now('UTC'); + + const vm = await render({ + components: { + CalendarCell, + }, + setup() { + const { calendarProps } = useCalendar({ + label: 'Calendar', + timeZone: 'UTC', + modelValue: currentDate.toDate(), + }); + + return { + calendarProps, + currentDate, + }; + }, + template: ` +
+
+ +
+
+ `, + }); + + await flush(); + await fireEvent.click(screen.getByText('Select Date')); + await flush(); + expect(vm.emitted('update:modelValue')[0]).toEqual([currentDate.toDate()]); + }); + + test('uses provided calendar type', async () => { + const calendar = createCalendar('islamic-umalqura'); + + await render({ + setup() { + const { selectedDate } = useCalendar({ + label: 'Calendar', + calendar, + }); + + return { + selectedDate, + }; + }, + template: ` +
+
{{ selectedDate.calendar.identifier }}
+
+ `, + }); + + await flush(); + expect(screen.getByText('islamic-umalqura')).toBeInTheDocument(); + }); + + test('handles Enter key on calendar cell', async () => { + const currentDate = now('UTC'); + + const vm = await render({ + components: { + CalendarCell, + }, + setup() { + const { calendarProps, focusedDate } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + calendarProps, + focusedDate, + currentDate, + }; + }, + template: ` +
+
+ +
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const cell = screen.getByTestId('calendar-cell'); + + // Test Enter key selects the date + await fireEvent.keyDown(cell, { code: 'Enter' }); + expect(vm.emitted('update:modelValue')[0]).toEqual([currentDate.toDate()]); + }); + + test('handles Enter key in different panels', async () => { + const currentDate = now('UTC'); + + const vm = await render({ + setup() { + const { calendarProps, focusedDate, gridLabelProps, currentView } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + calendarProps, + focusedDate, + gridLabelProps, + currentView, + }; + }, + template: ` +
+
{{ currentView.type }}
+
+
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const calendar = screen.getByTestId('calendar'); + const panelLabel = screen.getByTestId('panel-label'); + await fireEvent.keyDown(calendar, { code: 'Escape' }); + + // Test Enter in day panel + await fireEvent.keyDown(calendar, { code: 'Enter' }); + expect(vm.emitted('update:modelValue')[0]).toEqual([currentDate.toDate()]); + + // Switch to month panel + await fireEvent.click(panelLabel); + await fireEvent.keyDown(calendar, { code: 'Enter' }); + expect(screen.getByTestId('panel-label')).toHaveTextContent('weeks'); // Should switch back to day panel + + // Switch to year panel + await fireEvent.click(panelLabel); + await fireEvent.click(panelLabel); + await fireEvent.keyDown(calendar, { code: 'Enter' }); + expect(screen.getByTestId('panel-label')).toHaveTextContent('months'); // Should switch back to month panel + }); + }); + + describe('panel navigation', () => { + test('switches between day, month, and year panels', async () => { + await render({ + setup() { + const { gridLabelProps, currentView } = useCalendar({ label: 'Calendar' }); + + return { + gridLabelProps, + currentView, + }; + }, + template: ` +
+
{{ currentView.type }}
+
+ `, + }); + + await flush(); + const panelLabel = screen.getByTestId('panel-label'); + expect(panelLabel).toHaveTextContent('weeks'); + + await fireEvent.click(panelLabel); + expect(panelLabel).toHaveTextContent('months'); + + await fireEvent.click(panelLabel); + expect(panelLabel).toHaveTextContent('years'); + }); + + test('navigates to next/previous panels', async () => { + await render({ + setup() { + const { nextButtonProps, previousButtonProps, currentView } = useCalendar({ + label: 'Calendar', + }); + + return { + nextButtonProps, + previousButtonProps, + currentView, + }; + }, + template: ` +
+ +
{{ currentView.type }}
+ +
+ `, + }); + + await flush(); + expect(screen.getByTestId('panel-type')).toHaveTextContent('weeks'); + + // Test navigation buttons + await fireEvent.click(screen.getByText('Next')); + await fireEvent.click(screen.getByText('Previous')); + }); + + test('navigates months using next/previous buttons in month panel', async () => { + const currentDate = now('UTC'); + + await render({ + setup() { + const { nextButtonProps, previousButtonProps, gridLabelProps, focusedDate, calendarProps } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + nextButtonProps, + previousButtonProps, + gridLabelProps, + focusedDate, + calendarProps, + }; + }, + template: ` +
+
+
Month Panel
+ + +
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const panelLabel = screen.getByTestId('panel-label'); + + // Switch to month panel + await fireEvent.click(panelLabel); + + // Test next button (next year in month panel) + await fireEvent.click(screen.getByText('Next')); + expect(screen.getByText(currentDate.add({ years: 1 }).toString())).toBeInTheDocument(); + + // Test previous button (previous year in month panel) + await fireEvent.click(screen.getByText('Previous')); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test multiple clicks + await fireEvent.click(screen.getByText('Previous')); + await fireEvent.click(screen.getByText('Previous')); + expect(screen.getByText(currentDate.subtract({ years: 2 }).toString())).toBeInTheDocument(); + + await fireEvent.click(screen.getByText('Next')); + expect(screen.getByText(currentDate.subtract({ years: 1 }).toString())).toBeInTheDocument(); + }); + + test('navigates years using next/previous buttons in year panel', async () => { + const currentDate = now('UTC'); + + await render({ + setup() { + const { nextButtonProps, previousButtonProps, gridLabelProps, focusedDate, calendarProps } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + nextButtonProps, + previousButtonProps, + gridLabelProps, + focusedDate, + calendarProps, + }; + }, + template: ` +
+
+
Year Panel
+ + +
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const panelLabel = screen.getByTestId('panel-label'); + + // Switch to month panel then year panel + await fireEvent.click(panelLabel); + await fireEvent.click(panelLabel); + + // Test next button (next set of years) + await fireEvent.click(screen.getByText('Next')); + expect( + screen.getByText( + currentDate + .add({ years: 9 }) + .set({ month: 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toString(), + ), + ).toBeInTheDocument(); + + // Test previous button (previous set of years) + await fireEvent.click(screen.getByText('Previous')); + expect( + screen.getByText( + currentDate + .add({ years: 8 }) + .set({ month: 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toString(), + ), + ).toBeInTheDocument(); + + // Test multiple clicks + await fireEvent.click(screen.getByText('Previous')); + await fireEvent.click(screen.getByText('Previous')); + expect( + screen.getByText( + currentDate + .subtract({ years: 10 }) + .set({ month: 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toString(), + ), + ).toBeInTheDocument(); + + await fireEvent.click(screen.getByText('Next')); + expect( + screen.getByText( + currentDate + .subtract({ years: 9 }) + .set({ month: 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }) + .toString(), + ), + ).toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + test('handles arrow key navigation in day panel', async () => { + const currentDate = now('UTC'); + + await render({ + setup() { + const { calendarProps, selectedDate, focusedDate } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + calendarProps, + selectedDate, + focusedDate, + }; + }, + template: ` +
+
+
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const calendar = screen.getByTestId('calendar'); + + // Test right arrow (next day) + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + expect(screen.getByText(currentDate.add({ days: 1 }).toString())).toBeInTheDocument(); + + // Test left arrow (previous day) + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test down arrow (next week) + await fireEvent.keyDown(calendar, { code: 'ArrowDown' }); + expect(screen.getByText(currentDate.add({ weeks: 1 }).toString())).toBeInTheDocument(); + + // Test up arrow (previous week) + await fireEvent.keyDown(calendar, { code: 'ArrowUp' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test PageUp (previous month) + await fireEvent.keyDown(calendar, { code: 'PageUp' }); + expect(screen.getByText(currentDate.subtract({ months: 1 }).toString())).toBeInTheDocument(); + + // Test PageDown (next month) + await fireEvent.keyDown(calendar, { code: 'PageDown' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test Home (start of month) + await fireEvent.keyDown(calendar, { code: 'Home' }); + expect(screen.getByText(currentDate.set({ day: 1 }).toString())).toBeInTheDocument(); + + // Test End (end of month) + await fireEvent.keyDown(calendar, { code: 'End' }); + expect( + screen.getByText(currentDate.set({ day: currentDate.calendar.getDaysInMonth(currentDate) }).toString()), + ).toBeInTheDocument(); + }); + + test('handles arrow key navigation in month panel', async () => { + const currentDate = now('UTC'); + + await render({ + setup() { + const { calendarProps, selectedDate, focusedDate, gridLabelProps } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + calendarProps, + selectedDate, + focusedDate, + gridLabelProps, + }; + }, + template: ` +
+
+
Month Panel
+
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const calendar = screen.getByTestId('calendar'); + const panelLabel = screen.getByTestId('panel-label'); + + // Switch to month panel + await fireEvent.click(panelLabel); + + // Test right arrow (next month) + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + expect(screen.getByText(currentDate.add({ months: 1 }).toString())).toBeInTheDocument(); + + // Test left arrow (previous month) + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test down arrow (3 months forward) + await fireEvent.keyDown(calendar, { code: 'ArrowDown' }); + expect(screen.getByText(currentDate.add({ months: 3 }).toString())).toBeInTheDocument(); + + // Test up arrow (3 months back) + await fireEvent.keyDown(calendar, { code: 'ArrowUp' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test PageUp (previous year) + await fireEvent.keyDown(calendar, { code: 'PageUp' }); + expect(screen.getByText(currentDate.subtract({ years: 1 }).toString())).toBeInTheDocument(); + + // Test PageDown (next year) + await fireEvent.keyDown(calendar, { code: 'PageDown' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test Home (start of year) + await fireEvent.keyDown(calendar, { code: 'Home' }); + expect(screen.getByText(currentDate.set({ month: 1 }).toString())).toBeInTheDocument(); + + // Test End (end of year) + await fireEvent.keyDown(calendar, { code: 'End' }); + expect( + screen.getByText(currentDate.set({ month: currentDate.calendar.getMonthsInYear(currentDate) }).toString()), + ).toBeInTheDocument(); + }); + + test('handles arrow key navigation in year panel', async () => { + const currentDate = now('UTC'); + + await render({ + setup() { + const { calendarProps, selectedDate, focusedDate, gridLabelProps } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + }); + + return { + calendarProps, + selectedDate, + focusedDate, + gridLabelProps, + }; + }, + template: ` +
+
+
Year Panel
+
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const calendar = screen.getByTestId('calendar'); + const panelLabel = screen.getByTestId('panel-label'); + + // Switch to month panel then year panel + await fireEvent.click(panelLabel); + await fireEvent.click(panelLabel); + + // Test right arrow (next year) + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + expect(screen.getByText(currentDate.add({ years: 1 }).toString())).toBeInTheDocument(); + + // Test left arrow (previous year) + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test down arrow (3 years forward) + await fireEvent.keyDown(calendar, { code: 'ArrowDown' }); + expect(screen.getByText(currentDate.add({ years: 3 }).toString())).toBeInTheDocument(); + + // Test up arrow (3 years back) + await fireEvent.keyDown(calendar, { code: 'ArrowUp' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test PageUp (previous set of years) + await fireEvent.keyDown(calendar, { code: 'PageUp' }); + expect(screen.getByText(currentDate.subtract({ years: 9 }).toString())).toBeInTheDocument(); + + // Test PageDown (next set of years) + await fireEvent.keyDown(calendar, { code: 'PageDown' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Test Home (start of year set) + await fireEvent.keyDown(calendar, { code: 'Home' }); + expect(screen.getByText(currentDate.subtract({ years: 9 }).toString())).toBeInTheDocument(); + + // Test End (end of year set) + await fireEvent.keyDown(calendar, { code: 'End' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + }); + + test('respects min and max date boundaries', async () => { + const currentDate = now('UTC'); + const minDate = currentDate.subtract({ days: 1 }); + const maxDate = currentDate.add({ days: 1 }); + + await render({ + setup() { + const { calendarProps, selectedDate, focusedDate } = useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + min: minDate.toDate(), + max: maxDate.toDate(), + }); + + return { + calendarProps, + selectedDate, + focusedDate, + }; + }, + template: ` +
+
+
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + const calendar = screen.getByTestId('calendar'); + + // Try to go before min date + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + expect(screen.getByText(minDate.toString())).toBeInTheDocument(); + + // Try to go after max date + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + expect(screen.getByText(maxDate.toString())).toBeInTheDocument(); + }); + }); + + describe('disabled state', () => { + test('prevents all interactions when disabled', async () => { + const currentDate = now('UTC'); + + await render({ + components: { + CalendarCell, + }, + setup() { + const { calendarProps, gridLabelProps, nextButtonProps, previousButtonProps, focusedDate, currentView } = + useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + disabled: true, + }); + + return { + calendarProps, + gridLabelProps, + nextButtonProps, + previousButtonProps, + focusedDate, + currentView, + currentDate, + }; + }, + template: ` +
+
+
{{ currentView.type }}
+ + + +
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + + // Verify navigation buttons are disabled + expect(screen.getByText('Previous')).toBeDisabled(); + expect(screen.getByText('Next')).toBeDisabled(); + + // Try to click navigation buttons + await fireEvent.click(screen.getByText('Previous')); + await fireEvent.click(screen.getByText('Next')); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Try to change panel view + await fireEvent.click(screen.getByTestId('panel-label')); + expect(screen.getByTestId('panel-label')).toHaveTextContent('weeks'); + + // Try keyboard navigation + const calendar = screen.getByTestId('calendar'); + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + await fireEvent.keyDown(calendar, { code: 'ArrowUp' }); + await fireEvent.keyDown(calendar, { code: 'ArrowDown' }); + await fireEvent.keyDown(calendar, { code: 'PageUp' }); + await fireEvent.keyDown(calendar, { code: 'PageDown' }); + await fireEvent.keyDown(calendar, { code: 'Home' }); + await fireEvent.keyDown(calendar, { code: 'End' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Try to select a date + const cell = screen.getByTestId('calendar-cell'); + await fireEvent.click(cell); + await fireEvent.keyDown(cell, { code: 'Enter' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + }); + }); + + describe('readonly state', () => { + test('prevents all interactions when readonly', async () => { + const currentDate = now('UTC'); + + await render({ + components: { + CalendarCell, + }, + setup() { + const { calendarProps, gridLabelProps, nextButtonProps, previousButtonProps, focusedDate, currentView } = + useCalendar({ + label: 'Calendar', + modelValue: currentDate.toDate(), + timeZone: 'UTC', + readonly: true, + }); + + return { + calendarProps, + gridLabelProps, + nextButtonProps, + previousButtonProps, + focusedDate, + currentView, + currentDate, + }; + }, + template: ` +
+
+
{{ currentView.type }}
+ + + +
{{ focusedDate?.toString() }}
+
+
+ `, + }); + + await flush(); + + // Verify navigation buttons are disabled + expect(screen.getByText('Previous')).toBeDisabled(); + expect(screen.getByText('Next')).toBeDisabled(); + + // Try to click navigation buttons + await fireEvent.click(screen.getByText('Previous')); + await fireEvent.click(screen.getByText('Next')); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Try to change panel view + await fireEvent.click(screen.getByTestId('panel-label')); + expect(screen.getByTestId('panel-label')).toHaveTextContent('weeks'); + + // Try keyboard navigation + const calendar = screen.getByTestId('calendar'); + await fireEvent.keyDown(calendar, { code: 'ArrowRight' }); + await fireEvent.keyDown(calendar, { code: 'ArrowLeft' }); + await fireEvent.keyDown(calendar, { code: 'ArrowUp' }); + await fireEvent.keyDown(calendar, { code: 'ArrowDown' }); + await fireEvent.keyDown(calendar, { code: 'PageUp' }); + await fireEvent.keyDown(calendar, { code: 'PageDown' }); + await fireEvent.keyDown(calendar, { code: 'Home' }); + await fireEvent.keyDown(calendar, { code: 'End' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + + // Try to select a date + const cell = screen.getByTestId('calendar-cell'); + await fireEvent.click(cell); + await fireEvent.keyDown(cell, { code: 'Enter' }); + expect(screen.getByText(currentDate.toString())).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/core/src/useCalendar/useCalendar.ts b/packages/core/src/useCalendar/useCalendar.ts new file mode 100644 index 00000000..43b4e72d --- /dev/null +++ b/packages/core/src/useCalendar/useCalendar.ts @@ -0,0 +1,599 @@ +import { computed, inject, nextTick, provide, Ref, ref, shallowRef, toValue, watch } from 'vue'; +import { CalendarContext, CalendarViewType } from './types'; +import { hasKeyCode, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { Maybe, Reactivify, StandardSchema } from '../types'; +import { useLocale } from '../i18n'; +import { FieldTypePrefixes } from '../constants'; +import { blockEvent } from '../utils/events'; +import { useLabel } from '../a11y'; +import { useControlButtonProps } from '../helpers/useControlButtonProps'; +import { CalendarContextKey, YEAR_CELLS_COUNT } from './constants'; +import { CalendarView, useCalendarView } from './useCalendarView'; +import { Calendar, ZonedDateTime, now, toCalendar } from '@internationalized/date'; +import { createDisabledContext } from '../helpers/createDisabledContext'; +import { exposeField, FormField, useFormField } from '../useFormField'; +import { useInputValidity } from '../validation'; +import { fromDateToCalendarZonedDateTime, useTemporalStore } from '../useDateTimeField/useTemporalStore'; +import { PickerContextKey } from '../usePicker'; + +export interface CalendarProps { + /** + * The field name of the calendar. + */ + name?: string; + + /** + * The label for the calendar. + */ + label: string; + + /** + * The locale to use for the calendar. + */ + locale?: string; + + /** + * The current date to use for the calendar. + */ + modelValue?: Date; + + /** + * The initial value to use for the calendar. + */ + value?: Date; + + /** + * The calendar type to use for the calendar, e.g. `gregory`, `islamic-umalqura`, etc. + */ + calendar?: Calendar; + + /** + * The time zone to use for the calendar. + */ + timeZone?: string; + + /** + * Whether the calendar is disabled. + */ + disabled?: boolean; + + /** + * Whether the calendar is readonly. + */ + readonly?: boolean; + + /** + * The label for the next month button. + */ + nextMonthButtonLabel?: string; + + /** + * The label for the previous month button. + */ + previousMonthButtonLabel?: string; + + /** + * The minimum date to use for the calendar. + */ + min?: Maybe; + + /** + * The maximum date to use for the calendar. + */ + max?: Maybe; + + /** + * The format option for the days of the week. + */ + weekDayFormat?: Intl.DateTimeFormatOptions['weekday']; + + /** + * The format option for the month. + */ + monthFormat?: Intl.DateTimeFormatOptions['month']; + + /** + * The format option for the year. + */ + yearFormat?: Intl.DateTimeFormatOptions['year']; + + /** + * The available views to switch to and from in the calendar. + */ + allowedViews?: CalendarViewType[]; + + /** + * The form field to use for the calendar. + */ + field?: FormField; + + /** + * The schema to use for the calendar. + */ + schema?: StandardSchema; +} + +export function useCalendar(_props: Reactivify) { + const props = normalizeProps(_props, ['field', 'schema']); + const { weekInfo, locale, calendar, timeZone, direction } = useLocale(props.locale, { + calendar: () => toValue(props.calendar), + timeZone: () => toValue(props.timeZone), + }); + + const pickerContext = inject(PickerContextKey, null); + const calendarId = useUniqId(FieldTypePrefixes.Calendar); + const gridId = `${calendarId}-g`; + const calendarEl = ref(); + const gridEl = ref(); + const calendarLabelEl = ref(); + const field = + props.field ?? + useFormField>({ + path: props.name, + disabled: props.disabled, + initialValue: toValue(props.modelValue) ?? toValue(props.value), + schema: props.schema, + }); + + const temporalValue = useTemporalStore({ + calendar: calendar, + timeZone: timeZone, + locale: locale, + model: { + get: () => field.fieldValue.value, + set: value => field.setValue(value), + }, + }); + + // If no controlling field is provided, we should hook up the required hooks to promote the calender to a full form field. + if (!props.field) { + useInputValidity({ field }); + } + + const isDisabled = createDisabledContext(props.disabled); + const selectedDate = computed(() => temporalValue.value ?? toCalendar(now(toValue(timeZone)), calendar.value)); + const focusedDay = shallowRef(); + + function getFocusedOrSelected() { + if (focusedDay.value) { + return focusedDay.value; + } + + return selectedDate.value; + } + + const min = computed(() => fromDateToCalendarZonedDateTime(toValue(props.min), calendar.value, timeZone.value)); + const max = computed(() => fromDateToCalendarZonedDateTime(toValue(props.max), calendar.value, timeZone.value)); + + const context: CalendarContext = { + weekInfo, + locale, + calendar, + timeZone, + getSelectedDate: () => selectedDate.value, + getFocusedDate: getFocusedOrSelected, + setDate, + setFocusedDate: async (date: ZonedDateTime) => { + if (isDisabled.value || toValue(props.readonly)) { + return; + } + + focusedDay.value = date; + await nextTick(); + focusCurrent(); + }, + getMinDate: () => min.value, + getMaxDate: () => max.value, + }; + + provide(CalendarContextKey, context); + + const { + currentView, + setView, + viewLabel: gridLabel, + } = useCalendarView( + { + weekDayFormat: props.weekDayFormat, + monthFormat: props.monthFormat, + yearFormat: props.yearFormat, + }, + context, + ); + + function setDate(date: ZonedDateTime, view?: CalendarViewType) { + if (isDisabled.value || toValue(props.readonly)) { + return; + } + + temporalValue.value = date; + focusedDay.value = date; + if (view) { + setView(view); + } else if (currentView.value.type === 'weeks') { + // Automatically close the calendar when a day is selected + pickerContext?.close(); + } + } + + const handleKeyDown = useCalendarKeyboard(context, currentView); + + const pickerHandlers = { + onKeydown(e: KeyboardEvent) { + const handled = handleKeyDown(e); + if (handled) { + blockEvent(e); + return; + } + + if (hasKeyCode(e, 'Escape')) { + pickerContext?.close(); + } + + if (hasKeyCode(e, 'Tab')) { + pickerContext?.close(); + } + }, + }; + + function focusCurrent() { + const currentlySelected = gridEl.value?.querySelector('[tabindex="0"]') as HTMLElement | null; + if (currentlySelected) { + currentlySelected.focus(); + return; + } + } + + watch( + () => pickerContext?.isOpen(), + async value => { + if (pickerContext && !value) { + focusedDay.value = undefined; + setView('weeks'); + return; + } + + if (!focusedDay.value) { + focusedDay.value = selectedDate.value.copy(); + } + + await nextTick(); + focusCurrent(); + }, + { immediate: true }, + ); + + const calendarProps = computed(() => { + return withRefCapture( + { + id: calendarId, + ...pickerHandlers, + role: 'application', + dir: direction.value, + }, + calendarEl, + ); + }); + + const nextButtonProps = useControlButtonProps(() => ({ + id: `${calendarId}-next`, + 'aria-label': 'Next', + disabled: isDisabled.value || toValue(props.readonly), + onClick: () => { + if (currentView.value.type === 'weeks') { + context.setFocusedDate(context.getFocusedDate().add({ months: 1 })); + return; + } + + if (currentView.value.type === 'months') { + context.setFocusedDate(context.getFocusedDate().add({ years: 1 })); + return; + } + + context.setFocusedDate(currentView.value.years[currentView.value.years.length - 1].value.add({ years: 1 })); + }, + })); + + const previousButtonProps = useControlButtonProps(() => ({ + id: `${calendarId}-previous`, + 'aria-label': 'Previous', + disabled: isDisabled.value || toValue(props.readonly), + onClick: () => { + if (currentView.value.type === 'weeks') { + context.setFocusedDate(context.getFocusedDate().subtract({ months: 1 })); + return; + } + + if (currentView.value.type === 'months') { + context.setFocusedDate(context.getFocusedDate().subtract({ years: 1 })); + return; + } + + context.setFocusedDate(currentView.value.years[0].value.subtract({ years: 1 })); + }, + })); + + const { labelProps: monthYearLabelBaseProps, labelledByProps } = useLabel({ + targetRef: gridEl, + for: gridId, + label: gridLabel, + }); + + function isAllowedView(view: CalendarViewType) { + return toValue(props.allowedViews)?.includes(view) ?? true; + } + + const gridLabelProps = computed(() => { + return withRefCapture( + { + ...monthYearLabelBaseProps.value, + 'aria-live': 'polite' as const, + tabindex: '-1', + onClick: () => { + if (isDisabled.value || toValue(props.readonly)) { + return; + } + + if (currentView.value.type === 'weeks') { + if (isAllowedView('months')) { + setView('months'); + } + + return; + } + + if (currentView.value.type === 'months') { + if (isAllowedView('years')) { + setView('years'); + } + + return; + } + }, + }, + calendarLabelEl, + ); + }); + + const gridProps = computed(() => { + return withRefCapture( + { + id: gridId, + role: 'grid', + ...labelledByProps.value, + }, + gridEl, + ); + }); + + return exposeField( + { + /** + * The props for the calendar element. + */ + calendarProps, + /** + * The props for the grid element that displays the panel values. + */ + gridProps, + + /** + * The current date. + */ + selectedDate, + /** + * The focused date. + */ + focusedDate: focusedDay, + /** + * The current view. + */ + currentView, + /** + * Switches the current view (e.g: weeks, months, years) + */ + setView, + /** + * The props for the panel label element. + */ + gridLabelProps, + /** + * The props for the next panel values button. if it is a day panel, the button will move the panel to the next month. If it is a month panel, the button will move the panel to the next year. If it is a year panel, the button will move the panel to the next set of years. + */ + nextButtonProps, + /** + * The props for the previous panel values button. If it is a day panel, the button will move the panel to the previous month. If it is a month panel, the button will move the panel to the previous year. If it is a year panel, the button will move the panel to the previous set of years. + */ + previousButtonProps, + /** + * The label for the current panel. If it is a day panel, the label will be the month and year. If it is a month panel, the label will be the year. If it is a year panel, the label will be the range of years currently being displayed. + */ + gridLabel, + }, + field, + ); +} + +interface ShortcutDefinition { + fn: () => ZonedDateTime | undefined; + type: 'focus' | 'select'; +} + +export function useCalendarKeyboard(context: CalendarContext, currentPanel: Ref) { + function withCheckedBounds(fn: () => ZonedDateTime | undefined) { + const date = fn(); + if (!date) { + return undefined; + } + + const minDate = context.getMinDate(); + const maxDate = context.getMaxDate(); + + if (date && ((minDate && date.compare(minDate) < 0) || (maxDate && date.compare(maxDate) > 0))) { + return undefined; + } + + return date; + } + + function getIncrement(direction: 'up' | 'down' | 'left' | 'right') { + const panelType = currentPanel.value.type; + if (panelType === 'weeks') { + if (direction === 'up' || direction === 'down') { + return { weeks: 1 }; + } + + return { days: 1 }; + } + + if (panelType === 'months') { + if (direction === 'up' || direction === 'down') { + return { months: 3 }; + } + + return { months: 1 }; + } + + if (direction === 'up' || direction === 'down') { + return { years: 3 }; + } + + return { years: 1 }; + } + + const shortcuts: Partial> = { + ArrowLeft: { + fn: () => context.getFocusedDate().subtract(getIncrement('left')), + type: 'focus', + }, + ArrowRight: { + fn: () => context.getFocusedDate().add(getIncrement('right')), + type: 'focus', + }, + ArrowUp: { + fn: () => context.getFocusedDate().subtract(getIncrement('up')), + type: 'focus', + }, + ArrowDown: { + fn: () => context.getFocusedDate().add(getIncrement('down')), + type: 'focus', + }, + Enter: { + fn: () => context.getFocusedDate(), + type: 'select', + }, + PageDown: { + fn: () => { + const type = currentPanel.value.type; + if (type === 'weeks') { + return context.getFocusedDate().add({ months: 1 }); + } + + if (type === 'months') { + return context.getFocusedDate().add({ years: 1 }); + } + + return context.getFocusedDate().add({ years: YEAR_CELLS_COUNT }); + }, + type: 'focus', + }, + PageUp: { + fn: () => { + const type = currentPanel.value.type; + if (type === 'weeks') { + return context.getFocusedDate().subtract({ months: 1 }); + } + + if (type === 'months') { + return context.getFocusedDate().subtract({ years: 1 }); + } + + return context.getFocusedDate().subtract({ years: YEAR_CELLS_COUNT }); + }, + type: 'focus', + }, + Home: { + fn: () => { + const current = context.getFocusedDate(); + const type = currentPanel.value.type; + if (type === 'weeks') { + if (current.day === 1) { + return current.subtract({ months: 1 }).set({ day: 1 }); + } + + return current.set({ day: 1 }); + } + + if (type === 'months') { + if (current.month === 1) { + return current.subtract({ years: 1 }).set({ month: 1 }); + } + + return current.set({ month: 1 }); + } + + return current.set({ year: current.year - YEAR_CELLS_COUNT }); + }, + type: 'focus', + }, + End: { + type: 'focus', + fn: () => { + const type = currentPanel.value.type; + const current = context.getFocusedDate(); + if (type === 'weeks') { + if (current.day === current.calendar.getDaysInMonth(current)) { + return current.add({ months: 1 }).set({ day: 1 }); + } + + return current.set({ day: current.calendar.getDaysInMonth(current) }); + } + + if (type === 'months') { + if (current.month === current.calendar.getMonthsInYear(current)) { + return current.add({ years: 1 }).set({ month: 1 }); + } + + return current.set({ month: current.calendar.getMonthsInYear(current) }); + } + + return current.set({ year: current.year + YEAR_CELLS_COUNT }); + }, + }, + Escape: { + type: 'focus', + fn: () => { + const selected = context.getSelectedDate(); + const focused = context.getFocusedDate(); + if (selected.compare(focused) !== 0) { + return context.getSelectedDate(); + } + + return undefined; + }, + }, + }; + + function handleKeyDown(e: KeyboardEvent): boolean { + const shortcut = shortcuts[e.code]; + if (!shortcut) { + return false; + } + + const newDate = withCheckedBounds(shortcut.fn); + if (!newDate) { + return false; + } + + if (shortcut.type === 'focus') { + context.setFocusedDate(newDate); + } else { + const panelType = currentPanel.value.type; + context.setDate(newDate, panelType === 'years' ? 'months' : panelType === 'months' ? 'weeks' : undefined); + } + + return true; + } + + return handleKeyDown; +} diff --git a/packages/core/src/useCalendar/useCalendarCell.ts b/packages/core/src/useCalendar/useCalendarCell.ts new file mode 100644 index 00000000..e77ca3c5 --- /dev/null +++ b/packages/core/src/useCalendar/useCalendarCell.ts @@ -0,0 +1,66 @@ +import { Reactivify } from '../types'; +import { normalizeProps, withRefCapture } from '../utils/common'; +import { computed, defineComponent, h, inject, shallowRef, toValue } from 'vue'; +import { CalendarCellProps, CalendarViewType } from './types'; +import { CalendarContextKey } from './constants'; +import { createDisabledContext } from '../helpers/createDisabledContext'; +import { blockEvent } from '../utils/events'; + +export function useCalendarCell(_props: Reactivify) { + const props = normalizeProps(_props); + const cellEl = shallowRef(); + const calendarCtx = inject(CalendarContextKey, null); + const isDisabled = createDisabledContext(props.disabled); + + function handlePointerDown(e: PointerEvent) { + if (isDisabled.value) { + blockEvent(e); + return; + } + } + + function handleClick(e: MouseEvent) { + if (isDisabled.value) { + blockEvent(e); + return; + } + + const type = toValue(props.type); + const nextPanel: CalendarViewType | undefined = type === 'month' ? 'weeks' : type === 'year' ? 'months' : undefined; + calendarCtx?.setDate(toValue(props.value), nextPanel); + } + + const cellProps = computed(() => { + const isFocused = toValue(props.focused); + + return withRefCapture( + { + key: toValue(props.value).toString(), + onClick: handleClick, + onPointerdown: handlePointerDown, + 'aria-selected': toValue(props.selected), + 'aria-disabled': isDisabled.value, + tabindex: isDisabled.value || !isFocused ? '-1' : '0', + }, + cellEl, + ); + }); + + const label = computed(() => toValue(props.label)); + + return { + cellProps, + label, + }; +} + +export const CalendarCell = defineComponent({ + name: 'CalendarCell', + inheritAttrs: true, + props: ['value', 'selected', 'disabled', 'focused', 'label', 'type', 'monthOfYear', 'year'], + setup(props) { + const { cellProps, label } = useCalendarCell(props); + + return () => h('span', cellProps.value, label.value); + }, +}); diff --git a/packages/core/src/useCalendar/useCalendarView.ts b/packages/core/src/useCalendar/useCalendarView.ts new file mode 100644 index 00000000..d7ea605f --- /dev/null +++ b/packages/core/src/useCalendar/useCalendarView.ts @@ -0,0 +1,263 @@ +import { computed, MaybeRefOrGetter, shallowRef, toValue } from 'vue'; +import { CalendarContext, CalendarDayCell, CalendarMonthCell, CalendarViewType, CalendarYearCell } from './types'; +import { useDateFormatter } from '../i18n'; +import { Reactivify } from '../types'; +import { normalizeProps } from '../utils/common'; +import { YEAR_CELLS_COUNT } from './constants'; +import { now, toCalendar, toCalendarDate } from '@internationalized/date'; + +export interface CalendarWeeksView { + type: 'weeks'; + days: CalendarDayCell[]; + weekDays: string[]; +} + +export interface CalendarMonthsView { + type: 'months'; + months: CalendarMonthCell[]; +} + +export interface CalendarYearsView { + type: 'years'; + years: CalendarYearCell[]; +} + +export type CalendarView = CalendarWeeksView | CalendarMonthsView | CalendarYearsView; + +export interface CalendarViewProps { + /** + * The format option for the days of the week. + */ + weekDayFormat?: Intl.DateTimeFormatOptions['weekday']; + + /** + * The format option for the month. + */ + monthFormat?: Intl.DateTimeFormatOptions['month']; + + /** + * The format option for the year. + */ + yearFormat?: Intl.DateTimeFormatOptions['year']; +} + +export function useCalendarView(_props: Reactivify, context: CalendarContext) { + const props = normalizeProps(_props); + const viewType = shallowRef('weeks'); + const { days, weekDays } = useCalendarDaysView(context, props.weekDayFormat); + const { months, monthFormatter } = useCalendarMonthsView(context, props.monthFormat); + const { years, yearFormatter } = useCalendarYearsView(context, props.yearFormat); + + const currentView = computed(() => { + if (viewType.value === 'weeks') { + return { + type: 'weeks', + days: days.value, + weekDays: weekDays.value, + } as CalendarWeeksView; + } + + if (viewType.value === 'months') { + return { + type: 'months', + months: months.value, + } as CalendarMonthsView; + } + + return { + type: 'years', + years: years.value, + } as CalendarYearsView; + }); + + function setView(type: CalendarViewType) { + viewType.value = type; + } + + const viewLabel = computed(() => { + if (viewType.value === 'weeks') { + return `${monthFormatter.value.format(context.getFocusedDate().toDate())} ${yearFormatter.value.format(context.getFocusedDate().toDate())}`; + } + + if (viewType.value === 'months') { + return yearFormatter.value.format(context.getFocusedDate().toDate()); + } + + return `${yearFormatter.value.format(years.value[0].value.toDate())} - ${yearFormatter.value.format(years.value[years.value.length - 1].value.toDate())}`; + }); + + return { currentView, setView, viewLabel }; +} + +function useCalendarDaysView( + { weekInfo, getFocusedDate, getSelectedDate, locale, timeZone, calendar, getMinDate, getMaxDate }: CalendarContext, + daysOfWeekFormat?: MaybeRefOrGetter, +) { + const dayFormatter = useDateFormatter(locale, () => ({ weekday: toValue(daysOfWeekFormat) ?? 'short' })); + const dayNumberFormatter = useDateFormatter(locale, () => ({ day: 'numeric' })); + + const days = computed(() => { + const current = getSelectedDate(); + const focused = getFocusedDate(); + const startOfMonth = focused.set({ day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); + + const firstDayOfWeek = weekInfo.value.firstDay; + const startDayOfWeek = startOfMonth.toDate().getDay(); + const daysToSubtract = (startDayOfWeek - firstDayOfWeek + 7) % 7; + + // Move to first day of week + const firstDay = startOfMonth.subtract({ days: daysToSubtract }); + + // Always use 6 weeks (42 days) for consistent layout + const gridDays = 42; + const rightNow = toCalendar(now(timeZone.value), calendar.value); + const minDate = getMinDate(); + const maxDate = getMaxDate(); + + const rightNowDate = toCalendarDate(rightNow); + const focusedDate = toCalendarDate(focused); + const currentDate = toCalendarDate(current); + + return Array.from({ length: gridDays }, (_, i) => { + const dayOfMonth = firstDay.add({ days: i }); + let disabled = false; + + if (minDate && dayOfMonth.compare(minDate) < 0) { + disabled = true; + } + + if (maxDate && dayOfMonth.compare(maxDate) > 0) { + disabled = true; + } + + const domDate = toCalendarDate(dayOfMonth); + + return { + value: dayOfMonth, + label: dayNumberFormatter.value.format(dayOfMonth.toDate()), + dayOfMonth: dayOfMonth.day, + isToday: rightNowDate.compare(domDate) === 0, + selected: currentDate.compare(domDate) === 0, + isOutsideMonth: domDate.month !== focusedDate.month, + focused: focusedDate.compare(domDate) === 0, + disabled, + type: 'day', + } as CalendarDayCell; + }); + }); + + const weekDays = computed(() => { + let focused = getFocusedDate(); + const daysPerWeek = 7; + const firstDayOfWeek = weekInfo.value.firstDay; + // Get the current date's day of week (0-6) + const currentDayOfWeek = focused.toDate().getDay(); + + // Calculate how many days to go back to reach first day of week + const daysToSubtract = (currentDayOfWeek - firstDayOfWeek + 7) % 7; + + // Move current date back to first day of week + focused = focused.subtract({ days: daysToSubtract }); + + const days: string[] = []; + for (let i = 0; i < daysPerWeek; i++) { + days.push(dayFormatter.value.format(focused.add({ days: i }).toDate())); + } + + return days; + }); + + return { days, weekDays, dayFormatter }; +} + +function useCalendarMonthsView( + { getFocusedDate, locale, getSelectedDate, getMinDate, getMaxDate }: CalendarContext, + monthFormat?: MaybeRefOrGetter, +) { + const monthFormatter = useDateFormatter(locale, () => ({ month: toValue(monthFormat) ?? 'long' })); + + const months = computed(() => { + const focused = getFocusedDate(); + const current = getSelectedDate(); + const minDate = getMinDate(); + const maxDate = getMaxDate(); + + return Array.from({ length: focused.calendar.getMonthsInYear(focused) }, (_, i) => { + const date = focused.set({ month: i + 1, day: 1, hour: 0, minute: 0, second: 0, millisecond: 0 }); + let disabled = false; + + if (minDate && minDate.month < date.month) { + disabled = true; + } + + if (maxDate && maxDate.month > date.month) { + disabled = true; + } + + const cell: CalendarMonthCell = { + type: 'month', + label: monthFormatter.value.format(date.toDate()), + value: date, + monthOfYear: date.month, + selected: date.month === current.month && date.year === current.year, + focused: focused.month === date.month && focused.year === date.year, + disabled, + }; + + return cell; + }); + }); + + return { months, monthFormatter }; +} + +function useCalendarYearsView( + { getFocusedDate, locale, getSelectedDate, getMinDate, getMaxDate }: CalendarContext, + yearFormat?: MaybeRefOrGetter, +) { + const yearFormatter = useDateFormatter(locale, () => ({ year: toValue(yearFormat) ?? 'numeric' })); + + const years = computed(() => { + const focused = getFocusedDate(); + const current = getSelectedDate(); + const minDate = getMinDate(); + const maxDate = getMaxDate(); + + return Array.from({ length: YEAR_CELLS_COUNT }, (_, i) => { + const startYear = Math.floor(focused.year / YEAR_CELLS_COUNT) * YEAR_CELLS_COUNT; + const date = focused.set({ + year: startYear + i, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + }); + + let disabled = false; + + if (minDate && minDate.year < date.year) { + disabled = true; + } + + if (maxDate && maxDate.year > date.year) { + disabled = true; + } + + const cell: CalendarYearCell = { + type: 'year', + label: yearFormatter.value.format(date.toDate()), + value: date, + year: date.year, + selected: date.year === current.year, + focused: focused.year === date.year, + disabled, + }; + + return cell; + }); + }); + + return { years, yearFormatter }; +} diff --git a/packages/core/src/useComboBox/useComboBox.ts b/packages/core/src/useComboBox/useComboBox.ts index fd8f3770..9e836554 100644 --- a/packages/core/src/useComboBox/useComboBox.ts +++ b/packages/core/src/useComboBox/useComboBox.ts @@ -18,6 +18,7 @@ import { useListBox } from '../useListBox'; import { useErrorMessage } from '../a11y/useErrorMessage'; import { useInputValidity } from '../validation'; import { FilterFn } from '../collections'; +import { useControlButtonProps } from '../helpers/useControlButtonProps'; export interface ComboBoxProps { /** @@ -315,25 +316,16 @@ export function useComboBox( isPopupOpen.value = !isPopupOpen.value; } - const buttonProps = computed(() => { - const isButton = buttonEl.value?.tagName === 'BUTTON'; - - return withRefCapture( - { - id: inputId, - role: isButton ? undefined : 'button', - [isButton ? 'disabled' : 'aria-disabled']: isDisabled.value || undefined, - tabindex: '-1', - type: 'button' as const, - 'aria-haspopup': 'listbox' as const, - 'aria-expanded': isPopupOpen.value, - 'aria-activedescendant': findFocusedOption()?.id ?? undefined, - 'aria-controls': listBoxId, - onClick: onButtonClick, - }, - buttonEl, - ); - }); + const buttonProps = useControlButtonProps(() => ({ + id: `${inputId}-btn`, + disabled: isDisabled.value, + type: 'button' as const, + 'aria-haspopup': 'listbox' as const, + 'aria-expanded': isPopupOpen.value, + 'aria-activedescendant': findFocusedOption()?.id ?? undefined, + 'aria-controls': listBoxId, + onClick: onButtonClick, + })); // https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-autocomplete-list/#rps_label_textbox const inputProps = computed(() => { diff --git a/packages/core/src/useDateTimeField/constants.ts b/packages/core/src/useDateTimeField/constants.ts new file mode 100644 index 00000000..a8677e39 --- /dev/null +++ b/packages/core/src/useDateTimeField/constants.ts @@ -0,0 +1,55 @@ +import { DateTimeSegmentType } from './types'; +import type { DateTimeDuration } from '@internationalized/date'; + +export function isEditableSegmentType(type: DateTimeSegmentType) { + return !['era', 'timeZoneName', 'literal'].includes(type); +} + +export function isOptionalSegmentType(type: DateTimeSegmentType) { + const optionalTypes: DateTimeSegmentType[] = ['dayPeriod', 'weekday', 'era']; + + return optionalTypes.includes(type); +} + +export function segmentTypeToDurationLike(type: DateTimeSegmentType): keyof DateTimeDuration | undefined { + const map: Partial> = { + year: 'years', + month: 'months', + day: 'days', + hour: 'hours', + minute: 'minutes', + second: 'seconds', + dayPeriod: 'hours', + weekday: 'days', + }; + + return map[type]; +} + +export function getSegmentTypePlaceholder(type: DateTimeSegmentType) { + const map: Partial> = { + year: 'YYYY', + month: 'MM', + day: 'DD', + hour: 'HH', + minute: 'mm', + second: 'ss', + dayPeriod: 'AM', + weekday: 'ddd', + }; + + return map[type]; +} + +export function isNumericByDefault(type: DateTimeSegmentType) { + const map: Partial> = { + year: true, + month: true, + day: true, + hour: true, + minute: true, + second: true, + }; + + return map[type] ?? false; +} diff --git a/packages/core/src/useDateTimeField/index.ts b/packages/core/src/useDateTimeField/index.ts new file mode 100644 index 00000000..179f2c27 --- /dev/null +++ b/packages/core/src/useDateTimeField/index.ts @@ -0,0 +1,3 @@ +export * from './useDateTimeField'; +export * from './useDateTimeSegment'; +export * from './types'; diff --git a/packages/core/src/useDateTimeField/temporalPartial.spec.ts b/packages/core/src/useDateTimeField/temporalPartial.spec.ts new file mode 100644 index 00000000..f25a5edc --- /dev/null +++ b/packages/core/src/useDateTimeField/temporalPartial.spec.ts @@ -0,0 +1,117 @@ +import { createCalendar, now } from '@internationalized/date'; +import { createTemporalPartial, isTemporalPartial, isTemporalPartSet, toTemporalPartial } from './temporalPartial'; +import { DateTimeSegmentType } from './types'; + +describe('Temporal Partial', () => { + describe('createTemporalPartial', () => { + test('creates a temporal partial with empty set parts', () => { + const calendar = createCalendar('gregory'); + const partial = createTemporalPartial(calendar, 'UTC'); + + expect(partial['~fw_temporal_partial']).toEqual({}); + expect(isTemporalPartial(partial)).toBe(true); + }); + + test('creates temporal partial with different calendar systems', () => { + const islamicCalendar = createCalendar('islamic-umalqura'); + const partial = createTemporalPartial(islamicCalendar, 'UTC'); + + expect(partial.calendar.identifier).toBe('islamic-umalqura'); + expect(isTemporalPartial(partial)).toBe(true); + }); + }); + + describe('toTemporalPartial', () => { + test('converts ZonedDateTime to temporal partial', () => { + const date = now('UTC'); + const partial = toTemporalPartial(date); + + expect(isTemporalPartial(partial)).toBe(true); + expect(partial['~fw_temporal_partial']).toEqual({}); + }); + + test('clones existing temporal partial', () => { + const date = now('UTC'); + const partial1 = toTemporalPartial(date, ['day']); + const partial2 = toTemporalPartial(partial1); + + expect(partial2['~fw_temporal_partial']).toEqual(partial1['~fw_temporal_partial']); + expect(partial2).not.toBe(partial1); // Should be a new instance + }); + + test('sets specified parts as true', () => { + const date = now('UTC'); + const parts: DateTimeSegmentType[] = ['year', 'month', 'day']; + const partial = toTemporalPartial(date, parts); + + parts.forEach(part => { + expect(partial['~fw_temporal_partial'][part]).toBe(true); + }); + }); + + test('preserves existing set parts when adding new ones', () => { + const date = now('UTC'); + const partial1 = toTemporalPartial(date, ['year']); + const partial2 = toTemporalPartial(partial1, ['month']); + + expect(partial2['~fw_temporal_partial']).toEqual({ + year: true, + month: true, + }); + }); + }); + + describe('isTemporalPartial', () => { + test('returns true for temporal partials', () => { + const calendar = createCalendar('gregory'); + const partial = createTemporalPartial(calendar, 'UTC'); + + expect(isTemporalPartial(partial)).toBe(true); + }); + + test('returns false for regular ZonedDateTime', () => { + const date = now('UTC'); + + expect(isTemporalPartial(date)).toBe(false); + }); + }); + + describe('isTemporalPartSet', () => { + test('returns true for set parts', () => { + const date = now('UTC'); + const partial = toTemporalPartial(date, ['year', 'month']); + + expect(isTemporalPartSet(partial, 'year')).toBe(true); + expect(isTemporalPartSet(partial, 'month')).toBe(true); + }); + + test('returns false for unset parts', () => { + const date = now('UTC'); + const partial = toTemporalPartial(date, ['year']); + + expect(isTemporalPartSet(partial, 'month')).toBe(false); + expect(isTemporalPartSet(partial, 'day')).toBe(false); + }); + + test('handles multiple operations on the same partial', () => { + const date = now('UTC'); + let partial = toTemporalPartial(date, ['year']); + partial = toTemporalPartial(partial, ['month']); + partial = toTemporalPartial(partial, ['day']); + + expect(isTemporalPartSet(partial, 'year')).toBe(true); + expect(isTemporalPartSet(partial, 'month')).toBe(true); + expect(isTemporalPartSet(partial, 'day')).toBe(true); + expect(isTemporalPartSet(partial, 'hour')).toBe(false); + }); + }); + + test('temporal partial maintains date values', () => { + const date = now('UTC'); + const partial = toTemporalPartial(date, ['year', 'month', 'day']); + + expect(partial.year).toBe(date.year); + expect(partial.month).toBe(date.month); + expect(partial.day).toBe(date.day); + }); +}); diff --git a/packages/core/src/useDateTimeField/temporalPartial.ts b/packages/core/src/useDateTimeField/temporalPartial.ts new file mode 100644 index 00000000..e4564326 --- /dev/null +++ b/packages/core/src/useDateTimeField/temporalPartial.ts @@ -0,0 +1,33 @@ +import { DateTimeSegmentType, TemporalPartial } from './types'; +import { isObject } from '../../../shared/src'; +import { Calendar, ZonedDateTime, now, toCalendar } from '@internationalized/date'; + +export function createTemporalPartial(calendar: Calendar, timeZone: string) { + const zonedDateTime = toCalendar(now(timeZone), calendar) as TemporalPartial; + zonedDateTime['~fw_temporal_partial'] = {}; + + return zonedDateTime; +} + +export function toTemporalPartial( + value: ZonedDateTime | TemporalPartial, + setParts?: DateTimeSegmentType[], +): TemporalPartial { + const clone = value.copy() as TemporalPartial; + clone['~fw_temporal_partial'] = isTemporalPartial(value) ? value['~fw_temporal_partial'] : {}; + if (setParts) { + setParts.forEach(part => { + clone['~fw_temporal_partial'][part] = true; + }); + } + + return clone as TemporalPartial; +} + +export function isTemporalPartial(value: ZonedDateTime): value is TemporalPartial { + return isObject((value as TemporalPartial)['~fw_temporal_partial']); +} + +export function isTemporalPartSet(value: TemporalPartial, part: DateTimeSegmentType): boolean { + return part in value['~fw_temporal_partial'] && value['~fw_temporal_partial'][part] === true; +} diff --git a/packages/core/src/useDateTimeField/types.ts b/packages/core/src/useDateTimeField/types.ts new file mode 100644 index 00000000..a35a1ccc --- /dev/null +++ b/packages/core/src/useDateTimeField/types.ts @@ -0,0 +1,25 @@ +import { ZonedDateTime } from '@internationalized/date'; + +/** + * lib.es2017.intl.d.ts + */ +export type DateTimeSegmentType = + | 'day' + | 'dayPeriod' + | 'era' + | 'hour' + | 'literal' + | 'minute' + | 'month' + | 'second' + | 'timeZoneName' + | 'weekday' + | 'year'; + +export type DateValue = Date | ZonedDateTime; + +export type TemporalPartial = ZonedDateTime & { + [`~fw_temporal_partial`]: { + [key: string]: boolean | undefined; + }; +}; diff --git a/packages/core/src/useDateTimeField/useDateTimeField.spec.ts b/packages/core/src/useDateTimeField/useDateTimeField.spec.ts new file mode 100644 index 00000000..c3c0f62d --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeField.spec.ts @@ -0,0 +1,417 @@ +import { render, screen } from '@testing-library/vue'; +import { axe } from 'vitest-axe'; +import { useDateTimeField } from './'; +import { flush } from '@test-utils/flush'; +import { createCalendar, now, toCalendar } from '@internationalized/date'; +import { DateTimeSegment } from './useDateTimeSegment'; +import { ref, toValue } from 'vue'; +import { StandardSchema } from '../types'; +import { fireEvent } from '@testing-library/vue'; + +describe('useDateTimeField', () => { + const currentDate = new Date('2024-03-15T12:00:00Z'); + + describe('initialization', () => { + test('initializes with value prop', async () => { + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps } = useDateTimeField({ + label: 'Date', + name: 'date', + value: currentDate, + }); + + return { + segments, + controlProps, + labelProps, + }; + }, + template: ` +
+ +
+ +
+
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month'); + const daySegment = segments.find(el => el.dataset.segmentType === 'day'); + const yearSegment = segments.find(el => el.dataset.segmentType === 'year'); + + expect(monthSegment?.textContent).toBe('3'); + expect(daySegment?.textContent).toBe('15'); + expect(yearSegment?.textContent).toBe('2024'); + }); + + test('initializes with modelValue prop', async () => { + const modelValue = ref(currentDate); + + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps } = useDateTimeField({ + label: 'Date', + name: 'date', + modelValue, + }); + + return { + segments, + controlProps, + labelProps, + }; + }, + template: ` +
+ +
+ +
+
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month'); + expect(monthSegment?.textContent).toBe('3'); + }); + }); + + describe('calendar systems', () => { + test('supports different calendar systems', async () => { + const calendar = createCalendar('islamic-umalqura'); + const date = toCalendar(now('UTC'), calendar).set({ year: 1445, month: 9, day: 5 }); // Islamic date + + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps } = useDateTimeField({ + label: 'Date', + name: 'date', + calendar, + value: date.toDate(), + }); + + return { + segments, + controlProps, + labelProps, + }; + }, + template: ` +
+ +
+ +
+
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month'); + const yearSegment = segments.find(el => el.dataset.segmentType === 'year'); + + expect(monthSegment?.textContent).toBe('9'); + expect(yearSegment?.textContent).toBe('1445'); + }); + }); + + describe('accessibility', () => { + test('provides accessible label and description', async () => { + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps, descriptionProps } = useDateTimeField({ + label: 'Birth Date', + name: 'birthDate', + description: 'Enter your date of birth', + }); + + return { + segments, + controlProps, + labelProps, + descriptionProps, + }; + }, + template: ` +
+ Birth Date +
+ +
+
Enter your date of birth
+
+ `, + }); + + await flush(); + vi.useRealTimers(); + expect(await axe(screen.getByTestId('fixture'))).toHaveNoViolations(); + vi.useFakeTimers(); + + const control = screen.getByRole('group'); + expect(control).toHaveAccessibleDescription('Enter your date of birth'); + expect(control).toHaveAccessibleName('Birth Date'); + }); + + test('shows error message when validation fails', async () => { + const schema: StandardSchema = { + '~standard': { + vendor: 'formwerk', + version: 1, + validate: value => { + if (!value) { + return { + issues: [{ path: [], message: 'Date is required' }], + }; + } + + return { + value: value as Date, + }; + }, + }, + }; + + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps, errorMessageProps, errorMessage } = useDateTimeField({ + label: 'Date', + name: 'date', + schema, + }); + + return { + segments, + controlProps, + labelProps, + errorMessageProps, + errorMessage, + }; + }, + template: ` +
+ Date +
+ +
+
{{ errorMessage }}
+
+ `, + }); + + await flush(); + const control = screen.getByRole('group'); + expect(control).toHaveErrorMessage('Date is required'); + }); + + test('updates validation when date changes', async () => { + const modelValue = ref(undefined); + let updateVal!: (value: Date) => void; + const schema: StandardSchema = { + '~standard': { + vendor: 'formwerk', + version: 1, + validate: value => { + if (!value) { + return { + issues: [{ path: [], message: 'Date is required' }], + }; + } + + // Date must be in the future + if ((value as Date).getTime() < new Date().getTime()) { + return { + issues: [{ path: [], message: 'Date must be in the future' }], + }; + } + + return { + value: value as Date, + }; + }, + }, + }; + + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps, errorMessageProps, errorMessage, setValue } = useDateTimeField({ + label: 'Date', + name: 'date', + modelValue, + schema, + }); + updateVal = (value: Date) => { + setValue(value); + }; + + return { + segments, + controlProps, + labelProps, + errorMessageProps, + errorMessage, + }; + }, + template: ` +
+ Date +
+ +
+
{{ errorMessage }}
+
+ `, + }); + + await flush(); + const control = screen.getByRole('group'); + + // Initially should show required error + expect(control).toHaveErrorMessage('Date is required'); + + updateVal(new Date('2025-01-01')); + await flush(); + expect(control).toHaveErrorMessage('Date must be in the future'); + + // Set to a future date + updateVal(new Date()); + await flush(); + expect(control).not.toHaveErrorMessage(); + }); + + test('sets touched state when any segment is blurred', async () => { + await render({ + components: { DateTimeSegment }, + setup() { + const { segments, controlProps, labelProps, isTouched } = useDateTimeField({ + label: 'Date', + name: 'date', + }); + + return { + segments, + controlProps, + labelProps, + isTouched, + }; + }, + template: ` +
+ Date +
+ +
+
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const control = screen.getByRole('group'); + + // Initially not touched + expect(control.dataset.touched).toBe('false'); + + // Blur month segment + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + await fireEvent.blur(monthSegment); + await flush(); + expect(control.dataset.touched).toBe('true'); + }); + }); + + describe('constraints', () => { + test('respects min and max date constraints', async () => { + const minDate = now('UTC'); + const maxDate = now('UTC').add({ days: 1 }); + + await render({ + components: { DateTimeSegment }, + setup() { + const props = useDateTimeField({ + label: 'Date', + name: 'date', + timeZone: 'UTC', + min: minDate.toDate(), + max: maxDate.toDate(), + value: currentDate, + }); + + const { segments, controlProps, labelProps } = props; + + expect(toValue(props.calendarProps.value.min)).toEqual(minDate.toDate()); + expect(toValue(props.calendarProps.value.max)).toEqual(maxDate.toDate()); + + return { + segments, + controlProps, + labelProps, + }; + }, + template: ` +
+ +
+ +
+
+ `, + }); + + await flush(); + }); + }); +}); diff --git a/packages/core/src/useDateTimeField/useDateTimeField.ts b/packages/core/src/useDateTimeField/useDateTimeField.ts new file mode 100644 index 00000000..4890677f --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeField.ts @@ -0,0 +1,224 @@ +import { Maybe, Reactivify, StandardSchema } from '../types'; +import type { CalendarProps } from '../useCalendar'; +import { createDescribedByProps, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { computed, shallowRef, toValue } from 'vue'; +import { exposeField, useFormField } from '../useFormField'; +import { useDateTimeSegmentGroup } from './useDateTimeSegmentGroup'; +import { FieldTypePrefixes } from '../constants'; +import { useDateFormatter, useLocale } from '../i18n'; +import { useErrorMessage, useLabel } from '../a11y'; +import { fromDateToCalendarZonedDateTime, useTemporalStore } from './useTemporalStore'; +import { ZonedDateTime, Calendar } from '@internationalized/date'; +import { useInputValidity } from '../validation'; +import { createDisabledContext } from '../helpers/createDisabledContext'; + +export interface DateTimeFieldProps { + /** + * The label to use for the field. + */ + label: string; + + /** + * The name to use for the field. + */ + name?: string; + + /** + * The locale to use for the field. + */ + locale?: string; + + /** + * The calendar type to use for the field, e.g. `gregory`, `islamic-umalqura`, etc. + */ + calendar?: Calendar; + + /** + * The time zone to use for the field, e.g. `UTC`, `America/New_York`, etc. + */ + timeZone?: string; + + /** + * The Intl.DateTimeFormatOptions to use for the field, used to format the date value. + */ + formatOptions?: Intl.DateTimeFormatOptions; + + /** + * The description to use for the field. + */ + description?: string; + + /** + * The placeholder to use for the field. + */ + placeholder?: string; + + /** + * Whether the field is readonly. + */ + readonly?: boolean; + + /** + * Whether the field is disabled. + */ + disabled?: boolean; + + /** + * The value to use for the field. + */ + value?: Date; + + /** + * The model value to use for the field. + */ + modelValue?: Date; + + /** + * The schema to use for the field. + */ + schema?: StandardSchema; + + /** + * The minimum date to use for the field. + */ + min?: Date; + + /** + * The maximum date to use for the field. + */ + max?: Date; +} + +export function useDateTimeField(_props: Reactivify) { + const props = normalizeProps(_props, ['schema']); + const controlEl = shallowRef(); + const { locale, direction, timeZone, calendar } = useLocale(props.locale, { + calendar: () => toValue(props.calendar), + timeZone: () => toValue(props.timeZone), + }); + + const isDisabled = createDisabledContext(props.disabled); + const formatter = useDateFormatter(locale, props.formatOptions); + const controlId = useUniqId(FieldTypePrefixes.DateTimeField); + + const field = useFormField>({ + path: props.name, + disabled: props.disabled, + initialValue: toValue(props.modelValue) ?? toValue(props.value), + schema: props.schema, + }); + + useInputValidity({ field }); + + const temporalValue = useTemporalStore({ + calendar: calendar, + timeZone: timeZone, + locale: locale, + model: { + get: () => field.fieldValue.value, + set: value => field.setValue(value), + }, + }); + + function onValueChange(value: ZonedDateTime) { + temporalValue.value = value; + } + + const { segments } = useDateTimeSegmentGroup({ + formatter, + locale, + formatOptions: props.formatOptions, + direction, + controlEl, + temporalValue, + readonly: props.readonly, + onValueChange, + onTouched: () => field.setTouched(true), + min: computed(() => fromDateToCalendarZonedDateTime(toValue(props.min), calendar.value, timeZone.value)), + max: computed(() => fromDateToCalendarZonedDateTime(toValue(props.max), calendar.value, timeZone.value)), + }); + + const { labelProps, labelledByProps } = useLabel({ + for: controlId, + label: props.label, + targetRef: controlEl, + }); + + const { descriptionProps, describedByProps } = createDescribedByProps({ + inputId: controlId, + description: props.description, + }); + + const { errorMessageProps, accessibleErrorProps } = useErrorMessage({ + inputId: controlId, + errorMessage: field.errorMessage, + }); + + const calendarProps = computed(() => { + const propsObj: CalendarProps = { + label: toValue(props.label), + locale: locale.value, + name: undefined, + calendar: calendar.value, + min: toValue(props.min), + max: toValue(props.max), + field, + }; + + return propsObj; + }); + + const controlProps = computed(() => { + return withRefCapture( + { + id: controlId, + role: 'group', + ...labelledByProps.value, + ...describedByProps.value, + ...accessibleErrorProps.value, + 'aria-disabled': isDisabled.value || undefined, + }, + controlEl, + ); + }); + + return exposeField( + { + /** + * The props to use for the control element. + */ + controlProps, + + /** + * The props to use for the description element. + */ + descriptionProps, + + /** + * The props to use for the label element. + */ + labelProps, + + /** + * The props to use for the error message element. + */ + errorMessageProps, + + /** + * The datetime segments, you need to render these with the `DateTimeSegment` component. + */ + segments, + + /** + * The props to use for the calendar composable/component. + */ + calendarProps, + + /** + * The direction of the field. + */ + direction, + }, + field, + ); +} diff --git a/packages/core/src/useDateTimeField/useDateTimeSegment.ts b/packages/core/src/useDateTimeField/useDateTimeSegment.ts new file mode 100644 index 00000000..1e87f19e --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeSegment.ts @@ -0,0 +1,230 @@ +import { computed, CSSProperties, defineComponent, h, inject, shallowRef, toValue } from 'vue'; +import { Reactivify } from '../types'; +import { hasKeyCode, isNullOrUndefined, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { DateTimeSegmentGroupKey } from './useDateTimeSegmentGroup'; +import { FieldTypePrefixes } from '../constants'; +import { blockEvent } from '../utils/events'; +import { DateTimeSegmentType } from './types'; +import { isEditableSegmentType } from './constants'; +import { createDisabledContext } from '../helpers/createDisabledContext'; + +export interface DateTimeSegmentProps { + /** + * The type of the segment. + */ + type: DateTimeSegmentType; + + /** + * The text value of the segment. + */ + value: string; + + /** + * Whether the segment is disabled. + */ + disabled?: boolean; + + /** + * Whether the segment is readonly. + */ + readonly?: boolean; +} + +interface DateTimeSegmentDomProps { + id: string; + tabindex: number; + role?: string; + contenteditable: string | undefined; + 'aria-disabled': boolean | undefined; + 'aria-readonly': boolean | undefined; + 'data-segment-type': DateTimeSegmentType; + style: CSSProperties; + 'aria-label'?: string; + spellcheck?: boolean; + inputmode?: string; + autocorrect?: string; + enterkeyhint?: string; + 'aria-valuemin'?: number; + 'aria-valuemax'?: number; + 'aria-valuenow'?: number; + 'aria-valuetext'?: string; +} + +export function useDateTimeSegment(_props: Reactivify) { + const props = normalizeProps(_props); + const id = useUniqId(FieldTypePrefixes.DateTimeSegment); + const segmentEl = shallowRef(); + const segmentGroup = inject(DateTimeSegmentGroupKey, null); + const isDisabled = createDisabledContext(props.disabled); + const isNonEditable = () => isDisabled.value || !isEditableSegmentType(toValue(props.type)); + + if (!segmentGroup) { + throw new Error('DateTimeSegmentGroup is not provided'); + } + + const { + increment, + decrement, + setValue, + getMetadata, + onDone, + parser, + clear, + onTouched, + isLast, + focusNext, + isNumeric, + } = segmentGroup.useDateSegmentRegistration({ + id, + getElem: () => segmentEl.value, + getType: () => toValue(props.type), + }); + + let currentInput = ''; + + const handlers = { + onFocus() { + // Reset the current input when the segment is focused + currentInput = ''; + }, + onBeforeinput(evt: InputEvent) { + if (toValue(props.readonly) || isDisabled.value) { + blockEvent(evt); + return; + } + + // No data,like backspace or whatever + if (isNullOrUndefined(evt.data)) { + return; + } + + blockEvent(evt); + if (!isNumeric()) { + return; + } + + const nextValue = currentInput + evt.data; + currentInput = nextValue; + + const parsed = parser.parse(nextValue); + const { min, max, maxLength } = getMetadata(); + if (isNullOrUndefined(min) || isNullOrUndefined(max) || isNullOrUndefined(maxLength)) { + return; + } + + if (Number.isNaN(parsed) || parsed > max) { + return; + } + + if (segmentEl.value) { + segmentEl.value.textContent = currentInput; + } + + // If the current input length is greater than or equal to the max length, or the parsed value is greater than the max value, + // then we should signal the segment group that this segment is done and it can move to the next segment + if (currentInput.length >= maxLength || parsed * 10 > max) { + onDone(); + } + }, + onBlur() { + onTouched(); + const { min, max } = getMetadata(); + if (isNullOrUndefined(min) || isNullOrUndefined(max)) { + return; + } + + const parsed = parser.parse(segmentEl.value?.textContent || ''); + if (!Number.isNaN(parsed) && parsed >= min && parsed <= max) { + setValue(parsed); + } + + // Reset the current input when the segment is blurred + currentInput = ''; + }, + onKeydown(evt: KeyboardEvent) { + if (toValue(props.readonly) || isDisabled.value) { + return; + } + + if (hasKeyCode(evt, 'Enter')) { + blockEvent(evt); + focusNext(); + return; + } + + if (hasKeyCode(evt, 'ArrowUp')) { + blockEvent(evt); + if (!isNonEditable()) { + increment(); + } + return; + } + + if (hasKeyCode(evt, 'ArrowDown')) { + blockEvent(evt); + if (!isNonEditable()) { + decrement(); + } + return; + } + + if (hasKeyCode(evt, 'Backspace') || hasKeyCode(evt, 'Delete')) { + blockEvent(evt); + if (!isNonEditable()) { + clear(); + } + } + }, + }; + + const segmentProps = computed(() => { + const domProps: DateTimeSegmentDomProps = { + id, + tabindex: isNonEditable() ? -1 : 0, + contenteditable: isNonEditable() ? undefined : 'plaintext-only', + 'aria-disabled': isDisabled.value, + 'data-segment-type': toValue(props.type), + 'aria-label': isNonEditable() ? undefined : toValue(props.type), + 'aria-readonly': toValue(props.readonly) ? true : undefined, + autocorrect: isNonEditable() ? undefined : 'off', + spellcheck: isNonEditable() ? undefined : false, + enterkeyhint: isNonEditable() ? undefined : isLast() ? 'done' : 'next', + inputmode: 'none', + ...handlers, + style: { + caretColor: 'transparent', + }, + }; + + if (isNumeric()) { + const { min, max } = getMetadata(); + const value = parser.parse(toValue(props.value)); + domProps.role = 'spinbutton'; + domProps.inputmode = 'numeric'; + domProps['aria-valuemin'] = min ?? undefined; + domProps['aria-valuemax'] = max ?? undefined; + domProps['aria-valuenow'] = Number.isNaN(value) ? undefined : value; + domProps['aria-valuetext'] = Number.isNaN(value) ? 'Empty' : value.toString(); + } + + if (isNonEditable()) { + domProps.style.pointerEvents = 'none'; + } + + return withRefCapture(domProps, segmentEl); + }); + + return { + segmentProps, + }; +} + +export const DateTimeSegment = defineComponent({ + name: 'DateTimeSegment', + props: ['type', 'value', 'disabled', 'readonly'], + setup(props) { + const { segmentProps } = useDateTimeSegment(props); + + return () => h('span', segmentProps.value, props.value); + }, +}); diff --git a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts new file mode 100644 index 00000000..b9bebeed --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.spec.ts @@ -0,0 +1,763 @@ +import { DateFormatter, now } from '@internationalized/date'; +import { useDateTimeSegmentGroup } from './useDateTimeSegmentGroup'; +import { ref } from 'vue'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { flush } from '@test-utils/flush'; +import { DateTimeSegment } from './useDateTimeSegment'; +import { createTemporalPartial, isTemporalPartial } from './temporalPartial'; + +describe('useDateTimeSegmentGroup', () => { + const timeZone = 'UTC'; + const locale = 'en-US'; + const currentDate = now(timeZone); + + function createFormatter() { + return new DateFormatter(locale, { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }); + } + + describe('segment registration', () => { + test('registers and unregisters segments', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + setup() { + const { segments, useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + // Register a segment + const segment = { + id: 'test-segment', + getType: () => 'day' as const, + getElem: () => document.createElement('div'), + }; + + const registration = useDateSegmentRegistration(segment); + + return { + segments, + registration, + }; + }, + template: ` +
+
+ {{ segment.value }} +
+
+ `, + }); + + await flush(); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('segment navigation', () => { + test('handles keyboard navigation between segments', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment').filter(el => el.dataset.segmentType !== 'literal'); + segments[0].focus(); + + // Test right arrow navigation + await fireEvent.keyDown(segments[0], { code: 'ArrowRight' }); + expect(document.activeElement).toBe(segments[1]); + + // Test left arrow navigation + await fireEvent.keyDown(segments[1], { code: 'ArrowLeft' }); + expect(document.activeElement).toBe(segments[0]); + }); + + test('respects RTL direction', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + direction: 'rtl', + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment').filter(el => el.dataset.segmentType !== 'literal'); + segments[0].focus(); + + // Test right arrow navigation (should go left in RTL) + await fireEvent.keyDown(segments[1], { code: 'ArrowRight' }); + expect(document.activeElement).toBe(segments[0]); + + // Test left arrow navigation (should go right in RTL) + await fireEvent.keyDown(segments[0], { code: 'ArrowLeft' }); + expect(document.activeElement).toBe(segments[1]); + }); + }); + + describe('value updates', () => { + test('increments segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.increment(); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ months: 1 })); + }); + + test('decrements segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.decrement(); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ months: 1 })); + }); + + test('sets specific segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.setValue(6); + expect(onValueChange).toHaveBeenCalledWith(currentDate.set({ month: 6 })); + }); + + test('clears segment values', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + let monthRegistration!: ReturnType['useDateSegmentRegistration']>; + + await render({ + setup() { + const { useDateSegmentRegistration } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + const segment = { + id: 'month-segment', + getType: () => 'month' as const, + getElem: () => document.createElement('div'), + }; + + monthRegistration = useDateSegmentRegistration(segment) as any; + + return {}; + }, + template: '
', + }); + + monthRegistration.clear() as any; + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].month).toBe(false); + }); + }); + + describe('formatting', () => { + test('formats segments according to locale', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale: 'de-DE', + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + }; + }, + template: ` +
+ + {{ segment.value }} + +
+ `, + }); + + await flush(); + const monthSegment = document.querySelector('[data-testid="month"]'); + expect(monthSegment?.textContent?.trim()).toBe(currentDate.month.toString()); + }); + }); + + describe('segment input handling', () => { + test('handles numeric input', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + fireEvent.focus(monthSegment); + + // Test valid numeric input + const inputEvent = new InputEvent('beforeinput', { data: '1', cancelable: true }); + fireEvent(monthSegment, inputEvent); + expect(monthSegment.textContent).toBe('1'); + + // Test input completion on max length + const secondInputEvent = new InputEvent('beforeinput', { data: '2', cancelable: true }); + fireEvent(monthSegment, secondInputEvent); + expect(monthSegment.textContent).toBe('12'); + expect(document.activeElement).not.toBe(monthSegment); // Should move to next segment + + // Test invalid input (out of range) + fireEvent.focus(monthSegment); + const invalidInputEvent = new InputEvent('beforeinput', { data: '13', cancelable: true }); + fireEvent(monthSegment, invalidInputEvent); + expect(monthSegment.textContent).not.toBe('13'); + }); + + test('handles keyboard navigation and actions', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + + // Test increment with arrow up + await fireEvent.keyDown(monthSegment, { code: 'ArrowUp' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ months: 1 })); + + // Test decrement with arrow down + await fireEvent.keyDown(monthSegment, { code: 'ArrowDown' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ months: 1 })); + + // Test clearing with backspace + await fireEvent.keyDown(monthSegment, { code: 'Backspace' }); + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].month).toBe(false); + + // Test clearing with delete + await fireEvent.keyDown(monthSegment, { code: 'Delete' }); + const finalCall = onValueChange.mock.lastCall?.[0]; + expect(finalCall['~fw_temporal_partial'].month).toBe(false); + }); + + test('handles non-numeric input', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: {}, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + + // Test non-numeric input + const nonNumericEvent = new InputEvent('beforeinput', { data: 'a', cancelable: true }); + fireEvent(monthSegment, nonNumericEvent); + expect(nonNumericEvent.defaultPrevented).toBe(true); + expect(monthSegment.textContent).not.toBe('a'); + }); + + test('handles non-numeric segments (dayPeriod)', async () => { + const formatter = ref( + new DateFormatter(locale, { + hour: 'numeric', + hour12: true, + dayPeriod: 'short', + }), + ); + const controlEl = ref(); + const onValueChange = vi.fn(); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: currentDate, + formatOptions: { hour12: true }, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + const dayPeriodSegment = segments.find(el => el.dataset.segmentType === 'dayPeriod')!; + dayPeriodSegment.focus(); + + // Test numeric input is blocked + const inputEvent = new InputEvent('beforeinput', { data: '1', cancelable: true }); + fireEvent(dayPeriodSegment, inputEvent); + expect(inputEvent.defaultPrevented).toBe(true); + expect(dayPeriodSegment.textContent).not.toBe('1'); + + // Test arrow up changes period (AM -> PM) + await fireEvent.keyDown(dayPeriodSegment, { code: 'ArrowUp' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.add({ hours: 12 }).set({ day: currentDate.day })); + + // Test arrow down changes period (PM -> AM) + await fireEvent.keyDown(dayPeriodSegment, { code: 'ArrowDown' }); + expect(onValueChange).toHaveBeenCalledWith(currentDate.subtract({ hours: 12 }).set({ day: currentDate.day })); + + // Test clearing with backspace + await fireEvent.keyDown(dayPeriodSegment, { code: 'Backspace' }); + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(lastCall['~fw_temporal_partial'].dayPeriod).toBe(false); + + // Test clearing with delete + await fireEvent.keyDown(dayPeriodSegment, { code: 'Delete' }); + const finalCall = onValueChange.mock.lastCall?.[0]; + expect(finalCall['~fw_temporal_partial'].dayPeriod).toBe(false); + }); + + test.fails('converts to non-partial when all segments are filled', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + const initialDate = currentDate.set({ year: 2024, month: 1, day: 1 }); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: createTemporalPartial(initialDate.calendar, initialDate.timeZone), + formatOptions: { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + + // Fill in month segment + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + fireEvent.focus(monthSegment); + const monthInput = new InputEvent('beforeinput', { data: '3', cancelable: true }); + fireEvent(monthSegment, monthInput); + fireEvent.blur(monthSegment); + await flush(); + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + '~fw_temporal_partial': { + month: true, + }, + }), + ); + + // Fill in day segment + const daySegment = segments.find(el => el.dataset.segmentType === 'day')!; + fireEvent.focus(daySegment); + fireEvent.keyDown(daySegment, { code: 'ArrowUp' }); + await flush(); + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + '~fw_temporal_partial': { + month: true, + day: true, + }, + }), + ); + + // Fill in year segment + const yearSegment = segments.find(el => el.dataset.segmentType === 'year')!; + fireEvent.focus(yearSegment); + const yearInput = new InputEvent('beforeinput', { data: '2024', cancelable: true }); + fireEvent(yearSegment, yearInput); + fireEvent.blur(yearSegment); + await flush(); + expect(onValueChange).toHaveBeenLastCalledWith( + expect.objectContaining({ + '~fw_temporal_partial': { + month: true, + day: true, + year: true, + }, + }), + ); + // Verify final call is not a partial + const finalCall = onValueChange.mock.lastCall?.[0]; + expect(isTemporalPartial(finalCall)).toBe(false); + expect(finalCall.toString()).toBe(initialDate.set({ month: 3, day: 5 }).toString()); + }); + + test('preserves partial state when not all segments are filled', async () => { + const formatter = ref(createFormatter()); + const controlEl = ref(); + const onValueChange = vi.fn(); + const initialDate = currentDate.set({ year: 2024, month: 1, day: 1 }); + + await render({ + components: { + DateTimeSegment, + }, + setup() { + const { segments } = useDateTimeSegmentGroup({ + formatter, + temporalValue: createTemporalPartial(initialDate.calendar, initialDate.timeZone), + formatOptions: { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }, + locale, + controlEl, + onValueChange, + onTouched: () => {}, + }); + + return { + segments, + controlEl, + }; + }, + template: ` +
+ +
+ `, + }); + + await flush(); + const segments = screen.getAllByTestId('segment'); + + // Fill in only month and day segments + const monthSegment = segments.find(el => el.dataset.segmentType === 'month')!; + monthSegment.focus(); + const monthInput = new InputEvent('beforeinput', { data: '3', cancelable: true }); + monthSegment.dispatchEvent(monthInput); + + const daySegment = segments.find(el => el.dataset.segmentType === 'day')!; + daySegment.focus(); + const dayInput = new InputEvent('beforeinput', { data: '5', cancelable: true }); + daySegment.dispatchEvent(dayInput); + + // Verify the value is still a partial since year is not set + const lastCall = onValueChange.mock.lastCall?.[0]; + expect(isTemporalPartial(lastCall)).toBe(true); + expect(lastCall['~fw_temporal_partial']).toEqual({ + day: true, + }); + }); + }); +}); diff --git a/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts new file mode 100644 index 00000000..313bf94e --- /dev/null +++ b/packages/core/src/useDateTimeField/useDateTimeSegmentGroup.ts @@ -0,0 +1,439 @@ +import { InjectionKey, MaybeRefOrGetter, provide, ref, toValue, Ref, onBeforeUnmount, computed } from 'vue'; +import { DateTimeSegmentType, TemporalPartial } from './types'; +import { hasKeyCode } from '../utils/common'; +import { blockEvent } from '../utils/events'; +import { Direction, Maybe } from '../types'; +import { useEventListener } from '../helpers/useEventListener'; +import { + getSegmentTypePlaceholder, + isEditableSegmentType, + isNumericByDefault, + isOptionalSegmentType, + segmentTypeToDurationLike, +} from './constants'; +import { NumberParserContext, useNumberParser } from '../i18n'; +import { isTemporalPartial, isTemporalPartSet, toTemporalPartial } from './temporalPartial'; +import { ZonedDateTime, DateFormatter } from '@internationalized/date'; + +export interface DateTimeSegmentRegistration { + id: string; + getType(): DateTimeSegmentType; + getElem(): HTMLElement | undefined; +} + +export interface DateTimeSegmentGroupContext { + useDateSegmentRegistration(segment: DateTimeSegmentRegistration): { + parser: NumberParserContext; + increment(): void; + decrement(): void; + setValue(value: number): void; + getMetadata(): { min: number | null; max: number | null; maxLength: number | null }; + onDone(): void; + clear(): void; + isNumeric(): boolean; + onTouched(): void; + isLast(): boolean; + focusNext(): void; + }; +} + +export const DateTimeSegmentGroupKey: InjectionKey = Symbol('DateTimeSegmentGroupKey'); + +export interface DateTimeSegmentGroupProps { + formatter: Ref; + locale: MaybeRefOrGetter; + formatOptions: MaybeRefOrGetter>; + temporalValue: MaybeRefOrGetter; + direction?: MaybeRefOrGetter; + controlEl: Ref; + readonly?: MaybeRefOrGetter; + min?: MaybeRefOrGetter>; + max?: MaybeRefOrGetter>; + onValueChange: (value: ZonedDateTime) => void; + onTouched: () => void; +} + +export function useDateTimeSegmentGroup({ + formatter, + temporalValue, + formatOptions, + direction, + locale, + controlEl, + readonly, + min, + max, + onValueChange, + onTouched, +}: DateTimeSegmentGroupProps) { + const renderedSegments = ref([]); + const parser = useNumberParser(locale, { + maximumFractionDigits: 0, + useGrouping: false, + }); + + const { setPart, addToPart } = useDateArithmetic({ + currentDate: temporalValue, + min, + max, + }); + + const segments = computed(() => { + const date = toValue(temporalValue); + let parts = formatter.value.formatToParts(date.toDate()) as { + type: DateTimeSegmentType; + value: string; + }[]; + + if (isTemporalPartial(date)) { + for (const part of parts) { + if (!isEditableSegmentType(part.type)) { + continue; + } + + if (!isTemporalPartSet(date, part.type)) { + part.value = getSegmentTypePlaceholder(part.type) ?? part.value; + } + } + } + + if (toValue(readonly)) { + parts = parts.map(part => { + return { + ...part, + readonly: true, + }; + }); + } + + return parts; + }); + + function getRequiredParts() { + return segments.value + .filter(part => { + return isEditableSegmentType(part.type) || isOptionalSegmentType(part.type); + }) + .map(part => part.type); + } + + function isAllPartsSet(value: TemporalPartial) { + return segments.value.every(part => { + if (!isEditableSegmentType(part.type) || isOptionalSegmentType(part.type)) { + return true; + } + + return isTemporalPartSet(value, part.type); + }); + } + + function withAllPartsSet(value: ZonedDateTime) { + if (isTemporalPartial(value) && isAllPartsSet(value)) { + return value.copy(); // clones the value and drops the partial flag + } + + return value; + } + + function onSegmentDone() { + focusNextSegment(); + } + + function useDateSegmentRegistration(segment: DateTimeSegmentRegistration) { + renderedSegments.value.push(segment); + onBeforeUnmount(() => { + renderedSegments.value = renderedSegments.value.filter(s => s.id !== segment.id); + }); + + function increment() { + const type = segment.getType(); + const date = addToPart(type, 1); + + onValueChange(withAllPartsSet(date)); + } + + function decrement() { + const type = segment.getType(); + const date = addToPart(type, -1); + + onValueChange(withAllPartsSet(date)); + } + + function setValue(value: number) { + const type = segment.getType(); + const date = setPart(type, value); + + onValueChange(withAllPartsSet(date)); + } + + function getMetadata() { + const type = segment.getType(); + const date = toValue(temporalValue); + const maxPartsRecord: Partial> = { + day: date.calendar.getDaysInMonth(date), + month: date.calendar.getMonthsInYear(date), + year: 9999, + hour: toValue(formatOptions)?.hour12 ? 12 : 23, + minute: 59, + second: 59, + }; + + const minPartsRecord: Partial> = { + day: 1, + month: 1, + year: 0, + hour: 0, + minute: 0, + second: 0, + }; + + const maxLengths: Partial> = { + day: 2, + month: 2, + year: 4, + hour: 2, + minute: 2, + second: 2, + }; + + return { + min: minPartsRecord[type] ?? null, + max: maxPartsRecord[type] ?? null, + maxLength: maxLengths[type] ?? null, + }; + } + + function clear() { + const type = segment.getType(); + const date = toValue(temporalValue); + const next = toTemporalPartial(date, !isTemporalPartial(date) ? getRequiredParts() : []); + next['~fw_temporal_partial'][type] = false; + + onValueChange(next); + } + + function isNumeric() { + const type = segment.getType(); + const options = toValue(formatOptions); + if (type === 'literal') { + return false; + } + + const optionFormat = options?.[type]; + if (!optionFormat) { + return isNumericByDefault(type); + } + + return optionFormat === 'numeric' || optionFormat === '2-digit'; + } + + function isLast() { + return renderedSegments.value.at(-1)?.id === segment.id; + } + + function focusNext() { + if (isLast()) { + return; + } + + focusNextSegment(); + } + + return { + increment, + decrement, + setValue, + parser, + onSegmentDone, + getMetadata, + onDone: onSegmentDone, + clear, + onTouched, + isLast, + focusNext, + isNumeric, + }; + } + + function focusBasedOnDirection(evt: KeyboardEvent) { + const dir = toValue(direction) ?? 'ltr'; + if (hasKeyCode(evt, 'ArrowLeft')) { + return dir === 'ltr' ? focusPreviousSegment() : focusNextSegment(); + } + + if (hasKeyCode(evt, 'ArrowRight')) { + return dir === 'ltr' ? focusNextSegment() : focusPreviousSegment(); + } + } + + function getFocusedSegment() { + return renderedSegments.value.find(s => s.getElem() === document.activeElement); + } + + function getSegmentElements() { + return Array.from(controlEl.value?.querySelectorAll('[data-segment-type]') || []); + } + + function focusNextSegment() { + const focusedElement = getFocusedSegment()?.getElem(); + if (!focusedElement) { + return; + } + + const segmentElements = getSegmentElements(); + const currentIndex = segmentElements.indexOf(focusedElement); + const nextIndex = currentIndex + 1; + for (let i = nextIndex; i < segmentElements.length; i++) { + const element = segmentElements[i] as HTMLElement; + if (element.tabIndex === 0) { + element.focus(); + return; + } + } + } + + function focusPreviousSegment() { + const focusedElement = getFocusedSegment()?.getElem(); + if (!focusedElement) { + return; + } + + const segmentElements = getSegmentElements(); + const currentIndex = segmentElements.indexOf(focusedElement); + const previousIndex = currentIndex - 1; + for (let i = previousIndex; i >= 0; i--) { + const element = segmentElements[i] as HTMLElement; + if (element.tabIndex === 0) { + element.focus(); + return; + } + } + } + + function onKeydown(evt: KeyboardEvent) { + if (hasKeyCode(evt, 'ArrowLeft') || hasKeyCode(evt, 'ArrowRight')) { + blockEvent(evt); + focusBasedOnDirection(evt); + return; + } + } + + useEventListener(controlEl, 'keydown', onKeydown); + + provide(DateTimeSegmentGroupKey, { + useDateSegmentRegistration, + }); + + return { + segments, + useDateSegmentRegistration, + }; +} + +interface ArithmeticInit { + currentDate: MaybeRefOrGetter; + min?: MaybeRefOrGetter>; + max?: MaybeRefOrGetter>; +} + +function useDateArithmetic({ currentDate, min, max }: ArithmeticInit) { + function clampDate(date: ZonedDateTime) { + const minDate = toValue(min); + const maxDate = toValue(max); + + if (minDate && date.compare(minDate) < 0) { + return toValue(currentDate); + } + + if (maxDate && date.compare(maxDate) > 0) { + return toValue(currentDate); + } + + return date; + } + + function setPart(part: DateTimeSegmentType, value: number) { + const date = toValue(currentDate); + if (!isEditableSegmentType(part)) { + return date; + } + + if (part === 'dayPeriod') { + return date; + } + + const newDate = date.set({ + [part]: value, + }); + + if (isTemporalPartial(date)) { + (newDate as TemporalPartial)['~fw_temporal_partial'] = { + ...date['~fw_temporal_partial'], + [part]: true, + }; + } + + return clampDate(newDate); + } + + function addToPart(part: DateTimeSegmentType, diff: number) { + const date = toValue(currentDate); + if (!isEditableSegmentType(part)) { + return date; + } + + if (part === 'dayPeriod') { + diff = diff * 12; + } + + const durationPart = segmentTypeToDurationLike(part); + if (!durationPart) { + return date; + } + + if (isTemporalPartial(date)) { + let newDate: ZonedDateTime | TemporalPartial = date; + if (isTemporalPartSet(date, part)) { + newDate = date.add({ + [durationPart]: diff, + }); + } else { + newDate = + part === 'dayPeriod' + ? date + : date.set({ + [part]: part === 'year' ? date.year : 1, + }); + } + + (newDate as TemporalPartial)['~fw_temporal_partial'] = { + ...date['~fw_temporal_partial'], + [part]: true, + }; + + return clampDate(newDate); + } + + // Preserves the day, month, and year when adding to the part so it doesn't overflow. + const day = date.day; + const month = date.month; + const year = date.year; + + return clampDate( + date + .add({ + [durationPart]: diff, + }) + .set({ + day: part !== 'day' && part !== 'weekday' ? day : undefined, + month: part !== 'month' ? month : undefined, + year: part !== 'year' ? year : undefined, + }), + ); + } + + return { + setPart, + addToPart, + }; +} diff --git a/packages/core/src/useDateTimeField/useTemporalStore.spec.ts b/packages/core/src/useDateTimeField/useTemporalStore.spec.ts new file mode 100644 index 00000000..13451692 --- /dev/null +++ b/packages/core/src/useDateTimeField/useTemporalStore.spec.ts @@ -0,0 +1,259 @@ +import { createCalendar, fromDate, now } from '@internationalized/date'; +import { useTemporalStore } from './useTemporalStore'; +import { createTemporalPartial, isTemporalPartial } from './temporalPartial'; +import { ref } from 'vue'; +import { Maybe } from '../types'; +import { flush } from '@test-utils/flush'; +import { vi } from 'vitest'; + +describe('useTemporalStore', () => { + const calendar = createCalendar('gregory'); + const timeZone = 'UTC'; + const locale = 'en-US'; + + describe('initialization', () => { + test('initializes with Date value', () => { + const date = new Date(); + const store = useTemporalStore({ + model: { + get: () => date, + }, + calendar, + timeZone, + locale, + }); + + expect(store.value.toDate()).toEqual(date); + expect(isTemporalPartial(store.value)).toBe(false); + }); + + test('initializes with ZonedDateTime value', () => { + const date = now(timeZone); + const store = useTemporalStore({ + model: { + get: () => date.toDate(), + }, + calendar, + timeZone, + locale, + }); + + expect(store.value.toString()).toBe(date.toString()); + expect(isTemporalPartial(store.value)).toBe(false); + }); + + test('initializes with null value as temporal partial', () => { + const store = useTemporalStore({ + model: { + get: () => null, + }, + calendar, + timeZone, + locale, + }); + + expect(isTemporalPartial(store.value)).toBe(true); + expect(store.value.timeZone).toBe(timeZone); + expect(store.value.calendar.identifier).toBe(calendar.identifier); + }); + }); + + describe('model updates', () => { + test('updates when model value changes', async () => { + const modelValue = ref>(null); + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => (modelValue.value = value), + }, + calendar, + timeZone, + locale, + }); + + const newDate = new Date(); + modelValue.value = newDate; + await flush(); + + expect(store.value.toDate()).toEqual(newDate); + expect(isTemporalPartial(store.value)).toBe(false); + }); + + test('preserves temporal partial when model is null', async () => { + const modelValue = ref>(null); + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => (modelValue.value = value), + }, + calendar, + timeZone, + locale, + }); + + // Initial state is temporal partial + expect(isTemporalPartial(store.value)).toBe(true); + + // Update model to null + modelValue.value = null; + await flush(); + + // Should still be temporal partial + expect(isTemporalPartial(store.value)).toBe(true); + }); + + test('updates model when store value changes', () => { + const modelValue = ref>(null); + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => (modelValue.value = value), + }, + calendar, + timeZone, + locale, + }); + + const newDate = now(timeZone); + store.value = newDate; + + expect(modelValue.value).toEqual(newDate.toDate()); + }); + + test('sets model to undefined when store value is temporal partial', () => { + const modelValue = ref>(new Date()); + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => (modelValue.value = value), + }, + calendar, + timeZone, + locale, + }); + + // Change to temporal partial + store.value = createTemporalPartial(calendar, timeZone); + + expect(modelValue.value).toBeUndefined(); + }); + }); + + describe('date conversion', () => { + test('converts between Date and ZonedDateTime', () => { + const date = new Date(); + const store = useTemporalStore({ + model: { + get: () => date, + }, + calendar, + timeZone, + locale, + }); + + const expectedZonedDateTime = fromDate(date, timeZone); + expect(store.value.toString()).toBe(expectedZonedDateTime.toString()); + }); + + test('handles different calendar systems', () => { + const islamicCalendar = createCalendar('islamic-umalqura'); + const date = new Date(); + const store = useTemporalStore({ + model: { + get: () => date, + }, + calendar: islamicCalendar, + timeZone, + locale, + }); + + expect(store.value.calendar.identifier).toBe('islamic-umalqura'); + expect(store.value.toDate()).toEqual(date); + }); + + test('updates model with correct date when temporal value changes', () => { + const modelValue = ref>(new Date('2024-01-01T00:00:00Z')); + const onModelSet = vi.fn(); + + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => { + modelValue.value = value; + onModelSet(value); + }, + }, + calendar, + timeZone, + locale, + }); + + // Change year + store.value = store.value.set({ year: 2025 }); + expect(onModelSet).toHaveBeenLastCalledWith(new Date('2025-01-01T00:00:00Z')); + + // Change month + store.value = store.value.set({ month: 6 }); + expect(onModelSet).toHaveBeenLastCalledWith(new Date('2025-06-01T00:00:00Z')); + + // Change day + store.value = store.value.set({ day: 15 }); + expect(onModelSet).toHaveBeenLastCalledWith(new Date('2025-06-15T00:00:00Z')); + }); + + test('preserves time when updating date parts', () => { + const modelValue = ref>(new Date('2024-01-01T14:30:45Z')); + const onModelSet = vi.fn(); + + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => { + modelValue.value = value; + onModelSet(value); + }, + }, + calendar, + timeZone, + locale, + }); + + // Change date parts + store.value = store.value.set({ year: 2025, month: 6, day: 15 }); + + // Verify time components are preserved + const expectedDate = new Date('2025-06-15T14:30:45Z'); + expect(onModelSet).toHaveBeenLastCalledWith(expectedDate); + expect(modelValue.value?.getUTCHours()).toBe(14); + expect(modelValue.value?.getUTCMinutes()).toBe(30); + expect(modelValue.value?.getUTCSeconds()).toBe(45); + }); + + test('handles timezone conversions correctly', () => { + const modelValue = ref>(new Date('2024-01-01T00:00:00Z')); + const onModelSet = vi.fn(); + const timeZoneRef = ref('UTC'); + + const store = useTemporalStore({ + model: { + get: () => modelValue.value, + set: value => { + modelValue.value = value; + onModelSet(value); + }, + }, + calendar, + timeZone: timeZoneRef, + locale, + }); + + // Change timezone + timeZoneRef.value = 'America/New_York'; + store.value = store.value.set({ hour: 12 }); // Set to noon NY time + + // Verify the UTC time in the model is correctly adjusted + const lastSetDate = onModelSet.mock.lastCall?.[0] as Date; + expect(lastSetDate.getUTCHours()).toBe(12); // noon NY = 5pm UTC + }); + }); +}); diff --git a/packages/core/src/useDateTimeField/useTemporalStore.ts b/packages/core/src/useDateTimeField/useTemporalStore.ts new file mode 100644 index 00000000..1f66156e --- /dev/null +++ b/packages/core/src/useDateTimeField/useTemporalStore.ts @@ -0,0 +1,94 @@ +import { MaybeRefOrGetter, computed, shallowRef, toValue, watch } from 'vue'; +import { DateValue, TemporalPartial } from './types'; +import { Maybe } from '../types'; +import { isNullOrUndefined } from '../utils/common'; +import { createTemporalPartial, isTemporalPartial } from './temporalPartial'; +import { Calendar, fromDate, toCalendar, toTimeZone, type ZonedDateTime } from '@internationalized/date'; + +interface TemporalValueStoreInit { + model: { + get: () => Maybe; + set?: (value: Maybe) => void; + }; + locale: MaybeRefOrGetter; + timeZone: MaybeRefOrGetter; + calendar: MaybeRefOrGetter; + allowPartial?: boolean; +} + +export function useTemporalStore(init: TemporalValueStoreInit) { + const model = init.model; + function normalizeNullish(value: Maybe): ZonedDateTime | TemporalPartial { + if (isNullOrUndefined(value)) { + return createTemporalPartial(toValue(init.calendar), toValue(init.timeZone)); + } + + return value; + } + + const temporalVal = shallowRef( + normalizeNullish(fromDateToCalendarZonedDateTime(model.get(), toValue(init.calendar), toValue(init.timeZone))), + ); + + watch(model.get, value => { + if (!value && isTemporalPartial(temporalVal.value)) { + return; + } + + temporalVal.value = normalizeNullish( + fromDateToCalendarZonedDateTime(value, toValue(init.calendar), toValue(init.timeZone)), + ); + }); + + function toDate(value: Maybe): Maybe { + if (isNullOrUndefined(value)) { + return value; + } + + if (value instanceof Date) { + return value; + } + + const zonedDateTime = toZonedDateTime(value, toValue(init.timeZone)); + if (!zonedDateTime) { + return zonedDateTime; + } + + return zonedDateTime.toDate(); + } + + const temporalValue = computed({ + get: () => temporalVal.value, + set: value => { + temporalVal.value = value; + model.set?.(isTemporalPartial(value) ? undefined : toDate(value)); + }, + }); + + return temporalValue; +} + +export function fromDateToCalendarZonedDateTime( + date: Maybe, + calendar: Calendar, + timeZone: string, +): ZonedDateTime | null | undefined { + const zonedDt = toZonedDateTime(date, timeZone); + if (!zonedDt) { + return zonedDt; + } + + return toCalendar(toTimeZone(zonedDt, timeZone), calendar); +} + +export function toZonedDateTime(value: Maybe, timeZone: string): Maybe { + if (isNullOrUndefined(value)) { + return value; + } + + if (value instanceof Date) { + value = fromDate(value, timeZone); + } + + return value; +} diff --git a/packages/core/src/useNumberField/useNumberField.ts b/packages/core/src/useNumberField/useNumberField.ts index d754bcf7..ad2871c5 100644 --- a/packages/core/src/useNumberField/useNumberField.ts +++ b/packages/core/src/useNumberField/useNumberField.ts @@ -1,4 +1,4 @@ -import { Ref, computed, nextTick, shallowRef, toValue } from 'vue'; +import { Ref, computed, nextTick, shallowRef, toValue, watch } from 'vue'; import { createDescribedByProps, fromNumberish, @@ -144,8 +144,9 @@ export function useNumberField( const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.NumberField); const inputEl = elementRef || shallowRef(); - const { locale } = useLocale(); - const parser = useNumberParser(() => toValue(props.locale) ?? locale.value, props.formatOptions); + const { locale } = useLocale(props.locale); + const parser = useNumberParser(locale, props.formatOptions); + const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue) ?? fromNumberish(props.value), @@ -153,19 +154,28 @@ export function useNumberField( schema: props.schema, }); + const formattedText = shallowRef(''); const { validityDetails, updateValidity } = useInputValidity({ inputEl, field, disableHtmlValidation: props.disableHtmlValidation, }); + const { fieldValue, setValue, setTouched, errorMessage, isDisabled } = field; - const formattedText = computed(() => { - if (Number.isNaN(fieldValue.value) || isEmpty(fieldValue.value)) { - return ''; - } - return parser.format(fieldValue.value); - }); + watch( + [locale, () => toValue(props.formatOptions), fieldValue], + () => { + if (Number.isNaN(fieldValue.value) || isEmpty(fieldValue.value)) { + formattedText.value = ''; + + return; + } + + formattedText.value = parser.format(fieldValue.value); + }, + { immediate: true }, + ); const { labelProps, labelledByProps } = useLabel({ for: inputId, diff --git a/packages/core/src/usePicker/index.ts b/packages/core/src/usePicker/index.ts new file mode 100644 index 00000000..8867b723 --- /dev/null +++ b/packages/core/src/usePicker/index.ts @@ -0,0 +1 @@ +export * from './usePicker'; diff --git a/packages/core/src/usePicker/usePicker.ts b/packages/core/src/usePicker/usePicker.ts new file mode 100644 index 00000000..360908d2 --- /dev/null +++ b/packages/core/src/usePicker/usePicker.ts @@ -0,0 +1,78 @@ +import { computed, InjectionKey, provide, ref, toValue } from 'vue'; +import { usePopoverController } from '../helpers/usePopoverController'; +import { createDisabledContext } from '../helpers/createDisabledContext'; +import { normalizeProps, withRefCapture } from '../utils/common'; +import { Reactivify } from '../types'; +import { useControlButtonProps } from '../helpers/useControlButtonProps'; + +export interface PickerProps { + /** + * Whether the picker is disabled. + */ + disabled?: boolean; + + /** + * The label for the picker. + */ + label: string; +} + +export interface PickerContext { + isOpen: () => boolean; + close: () => void; +} + +export const PickerContextKey: InjectionKey = Symbol('PickerContext'); + +export function usePicker(_props: Reactivify) { + const props = normalizeProps(_props); + const pickerEl = ref(); + const disabled = createDisabledContext(props.disabled); + + const { isOpen } = usePopoverController(pickerEl, { disabled }); + + const pickerProps = computed(() => { + return withRefCapture( + { + role: 'dialog', + 'aria-modal': 'true' as const, + 'aria-label': toValue(props.label), + }, + pickerEl, + ); + }); + + function onOpen() { + isOpen.value = true; + } + + const pickerTriggerProps = useControlButtonProps(() => { + return { + 'aria-label': toValue(props.label), + disabled: disabled.value, + onClick: onOpen, + }; + }); + + provide(PickerContextKey, { + isOpen: () => isOpen.value, + close: () => (isOpen.value = false), + }); + + return { + /** + * Whether the picker should be open. + */ + isOpen, + + /** + * The props for the picker element. + */ + pickerProps, + + /** + * The props for the picker trigger element. + */ + pickerTriggerProps, + }; +} diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 6c22a436..a327d5ee 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -47,3 +47,9 @@ export function onlyMainMouseButton(cb: () => unknown) { } }; } + +export function blockEvent(evt: Event) { + evt.preventDefault(); + evt.stopPropagation(); + evt.stopImmediatePropagation(); +} diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index 86b5d551..e253f1f4 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -128,6 +128,11 @@ export function useInputValidity(opts: InputValidityOptions) { useEventListener(opts.inputEl, opts?.events || ['invalid'], () => validateNative(true)); } + // TODO: is this the best approach? + if (!opts.inputEl) { + watch(opts.field.fieldValue, updateValidity); + } + /** * Validity is always updated on mount. */ diff --git a/packages/playground/package.json b/packages/playground/package.json index 937682f3..78a9ff85 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "scripts": { - "dev": "vite", + "dev": "vite --host", "build": "vue-tsc && vite build", "preview": "vite preview" }, @@ -12,6 +12,7 @@ "@tailwindcss/postcss": "^4.0.6", "fuse.js": "^7.1.0", "tailwindcss": "^4.0.6", + "@internationalized/date": "^3.7.0", "vue": "^3.5.13", "vue-i18n": "^11.1.1", "yup": "^1.6.1", diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index d111d158..cf768d80 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,33 +1,18 @@ diff --git a/packages/playground/src/components/Calendar.vue b/packages/playground/src/components/Calendar.vue new file mode 100644 index 00000000..3db68181 --- /dev/null +++ b/packages/playground/src/components/Calendar.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/playground/src/components/DateField.vue b/packages/playground/src/components/DateField.vue new file mode 100644 index 00000000..3c578b38 --- /dev/null +++ b/packages/playground/src/components/DateField.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37affa97..af9c645a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,9 @@ importers: packages/core: dependencies: + '@internationalized/date': + specifier: ^3.7.0 + version: 3.7.0 '@standard-schema/spec': specifier: 1.0.0 version: 1.0.0 @@ -185,6 +188,9 @@ importers: '@formwerk/core': specifier: workspace:* version: link:../core + '@internationalized/date': + specifier: ^3.7.0 + version: 3.7.0 '@tailwindcss/postcss': specifier: ^4.0.6 version: 4.0.6 @@ -809,6 +815,9 @@ packages: resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==} engines: {node: '>=18.18'} + '@internationalized/date@3.7.0': + resolution: {integrity: sha512-VJ5WS3fcVx0bejE/YHfbDKR/yawZgKqn/if+oEeLqNwBtPzVB06olkfcnojTmEMX+gTpH+FlQ69SHNitJ8/erQ==} + '@intlify/core-base@11.1.1': resolution: {integrity: sha512-bb8gZvoeKExCI2r/NVCK9E4YyOkvYGaSCPxVZe8T0jz8aX+dHEOZWxK06Z/Y9mWRkJfBiCH4aOhDF1yr1t5J8Q==} engines: {node: '>= 16'} @@ -1125,6 +1134,9 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/node@4.0.6': resolution: {integrity: sha512-jb6E0WeSq7OQbVYcIJ6LxnZTeC4HjMvbzFBMCrQff4R50HBlo/obmYNk6V2GCUXDeqiXtvtrQgcIbT+/boB03Q==} @@ -4406,6 +4418,10 @@ snapshots: '@humanwhocodes/retry@0.4.1': {} + '@internationalized/date@3.7.0': + dependencies: + '@swc/helpers': 0.5.15 + '@intlify/core-base@11.1.1': dependencies: '@intlify/message-compiler': 11.1.1 @@ -4700,6 +4716,10 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@tailwindcss/node@4.0.6': dependencies: enhanced-resolve: 5.18.0 diff --git a/scripts/config.ts b/scripts/config.ts index 8c28a82e..fddd65c7 100644 --- a/scripts/config.ts +++ b/scripts/config.ts @@ -75,6 +75,7 @@ async function createConfig(pkg: keyof typeof pkgNameMap, format: ModuleFormat) 'klona', '@standard-schema/utils', '@standard-schema/spec', + '@internationalized/date', ].filter(Boolean) as string[], plugins: createPlugins({ version, pkg, format }), },