Skip to content

Commit

Permalink
feat: Implement useDateField with @internationalized/date (#132)
Browse files Browse the repository at this point in the history
* feat: draft date fields and segments

* feat: implement focus directionality

* feat: single increments

* feat: define which segments are incrementable

* fix: focus order when clicking not editable part

* feat: add temporals

* fix: maintain stability of the various segments

* feat: allow overriding locale config for ease of access

* feat(perf): optimize date formatter instantiation

* feat: basic calendar implementation

* feat: rework the locale to hold the calendar data

* feat: hook the calendar with the date field

* fix: auto add the calendar system to the given locale

* feat: extract the calendar as a prop on the date field for ease

* feat: optimizations around timezone and calendar instantiation

* feat: maintain the field's date value type

* fix: disallow interaction with literal segments

* feat: allow editing segment values with keyboard

* fix: prevent editing non numeric parts

* feat: auto focus the next segment if the current one is done

* fix: ensure model is synced with temporal value

* fix: re-resolve number parser after locale change

* Revert "fix: re-resolve number parser after locale change"

This reverts commit c7e114d.

* fix: re-resolve number parser after locale change

* feat: arrow key navigation of calendar

* feat: more shortcuts

* feat: next/previous month buttons and month/year label

* feat: build a control btn util

* fix: make it a prop factory instead

* feat: implement max and min date validation

* feat: implement placeholders

* fix: types

* fix: set date to undefined if a part is cleared

* feat: implement date panels

* fix: year panel label

* feat: make the next/previous buttons adapt to current panel type

* feat: make home/end, page up/down keys work with month and years panels

* dev: basic conversion to internationalized

* feat: use @internationalized/date instead

* chore: testing bundle-size

* chore: added changeset

* test: added calendar tests

* test: added tests for some date field utils

* test: added tests for datetime segment

* fix: timezone initialization when passing them to calendarProps

* fix: validation state of the useDateTimeField

* fix: validation should run whenever the date value changes

* fix: touched state

* fix: better numeric segment check and added keyboard hints

* fix: types

* feat: rename week days prop

* fix: aria labels for the grid

* feat: rename panel types and add allowed panel types option

* test: fix broken tests

* feat: added spinbutton role to the date segment

* refactor: make the calendar API more standalone

* refactor: promote the calendar composable to be a full field optionally

* refactor: reduce memory footprint with min/max dates

* fix: reset calendar cells to have zero time component

* fix: properly clamp the date with segments

* refactor: externalize picker logic

* feat: added aria props to picker logic

* fix: types and focused date logic

* fix: focused date should match selected

* fix: hook cells into the disabled context

* feat: auto inject the calendar direction

* fix: properly render days in their locale

* fix: completely block click event if the cell is disabled

* feat: disable the navigation if readonly

* test: added test case for readonly and disabled

* fix: direction support in segment groups and readonly/disabled support

* fix: name should not be required

* fix: switch to EG locale in example cause its my country
  • Loading branch information
logaretm authored Feb 19, 2025
1 parent 48c428a commit 20001c5
Show file tree
Hide file tree
Showing 47 changed files with 4,958 additions and 76 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-cups-destroy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@formwerk/core': minor
---

feat: implement useDateTimeField
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const FieldTypePrefixes = {
CustomField: 'cf',
ComboBox: 'cbx',
ListBox: 'lb',
DateTimeField: 'dtf',
DateTimeSegment: 'dts',
Calendar: 'cal',
} as const;

export const NOOP = () => {};
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/helpers/useControlButtonProps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './useControlButtonProps';
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>();

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;
}
11 changes: 11 additions & 0 deletions packages/core/src/i18n/getCalendar.ts
Original file line number Diff line number Diff line change
@@ -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';
}
6 changes: 3 additions & 3 deletions packages/core/src/i18n/getDirection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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 });
});
11 changes: 5 additions & 6 deletions packages/core/src/i18n/getDirection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/i18n/getTimezone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getTimeZone(locale: Intl.Locale) {
return new Intl.DateTimeFormat(locale).resolvedOptions().timeZone;
}
26 changes: 26 additions & 0 deletions packages/core/src/i18n/getWeekInfo.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions packages/core/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './getSiteLocale';
export * from './useLocale';
export * from './useNumberParser';
export * from './checkLocaleMismatch';
export * from './useDateFormatter';
40 changes: 40 additions & 0 deletions packages/core/src/i18n/useDateFormatter.ts
Original file line number Diff line number Diff line change
@@ -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<string, DateFormatter>();

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<string | undefined>,
opts?: MaybeRefOrGetter<Intl.DateTimeFormatOptions | undefined>,
) {
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;
}
54 changes: 49 additions & 5 deletions packages/core/src/i18n/useLocale.ts
Original file line number Diff line number Diff line change
@@ -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<NumberLocaleExtension>;
calendar: Maybe<Calendar>;
timeZone: Maybe<string>;
}

/**
* 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<Maybe<string>>,
extensions: Partial<Reactivify<LocaleExtension>> = {},
) {
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 };
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useNumberParser } from '.';
import { useNumberParser } from './useNumberParser';

const enNumber = 1234567890.12;

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -57,7 +57,7 @@ interface NumberSymbols {
resolveNumber: (number: string) => string;
}

interface NumberParser {
export interface NumberParser {
formatter: Intl.NumberFormat;
options: Intl.ResolvedNumberFormatOptions;
locale: string;
Expand All @@ -67,6 +67,7 @@ interface NumberParser {
isValidNumberPart(value: string): boolean;
}

// TODO: May memory leak in SSR
const numberParserCache = new Map<string, NumberParser>();

function getParser(locale: string, options: Intl.NumberFormatOptions) {
Expand Down Expand Up @@ -206,6 +207,8 @@ export function defineNumberParser(locale: string, options: Intl.NumberFormatOpt
};
}

export type NumberParserContext = Pick<NumberParser, 'parse' | 'isValidNumberPart'>;

export function useNumberParser(
locale: MaybeRefOrGetter<string | undefined>,
opts?: MaybeRefOrGetter<Intl.NumberFormatOptions | undefined>,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/useCalendar/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { InjectionKey } from 'vue';
import { CalendarContext } from './types';

export const CalendarContextKey: InjectionKey<CalendarContext> = Symbol('CalendarContext');

export const YEAR_CELLS_COUNT = 9;

export const MONTHS_COLUMNS_COUNT = 3;

export const YEARS_COLUMNS_COUNT = 3;
4 changes: 4 additions & 0 deletions packages/core/src/useCalendar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './useCalendar';
export * from './types';
export * from './useCalendarCell';
export * from './useCalendarView';
53 changes: 53 additions & 0 deletions packages/core/src/useCalendar/types.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
weekInfo: Ref<WeekInfo>;
calendar: Ref<Calendar>;
timeZone: Ref<string>;
getSelectedDate: () => ZonedDateTime;
getMinDate: () => Maybe<ZonedDateTime>;
getMaxDate: () => Maybe<ZonedDateTime>;
getFocusedDate: () => ZonedDateTime;
setFocusedDate: (date: ZonedDateTime) => void;
setDate: (date: ZonedDateTime, view?: CalendarViewType) => void;
}
Loading

0 comments on commit 20001c5

Please sign in to comment.