Skip to content

Commit e228ed8

Browse files
authored
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
1 parent cbdf710 commit e228ed8

28 files changed

+526
-136
lines changed

packages/@react-aria/datepicker/docs/useDateField.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,12 @@ function DateSegment({segment, state}) {
130130
let {segmentProps} = useDateSegment(segment, state, ref);
131131

132132
return (
133-
<div
133+
<span
134134
{...segmentProps}
135135
ref={ref}
136136
className={`segment ${segment.isPlaceholder ? 'placeholder' : ''}`}>
137137
{segment.text}
138-
</div>
138+
</span>
139139
);
140140
}
141141

@@ -153,7 +153,7 @@ function DateSegment({segment, state}) {
153153
}
154154

155155
.field {
156-
display: inline-flex;
156+
display: block;
157157
padding: 2px 4px;
158158
border-radius: 2px;
159159
border: 1px solid var(--gray);

packages/@react-aria/datepicker/src/useDateField.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldOptions<T>
181181
if (props.onKeyUp) {
182182
props.onKeyUp(e);
183183
}
184+
},
185+
style: {
186+
unicodeBidi: 'isolate'
184187
}
185188
}),
186189
inputProps,

packages/@react-aria/datepicker/src/useDatePickerGroup.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,48 @@
11
import {createFocusManager, getFocusableTreeWalker} from '@react-aria/focus';
22
import {DateFieldState, DatePickerState, DateRangePickerState} from '@react-stately/datepicker';
33
import {FocusableElement, KeyboardEvent, RefObject} from '@react-types/shared';
4-
import {mergeProps} from '@react-aria/utils';
4+
import {mergeProps, useLayoutEffect} from '@react-aria/utils';
55
import {useLocale} from '@react-aria/i18n';
6-
import {useMemo} from 'react';
6+
import {useMemo, useRef} from 'react';
77
import {usePress} from '@react-aria/interactions';
88

