From e228ed814b58e162779fb8c2f7270c8b8e844c15 Mon Sep 17 00:00:00 2001
From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com>
Date: Tue, 28 Jan 2025 20:06:50 -0500
Subject: [PATCH] fix: correctly format date/time in RTL (#7423)
* bdo on timefield, reverse segments on timefield in datefield
* fix lint
* make things inline
* use unicode character to wrap segments
* fix test
* append unicode to text in hooks, update rac
* add comment
* skip failing test for now
* update keyboard nav
* update logic of how unicode is applied
* fix spacing
* add comments
* update tests
* undo some previous changes
* wrap time segments in lri, wrap fields in unicode isolate
* fix ssr test
* fix spacing
* fix css logic
* fix lint
* fix keyboard nav in rac datepicker popover
* fix lint
* prevent overflow in date range picker
* move overflow hidden to separate new div to fix weird focus ring around the button
* this time actually fix the overflow and focus ring issue
* update var names to be nicer
* fix japanese placeholder for extra space
* fix css positioning
* fix custom width
* small css changes so that rtl will format properly
* memo ordering of segments for keyboard navigation
* add chromatic tests
* fix lint
* add tests to rsp date components
* add tests to rac
* fix tests
* remove comment
* fix chromatic stories
* add chromatic story
* remove style tests
* fix lint
* update to uselayouteffect and update keyboard nav test
* make date input more consistent with using display inline
* update timefield docs css to use display inline
* fix showFormatHelpText
* small change
* fix lint
* add divs to keyboard navigation so it works with older versions
* fix lint + fix tests
---
.../datepicker/docs/useDateField.mdx | 6 +-
.../datepicker/src/useDateField.ts | 3 +
.../datepicker/src/useDatePickerGroup.ts | 73 ++++++++-
.../datepicker/src/useDateSegment.ts | 18 +-
.../chromatic/DateField.stories.tsx | 12 +-
.../chromatic/DatePicker.stories.tsx | 76 ++++++++-
.../chromatic/DateRangePicker.stories.tsx | 80 ++++++++-
.../chromatic/TimeField.stories.tsx | 12 +-
.../datepicker/src/DatePickerField.tsx | 4 +-
.../datepicker/src/DatePickerSegment.tsx | 4 +-
.../datepicker/src/DateRangePicker.tsx | 58 +++----
.../@react-spectrum/datepicker/src/styles.css | 7 +-
.../datepicker/src/{utils.ts => utils.tsx} | 16 +-
.../datepicker/stories/DateField.stories.tsx | 2 +-
.../datepicker/test/DateField.test.js | 4 +-
.../datepicker/test/DatePicker.test.js | 8 +-
.../datepicker/test/DatePickerBase.test.js | 154 ++++++++++++++----
.../datepicker/test/DateRangePicker.test.js | 6 +-
.../datepicker/src/placeholders.ts | 2 +-
.../datepicker/src/useDateFieldState.ts | 83 +++++++---
.../react-aria-components/docs/DateField.mdx | 4 +-
.../react-aria-components/docs/TimeField.mdx | 4 +-
.../react-aria-components/example/index.css | 1 -
.../react-aria-components/src/DateField.tsx | 3 +-
.../react-aria-components/src/Popover.tsx | 10 +-
.../stories/DatePicker.stories.tsx | 8 +-
.../test/DatePicker.test.js | 2 +-
.../test/TimeField.test.js | 2 +-
28 files changed, 526 insertions(+), 136 deletions(-)
rename packages/@react-spectrum/datepicker/src/{utils.ts => utils.tsx} (89%)
diff --git a/packages/@react-aria/datepicker/docs/useDateField.mdx b/packages/@react-aria/datepicker/docs/useDateField.mdx
index b8a66e9e06f..f82dc860993 100644
--- a/packages/@react-aria/datepicker/docs/useDateField.mdx
+++ b/packages/@react-aria/datepicker/docs/useDateField.mdx
@@ -130,12 +130,12 @@ function DateSegment({segment, state}) {
let {segmentProps} = useDateSegment(segment, state, ref);
return (
-
{segment.text}
-
+
);
}
@@ -153,7 +153,7 @@ function DateSegment({segment, state}) {
}
.field {
- display: inline-flex;
+ display: block;
padding: 2px 4px;
border-radius: 2px;
border: 1px solid var(--gray);
diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts
index 877678b3ebe..cf4e64216f4 100644
--- a/packages/@react-aria/datepicker/src/useDateField.ts
+++ b/packages/@react-aria/datepicker/src/useDateField.ts
@@ -181,6 +181,9 @@ export function useDateField(props: AriaDateFieldOptions
if (props.onKeyUp) {
props.onKeyUp(e);
}
+ },
+ style: {
+ unicodeBidi: 'isolate'
}
}),
inputProps,
diff --git a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
index 43f8521f02d..478012de5fc 100644
--- a/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
+++ b/packages/@react-aria/datepicker/src/useDatePickerGroup.ts
@@ -1,14 +1,48 @@
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
import {FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared';
-import {mergeProps} from '@react-aria/utils';
+import {mergeProps, useLayoutEffect} from '@react-aria/utils';
import {useLocale} from '@react-aria/i18n';
-import {useMemo} from 'react';
+import {useMemo, useRef} from 'react';
import {usePress} from '@react-aria/interactions';
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject, disableArrowNavigation?: boolean) {
let {direction} = useLocale();
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
+ let segments = useRef(undefined);
+ useLayoutEffect(() => {
+ if (ref?.current) {
+
+ let update = () => {
+ if (ref.current) {
+ // TODO: For now, just querying this list of elements. However, it's possible that either through hooks or RAC that some users may include other focusable items that they would want to able to keyboard navigate to. In that case, we might want to utilize focusableElements in isFocusable.ts
+ let editableSegments: NodeListOf | undefined = ref.current?.querySelectorAll('span[role="spinbutton"], span[role="textbox"], button, div[role="spinbutton"], div[role="textbox"]');
+
+ let segmentsArr = Array.from(editableSegments as NodeListOf).filter(Boolean).map(node => {
+ return {
+ element: node as FocusableElement,
+ rectX: node.getBoundingClientRect().left
+ };
+ });
+
+ let orderedSegments = segmentsArr.sort((a, b) => a.rectX - b.rectX).map((item => item.element));
+ segments.current = orderedSegments;
+ }
+ };
+
+ update();
+
+ let observer = new MutationObserver(update);
+ observer.observe(ref.current, {
+ subtree: true,
+ childList: true
+ });
+
+ return () => {
+ observer.disconnect();
+ };
+ }
+ }, []);
// Open the popover on alt + arrow down
let onKeyDown = (e: KeyboardEvent) => {
@@ -31,7 +65,21 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.preventDefault();
e.stopPropagation();
if (direction === 'rtl') {
- focusManager.focusNext();
+ if (segments.current) {
+ let orderedSegments = segments.current;
+ let target = e.target as FocusableElement;
+ let index = orderedSegments.indexOf(target);
+
+ if (index === 0) {
+ target = orderedSegments[0] || target;
+ } else {
+ target = orderedSegments[index - 1] || target;
+ }
+
+ if (target) {
+ target.focus();
+ }
+ }
} else {
focusManager.focusPrevious();
}
@@ -40,7 +88,24 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
e.preventDefault();
e.stopPropagation();
if (direction === 'rtl') {
- focusManager.focusPrevious();
+ if (segments.current) {
+ let orderedSegments = segments.current;
+ let target = e.target as FocusableElement;
+ let index = orderedSegments.indexOf(target);
+
+ if (index === orderedSegments.length - 1) {
+ target = orderedSegments[orderedSegments.length - 1] || target;
+ } else {
+ target = orderedSegments[index - 1] || target;
+ }
+
+
+ target = orderedSegments[index + 1] || target;
+
+ if (target) {
+ target.focus();
+ }
+ }
} else {
focusManager.focusNext();
}
diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts
index d022ece89e0..4a193ac3a02 100644
--- a/packages/@react-aria/datepicker/src/useDateSegment.ts
+++ b/packages/@react-aria/datepicker/src/useDateSegment.ts
@@ -15,7 +15,7 @@ import {DateFieldState, DateSegment} from '@react-stately/datepicker';
import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
import {hookData} from './useDateField';
import {NumberParser} from '@internationalized/number';
-import React, {useMemo, useRef} from 'react';
+import React, {CSSProperties, useMemo, useRef} from 'react';
import {RefObject} from '@react-types/shared';
import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n';
import {useDisplayNames} from './useDisplayNames';
@@ -33,7 +33,7 @@ export interface DateSegmentAria {
*/
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject): DateSegmentAria {
let enteredKeys = useRef('');
- let {locale} = useLocale();
+ let {locale, direction} = useLocale();
let displayNames = useDisplayNames();
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state)!;
@@ -385,6 +385,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
};
}
+ let dateSegments = ['day', 'month', 'year'];
+ let segmentStyle : CSSProperties = {caretColor: 'transparent'};
+ if (direction === 'rtl') {
+ if (dateSegments.includes(segment.type)) {
+ segmentStyle = {caretColor: 'transparent', direction: 'ltr', unicodeBidi: 'embed'};
+ } else if (segment.type === 'timeZoneName') {
+ segmentStyle = {caretColor: 'transparent', unicodeBidi: 'embed'};
+ }
+ }
+
return {
segmentProps: mergeProps(spinButtonProps, labelProps, {
id,
@@ -403,9 +413,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
tabIndex: state.isDisabled ? undefined : 0,
onKeyDown,
onFocus,
- style: {
- caretColor: 'transparent'
- },
+ style: segmentStyle,
// Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
onPointerDown(e) {
e.stopPropagation();
diff --git a/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx b/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx
index 23cf9880a2a..363a9c17424 100644
--- a/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx
+++ b/packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx
@@ -21,7 +21,7 @@ export default {
title: 'DateField',
parameters: {
chromaticProvider: {
- locales: ['en-US', 'ar-EG', 'ja-JP']
+ locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
}
}
};
@@ -41,6 +41,16 @@ PlaceholderFocus.parameters = {
}
};
+export const PlaceholderFocusRTL = () => ;
+PlaceholderFocusRTL.parameters = {
+ chromaticProvider: {
+ locales: ['he-IL'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+
export const PlaceholderFocusExpress = () => ;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
diff --git a/packages/@react-spectrum/datepicker/chromatic/DatePicker.stories.tsx b/packages/@react-spectrum/datepicker/chromatic/DatePicker.stories.tsx
index 0c9c8c5a3e1..e928ab54be3 100644
--- a/packages/@react-spectrum/datepicker/chromatic/DatePicker.stories.tsx
+++ b/packages/@react-spectrum/datepicker/chromatic/DatePicker.stories.tsx
@@ -16,12 +16,13 @@ import {ContextualHelp} from '@react-spectrum/contextualhelp';
import {DatePicker} from '../';
import {Heading} from '@react-spectrum/text';
import React from 'react';
+import {userEvent, within} from '@storybook/testing-library';
export default {
title: 'DatePicker',
parameters: {
chromaticProvider: {
- locales: ['en-US'/* , 'ar-EG', 'ja-JP' */]
+ locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
}
}
};
@@ -37,6 +38,17 @@ const focusParams = {
const openParams = {
chromaticProvider: {
+ locales: ['en-US'],
+ colorSchemes: ['light'],
+ scales: ['medium'],
+ disableAnimations: true,
+ express: false
+ }
+};
+
+const openParamsRTL = {
+ chromaticProvider: {
+ locales: ['he-IL'],
colorSchemes: ['light'],
scales: ['medium'],
disableAnimations: true,
@@ -54,6 +66,16 @@ export const Placeholder = () => ;
PlaceholderFocus.parameters = focusParams;
+export const PlaceholderFocusRTL = () => ;
+PlaceholderFocusRTL.parameters = {
+ chromaticProvider: {
+ locales: ['ar-EG'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+
export const PlaceholderFocusExpress = () => ;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
@@ -110,10 +132,18 @@ export const OpenPlaceholder = () => ;
+OpenPlaceholderRTL.parameters = openParamsRTL;
+OpenPlaceholderRTL.decorators = openDecorators;
+
export const OpenValue = () => ;
OpenValue.parameters = openParams;
OpenValue.decorators = openDecorators;
+export const OpenValueRTL = () => ;
+OpenValueRTL.parameters = openParamsRTL;
+OpenValueRTL.decorators = openDecorators;
+
export const OpenTime = () => ;
OpenTime.parameters = openParams;
OpenTime.decorators = openDecorators;
@@ -147,6 +177,50 @@ OpenExpress.parameters = {
};
OpenExpress.decorators = openDecorators;
+export const OpenLTRInteractions = () => ;
+OpenLTRInteractions.parameters = {
+ chromaticProvider: {
+ locales: ['en-US'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+OpenLTRInteractions.decorators = openDecorators;
+
+OpenLTRInteractions.play = async ({canvasElement}) => {
+ await userEvent.tab();
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[Enter]]');
+ let body = canvasElement.ownerDocument.body;
+ await within(body).findByRole('dialog');
+ await userEvent.keyboard('[ArrowRight]');
+};
+
+export const OpenRTLInteractions = () => ;
+OpenRTLInteractions.parameters = {
+ chromaticProvider: {
+ locales: ['ar-EG'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+OpenRTLInteractions.decorators = openDecorators;
+
+OpenRTLInteractions.play = async ({canvasElement}) => {
+ await userEvent.tab();
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[Enter]]');
+ let body = canvasElement.ownerDocument.body;
+ await within(body).findByRole('dialog');
+ await userEvent.keyboard('[ArrowLeft]');
+};
+
export const MultipleMonths = () => ;
MultipleMonths.parameters = openParams;
MultipleMonths.decorators = [Story =>
];
diff --git a/packages/@react-spectrum/datepicker/chromatic/DateRangePicker.stories.tsx b/packages/@react-spectrum/datepicker/chromatic/DateRangePicker.stories.tsx
index e70a6d46067..411030f8d1c 100644
--- a/packages/@react-spectrum/datepicker/chromatic/DateRangePicker.stories.tsx
+++ b/packages/@react-spectrum/datepicker/chromatic/DateRangePicker.stories.tsx
@@ -16,12 +16,13 @@ import {ContextualHelp} from '@react-spectrum/contextualhelp';
import {DateRangePicker} from '../';
import {Heading} from '@react-spectrum/text';
import React from 'react';
+import {userEvent, within} from '@storybook/testing-library';
export default {
title: 'DateRangePicker',
parameters: {
chromaticProvider: {
- locales: ['en-US'/* , 'ar-EG', 'ja-JP' */]
+ locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
}
}
};
@@ -37,6 +38,17 @@ const focusParams = {
const openParams = {
chromaticProvider: {
+ locales: ['en-US'],
+ colorSchemes: ['light'],
+ scales: ['medium'],
+ disableAnimations: true,
+ express: false
+ }
+};
+
+const openParamsRTL = {
+ chromaticProvider: {
+ locales: ['ar-EG'],
colorSchemes: ['light'],
scales: ['medium'],
disableAnimations: true,
@@ -65,6 +77,16 @@ export const Placeholder = () => ;
PlaceholderFocus.parameters = focusParams;
+export const PlaceholderFocusRTL = () => ;
+PlaceholderFocusRTL.parameters = {
+ chromaticProvider: {
+ locales: ['he-IL'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+
export const PlaceholderFocusExpress = () => ;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
@@ -121,10 +143,18 @@ export const OpenPlaceholder = () => ;
+OpenPlaceholderRTL.parameters = openParamsRTL;
+OpenPlaceholderRTL.decorators = openDecorators;
+
export const OpenValue = () => ;
OpenValue.parameters = openParams;
OpenValue.decorators = openDecorators;
+export const OpenValueRTL = () => ;
+OpenValueRTL.parameters = openParamsRTL;
+OpenValueRTL.decorators = openDecorators;
+
export const OpenTime = () => ;
OpenTime.parameters = openParams;
OpenTime.decorators = openDecorators;
@@ -158,6 +188,54 @@ OpenExpress.parameters = {
};
OpenExpress.decorators = openDecorators;
+export const OpenFocusLTRInteractions = () => ;
+OpenFocusLTRInteractions.parameters = {
+ chromaticProvider: {
+ locales: ['en-US'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+OpenFocusLTRInteractions.decorators = openDecorators;
+
+OpenFocusLTRInteractions.play = async ({canvasElement}) => {
+ await userEvent.tab();
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[ArrowRight]');
+ await userEvent.keyboard('[Enter]');
+ let body = canvasElement.ownerDocument.body;
+ await within(body).findByRole('dialog');
+ await userEvent.keyboard('[ArrowLeft]');
+};
+
+export const OpenFocusRTLInteractions = () => ;
+OpenFocusRTLInteractions.parameters = {
+ chromaticProvider: {
+ locales: ['he-IL'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+OpenFocusRTLInteractions.decorators = openDecorators;
+
+OpenFocusRTLInteractions.play = async ({canvasElement}) => {
+ await userEvent.tab();
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[ArrowLeft]');
+ await userEvent.keyboard('[Enter]');
+ let body = canvasElement.ownerDocument.body;
+ await within(body).findByRole('dialog');
+ await userEvent.keyboard('[ArrowRight]');
+};
+
export const MultipleMonths = () => ;
MultipleMonths.parameters = openParams;
MultipleMonths.decorators = [Story =>
];
diff --git a/packages/@react-spectrum/datepicker/chromatic/TimeField.stories.tsx b/packages/@react-spectrum/datepicker/chromatic/TimeField.stories.tsx
index 8294c546115..1c63080e598 100644
--- a/packages/@react-spectrum/datepicker/chromatic/TimeField.stories.tsx
+++ b/packages/@react-spectrum/datepicker/chromatic/TimeField.stories.tsx
@@ -21,7 +21,7 @@ export default {
title: 'TimeField',
parameters: {
chromaticProvider: {
- locales: ['en-US'/* , 'ar-EG', 'ja-JP' */]
+ locales: ['en-US', 'ar-EG', 'ja-JP']
}
}
};
@@ -41,6 +41,16 @@ PlaceholderFocus.parameters = {
}
};
+export const PlaceholderFocusRTL = () => ;
+PlaceholderFocusRTL.parameters = {
+ chromaticProvider: {
+ locales: ['ar-EG'],
+ scales: ['medium'],
+ colorSchemes: ['light'],
+ express: false
+ }
+};
+
export const PlaceholderFocusExpress = () => ;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
diff --git a/packages/@react-spectrum/datepicker/src/DatePickerField.tsx b/packages/@react-spectrum/datepicker/src/DatePickerField.tsx
index 210bfd21324..275bd477ad7 100644
--- a/packages/@react-spectrum/datepicker/src/DatePickerField.tsx
+++ b/packages/@react-spectrum/datepicker/src/DatePickerField.tsx
@@ -45,7 +45,7 @@ export function DatePickerField(props: DatePickerFieldProps
let {fieldProps, inputProps} = useDateField({...props, inputRef}, state, ref);
return (
-
+
{state.segments.map((segment, i) =>
((props: DatePickerFieldProps
isRequired={isRequired} />)
)}
-
+
);
}
diff --git a/packages/@react-spectrum/datepicker/src/DatePickerSegment.tsx b/packages/@react-spectrum/datepicker/src/DatePickerSegment.tsx
index 82f43c90e0f..acddf30c569 100644
--- a/packages/@react-spectrum/datepicker/src/DatePickerSegment.tsx
+++ b/packages/@react-spectrum/datepicker/src/DatePickerSegment.tsx
@@ -54,7 +54,7 @@ function EditableSegment({segment, state}: DatePickerSegmentProps) {
let {segmentProps} = useDateSegment(segment, state, ref);
return (
-
{segment.isPlaceholder ? {segment.placeholder} : segment.text}
-
+
);
}
diff --git a/packages/@react-spectrum/datepicker/src/DateRangePicker.tsx b/packages/@react-spectrum/datepicker/src/DateRangePicker.tsx
index 359df856ae2..9a7026a2459 100644
--- a/packages/@react-spectrum/datepicker/src/DateRangePicker.tsx
+++ b/packages/@react-spectrum/datepicker/src/DateRangePicker.tsx
@@ -138,33 +138,35 @@ export const DateRangePicker = React.forwardRef(function DateRangePicker
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/packages/@react-spectrum/datepicker/src/styles.css b/packages/@react-spectrum/datepicker/src/styles.css
index ecfe513b8af..ee2ee6677de 100644
--- a/packages/@react-spectrum/datepicker/src/styles.css
+++ b/packages/@react-spectrum/datepicker/src/styles.css
@@ -34,7 +34,7 @@
}
.react-spectrum-Datepicker-field.react-spectrum-Datepicker-field {
- width: auto;
+ width: 100%;
}
.react-spectrum-Datepicker-field .react-spectrum-DateField-Input {
@@ -77,8 +77,7 @@
}
.react-spectrum-Datepicker-inputSized {
- display: flex;
- height: 100%;
+ display: inline;
align-items: center;
}
@@ -89,7 +88,7 @@
}
.react-spectrum-Datepicker-segments {
- display: flex;
+ display: inline;
align-items: center;
}
diff --git a/packages/@react-spectrum/datepicker/src/utils.ts b/packages/@react-spectrum/datepicker/src/utils.tsx
similarity index 89%
rename from packages/@react-spectrum/datepicker/src/utils.ts
rename to packages/@react-spectrum/datepicker/src/utils.tsx
index 9e9fad08211..12779ef01ce 100644
--- a/packages/@react-spectrum/datepicker/src/utils.ts
+++ b/packages/@react-spectrum/datepicker/src/utils.tsx
@@ -12,10 +12,10 @@
import {createDOMRef} from '@react-spectrum/utils';
import {createFocusManager} from '@react-aria/focus';
import {FocusableRef} from '@react-types/shared';
+import React, {useImperativeHandle, useMemo, useRef, useState} from 'react';
import {SpectrumDatePickerBase} from '@react-types/datepicker';
import {useDateFormatter, useLocale} from '@react-aria/i18n';
import {useDisplayNames} from '@react-aria/datepicker';
-import {useImperativeHandle, useMemo, useRef, useState} from 'react';
import {useLayoutEffect} from '@react-aria/utils';
import {useProvider} from '@react-spectrum/provider';
@@ -28,13 +28,15 @@ export function useFormatHelpText(props: Pick, 'desc
}
if (props.showFormatHelpText) {
- return formatter.formatToParts(new Date()).map(s => {
- if (s.type === 'literal') {
- return s.value;
- }
+ return (
+ formatter.formatToParts(new Date()).map((s, i) => {
+ if (s.type === 'literal') {
+ return {s.value};
+ }
- return displayNames.of(s.type);
- }).join(' ');
+ return {displayNames.of(s.type)};
+ })
+ );
}
return '';
diff --git a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx
index 59a91141b06..682e5484224 100644
--- a/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx
+++ b/packages/@react-spectrum/datepicker/stories/DateField.stories.tsx
@@ -277,7 +277,7 @@ const preferences = [
{locale: '', label: 'Default', ordering: 'gregory'},
{label: 'Arabic (Algeria)', locale: 'ar-DZ', territories: 'DJ DZ EH ER IQ JO KM LB LY MA MR OM PS SD SY TD TN YE', ordering: 'gregory islamic islamic-civil islamic-tbla'},
{label: 'Arabic (United Arab Emirates)', locale: 'ar-AE', territories: 'AE BH KW QA', ordering: 'gregory islamic-umalqura islamic islamic-civil islamic-tbla'},
- {label: 'Arabic (Egypt)', locale: 'AR-EG', territories: 'EG', ordering: 'gregory coptic islamic islamic-civil islamic-tbla'},
+ {label: 'Arabic (Egypt)', locale: 'ar-EG', territories: 'EG', ordering: 'gregory coptic islamic islamic-civil islamic-tbla'},
{label: 'Arabic (Saudi Arabia)', locale: 'ar-SA', territories: 'SA', ordering: 'islamic-umalqura gregory islamic islamic-rgsa'},
{label: 'Farsi (Afghanistan)', locale: 'fa-AF', territories: 'AF IR', ordering: 'persian gregory islamic islamic-civil islamic-tbla'},
// {territories: 'CN CX HK MO SG', ordering: 'gregory chinese'},
diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js
index fcf31ab53e5..41c58a5f9d7 100644
--- a/packages/@react-spectrum/datepicker/test/DateField.test.js
+++ b/packages/@react-spectrum/datepicker/test/DateField.test.js
@@ -167,14 +167,12 @@ describe('DateField', function () {
});
it('should support format help text', function () {
- let {getByRole, getByText, getAllByRole} = render();
+ let {getByRole, getAllByRole} = render();
// Not needed in aria-described by because each segment has a label already, so this would be duplicative.
let group = getByRole('group');
expect(group).not.toHaveAttribute('aria-describedby');
- expect(getByText('month / day / year')).toBeVisible();
-
let segments = getAllByRole('spinbutton');
for (let segment of segments) {
expect(segment).not.toHaveAttribute('aria-describedby');
diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js
index 2c4511b8e3f..1e8636b95a8 100644
--- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js
+++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js
@@ -31,7 +31,7 @@ function getTextValue(el) {
return '';
}
- return el.textContent;
+ return el.textContent.replace(/[\u2066-\u2069]/g, '');
}
function expectPlaceholder(el, placeholder) {
@@ -955,7 +955,7 @@ describe('DatePicker', function () {
});
it('should support format help text', function () {
- let {getAllByRole, getByText, getByRole, getByTestId} = render();
+ let {getAllByRole, getByRole, getByTestId} = render();
// Not needed in aria-described by because each segment has a label already, so this would be duplicative.
let group = getByRole('group');
@@ -963,8 +963,6 @@ describe('DatePicker', function () {
expect(group).not.toHaveAttribute('aria-describedby');
expect(field).not.toHaveAttribute('aria-describedby');
- expect(getByText('month / day / year')).toBeVisible();
-
let segments = getAllByRole('spinbutton');
for (let segment of segments) {
expect(segment).not.toHaveAttribute('aria-describedby');
@@ -1043,7 +1041,7 @@ describe('DatePicker', function () {
await user.keyboard('{ArrowUp}');
expect(queryByTestId('era')).toBeNull();
- expect(document.activeElement).toBe(field.firstChild);
+ expect(document.activeElement.textContent.replace(/[\u2066-\u2069]/g, '')).toBe('3');
});
it('does not try to shift focus when the entire datepicker is unmounted while focused', function () {
diff --git a/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js b/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js
index 3937bb3880e..89e8f25b97e 100644
--- a/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js
+++ b/packages/@react-spectrum/datepicker/test/DatePickerBase.test.js
@@ -427,36 +427,6 @@ describe('DatePickerBase', function () {
fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'});
}
});
-
- it.each`
- Name | Component
- ${'DatePicker'} | ${DatePicker}
- ${'DateRangePicker'} | ${DateRangePicker}
- `('$Name should support arrow keys to move between segments in an RTL locale', ({Component}) => {
- let {getAllByRole} = render(
-
-
-
- );
-
- let segments = getAllByRole('spinbutton');
- let button = getAllByRole('button')[0];
- act(() => {segments[0].focus();});
-
- for (let i = 0; i < segments.length; i++) {
- expect(segments[i]).toHaveFocus();
- fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'});
- }
-
- expect(button).toHaveFocus();
- fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
-
- for (let i = segments.length - 1; i >= 0; i--) {
- expect(segments[i]).toHaveFocus();
- fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
- }
- });
-
it.each`
Name | Component
${'DatePicker'} | ${DatePicker}
@@ -492,6 +462,130 @@ describe('DatePickerBase', function () {
let segments = getAllByRole('spinbutton');
expect(segments[0]).toHaveFocus();
});
+
+ describe('RTL focus management', function () {
+ beforeEach(() => {
+ jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () {
+ let x = 0;
+ let el = this;
+
+ if (el.getAttribute('role') === 'spinbutton') {
+ let dataType = el.getAttribute('data-testid');
+ if (el.parentElement.getAttribute('data-testid') === 'end-date') {
+ if (dataType === 'day') {
+ x = 60;
+ } else if (dataType === 'month') {
+ x = 50;
+ } else if (dataType === 'year') {
+ x = 40;
+ } else if (dataType === 'hour') {
+ x = 20;
+ } else if (dataType === 'minute') {
+ x = 30;
+ } else if (dataType === 'dayPeriod') {
+ x = 10;
+ }
+ } else {
+ if (dataType === 'day') {
+ x = 120;
+ } else if (dataType === 'month') {
+ x = 110;
+ } else if (dataType === 'year') {
+ x = 100;
+ } else if (dataType === 'hour') {
+ x = 80;
+ } else if (dataType === 'minute') {
+ x = 90;
+ } else if (dataType === 'dayPeriod') {
+ x = 70;
+ }
+ }
+ }
+
+ if (el.getAttribute('role') === 'button') {
+ x = 0;
+ }
+
+ return {
+ left: x,
+ right: x + 10,
+ top: 10,
+ bottom: 0,
+ x: x,
+ y: 10,
+ width: 10,
+ height: 10
+ };
+ });
+
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ act(() => jest.runAllTimers());
+ });
+
+ it('DatePicker should support arrow keys to move between segments in an RTL locale', () => {
+ let {getAllByRole} = render(
+
+
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ let button = getAllByRole('button')[0];
+ act(() => {segments[0].focus();});
+
+ // Segment order corresponds to the following: [day, month, year, minute, hour, dayPeriod]
+ // In arabic, the absolute position of the segments in a DateField is: DayPeriod Hour:Minute Year/Month/Day
+ let segmentOrder = [0, 1, 2, 4, 3, 5];
+
+ for (let i = 0 ; i < segments.length; i++) {
+ let index = segmentOrder[i];
+ expect(segments[index]).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'});
+ }
+
+ expect(button).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
+
+ for (let i = segments.length - 1; i >= 0; i--) {
+ let index = segmentOrder[i];
+ expect(segments[index]).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
+ }
+ });
+
+ it('DateRangePicker should support arrow keys to move between segments in an RTL locale', () => {
+ let {getAllByRole} = render(
+
+
+
+ );
+
+ let segments = getAllByRole('spinbutton');
+ let button = getAllByRole('button')[0];
+ act(() => {segments[0].focus();});
+
+ // In arabic, the absolute position of the segments in a DateField is: DayPeriod Hour:Minute Year/Month/Day
+ let segmentOrder = [0, 1, 2, 4, 3, 5, 6, 7, 8, 10, 9, 11];
+
+ for (let i = 0 ; i < segments.length; i++) {
+ let index = segmentOrder[i];
+ expect(segments[index]).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowLeft'});
+ }
+
+ expect(button).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
+
+ for (let i = segments.length - 1; i >= 0; i--) {
+ let index = segmentOrder[i];
+ expect(segments[index]).toHaveFocus();
+ fireEvent.keyDown(document.activeElement, {key: 'ArrowRight'});
+ }
+ });
+ });
});
describe('validation', function () {
diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js
index 93ad2439f13..06a9a7753c3 100644
--- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js
+++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js
@@ -42,7 +42,7 @@ function getTextValue(el) {
return '';
}
- return [...el.childNodes].map(el => el.nodeType === 3 ? el.textContent : getTextValue(el)).join('');
+ return [...el.childNodes].map(el => el.nodeType === 3 ? el.textContent.replace(/[\u2066-\u2069]/g, '') : getTextValue(el)).join('');
}
function expectPlaceholder(el, placeholder) {
@@ -1054,7 +1054,7 @@ describe('DateRangePicker', function () {
});
it('should support format help text', function () {
- let {getAllByRole, getByText, getByRole, getByTestId} = render();
+ let {getAllByRole, getByRole, getByTestId} = render();
// Not needed in aria-described by because each segment has a label already, so this would be duplicative.
let group = getByRole('group');
@@ -1064,8 +1064,6 @@ describe('DateRangePicker', function () {
expect(startField).not.toHaveAttribute('aria-describedby');
expect(endField).not.toHaveAttribute('aria-describedby');
- expect(getByText('month / day / year')).toBeVisible();
-
let segments = getAllByRole('spinbutton');
for (let segment of segments) {
expect(segment).not.toHaveAttribute('aria-describedby');
diff --git a/packages/@react-stately/datepicker/src/placeholders.ts b/packages/@react-stately/datepicker/src/placeholders.ts
index d659e5f0f33..fccf81a4849 100644
--- a/packages/@react-stately/datepicker/src/placeholders.ts
+++ b/packages/@react-stately/datepicker/src/placeholders.ts
@@ -57,7 +57,7 @@ const placeholders = new LocalizedStringDictionary({
ia: {year: 'aaaa', month: 'mm', day: 'dd'},
id: {year: 'tttt', month: 'bb', day: 'hh'},
it: {year: 'aaaa', month: 'mm', day: 'gg'},
- ja: {year: ' 年 ', month: '月', day: '日'},
+ ja: {year: '年', month: '月', day: '日'},
ka: {year: 'წწწწ', month: 'თთ', day: 'რრ'},
kk: {year: 'жжжж', month: 'аа', day: 'кк'},
kn: {year: 'ವವವವ', month: 'ಮಿಮೀ', day: 'ದಿದಿ'},
diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts
index 6eaf4426995..1c9660fc612 100644
--- a/packages/@react-stately/datepicker/src/useDateFieldState.ts
+++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts
@@ -268,26 +268,9 @@ export function useDateFieldState(props: DateFi
};
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) {
- isEditable = false;
- }
-
- 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
- } as DateSegment;
- })
- , [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale]);
+ 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.
@@ -425,6 +408,66 @@ export function useDateFieldState(props: DateFi
};
}
+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 isEditable = EDITABLE_SEGMENTS[segment.type];
+ 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 dateSegment = {
+ type: TYPE_MAPPING[segment.type] || segment.type,
+ text: isPlaceholder ? placeholder : segment.value,
+ ...getSegmentLimits(displayValue, segment.type, resolvedOptions),
+ isPlaceholder,
+ placeholder,
+ isEditable
+ } as DateSegment;
+
+ if (segment.type === 'hour') {
+ processedSegments.push({
+ type: 'literal',
+ text: '\u2066',
+ ...getSegmentLimits(displayValue, 'literal', resolvedOptions),
+ isPlaceholder: false,
+ placeholder: '',
+ isEditable: false
+ });
+ processedSegments.push(dateSegment);
+ if (segment.type === granularity) {
+ processedSegments.push({
+ type: 'literal',
+ text: '\u2069',
+ ...getSegmentLimits(displayValue, 'literal', resolvedOptions),
+ isPlaceholder: false,
+ placeholder: '',
+ isEditable: false
+ });
+ }
+ } else if (timeValue.includes(granularity) && segment.type === granularity) {
+ processedSegments.push(dateSegment);
+ processedSegments.push({
+ type: 'literal',
+ text: '\u2069',
+ ...getSegmentLimits(displayValue, 'literal', resolvedOptions),
+ isPlaceholder: false,
+ placeholder: '',
+ isEditable: false
+ });
+ } else {
+ processedSegments.push(dateSegment);
+ }
+ }
+
+ return processedSegments;
+}
+
function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) {
switch (type) {
case 'era': {
diff --git a/packages/react-aria-components/docs/DateField.mdx b/packages/react-aria-components/docs/DateField.mdx
index 1f63ea4ed15..d934e350d99 100644
--- a/packages/react-aria-components/docs/DateField.mdx
+++ b/packages/react-aria-components/docs/DateField.mdx
@@ -68,10 +68,12 @@ import {DateField, Label, DateInput, DateSegment} from 'react-aria-components';
.react-aria-DateField {
color: var(--text-color);
+ display: flex;
+ flex-direction: column;
}
.react-aria-DateInput {
- display: flex;
+ display: inline;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
diff --git a/packages/react-aria-components/docs/TimeField.mdx b/packages/react-aria-components/docs/TimeField.mdx
index ef418b07d99..c1be455550f 100644
--- a/packages/react-aria-components/docs/TimeField.mdx
+++ b/packages/react-aria-components/docs/TimeField.mdx
@@ -65,10 +65,12 @@ import {TimeField, Label, DateInput, DateSegment} from 'react-aria-components';
.react-aria-TimeField {
color: var(--text-color);
+ display: flex;
+ flex-direction: column;
}
.react-aria-DateInput {
- display: flex;
+ display: inline;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 6px;
diff --git a/packages/react-aria-components/example/index.css b/packages/react-aria-components/example/index.css
index 829a370ffae..1e65e4996f8 100644
--- a/packages/react-aria-components/example/index.css
+++ b/packages/react-aria-components/example/index.css
@@ -146,7 +146,6 @@ html {
}
.field {
- display: inline-flex;
padding: 2px 4px;
border-radius: 2px;
border: 1px solid gray;
diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx
index 4d98abdab4b..154aff7f9be 100644
--- a/packages/react-aria-components/src/DateField.tsx
+++ b/packages/react-aria-components/src/DateField.tsx
@@ -341,9 +341,10 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function
return (
-
+ isExiting={isExiting}
+ dir={direction} />
);
});
@@ -126,7 +128,8 @@ interface PopoverInnerProps extends AriaPopoverProps, RenderProps (
-
+
{segment => }
–
-
+
{segment => }
@@ -131,11 +131,11 @@ export const DateRangePickerTriggerWidthExample = () => (
-
+
{segment => }
–
-
+
{segment => }
diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js
index f2b9f4f629a..ccd2f03bb1d 100644
--- a/packages/react-aria-components/test/DatePicker.test.js
+++ b/packages/react-aria-components/test/DatePicker.test.js
@@ -55,7 +55,7 @@ describe('DatePicker', () => {
let group = getByRole('group');
let input = group.querySelector('.react-aria-DateInput');
let button = getByRole('button');
- expect(input).toHaveTextContent('mm/dd/yyyy');
+ expect(input.textContent.replace(/[\u2066-\u2069]/g, '')).toBe('mm/dd/yyyy');
expect(button).toHaveAttribute('aria-label', 'Calendar');
expect(input.closest('.react-aria-DatePicker')).toHaveAttribute('data-foo', 'bar');
diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js
index b8e928c1c7d..5978dd9e9f0 100644
--- a/packages/react-aria-components/test/TimeField.test.js
+++ b/packages/react-aria-components/test/TimeField.test.js
@@ -37,7 +37,7 @@ describe('TimeField', () => {
);
let input = getByRole('group');
- expect(input).toHaveTextContent('––:–– AM');
+ expect(input.textContent.replace(' ', ' ').replace(/[\u2066-\u2069]/g, '')).toBe('––:–– AM');
expect(input).toHaveAttribute('class', 'react-aria-DateInput');
expect(input).toHaveAttribute('data-bar', 'foo');