From 3ed43ed6cc6d95639c45b0f44061bf3026438fdb Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 20 Mar 2025 15:27:25 +0100 Subject: [PATCH 1/6] fix: force data-readonly when datefield is readonly --- .../@react-stately/datepicker/src/useDateFieldState.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 864c0bf21f3..2c8d9eb8456 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -269,8 +269,8 @@ export function useDateFieldState(props: DateFi let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), - [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly), + [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly]); // 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. @@ -408,13 +408,13 @@ export function useDateFieldState(props: DateFi }; } -function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity) : DateSegment[] { +function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly) : DateSegment[] { let timeValue = ['hour', 'minute', 'second']; let segments = dateFormatter.formatToParts(dateValue); let processedSegments: DateSegment[] = []; for (let segment of segments) { let isEditable = EDITABLE_SEGMENTS[segment.type]; - if (segment.type === 'era' && calendar.getEras().length === 1) { + if (segment.type === 'era' && calendar.getEras().length === 1 || isReadOnly) { isEditable = false; } From 5f8443ff6faf71d41d7424643ea301f1fbbd040b Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 20 Mar 2025 16:09:19 +0100 Subject: [PATCH 2/6] chore: simplify isEditable --- packages/@react-stately/datepicker/src/useDateFieldState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 2c8d9eb8456..81ccaf93ba6 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -413,8 +413,8 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption let segments = dateFormatter.formatToParts(dateValue); let processedSegments: DateSegment[] = []; for (let segment of segments) { - let isEditable = EDITABLE_SEGMENTS[segment.type]; - if (segment.type === 'era' && calendar.getEras().length === 1 || isReadOnly) { + let isEditable = !isReadOnly && EDITABLE_SEGMENTS[segment.type]; + if (segment.type === 'era' && calendar.getEras().length === 1) { isEditable = false; } From c91c224784778751766eb22a87e194e6969ed330 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 20 May 2025 09:38:02 +1000 Subject: [PATCH 3/6] adding tests --- .../test/DateField.test.js | 72 +++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 5bb55d52e93..35dfd906ae0 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -56,6 +56,11 @@ describe('DateField', () => { expect(segment).toHaveAttribute('data-placeholder', 'true'); expect(segment).toHaveAttribute('data-type'); expect(segment).toHaveAttribute('data-test', 'test'); + expect(segment).not.toHaveAttribute('data-readonly'); + } + + for (let literal of [...input.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).not.toHaveAttribute('data-readonly'); } }); @@ -164,7 +169,7 @@ describe('DateField', () => { }); it('should support disabled state', () => { - let {getByRole} = render( + let {getByRole, getAllByRole} = render( isDisabled ? 'disabled' : ''}> @@ -175,6 +180,65 @@ describe('DateField', () => { let group = getByRole('group'); expect(group).toHaveAttribute('data-disabled'); expect(group).toHaveClass('disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).not.toHaveAttribute('data-readonly'); + expect(segment).toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).not.toHaveAttribute('data-readonly'); + expect(literal).toHaveAttribute('data-disabled'); + } + }); + + it('should support readonly with disabled state', () => { + let {getByRole, getAllByRole} = render( + + + + {segment => } + + + ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-readonly'); + expect(group).toHaveAttribute('data-disabled'); + expect(group).toHaveClass('disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).toHaveAttribute('data-readonly'); + expect(segment).toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).toHaveAttribute('data-readonly'); + expect(literal).toHaveAttribute('data-disabled'); + } + }); + + it('should support readonly state', () => { + let {getByRole, getAllByRole} = render( + + + + {segment => } + + + ); + + let group = getByRole('group'); + expect(group).toHaveAttribute('data-readonly'); + expect(group).not.toHaveAttribute('data-disabled'); + expect(group).not.toHaveClass('disabled'); + + for (let segment of getAllByRole('spinbutton')) { + expect(segment).toHaveAttribute('data-readonly'); + expect(segment).not.toHaveAttribute('data-disabled'); + } + for (let literal of [...group.children].filter(child => child.getAttribute('data-type') === 'literal')) { + expect(literal).toHaveAttribute('data-readonly'); + expect(literal).not.toHaveAttribute('data-disabled'); + } }); it('should support render props', () => { @@ -204,12 +268,12 @@ describe('DateField', () => { {({isDisabled}) => ( <> - {segment => } - )} + )} ); let group = getByRole('group'); @@ -317,7 +381,7 @@ describe('DateField', () => { ); - + let segments = getAllByRole('spinbutton'); await user.click(segments[2]); expect(document.activeElement).toBe(segments[2]); From d6fb6af3bb71d2712f143989ad77d7cf22d34110 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 15 Jul 2025 17:14:22 +1000 Subject: [PATCH 4/6] update based on expectations --- .../datepicker/src/useDateFieldState.ts | 20 +++++++++---------- .../react-aria-components/src/DateField.tsx | 14 ++++++------- packages/react-aria-components/src/Group.tsx | 7 +++++-- .../stories/DateField.stories.tsx | 6 ++++++ .../test/DateField.test.js | 1 - 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index df20729340e..d5972fd6fb4 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -264,8 +264,8 @@ export function useDateFieldState(props: DateFi setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); setValidSegments({}); } else if ( - validKeys.length === 0 || - validKeys.length >= allKeys.length || + validKeys.length === 0 || + validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod') ) { // If the field was empty (no valid segments) or all segments are completed, commit the new value. @@ -286,9 +286,9 @@ export function useDateFieldState(props: DateFi }; let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); - let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly), - [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly]); + let segments = useMemo(() => + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), + [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); // 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. @@ -427,13 +427,13 @@ export function useDateFieldState(props: DateFi }; } -function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly) : DateSegment[] { +function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity) : DateSegment[] { let timeValue = ['hour', 'minute', 'second']; let segments = dateFormatter.formatToParts(dateValue); let processedSegments: DateSegment[] = []; for (let segment of segments) { let type = TYPE_MAPPING[segment.type] || segment.type; - let isEditable = !isReadOnly && EDITABLE_SEGMENTS[type]; + let isEditable = EDITABLE_SEGMENTS[type]; if (type === 'era' && calendar.getEras().length === 1) { isEditable = false; } @@ -452,9 +452,9 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption // There is an issue in RTL languages where time fields render (minute:hour) instead of (hour:minute). // To force an LTR direction on the time field since, we wrap the time segments in LRI (left-to-right) isolate unicode. See https://www.w3.org/International/questions/qa-bidi-unicode-controls. - // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. + // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. if (type === 'hour') { - // This marks the start of the embedded direction change. + // This marks the start of the embedded direction change. processedSegments.push({ type: 'literal', text: '\u2066', @@ -487,7 +487,7 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption isEditable: false }); } else { - // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. + // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. processedSegments.push(dateSegment); } } diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx index 1a590f9831f..863c4d6df2a 100644 --- a/packages/react-aria-components/src/DateField.tsx +++ b/packages/react-aria-components/src/DateField.tsx @@ -111,11 +111,11 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D slot={props.slot || undefined} data-invalid={state.isInvalid || undefined} data-disabled={state.isDisabled || undefined} /> - + state={state} /> ); }); @@ -180,7 +180,7 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T {...renderProps} ref={ref} slot={props.slot || undefined} - data-invalid={state.isInvalid || undefined} + data-invalid={state.isInvalid || undefined} data-disabled={state.isDisabled || undefined} /> ); @@ -269,6 +269,7 @@ const DateInputInner = forwardRef((props: DateInputProps, ref: ForwardedRef {state.segments.map((segment, i) => cloneElement(children(segment), {key: i}))} @@ -337,12 +338,11 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function let {segmentProps} = useDateSegment(segment, state, domRef); let {focusProps, isFocused, isFocusVisible} = useFocusRing(); let {hoverProps, isHovered} = useHover({...otherProps, isDisabled: state.isDisabled || segment.type === 'literal'}); - let {isEditable, ...segmentRest} = segment; let renderProps = useRenderProps({ ...otherProps, values: { - ...segmentRest, - isReadOnly: !isEditable, + ...segment, + isReadOnly: state.isReadOnly, isInvalid: state.isInvalid, isDisabled: state.isDisabled, isHovered, @@ -361,7 +361,7 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function ref={domRef} data-placeholder={segment.isPlaceholder || undefined} data-invalid={state.isInvalid || undefined} - data-readonly={!isEditable || undefined} + data-readonly={state.isReadOnly || undefined} data-disabled={state.isDisabled || undefined} data-type={segment.type} data-hovered={isHovered || undefined} diff --git a/packages/react-aria-components/src/Group.tsx b/packages/react-aria-components/src/Group.tsx index 222ca6a0037..03f398f5728 100644 --- a/packages/react-aria-components/src/Group.tsx +++ b/packages/react-aria-components/src/Group.tsx @@ -48,6 +48,8 @@ export interface GroupProps extends AriaLabelingProps, Omit) { [props, ref] = useContextProps(props, ref, GroupContext); - let {isDisabled, isInvalid, onHoverStart, onHoverChange, onHoverEnd, ...otherProps} = props; + let {isDisabled, isInvalid, isReadOnly, onHoverStart, onHoverChange, onHoverEnd, ...otherProps} = props; let {hoverProps, isHovered} = useHover({onHoverStart, onHoverChange, onHoverEnd, isDisabled}); let {isFocused, isFocusVisible, focusProps} = useFocusRing({ @@ -92,7 +94,8 @@ export const Group = /*#__PURE__*/ (forwardRef as forwardRefType)(function Group data-hovered={isHovered || undefined} data-focus-visible={isFocusVisible || undefined} data-disabled={isDisabled || undefined} - data-invalid={isInvalid || undefined}> + data-invalid={isInvalid || undefined} + data-readonly={isReadOnly || undefined}> {renderProps.children} ); diff --git a/packages/react-aria-components/stories/DateField.stories.tsx b/packages/react-aria-components/stories/DateField.stories.tsx index 68c7dd1a7b0..3426b8addc4 100644 --- a/packages/react-aria-components/stories/DateField.stories.tsx +++ b/packages/react-aria-components/stories/DateField.stories.tsx @@ -42,6 +42,12 @@ export default { isInvalid: { control: 'boolean' }, + isDisabled: { + control: 'boolean' + }, + isReadOnly: { + control: 'boolean' + }, validationBehavior: { control: 'select', options: ['native', 'aria'] diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index cea84ec279e..12c18d1a639 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -204,7 +204,6 @@ describe('DateField', () => { let group = getByRole('group'); expect(group).toHaveAttribute('data-readonly'); expect(group).toHaveAttribute('data-disabled'); - expect(group).toHaveClass('disabled'); for (let segment of getAllByRole('spinbutton')) { expect(segment).toHaveAttribute('data-readonly'); From 51bb1e06409fa688a09b18b207d83447952fb6d2 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 15 Jul 2025 17:16:43 +1000 Subject: [PATCH 5/6] fix formatting --- .../datepicker/src/useDateFieldState.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index d5972fd6fb4..c4f6795b7a8 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -264,8 +264,8 @@ export function useDateFieldState(props: DateFi setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); setValidSegments({}); } else if ( - validKeys.length === 0 || - validKeys.length >= allKeys.length || + validKeys.length === 0 || + validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod') ) { // If the field was empty (no valid segments) or all segments are completed, commit the new value. @@ -286,9 +286,9 @@ export function useDateFieldState(props: DateFi }; let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); - let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), - [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); + let segments = useMemo(() => + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly), + [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly]); // 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. @@ -452,9 +452,9 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption // There is an issue in RTL languages where time fields render (minute:hour) instead of (hour:minute). // To force an LTR direction on the time field since, we wrap the time segments in LRI (left-to-right) isolate unicode. See https://www.w3.org/International/questions/qa-bidi-unicode-controls. - // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. + // These unicode characters will be added to the array of processed segments as literals and will mark the start and end of the embedded direction change. if (type === 'hour') { - // This marks the start of the embedded direction change. + // This marks the start of the embedded direction change. processedSegments.push({ type: 'literal', text: '\u2066', @@ -487,7 +487,7 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption isEditable: false }); } else { - // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. + // We only want to "wrap" the unicode around segments that are hour, minute, or second. If they aren't, just process as normal. processedSegments.push(dateSegment); } } From 948873808bf8cd101332fe521cf553a075b8f160 Mon Sep 17 00:00:00 2001 From: Robert Snow Date: Tue, 15 Jul 2025 17:20:26 +1000 Subject: [PATCH 6/6] Apply suggestion from @snowystinger --- packages/@react-stately/datepicker/src/useDateFieldState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index c4f6795b7a8..d66f5881c86 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -287,8 +287,8 @@ export function useDateFieldState(props: DateFi let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly), - [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isReadOnly]); + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), + [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); // 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.