99
export function useDatePickerGroup(state: DatePickerState | DateRangePickerState | DateFieldState, ref: RefObject<Element | null>, disableArrowNavigation?: boolean) {
1010
let {direction} = useLocale();
1111
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
12+
let segments = useRef<FocusableElement[]>(undefined);
13+
useLayoutEffect(() => {
14+
if (ref?.current) {
15+
16+
let update = () => {
17+
if (ref.current) {
18+
// 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
19+
let editableSegments: NodeListOf<Element> | undefined = ref.current?.querySelectorAll('span[role="spinbutton"], span[role="textbox"], button, div[role="spinbutton"], div[role="textbox"]');
20+
21+
let segmentsArr = Array.from(editableSegments as NodeListOf<Element>).filter(Boolean).map(node => {
22+
return {
23+
element: node as FocusableElement,
24+
rectX: node.getBoundingClientRect().left
25+
};
26+
});
27+
28+
let orderedSegments = segmentsArr.sort((a, b) => a.rectX - b.rectX).map((item => item.element));
29+
segments.current = orderedSegments;
30+
}
31+
};
32+
33+
update();
34+
35+
let observer = new MutationObserver(update);
36+
observer.observe(ref.current, {
37+
subtree: true,
38+
childList: true
39+
});
40+
41+
return () => {
42+
observer.disconnect();
43+
};
44+
}
45+
}, []);
1246

1347
// Open the popover on alt + arrow down
1448
let onKeyDown = (e: KeyboardEvent) => {
@@ -31,7 +65,21 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
3165
e.preventDefault();
3266
e.stopPropagation();
3367
if (direction === 'rtl') {
34-
focusManager.focusNext();
68+
if (segments.current) {
69+
let orderedSegments = segments.current;
70+
let target = e.target as FocusableElement;
71+
let index = orderedSegments.indexOf(target);
72+
73+
if (index === 0) {
74+
target = orderedSegments[0] || target;
75+
} else {
76+
target = orderedSegments[index - 1] || target;
77+
}
78+
79+
if (target) {
80+
target.focus();
81+
}
82+
}
3583
} else {
3684
focusManager.focusPrevious();
3785
}
@@ -40,7 +88,24 @@ export function useDatePickerGroup(state: DatePickerState | DateRangePickerState
4088
e.preventDefault();
4189
e.stopPropagation();
4290
if (direction === 'rtl') {
43-
focusManager.focusPrevious();
91+
if (segments.current) {
92+
let orderedSegments = segments.current;
93+
let target = e.target as FocusableElement;
94+
let index = orderedSegments.indexOf(target);
95+
96+
if (index === orderedSegments.length - 1) {
97+
target = orderedSegments[orderedSegments.length - 1] || target;
98+
} else {
99+
target = orderedSegments[index - 1] || target;
100+
}
101+
102+
103+
target = orderedSegments[index + 1] || target;
104+
105+
if (target) {
106+
target.focus();
107+
}
108+
}
44109
} else {
45110
focusManager.focusNext();
46111
}

packages/@react-aria/datepicker/src/useDateSegment.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {DateFieldState, DateSegment} from '@react-stately/datepicker';
1515
import {getScrollParent, isIOS, isMac, mergeProps, scrollIntoViewport, useEvent, useId, useLabels, useLayoutEffect} from '@react-aria/utils';
1616
import {hookData} from './useDateField';
1717
import {NumberParser} from '@internationalized/number';
18-
import React, {useMemo, useRef} from 'react';
18+
import React, {CSSProperties, useMemo, useRef} from 'react';
1919
import {RefObject} from '@react-types/shared';
2020
import {useDateFormatter, useFilter, useLocale} from '@react-aria/i18n';
2121
import {useDisplayNames} from './useDisplayNames';
@@ -33,7 +33,7 @@ export interface DateSegmentAria {
3333
*/
3434
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement | null>): DateSegmentAria {
3535
let enteredKeys = useRef('');
36-
let {locale} = useLocale();
36+
let {locale, direction} = useLocale();
3737
let displayNames = useDisplayNames();
3838
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state)!;
3939

@@ -385,6 +385,16 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
385385
};
386386
}
387387

388+
let dateSegments = ['day', 'month', 'year'];
389+
let segmentStyle : CSSProperties = {caretColor: 'transparent'};
390+
if (direction === 'rtl') {
391+
if (dateSegments.includes(segment.type)) {
392+
segmentStyle = {caretColor: 'transparent', direction: 'ltr', unicodeBidi: 'embed'};
393+
} else if (segment.type === 'timeZoneName') {
394+
segmentStyle = {caretColor: 'transparent', unicodeBidi: 'embed'};
395+
}
396+
}
397+
388398
return {
389399
segmentProps: mergeProps(spinButtonProps, labelProps, {
390400
id,
@@ -403,9 +413,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref:
403413
tabIndex: state.isDisabled ? undefined : 0,
404414
onKeyDown,
405415
onFocus,
406-
style: {
407-
caretColor: 'transparent'
408-
},
416+
style: segmentStyle,
409417
// Prevent pointer events from reaching useDatePickerGroup, and allow native browser behavior to focus the segment.
410418
onPointerDown(e) {
411419
e.stopPropagation();

packages/@react-spectrum/datepicker/chromatic/DateField.stories.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default {
2121
title: 'DateField',
2222
parameters: {
2323
chromaticProvider: {
24-
locales: ['en-US', 'ar-EG', 'ja-JP']
24+
locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
2525
}
2626
}
2727
};
@@ -41,6 +41,16 @@ PlaceholderFocus.parameters = {
4141
}
4242
};
4343

44+
export const PlaceholderFocusRTL = () => <DateField label="Date" placeholderValue={date} autoFocus />;
45+
PlaceholderFocusRTL.parameters = {
46+
chromaticProvider: {
47+
locales: ['he-IL'],
48+
scales: ['medium'],
49+
colorSchemes: ['light'],
50+
express: false
51+
}
52+
};
53+
4454
export const PlaceholderFocusExpress = () => <DateField label="Date" placeholderValue={date} autoFocus />;
4555
PlaceholderFocusExpress.parameters = {
4656
chromaticProvider: {

packages/@react-spectrum/datepicker/chromatic/DatePicker.stories.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@ import {ContextualHelp} from '@react-spectrum/contextualhelp';
1616
import {DatePicker} from '../';
1717
import {Heading} from '@react-spectrum/text';
1818
import React from 'react';
19+
import {userEvent, within} from '@storybook/testing-library';
1920

2021
export default {
2122
title: 'DatePicker',
2223
parameters: {
2324
chromaticProvider: {
24-
locales: ['en-US'/* , 'ar-EG', 'ja-JP' */]
25+
locales: ['en-US', 'ar-EG', 'ja-JP', 'he-IL']
2526
}
2627
}
2728
};
@@ -37,6 +38,17 @@ const focusParams = {
3738

3839
const openParams = {
3940
chromaticProvider: {
41+
locales: ['en-US'],
42+
colorSchemes: ['light'],
43+
scales: ['medium'],
44+
disableAnimations: true,
45+
express: false
46+
}
47+
};
48+
49+
const openParamsRTL = {
50+
chromaticProvider: {
51+
locales: ['he-IL'],
4052
colorSchemes: ['light'],
4153
scales: ['medium'],
4254
disableAnimations: true,
@@ -54,6 +66,16 @@ export const Placeholder = () => <DatePicker label="Date" placeholderValue={date
5466
export const PlaceholderFocus = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
5567
PlaceholderFocus.parameters = focusParams;
5668

69+
export const PlaceholderFocusRTL = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
70+
PlaceholderFocusRTL.parameters = {
71+
chromaticProvider: {
72+
locales: ['ar-EG'],
73+
scales: ['medium'],
74+
colorSchemes: ['light'],
75+
express: false
76+
}
77+
};
78+
5779
export const PlaceholderFocusExpress = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
5880
PlaceholderFocusExpress.parameters = {
5981
chromaticProvider: {
@@ -110,10 +132,18 @@ export const OpenPlaceholder = () => <DatePicker label="Date" placeholderValue={
110132
OpenPlaceholder.parameters = openParams;
111133
OpenPlaceholder.decorators = openDecorators;
112134

135+
export const OpenPlaceholderRTL = () => <DatePicker label="Date" placeholderValue={date} isOpen shouldFlip={false} />;
136+
OpenPlaceholderRTL.parameters = openParamsRTL;
137+
OpenPlaceholderRTL.decorators = openDecorators;
138+
113139
export const OpenValue = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} />;
114140
OpenValue.parameters = openParams;
115141
OpenValue.decorators = openDecorators;
116142

143+
export const OpenValueRTL = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} />;
144+
OpenValueRTL.parameters = openParamsRTL;
145+
OpenValueRTL.decorators = openDecorators;
146+
117147
export const OpenTime = () => <DatePicker label="Date" value={dateTime} isOpen shouldFlip={false} />;
118148
OpenTime.parameters = openParams;
119149
OpenTime.decorators = openDecorators;
@@ -147,6 +177,50 @@ OpenExpress.parameters = {
147177
};
148178
OpenExpress.decorators = openDecorators;
149179

180+
export const OpenLTRInteractions = () => <DatePicker label="Date" value={date} />;
181+
OpenLTRInteractions.parameters = {
182+
chromaticProvider: {
183+
locales: ['en-US'],
184+
scales: ['medium'],
185+
colorSchemes: ['light'],
186+
express: false
187+
}
188+
};
189+
OpenLTRInteractions.decorators = openDecorators;
190+
191+
OpenLTRInteractions.play = async ({canvasElement}) => {
192+
await userEvent.tab();
193+
await userEvent.keyboard('[ArrowRight]');
194+
await userEvent.keyboard('[ArrowRight]');
195+
await userEvent.keyboard('[ArrowRight]');
196+
await userEvent.keyboard('[Enter]]');
197+
let body = canvasElement.ownerDocument.body;
198+
await within(body).findByRole('dialog');
199+
await userEvent.keyboard('[ArrowRight]');
200+
};
201+
202+
export const OpenRTLInteractions = () => <DatePicker label="Date" value={date} />;
203+
OpenRTLInteractions.parameters = {
204+
chromaticProvider: {
205+
locales: ['ar-EG'],
206+
scales: ['medium'],
207+
colorSchemes: ['light'],
208+
express: false
209+
}
210+
};
211+
OpenRTLInteractions.decorators = openDecorators;
212+
213+
OpenRTLInteractions.play = async ({canvasElement}) => {
214+
await userEvent.tab();
215+
await userEvent.keyboard('[ArrowLeft]');
216+
await userEvent.keyboard('[ArrowLeft]');
217+
await userEvent.keyboard('[ArrowLeft]');
218+
await userEvent.keyboard('[Enter]]');
219+
let body = canvasElement.ownerDocument.body;
220+
await within(body).findByRole('dialog');
221+
await userEvent.keyboard('[ArrowLeft]');
222+
};
223+
150224
export const MultipleMonths = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} maxVisibleMonths={3} />;
151225
MultipleMonths.parameters = openParams;
152226
MultipleMonths.decorators = [Story => <div style={{height: 550, width: 1000}}><Story /></div>];

0 commit comments

Comments
 (0)