From 49ed5f75da8e0a9586c2d9d72c7df8e29d7b0aa2 Mon Sep 17 00:00:00 2001 From: charlotte-whiting Date: Tue, 10 Dec 2024 17:21:39 -0800 Subject: [PATCH 1/3] fix: dayperiod segment no longer reset --- .../datepicker/src/useDateFieldState.ts | 527 ++++++++++++------ 1 file changed, 350 insertions(+), 177 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index da1eed6d66c..d60e3e50c13 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -10,92 +10,128 @@ * governing permissions and limitations under the License. */ -import {Calendar, DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, GregorianCalendar, toCalendar} from '@internationalized/date'; -import {convertValue, createPlaceholderDate, FieldOptions, FormatterOptions, getFormatOptions, getValidationResult, useDefaultProps} from './utils'; -import {DatePickerProps, DateValue, Granularity, MappedDateValue} from '@react-types/datepicker'; -import {FormValidationState, useFormValidationState} from '@react-stately/form'; -import {getPlaceholder} from './placeholders'; -import {useControlledState} from '@react-stately/utils'; -import {useEffect, useMemo, useRef, useState} from 'react'; -import {ValidationState} from '@react-types/shared'; - -export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'; +import { + Calendar, + DateFormatter, + getMinimumDayInMonth, + getMinimumMonthInYear, + GregorianCalendar, + toCalendar, +} from "@internationalized/date"; +import { + convertValue, + createPlaceholderDate, + FieldOptions, + FormatterOptions, + getFormatOptions, + getValidationResult, + useDefaultProps, +} from "./utils"; +import { + DatePickerProps, + DateValue, + Granularity, + MappedDateValue, +} from "@react-types/datepicker"; +import { + FormValidationState, + useFormValidationState, +} from "@react-stately/form"; +import { getPlaceholder } from "./placeholders"; +import { useControlledState } from "@react-stately/utils"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { ValidationState } from "@react-types/shared"; + +export type SegmentType = + | "era" + | "year" + | "month" + | "day" + | "hour" + | "minute" + | "second" + | "dayPeriod" + | "literal" + | "timeZoneName"; export interface DateSegment { /** The type of segment. */ - type: SegmentType, + type: SegmentType; /** The formatted text for the segment. */ - text: string, + text: string; /** The numeric value for the segment, if applicable. */ - value?: number, + value?: number; /** The minimum numeric value for the segment, if applicable. */ - minValue?: number, + minValue?: number; /** The maximum numeric value for the segment, if applicable. */ - maxValue?: number, + maxValue?: number; /** Whether the value is a placeholder. */ - isPlaceholder: boolean, + isPlaceholder: boolean; /** A placeholder string for the segment. */ - placeholder: string, + placeholder: string; /** Whether the segment is editable. */ - isEditable: boolean + isEditable: boolean; } export interface DateFieldState extends FormValidationState { /** The current field value. */ - value: DateValue | null, + value: DateValue | null; /** The current value, converted to a native JavaScript `Date` object. */ - dateValue: Date, + dateValue: Date; /** The calendar system currently in use. */ - calendar: Calendar, + calendar: Calendar; /** Sets the field's value. */ - setValue(value: DateValue | null): void, + setValue(value: DateValue | null): void; /** A list of segments for the current value. */ - segments: DateSegment[], + segments: DateSegment[]; /** A date formatter configured for the current locale and format. */ - dateFormatter: DateFormatter, + dateFormatter: DateFormatter; /** * The current validation state of the date field, based on the `validationState`, `minValue`, and `maxValue` props. * @deprecated Use `isInvalid` instead. */ - validationState: ValidationState | null, + validationState: ValidationState | null; /** Whether the date field is invalid, based on the `isInvalid`, `minValue`, and `maxValue` props. */ - isInvalid: boolean, + isInvalid: boolean; /** The granularity for the field, based on the `granularity` prop and current value. */ - granularity: Granularity, + granularity: Granularity; /** The maximum date or time unit that is displayed in the field. */ - maxGranularity: 'year' | 'month' | Granularity, + maxGranularity: "year" | "month" | Granularity; /** Whether the field is disabled. */ - isDisabled: boolean, + isDisabled: boolean; /** Whether the field is read only. */ - isReadOnly: boolean, + isReadOnly: boolean; /** Whether the field is required. */ - isRequired: boolean, + isRequired: boolean; /** Increments the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - increment(type: SegmentType): void, + increment(type: SegmentType): void; /** Decrements the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrement(type: SegmentType): void, + decrement(type: SegmentType): void; /** * Increments the given segment by a larger amount, rounding it to the nearest increment. * The amount to increment by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - incrementPage(type: SegmentType): void, + incrementPage(type: SegmentType): void; /** * Decrements the given segment by a larger amount, rounding it to the nearest increment. * The amount to decrement by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrementPage(type: SegmentType): void, + decrementPage(type: SegmentType): void; /** Sets the value of the given segment. */ - setSegment(type: 'era', value: string): void, - setSegment(type: SegmentType, value: number): void, + setSegment(type: "era", value: string): void; + setSegment(type: SegmentType, value: number): void; /** Updates the remaining unfilled segments with the placeholder value. */ - confirmPlaceholder(): void, + confirmPlaceholder(): void; /** Clears the value of the given segment, reverting it to the placeholder. */ - clearSegment(type: SegmentType): void, + clearSegment(type: SegmentType): void; /** Formats the current date value using the given options. */ - formatValue(fieldOptions: FieldOptions): string, + formatValue(fieldOptions: FieldOptions): string; /** Gets a formatter based on state's props. */ - getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter + getDateFormatter( + locale: string, + formatOptions: FormatterOptions + ): DateFormatter; } const EDITABLE_SEGMENTS = { @@ -106,7 +142,7 @@ const EDITABLE_SEGMENTS = { minute: true, second: true, dayPeriod: true, - era: true + era: true, }; const PAGE_STEP = { @@ -115,29 +151,30 @@ const PAGE_STEP = { day: 7, hour: 2, minute: 15, - second: 15 + second: 15, }; // Node seems to convert everything to lowercase... const TYPE_MAPPING = { - dayperiod: 'dayPeriod' + dayperiod: "dayPeriod", }; -export interface DateFieldStateOptions extends DatePickerProps { +export interface DateFieldStateOptions + extends DatePickerProps { /** * The maximum unit to display in the date field. * @default 'year' */ - maxGranularity?: 'year' | 'month' | Granularity, + maxGranularity?: "year" | "month" | Granularity; /** The locale to display and edit the value according to. */ - locale: string, + locale: string; /** * A function that creates a [Calendar](../internationalized/date/Calendar.html) * object for a given calendar identifier. Such a function may be imported from the * `@internationalized/date` package, or manually implemented to include support for * only certain calendars. */ - createCalendar: (name: string) => Calendar + createCalendar: (name: string) => Calendar; } /** @@ -145,7 +182,9 @@ export interface DateFieldStateOptions extends * A date field allows users to enter and edit date and time values using a keyboard. * Each part of a date value is displayed in an individually editable segment. */ -export function useDateFieldState(props: DateFieldStateOptions): DateFieldState { +export function useDateFieldState( + props: DateFieldStateOptions +): DateFieldState { let { locale, createCalendar, @@ -155,65 +194,98 @@ export function useDateFieldState(props: DateFi isRequired = false, minValue, maxValue, - isDateUnavailable + isDateUnavailable, } = props; - let v: DateValue | null = props.value || props.defaultValue || props.placeholderValue || null; + let v: DateValue | null = + props.value || props.defaultValue || props.placeholderValue || null; let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); - let timeZone = defaultTimeZone || 'UTC'; + let timeZone = defaultTimeZone || "UTC"; // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { - throw new Error('Invalid granularity ' + granularity + ' for value ' + v.toString()); + throw new Error( + "Invalid granularity " + granularity + " for value " + v.toString() + ); } let defaultFormatter = useMemo(() => new DateFormatter(locale), [locale]); - let calendar = useMemo(() => createCalendar(defaultFormatter.resolvedOptions().calendar), [createCalendar, defaultFormatter]); - - let [value, setDate] = useControlledState | null>( - props.value, - props.defaultValue ?? null, - props.onChange + let calendar = useMemo( + () => createCalendar(defaultFormatter.resolvedOptions().calendar), + [createCalendar, defaultFormatter] ); - let calendarValue = useMemo(() => convertValue(value, calendar) ?? null, [value, calendar]); + let [value, setDate] = useControlledState< + DateValue | null, + MappedDateValue | null + >(props.value, props.defaultValue ?? null, props.onChange); + + let calendarValue = useMemo( + () => convertValue(value, calendar) ?? null, + [value, calendar] + ); // We keep track of the placeholder date separately in state so that onChange is not called // until all segments are set. If the value === null (not undefined), then assume the component // is controlled, so use the placeholder as the value until all segments are entered so it doesn't // change from uncontrolled to controlled and emit a warning. - let [placeholderDate, setPlaceholderDate] = useState( - () => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) + let [placeholderDate, setPlaceholderDate] = useState(() => + createPlaceholderDate( + props.placeholderValue, + granularity, + calendar, + defaultTimeZone + ) ); let val = calendarValue || placeholderDate; - let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; - let formatOpts = useMemo(() => ({ - granularity, - maxGranularity: props.maxGranularity ?? 'year', - timeZone: defaultTimeZone, - hideTimeZone, - hourCycle: props.hourCycle, - showEra, - shouldForceLeadingZeros: props.shouldForceLeadingZeros - }), [props.maxGranularity, granularity, props.hourCycle, props.shouldForceLeadingZeros, defaultTimeZone, hideTimeZone, showEra]); + let showEra = calendar.identifier === "gregory" && val.era === "BC"; + let formatOpts = useMemo( + () => ({ + granularity, + maxGranularity: props.maxGranularity ?? "year", + timeZone: defaultTimeZone, + hideTimeZone, + hourCycle: props.hourCycle, + showEra, + shouldForceLeadingZeros: props.shouldForceLeadingZeros, + }), + [ + props.maxGranularity, + granularity, + props.hourCycle, + props.shouldForceLeadingZeros, + defaultTimeZone, + hideTimeZone, + showEra, + ] + ); let opts = useMemo(() => getFormatOptions({}, formatOpts), [formatOpts]); - let dateFormatter = useMemo(() => new DateFormatter(locale, opts), [locale, opts]); - let resolvedOptions = useMemo(() => dateFormatter.resolvedOptions(), [dateFormatter]); + let dateFormatter = useMemo( + () => new DateFormatter(locale, opts), + [locale, opts] + ); + let resolvedOptions = useMemo( + () => dateFormatter.resolvedOptions(), + [dateFormatter] + ); // Determine how many editable segments there are for validation purposes. // The result is cached for performance. - let allSegments: Partial = useMemo(() => - dateFormatter.formatToParts(new Date()) - .filter(seg => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => (p[seg.type] = true, p), {}) - , [dateFormatter]); - - let [validSegments, setValidSegments] = useState>( - () => props.value || props.defaultValue ? {...allSegments} : {} + let allSegments: Partial = useMemo( + () => + dateFormatter + .formatToParts(new Date()) + .filter((seg) => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => ((p[seg.type] = true), p), {}), + [dateFormatter] ); + let [validSegments, setValidSegments] = useState< + Partial + >(() => (props.value || props.defaultValue ? { ...allSegments } : {})); + let clearedSegment = useRef(null); // Reset placeholder when calendar changes @@ -221,29 +293,57 @@ export function useDateFieldState(props: DateFi useEffect(() => { if (calendar.identifier !== lastCalendarIdentifier.current) { lastCalendarIdentifier.current = calendar.identifier; - setPlaceholderDate(placeholder => + setPlaceholderDate((placeholder) => Object.keys(validSegments).length > 0 ? toCalendar(placeholder, calendar) - : createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) + : createPlaceholderDate( + props.placeholderValue, + granularity, + calendar, + defaultTimeZone + ) ); } - }, [calendar, granularity, validSegments, defaultTimeZone, props.placeholderValue]); + }, [ + calendar, + granularity, + validSegments, + defaultTimeZone, + props.placeholderValue, + ]); // If there is a value prop, and some segments were previously placeholders, mark them all as valid. - if (value && Object.keys(validSegments).length < Object.keys(allSegments).length) { - validSegments = {...allSegments}; + if ( + value && + Object.keys(validSegments).length < Object.keys(allSegments).length + ) { + validSegments = { ...allSegments }; setValidSegments(validSegments); } // If the value is set to null and all segments are valid, reset the placeholder. - if (value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { + if ( + value == null && + Object.keys(validSegments).length === Object.keys(allSegments).length + ) { validSegments = {}; setValidSegments(validSegments); - setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); + setPlaceholderDate( + createPlaceholderDate( + props.placeholderValue, + granularity, + calendar, + defaultTimeZone + ) + ); } // If all segments are valid, use the date from state, otherwise use the placeholder date. - let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; + let displayValue = + calendarValue && + Object.keys(validSegments).length >= Object.keys(allSegments).length + ? calendarValue + : placeholderDate; let setValue = (newValue: DateValue) => { if (props.isDisabled || props.isReadOnly) { return; @@ -254,9 +354,22 @@ export function useDateFieldState(props: DateFi // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared if (newValue == null) { setDate(null); - setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); + setPlaceholderDate( + createPlaceholderDate( + props.placeholderValue, + granularity, + calendar, + defaultTimeZone + ) + ); setValidSegments({}); - } else if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { + } else if ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && + allSegments.dayPeriod && + !validSegments.dayPeriod && + clearedSegment.current !== "dayPeriod") + ) { // The display calendar should not have any effect on the emitted value. // Emit dates in the same calendar as the original value, if any, otherwise gregorian. newValue = toCalendar(newValue, v?.calendar || new GregorianCalendar()); @@ -267,44 +380,59 @@ export function useDateFieldState(props: DateFi clearedSegment.current = null; }; - let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); - let segments = useMemo(() => - dateFormatter.formatToParts(dateValue) - .map(segment => { + let dateValue = useMemo( + () => displayValue.toDate(timeZone), + [displayValue, timeZone] + ); + let segments = useMemo( + () => + dateFormatter.formatToParts(dateValue).map((segment) => { let isEditable = EDITABLE_SEGMENTS[segment.type]; - if (segment.type === 'era' && calendar.getEras().length === 1) { + if (segment.type === "era" && calendar.getEras().length === 1) { isEditable = false; } - let isPlaceholder = EDITABLE_SEGMENTS[segment.type] && !validSegments[segment.type]; - let placeholder = EDITABLE_SEGMENTS[segment.type] ? getPlaceholder(segment.type, segment.value, locale) : null; + let isPlaceholder = + EDITABLE_SEGMENTS[segment.type] && !validSegments[segment.type]; + let placeholder = EDITABLE_SEGMENTS[segment.type] + ? getPlaceholder(segment.type, segment.value, locale) + : null; return { type: TYPE_MAPPING[segment.type] || segment.type, text: isPlaceholder ? placeholder : segment.value, ...getSegmentLimits(displayValue, segment.type, resolvedOptions), isPlaceholder, placeholder, - isEditable + isEditable, } as DateSegment; - }) - , [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale]); + }), + [ + dateValue, + validSegments, + dateFormatter, + resolvedOptions, + displayValue, + calendar, + locale, + ] + ); // When the era field appears, mark it valid if the year field is already valid. // If the era field disappears, remove it from the valid segments. if (allSegments.era && validSegments.year && !validSegments.era) { validSegments.era = true; - setValidSegments({...validSegments}); + setValidSegments({ ...validSegments }); } else if (!allSegments.era && validSegments.era) { delete validSegments.era; - setValidSegments({...validSegments}); + setValidSegments({ ...validSegments }); } let markValid = (part: Intl.DateTimeFormatPartTypes) => { validSegments[part] = true; - if (part === 'year' && allSegments.era) { + if (part === "year" && allSegments.era) { validSegments.era = true; } - setValidSegments({...validSegments}); + setValidSegments({ ...validSegments }); }; let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { @@ -312,7 +440,12 @@ export function useDateFieldState(props: DateFi markValid(type); let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { + if ( + validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && + allSegments.dayPeriod && + !validSegments.dayPeriod) + ) { setValue(displayValue); } } else { @@ -320,22 +453,27 @@ export function useDateFieldState(props: DateFi } }; - let builtinValidation = useMemo(() => getValidationResult( - value, - minValue, - maxValue, - isDateUnavailable, - formatOpts - ), [value, minValue, maxValue, isDateUnavailable, formatOpts]); + let builtinValidation = useMemo( + () => + getValidationResult( + value, + minValue, + maxValue, + isDateUnavailable, + formatOpts + ), + [value, minValue, maxValue, isDateUnavailable, formatOpts] + ); let validation = useFormValidationState({ ...props, value: value as MappedDateValue | null, - builtinValidation + builtinValidation, }); let isValueInvalid = validation.displayValidation.isInvalid; - let validationState: ValidationState | null = props.validationState || (isValueInvalid ? 'invalid' : null); + let validationState: ValidationState | null = + props.validationState || (isValueInvalid ? "invalid" : null); return { ...validation, @@ -348,7 +486,7 @@ export function useDateFieldState(props: DateFi validationState, isInvalid: isValueInvalid, granularity, - maxGranularity: props.maxGranularity ?? 'year', + maxGranularity: props.maxGranularity ?? "year", isDisabled, isReadOnly, isRequired, @@ -376,8 +514,12 @@ export function useDateFieldState(props: DateFi // Confirm the placeholder if only the day period is not filled in. let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod) { - validSegments = {...allSegments}; + if ( + validKeys.length === allKeys.length - 1 && + allSegments.dayPeriod && + !validSegments.dayPeriod + ) { + validSegments = { ...allSegments }; setValidSegments(validSegments); setValue(displayValue.copy()); } @@ -385,22 +527,39 @@ export function useDateFieldState(props: DateFi clearSegment(part) { delete validSegments[part]; clearedSegment.current = part; - setValidSegments({...validSegments}); + setValidSegments({ ...validSegments }); - let placeholder = createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone); + let placeholder = createPlaceholderDate( + props.placeholderValue, + granularity, + calendar, + defaultTimeZone + ); let value = displayValue; // Reset day period to default without changing the hour. - if (part === 'dayPeriod' && 'hour' in displayValue && 'hour' in placeholder) { + console.log(part); + if ( + part === "dayPeriod" && + "hour" in displayValue && + "hour" in placeholder + ) { let isPM = displayValue.hour >= 12; let shouldBePM = placeholder.hour >= 12; if (isPM && !shouldBePM) { - value = displayValue.set({hour: displayValue.hour - 12}); + value = displayValue.set({ hour: displayValue.hour - 12 }); } else if (!isPM && shouldBePM) { - value = displayValue.set({hour: displayValue.hour + 12}); + value = displayValue.set({ hour: displayValue.hour + 12 }); } + } else if ( + part === "hour" && + "hour" in displayValue && + displayValue.hour >= 12 && + validSegments.dayPeriod + ) { + value = displayValue.set({ hour: placeholder["hour"] + 12 }); } else if (part in displayValue) { - value = displayValue.set({[part]: placeholder[part]}); + value = displayValue.set({ [part]: placeholder[part] }); } setDate(null); @@ -408,7 +567,7 @@ export function useDateFieldState(props: DateFi }, formatValue(fieldOptions: FieldOptions) { if (!calendarValue) { - return ''; + return ""; } let formatOptions = getFormatOptions(fieldOptions, formatOpts); @@ -416,77 +575,81 @@ export function useDateFieldState(props: DateFi return formatter.format(dateValue); }, getDateFormatter(locale, formatOptions: FormatterOptions) { - let newOptions = {...formatOpts, ...formatOptions}; + let newOptions = { ...formatOpts, ...formatOptions }; let newFormatOptions = getFormatOptions({}, newOptions); return new DateFormatter(locale, newFormatOptions); - } + }, }; } -function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { +function getSegmentLimits( + date: DateValue, + type: string, + options: Intl.ResolvedDateTimeFormatOptions +) { switch (type) { - case 'era': { + case "era": { let eras = date.calendar.getEras(); return { value: eras.indexOf(date.era), minValue: 0, - maxValue: eras.length - 1 + maxValue: eras.length - 1, }; } - case 'year': + case "year": return { value: date.year, minValue: 1, - maxValue: date.calendar.getYearsInEra(date) + maxValue: date.calendar.getYearsInEra(date), }; - case 'month': + case "month": return { value: date.month, minValue: getMinimumMonthInYear(date), - maxValue: date.calendar.getMonthsInYear(date) + maxValue: date.calendar.getMonthsInYear(date), }; - case 'day': + case "day": return { value: date.day, minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date) + maxValue: date.calendar.getDaysInMonth(date), }; } - if ('hour' in date) { + if ("hour" in date) { switch (type) { - case 'dayPeriod': + case "dayPeriod": return { value: date.hour >= 12 ? 12 : 0, minValue: 0, - maxValue: 12 + maxValue: 12, }; - case 'hour': + case "hour": if (options.hour12) { let isPM = date.hour >= 12; return { value: date.hour, minValue: isPM ? 12 : 0, - maxValue: isPM ? 23 : 11 + maxValue: isPM ? 23 : 11, }; } return { value: date.hour, minValue: 0, - maxValue: 23 + maxValue: 23, }; - case 'minute': + case "minute": return { value: date.minute, minValue: 0, - maxValue: 59 + maxValue: 59, }; - case 'second': + case "second": return { value: date.second, minValue: 0, - maxValue: 59 + maxValue: 59, }; } } @@ -494,56 +657,66 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return {}; } -function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { +function addSegment( + value: DateValue, + part: string, + amount: number, + options: Intl.ResolvedDateTimeFormatOptions +) { switch (part) { - case 'era': - case 'year': - case 'month': - case 'day': - return value.cycle(part, amount, {round: part === 'year'}); + case "era": + case "year": + case "month": + case "day": + return value.cycle(part, amount, { round: part === "year" }); } - if ('hour' in value) { + if ("hour" in value) { switch (part) { - case 'dayPeriod': { + case "dayPeriod": { let hours = value.hour; let isPM = hours >= 12; - return value.set({hour: isPM ? hours - 12 : hours + 12}); + return value.set({ hour: isPM ? hours - 12 : hours + 12 }); } - case 'hour': - case 'minute': - case 'second': + case "hour": + case "minute": + case "second": return value.cycle(part, amount, { - round: part !== 'hour', - hourCycle: options.hour12 ? 12 : 24 + round: part !== "hour", + hourCycle: options.hour12 ? 12 : 24, }); } } - throw new Error('Unknown segment: ' + part); + throw new Error("Unknown segment: " + part); } -function setSegment(value: DateValue, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { +function setSegment( + value: DateValue, + part: string, + segmentValue: number | string, + options: Intl.ResolvedDateTimeFormatOptions +) { switch (part) { - case 'day': - case 'month': - case 'year': - case 'era': - return value.set({[part]: segmentValue}); + case "day": + case "month": + case "year": + case "era": + return value.set({ [part]: segmentValue }); } - if ('hour' in value && typeof segmentValue === 'number') { + if ("hour" in value && typeof segmentValue === "number") { switch (part) { - case 'dayPeriod': { + case "dayPeriod": { let hours = value.hour; let wasPM = hours >= 12; let isPM = segmentValue >= 12; if (isPM === wasPM) { return value; } - return value.set({hour: wasPM ? hours - 12 : hours + 12}); + return value.set({ hour: wasPM ? hours - 12 : hours + 12 }); } - case 'hour': + case "hour": // In 12 hour time, ensure that AM/PM does not change if (options.hour12) { let hours = value.hour; @@ -555,12 +728,12 @@ function setSegment(value: DateValue, part: string, segmentValue: number | strin segmentValue += 12; } } - // fallthrough - case 'minute': - case 'second': - return value.set({[part]: segmentValue}); + // fallthrough + case "minute": + case "second": + return value.set({ [part]: segmentValue }); } } - throw new Error('Unknown segment: ' + part); + throw new Error("Unknown segment: " + part); } From a66896bf565fe53ca0061097f5313ea057de6117 Mon Sep 17 00:00:00 2001 From: charlotte-whiting Date: Wed, 18 Dec 2024 13:15:58 -0800 Subject: [PATCH 2/3] undo prettier --- .../datepicker/src/useDateFieldState.ts | 529 ++++++------------ 1 file changed, 179 insertions(+), 350 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index d60e3e50c13..6eaf4426995 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -10,128 +10,92 @@ * governing permissions and limitations under the License. */ -import { - Calendar, - DateFormatter, - getMinimumDayInMonth, - getMinimumMonthInYear, - GregorianCalendar, - toCalendar, -} from "@internationalized/date"; -import { - convertValue, - createPlaceholderDate, - FieldOptions, - FormatterOptions, - getFormatOptions, - getValidationResult, - useDefaultProps, -} from "./utils"; -import { - DatePickerProps, - DateValue, - Granularity, - MappedDateValue, -} from "@react-types/datepicker"; -import { - FormValidationState, - useFormValidationState, -} from "@react-stately/form"; -import { getPlaceholder } from "./placeholders"; -import { useControlledState } from "@react-stately/utils"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { ValidationState } from "@react-types/shared"; - -export type SegmentType = - | "era" - | "year" - | "month" - | "day" - | "hour" - | "minute" - | "second" - | "dayPeriod" - | "literal" - | "timeZoneName"; +import {Calendar, DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, GregorianCalendar, toCalendar} from '@internationalized/date'; +import {convertValue, createPlaceholderDate, FieldOptions, FormatterOptions, getFormatOptions, getValidationResult, useDefaultProps} from './utils'; +import {DatePickerProps, DateValue, Granularity, MappedDateValue} from '@react-types/datepicker'; +import {FormValidationState, useFormValidationState} from '@react-stately/form'; +import {getPlaceholder} from './placeholders'; +import {useControlledState} from '@react-stately/utils'; +import {useEffect, useMemo, useRef, useState} from 'react'; +import {ValidationState} from '@react-types/shared'; + +export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'; export interface DateSegment { /** The type of segment. */ - type: SegmentType; + type: SegmentType, /** The formatted text for the segment. */ - text: string; + text: string, /** The numeric value for the segment, if applicable. */ - value?: number; + value?: number, /** The minimum numeric value for the segment, if applicable. */ - minValue?: number; + minValue?: number, /** The maximum numeric value for the segment, if applicable. */ - maxValue?: number; + maxValue?: number, /** Whether the value is a placeholder. */ - isPlaceholder: boolean; + isPlaceholder: boolean, /** A placeholder string for the segment. */ - placeholder: string; + placeholder: string, /** Whether the segment is editable. */ - isEditable: boolean; + isEditable: boolean } export interface DateFieldState extends FormValidationState { /** The current field value. */ - value: DateValue | null; + value: DateValue | null, /** The current value, converted to a native JavaScript `Date` object. */ - dateValue: Date; + dateValue: Date, /** The calendar system currently in use. */ - calendar: Calendar; + calendar: Calendar, /** Sets the field's value. */ - setValue(value: DateValue | null): void; + setValue(value: DateValue | null): void, /** A list of segments for the current value. */ - segments: DateSegment[]; + segments: DateSegment[], /** A date formatter configured for the current locale and format. */ - dateFormatter: DateFormatter; + dateFormatter: DateFormatter, /** * The current validation state of the date field, based on the `validationState`, `minValue`, and `maxValue` props. * @deprecated Use `isInvalid` instead. */ - validationState: ValidationState | null; + validationState: ValidationState | null, /** Whether the date field is invalid, based on the `isInvalid`, `minValue`, and `maxValue` props. */ - isInvalid: boolean; + isInvalid: boolean, /** The granularity for the field, based on the `granularity` prop and current value. */ - granularity: Granularity; + granularity: Granularity, /** The maximum date or time unit that is displayed in the field. */ - maxGranularity: "year" | "month" | Granularity; + maxGranularity: 'year' | 'month' | Granularity, /** Whether the field is disabled. */ - isDisabled: boolean; + isDisabled: boolean, /** Whether the field is read only. */ - isReadOnly: boolean; + isReadOnly: boolean, /** Whether the field is required. */ - isRequired: boolean; + isRequired: boolean, /** Increments the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - increment(type: SegmentType): void; + increment(type: SegmentType): void, /** Decrements the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrement(type: SegmentType): void; + decrement(type: SegmentType): void, /** * Increments the given segment by a larger amount, rounding it to the nearest increment. * The amount to increment by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - incrementPage(type: SegmentType): void; + incrementPage(type: SegmentType): void, /** * Decrements the given segment by a larger amount, rounding it to the nearest increment. * The amount to decrement by depends on the field, for example 15 minutes, 7 days, and 5 years. * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ - decrementPage(type: SegmentType): void; + decrementPage(type: SegmentType): void, /** Sets the value of the given segment. */ - setSegment(type: "era", value: string): void; - setSegment(type: SegmentType, value: number): void; + setSegment(type: 'era', value: string): void, + setSegment(type: SegmentType, value: number): void, /** Updates the remaining unfilled segments with the placeholder value. */ - confirmPlaceholder(): void; + confirmPlaceholder(): void, /** Clears the value of the given segment, reverting it to the placeholder. */ - clearSegment(type: SegmentType): void; + clearSegment(type: SegmentType): void, /** Formats the current date value using the given options. */ - formatValue(fieldOptions: FieldOptions): string; + formatValue(fieldOptions: FieldOptions): string, /** Gets a formatter based on state's props. */ - getDateFormatter( - locale: string, - formatOptions: FormatterOptions - ): DateFormatter; + getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter } const EDITABLE_SEGMENTS = { @@ -142,7 +106,7 @@ const EDITABLE_SEGMENTS = { minute: true, second: true, dayPeriod: true, - era: true, + era: true }; const PAGE_STEP = { @@ -151,30 +115,29 @@ const PAGE_STEP = { day: 7, hour: 2, minute: 15, - second: 15, + second: 15 }; // Node seems to convert everything to lowercase... const TYPE_MAPPING = { - dayperiod: "dayPeriod", + dayperiod: 'dayPeriod' }; -export interface DateFieldStateOptions - extends DatePickerProps { +export interface DateFieldStateOptions extends DatePickerProps { /** * The maximum unit to display in the date field. * @default 'year' */ - maxGranularity?: "year" | "month" | Granularity; + maxGranularity?: 'year' | 'month' | Granularity, /** The locale to display and edit the value according to. */ - locale: string; + locale: string, /** * A function that creates a [Calendar](../internationalized/date/Calendar.html) * object for a given calendar identifier. Such a function may be imported from the * `@internationalized/date` package, or manually implemented to include support for * only certain calendars. */ - createCalendar: (name: string) => Calendar; + createCalendar: (name: string) => Calendar } /** @@ -182,9 +145,7 @@ export interface DateFieldStateOptions * A date field allows users to enter and edit date and time values using a keyboard. * Each part of a date value is displayed in an individually editable segment. */ -export function useDateFieldState( - props: DateFieldStateOptions -): DateFieldState { +export function useDateFieldState(props: DateFieldStateOptions): DateFieldState { let { locale, createCalendar, @@ -194,98 +155,65 @@ export function useDateFieldState( isRequired = false, minValue, maxValue, - isDateUnavailable, + isDateUnavailable } = props; - let v: DateValue | null = - props.value || props.defaultValue || props.placeholderValue || null; + let v: DateValue | null = props.value || props.defaultValue || props.placeholderValue || null; let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); - let timeZone = defaultTimeZone || "UTC"; + let timeZone = defaultTimeZone || 'UTC'; // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { - throw new Error( - "Invalid granularity " + granularity + " for value " + v.toString() - ); + throw new Error('Invalid granularity ' + granularity + ' for value ' + v.toString()); } let defaultFormatter = useMemo(() => new DateFormatter(locale), [locale]); - let calendar = useMemo( - () => createCalendar(defaultFormatter.resolvedOptions().calendar), - [createCalendar, defaultFormatter] - ); - - let [value, setDate] = useControlledState< - DateValue | null, - MappedDateValue | null - >(props.value, props.defaultValue ?? null, props.onChange); + let calendar = useMemo(() => createCalendar(defaultFormatter.resolvedOptions().calendar), [createCalendar, defaultFormatter]); - let calendarValue = useMemo( - () => convertValue(value, calendar) ?? null, - [value, calendar] + let [value, setDate] = useControlledState | null>( + props.value, + props.defaultValue ?? null, + props.onChange ); + let calendarValue = useMemo(() => convertValue(value, calendar) ?? null, [value, calendar]); + // We keep track of the placeholder date separately in state so that onChange is not called // until all segments are set. If the value === null (not undefined), then assume the component // is controlled, so use the placeholder as the value until all segments are entered so it doesn't // change from uncontrolled to controlled and emit a warning. - let [placeholderDate, setPlaceholderDate] = useState(() => - createPlaceholderDate( - props.placeholderValue, - granularity, - calendar, - defaultTimeZone - ) + let [placeholderDate, setPlaceholderDate] = useState( + () => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) ); let val = calendarValue || placeholderDate; - let showEra = calendar.identifier === "gregory" && val.era === "BC"; - let formatOpts = useMemo( - () => ({ - granularity, - maxGranularity: props.maxGranularity ?? "year", - timeZone: defaultTimeZone, - hideTimeZone, - hourCycle: props.hourCycle, - showEra, - shouldForceLeadingZeros: props.shouldForceLeadingZeros, - }), - [ - props.maxGranularity, - granularity, - props.hourCycle, - props.shouldForceLeadingZeros, - defaultTimeZone, - hideTimeZone, - showEra, - ] - ); + let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; + let formatOpts = useMemo(() => ({ + granularity, + maxGranularity: props.maxGranularity ?? 'year', + timeZone: defaultTimeZone, + hideTimeZone, + hourCycle: props.hourCycle, + showEra, + shouldForceLeadingZeros: props.shouldForceLeadingZeros + }), [props.maxGranularity, granularity, props.hourCycle, props.shouldForceLeadingZeros, defaultTimeZone, hideTimeZone, showEra]); let opts = useMemo(() => getFormatOptions({}, formatOpts), [formatOpts]); - let dateFormatter = useMemo( - () => new DateFormatter(locale, opts), - [locale, opts] - ); - let resolvedOptions = useMemo( - () => dateFormatter.resolvedOptions(), - [dateFormatter] - ); + let dateFormatter = useMemo(() => new DateFormatter(locale, opts), [locale, opts]); + let resolvedOptions = useMemo(() => dateFormatter.resolvedOptions(), [dateFormatter]); // Determine how many editable segments there are for validation purposes. // The result is cached for performance. - let allSegments: Partial = useMemo( - () => - dateFormatter - .formatToParts(new Date()) - .filter((seg) => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => ((p[seg.type] = true), p), {}), - [dateFormatter] + let allSegments: Partial = useMemo(() => + dateFormatter.formatToParts(new Date()) + .filter(seg => EDITABLE_SEGMENTS[seg.type]) + .reduce((p, seg) => (p[seg.type] = true, p), {}) + , [dateFormatter]); + + let [validSegments, setValidSegments] = useState>( + () => props.value || props.defaultValue ? {...allSegments} : {} ); - let [validSegments, setValidSegments] = useState< - Partial - >(() => (props.value || props.defaultValue ? { ...allSegments } : {})); - let clearedSegment = useRef(null); // Reset placeholder when calendar changes @@ -293,57 +221,29 @@ export function useDateFieldState( useEffect(() => { if (calendar.identifier !== lastCalendarIdentifier.current) { lastCalendarIdentifier.current = calendar.identifier; - setPlaceholderDate((placeholder) => + setPlaceholderDate(placeholder => Object.keys(validSegments).length > 0 ? toCalendar(placeholder, calendar) - : createPlaceholderDate( - props.placeholderValue, - granularity, - calendar, - defaultTimeZone - ) + : createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) ); } - }, [ - calendar, - granularity, - validSegments, - defaultTimeZone, - props.placeholderValue, - ]); + }, [calendar, granularity, validSegments, defaultTimeZone, props.placeholderValue]); // If there is a value prop, and some segments were previously placeholders, mark them all as valid. - if ( - value && - Object.keys(validSegments).length < Object.keys(allSegments).length - ) { - validSegments = { ...allSegments }; + if (value && Object.keys(validSegments).length < Object.keys(allSegments).length) { + validSegments = {...allSegments}; setValidSegments(validSegments); } // If the value is set to null and all segments are valid, reset the placeholder. - if ( - value == null && - Object.keys(validSegments).length === Object.keys(allSegments).length - ) { + if (value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { validSegments = {}; setValidSegments(validSegments); - setPlaceholderDate( - createPlaceholderDate( - props.placeholderValue, - granularity, - calendar, - defaultTimeZone - ) - ); + setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); } // If all segments are valid, use the date from state, otherwise use the placeholder date. - let displayValue = - calendarValue && - Object.keys(validSegments).length >= Object.keys(allSegments).length - ? calendarValue - : placeholderDate; + let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; let setValue = (newValue: DateValue) => { if (props.isDisabled || props.isReadOnly) { return; @@ -354,22 +254,9 @@ export function useDateFieldState( // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared if (newValue == null) { setDate(null); - setPlaceholderDate( - createPlaceholderDate( - props.placeholderValue, - granularity, - calendar, - defaultTimeZone - ) - ); + setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); setValidSegments({}); - } else if ( - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && - allSegments.dayPeriod && - !validSegments.dayPeriod && - clearedSegment.current !== "dayPeriod") - ) { + } else if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { // The display calendar should not have any effect on the emitted value. // Emit dates in the same calendar as the original value, if any, otherwise gregorian. newValue = toCalendar(newValue, v?.calendar || new GregorianCalendar()); @@ -380,59 +267,44 @@ export function useDateFieldState( clearedSegment.current = null; }; - let dateValue = useMemo( - () => displayValue.toDate(timeZone), - [displayValue, timeZone] - ); - let segments = useMemo( - () => - dateFormatter.formatToParts(dateValue).map((segment) => { + let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); + let segments = useMemo(() => + dateFormatter.formatToParts(dateValue) + .map(segment => { let isEditable = EDITABLE_SEGMENTS[segment.type]; - if (segment.type === "era" && calendar.getEras().length === 1) { + if (segment.type === 'era' && calendar.getEras().length === 1) { isEditable = false; } - let isPlaceholder = - EDITABLE_SEGMENTS[segment.type] && !validSegments[segment.type]; - let placeholder = EDITABLE_SEGMENTS[segment.type] - ? getPlaceholder(segment.type, segment.value, locale) - : null; + let isPlaceholder = EDITABLE_SEGMENTS[segment.type] && !validSegments[segment.type]; + let placeholder = EDITABLE_SEGMENTS[segment.type] ? getPlaceholder(segment.type, segment.value, locale) : null; return { type: TYPE_MAPPING[segment.type] || segment.type, text: isPlaceholder ? placeholder : segment.value, ...getSegmentLimits(displayValue, segment.type, resolvedOptions), isPlaceholder, placeholder, - isEditable, + isEditable } as DateSegment; - }), - [ - dateValue, - validSegments, - dateFormatter, - resolvedOptions, - displayValue, - calendar, - locale, - ] - ); + }) + , [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale]); // When the era field appears, mark it valid if the year field is already valid. // If the era field disappears, remove it from the valid segments. if (allSegments.era && validSegments.year && !validSegments.era) { validSegments.era = true; - setValidSegments({ ...validSegments }); + setValidSegments({...validSegments}); } else if (!allSegments.era && validSegments.era) { delete validSegments.era; - setValidSegments({ ...validSegments }); + setValidSegments({...validSegments}); } let markValid = (part: Intl.DateTimeFormatPartTypes) => { validSegments[part] = true; - if (part === "year" && allSegments.era) { + if (part === 'year' && allSegments.era) { validSegments.era = true; } - setValidSegments({ ...validSegments }); + setValidSegments({...validSegments}); }; let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { @@ -440,12 +312,7 @@ export function useDateFieldState( markValid(type); let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if ( - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && - allSegments.dayPeriod && - !validSegments.dayPeriod) - ) { + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { setValue(displayValue); } } else { @@ -453,27 +320,22 @@ export function useDateFieldState( } }; - let builtinValidation = useMemo( - () => - getValidationResult( - value, - minValue, - maxValue, - isDateUnavailable, - formatOpts - ), - [value, minValue, maxValue, isDateUnavailable, formatOpts] - ); + let builtinValidation = useMemo(() => getValidationResult( + value, + minValue, + maxValue, + isDateUnavailable, + formatOpts + ), [value, minValue, maxValue, isDateUnavailable, formatOpts]); let validation = useFormValidationState({ ...props, value: value as MappedDateValue | null, - builtinValidation, + builtinValidation }); let isValueInvalid = validation.displayValidation.isInvalid; - let validationState: ValidationState | null = - props.validationState || (isValueInvalid ? "invalid" : null); + let validationState: ValidationState | null = props.validationState || (isValueInvalid ? 'invalid' : null); return { ...validation, @@ -486,7 +348,7 @@ export function useDateFieldState( validationState, isInvalid: isValueInvalid, granularity, - maxGranularity: props.maxGranularity ?? "year", + maxGranularity: props.maxGranularity ?? 'year', isDisabled, isReadOnly, isRequired, @@ -514,12 +376,8 @@ export function useDateFieldState( // Confirm the placeholder if only the day period is not filled in. let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if ( - validKeys.length === allKeys.length - 1 && - allSegments.dayPeriod && - !validSegments.dayPeriod - ) { - validSegments = { ...allSegments }; + if (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod) { + validSegments = {...allSegments}; setValidSegments(validSegments); setValue(displayValue.copy()); } @@ -527,39 +385,24 @@ export function useDateFieldState( clearSegment(part) { delete validSegments[part]; clearedSegment.current = part; - setValidSegments({ ...validSegments }); + setValidSegments({...validSegments}); - let placeholder = createPlaceholderDate( - props.placeholderValue, - granularity, - calendar, - defaultTimeZone - ); + let placeholder = createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone); let value = displayValue; // Reset day period to default without changing the hour. - console.log(part); - if ( - part === "dayPeriod" && - "hour" in displayValue && - "hour" in placeholder - ) { + if (part === 'dayPeriod' && 'hour' in displayValue && 'hour' in placeholder) { let isPM = displayValue.hour >= 12; let shouldBePM = placeholder.hour >= 12; if (isPM && !shouldBePM) { - value = displayValue.set({ hour: displayValue.hour - 12 }); + value = displayValue.set({hour: displayValue.hour - 12}); } else if (!isPM && shouldBePM) { - value = displayValue.set({ hour: displayValue.hour + 12 }); + value = displayValue.set({hour: displayValue.hour + 12}); } - } else if ( - part === "hour" && - "hour" in displayValue && - displayValue.hour >= 12 && - validSegments.dayPeriod - ) { - value = displayValue.set({ hour: placeholder["hour"] + 12 }); + } else if (part === 'hour' && 'hour' in displayValue && displayValue.hour >= 12 && validSegments.dayPeriod) { + value = displayValue.set({hour: placeholder['hour'] + 12}); } else if (part in displayValue) { - value = displayValue.set({ [part]: placeholder[part] }); + value = displayValue.set({[part]: placeholder[part]}); } setDate(null); @@ -567,7 +410,7 @@ export function useDateFieldState( }, formatValue(fieldOptions: FieldOptions) { if (!calendarValue) { - return ""; + return ''; } let formatOptions = getFormatOptions(fieldOptions, formatOpts); @@ -575,81 +418,77 @@ export function useDateFieldState( return formatter.format(dateValue); }, getDateFormatter(locale, formatOptions: FormatterOptions) { - let newOptions = { ...formatOpts, ...formatOptions }; + let newOptions = {...formatOpts, ...formatOptions}; let newFormatOptions = getFormatOptions({}, newOptions); return new DateFormatter(locale, newFormatOptions); - }, + } }; } -function getSegmentLimits( - date: DateValue, - type: string, - options: Intl.ResolvedDateTimeFormatOptions -) { +function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { switch (type) { - case "era": { + case 'era': { let eras = date.calendar.getEras(); return { value: eras.indexOf(date.era), minValue: 0, - maxValue: eras.length - 1, + maxValue: eras.length - 1 }; } - case "year": + case 'year': return { value: date.year, minValue: 1, - maxValue: date.calendar.getYearsInEra(date), + maxValue: date.calendar.getYearsInEra(date) }; - case "month": + case 'month': return { value: date.month, minValue: getMinimumMonthInYear(date), - maxValue: date.calendar.getMonthsInYear(date), + maxValue: date.calendar.getMonthsInYear(date) }; - case "day": + case 'day': return { value: date.day, minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date), + maxValue: date.calendar.getDaysInMonth(date) }; } - if ("hour" in date) { + if ('hour' in date) { switch (type) { - case "dayPeriod": + case 'dayPeriod': return { value: date.hour >= 12 ? 12 : 0, minValue: 0, - maxValue: 12, + maxValue: 12 }; - case "hour": + case 'hour': if (options.hour12) { let isPM = date.hour >= 12; return { value: date.hour, minValue: isPM ? 12 : 0, - maxValue: isPM ? 23 : 11, + maxValue: isPM ? 23 : 11 }; } return { value: date.hour, minValue: 0, - maxValue: 23, + maxValue: 23 }; - case "minute": + case 'minute': return { value: date.minute, minValue: 0, - maxValue: 59, + maxValue: 59 }; - case "second": + case 'second': return { value: date.second, minValue: 0, - maxValue: 59, + maxValue: 59 }; } } @@ -657,66 +496,56 @@ function getSegmentLimits( return {}; } -function addSegment( - value: DateValue, - part: string, - amount: number, - options: Intl.ResolvedDateTimeFormatOptions -) { +function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { switch (part) { - case "era": - case "year": - case "month": - case "day": - return value.cycle(part, amount, { round: part === "year" }); + case 'era': + case 'year': + case 'month': + case 'day': + return value.cycle(part, amount, {round: part === 'year'}); } - if ("hour" in value) { + if ('hour' in value) { switch (part) { - case "dayPeriod": { + case 'dayPeriod': { let hours = value.hour; let isPM = hours >= 12; - return value.set({ hour: isPM ? hours - 12 : hours + 12 }); + return value.set({hour: isPM ? hours - 12 : hours + 12}); } - case "hour": - case "minute": - case "second": + case 'hour': + case 'minute': + case 'second': return value.cycle(part, amount, { - round: part !== "hour", - hourCycle: options.hour12 ? 12 : 24, + round: part !== 'hour', + hourCycle: options.hour12 ? 12 : 24 }); } } - throw new Error("Unknown segment: " + part); + throw new Error('Unknown segment: ' + part); } -function setSegment( - value: DateValue, - part: string, - segmentValue: number | string, - options: Intl.ResolvedDateTimeFormatOptions -) { +function setSegment(value: DateValue, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { switch (part) { - case "day": - case "month": - case "year": - case "era": - return value.set({ [part]: segmentValue }); + case 'day': + case 'month': + case 'year': + case 'era': + return value.set({[part]: segmentValue}); } - if ("hour" in value && typeof segmentValue === "number") { + if ('hour' in value && typeof segmentValue === 'number') { switch (part) { - case "dayPeriod": { + case 'dayPeriod': { let hours = value.hour; let wasPM = hours >= 12; let isPM = segmentValue >= 12; if (isPM === wasPM) { return value; } - return value.set({ hour: wasPM ? hours - 12 : hours + 12 }); + return value.set({hour: wasPM ? hours - 12 : hours + 12}); } - case "hour": + case 'hour': // In 12 hour time, ensure that AM/PM does not change if (options.hour12) { let hours = value.hour; @@ -728,12 +557,12 @@ function setSegment( segmentValue += 12; } } - // fallthrough - case "minute": - case "second": - return value.set({ [part]: segmentValue }); + // fallthrough + case 'minute': + case 'second': + return value.set({[part]: segmentValue}); } } - throw new Error("Unknown segment: " + part); + throw new Error('Unknown segment: ' + part); } From 56d855fe0d26adbf5f588d99068cc1d37e2db9e0 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 24 Jan 2025 12:36:07 -0800 Subject: [PATCH 3/3] add test --- .../datepicker/test/TimeField.test.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/datepicker/test/TimeField.test.js b/packages/@react-spectrum/datepicker/test/TimeField.test.js index b1b0570bf1d..0b6bee9627d 100644 --- a/packages/@react-spectrum/datepicker/test/TimeField.test.js +++ b/packages/@react-spectrum/datepicker/test/TimeField.test.js @@ -13,7 +13,7 @@ import {act, fireEvent, pointerMap, render as render_, within} from '@react-spectrum/test-utils-internal'; import {Button} from '@react-spectrum/button'; import {Form} from '@react-spectrum/form'; -import {parseZonedDateTime, Time} from '@internationalized/date'; +import {parseTime, parseZonedDateTime, Time} from '@internationalized/date'; import {Provider} from '@react-spectrum/provider'; import React from 'react'; import {theme} from '@react-spectrum/theme-default'; @@ -167,6 +167,23 @@ describe('TimeField', function () { expect(onFocusSpy).toHaveBeenCalledTimes(1); }); + it('should keep dayPeriod the same when hour segment that has a value >= 12 is cleared', async function () { + let {getAllByRole} = render(); + let segments = getAllByRole('spinbutton'); + + await user.tab(); + expect(segments[0]).toHaveFocus(); + expect(segments[0]).toHaveAttribute('aria-valuetext', '8 PM'); + expect(segments[2].getAttribute('aria-label')).toBe('AM/PM, '); + expect(within(segments[2]).getByText('PM')).toBeInTheDocument(); + + fireEvent.keyDown(document.activeElement, {key: 'Backspace'}); + fireEvent.keyUp(document.activeElement, {key: 'Backspace'}); + expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty'); + expect(segments[2].getAttribute('aria-label')).toBe('AM/PM, '); + expect(within(segments[2]).getByText('PM')).toBeInTheDocument(); + }); + it('should trigger right arrow key event for segment navigation', async function () { let {getAllByRole} = render(); let segments = getAllByRole('spinbutton');