Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement useDateField with @internationalized/date #132

Merged
merged 73 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
55d4faa
feat: draft date fields and segments
logaretm Jan 25, 2025
e180d33
feat: implement focus directionality
logaretm Jan 25, 2025
61a73a3
feat: single increments
logaretm Jan 25, 2025
f4a6a72
feat: define which segments are incrementable
logaretm Jan 25, 2025
bed22a9
fix: focus order when clicking not editable part
logaretm Jan 25, 2025
f6140d2
feat: add temporals
logaretm Jan 25, 2025
d289ae1
fix: maintain stability of the various segments
logaretm Jan 25, 2025
b165b8c
feat: allow overriding locale config for ease of access
logaretm Jan 25, 2025
adf998a
feat(perf): optimize date formatter instantiation
logaretm Jan 25, 2025
8f1b7b4
feat: basic calendar implementation
logaretm Jan 25, 2025
e70409d
feat: rework the locale to hold the calendar data
logaretm Jan 25, 2025
6fd548a
feat: hook the calendar with the date field
logaretm Jan 25, 2025
99824fb
fix: auto add the calendar system to the given locale
logaretm Jan 26, 2025
26fd002
feat: extract the calendar as a prop on the date field for ease
logaretm Jan 26, 2025
474bdbf
feat: optimizations around timezone and calendar instantiation
logaretm Jan 26, 2025
dd0b13f
feat: maintain the field's date value type
logaretm Jan 26, 2025
8ee98b1
fix: disallow interaction with literal segments
logaretm Jan 26, 2025
be59673
feat: allow editing segment values with keyboard
logaretm Jan 26, 2025
17e663a
fix: prevent editing non numeric parts
logaretm Jan 26, 2025
fcedd1d
feat: auto focus the next segment if the current one is done
logaretm Jan 26, 2025
39f133a
fix: ensure model is synced with temporal value
logaretm Jan 26, 2025
b4910ed
fix: re-resolve number parser after locale change
logaretm Jan 28, 2025
d70d793
Revert "fix: re-resolve number parser after locale change"
logaretm Jan 28, 2025
eefc1f9
fix: re-resolve number parser after locale change
logaretm Jan 28, 2025
850f356
feat: arrow key navigation of calendar
logaretm Feb 3, 2025
07a4b8a
feat: more shortcuts
logaretm Feb 4, 2025
eaeb2e7
feat: next/previous month buttons and month/year label
logaretm Feb 4, 2025
e1338e0
feat: build a control btn util
logaretm Feb 8, 2025
2544793
fix: make it a prop factory instead
logaretm Feb 8, 2025
0adb506
feat: implement max and min date validation
logaretm Feb 8, 2025
0f9d8c5
feat: implement placeholders
logaretm Feb 8, 2025
2d9a129
fix: types
logaretm Feb 8, 2025
aa719a1
fix: set date to undefined if a part is cleared
logaretm Feb 9, 2025
7b3e9a0
feat: implement date panels
logaretm Feb 9, 2025
c9bd243
fix: year panel label
logaretm Feb 9, 2025
f085014
feat: make the next/previous buttons adapt to current panel type
logaretm Feb 9, 2025
404c343
feat: make home/end, page up/down keys work with month and years panels
logaretm Feb 10, 2025
34ab489
dev: basic conversion to internationalized
logaretm Feb 13, 2025
86cd754
feat: use @internationalized/date instead
logaretm Feb 13, 2025
6c58225
chore: testing bundle-size
logaretm Feb 13, 2025
b756182
chore: added changeset
logaretm Feb 13, 2025
605948d
test: added calendar tests
logaretm Feb 13, 2025
06caac8
test: added tests for some date field utils
logaretm Feb 14, 2025
2ff3312
test: added tests for datetime segment
logaretm Feb 14, 2025
5519691
fix: timezone initialization when passing them to calendarProps
logaretm Feb 15, 2025
e300eac
fix: validation state of the useDateTimeField
logaretm Feb 15, 2025
3700aba
fix: validation should run whenever the date value changes
logaretm Feb 15, 2025
929d412
fix: touched state
logaretm Feb 15, 2025
4d06978
fix: better numeric segment check and added keyboard hints
logaretm Feb 15, 2025
4ccd002
fix: types
logaretm Feb 15, 2025
bdd8f04
feat: rename week days prop
logaretm Feb 15, 2025
1f3f5a2
fix: aria labels for the grid
logaretm Feb 15, 2025
aa3d435
feat: rename panel types and add allowed panel types option
logaretm Feb 15, 2025
94d43b9
test: fix broken tests
logaretm Feb 15, 2025
140885b
feat: added spinbutton role to the date segment
logaretm Feb 16, 2025
63abd13
refactor: make the calendar API more standalone
logaretm Feb 16, 2025
acc5880
refactor: promote the calendar composable to be a full field optionally
logaretm Feb 16, 2025
10e0a2f
refactor: reduce memory footprint with min/max dates
logaretm Feb 16, 2025
0b84513
fix: reset calendar cells to have zero time component
logaretm Feb 16, 2025
ce0c552
fix: properly clamp the date with segments
logaretm Feb 16, 2025
b6dde1b
refactor: externalize picker logic
logaretm Feb 16, 2025
6e86eae
feat: added aria props to picker logic
logaretm Feb 16, 2025
141136f
fix: types and focused date logic
logaretm Feb 16, 2025
5fc0539
fix: focused date should match selected
logaretm Feb 16, 2025
acb4952
fix: hook cells into the disabled context
logaretm Feb 16, 2025
9a26260
feat: auto inject the calendar direction
logaretm Feb 16, 2025
3f30194
fix: properly render days in their locale
logaretm Feb 16, 2025
2442227
fix: completely block click event if the cell is disabled
logaretm Feb 16, 2025
9df62f2
feat: disable the navigation if readonly
logaretm Feb 16, 2025
1f1d7b7
test: added test case for readonly and disabled
logaretm Feb 16, 2025
379b5cf
fix: direction support in segment groups and readonly/disabled support
logaretm Feb 19, 2025
44d61b6
fix: name should not be required
logaretm Feb 19, 2025
f68dacb
fix: switch to EG locale in example cause its my country
logaretm Feb 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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