Skip to content

Commit

Permalink
fix: correctly format date/time in RTL (#7423)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
yihuiliao authored Jan 29, 2025
1 parent cbdf710 commit e228ed8
Show file tree
Hide file tree
Showing 28 changed files with 526 additions and 136 deletions.
6 changes: 3 additions & 3 deletions packages/@react-aria/datepicker/docs/useDateField.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,12 @@ function DateSegment({segment, state}) {
let {segmentProps} = useDateSegment(segment, state, ref);

return (
<div
<span
{...segmentProps}
ref={ref}
className={`segment ${segment.isPlaceholder ? 'placeholder' : ''}`}>
{segment.text}
</div>
</span>
);
}

Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-aria/datepicker/src/useDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldOptions<T>
if (props.onKeyUp) {
props.onKeyUp(e);
}
},
style: {
unicodeBidi: 'isolate'
}
}),
inputProps,
Expand Down
73 changes: 69 additions & 4 deletions packages/@react-aria/datepicker/src/useDatePickerGroup.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null>, disableArrowNavigation?: boolean) {
let {direction} = useLocale();
let focusManager = useMemo(() => createFocusManager(ref), [ref]);
let segments = useRef<FocusableElement[]>(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<Element> | undefined = ref.current?.querySelectorAll('span[role="spinbutton"], span[role="textbox"], button, div[role="spinbutton"], div[role="textbox"]');

let segmentsArr = Array.from(editableSegments as NodeListOf<Element>).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) => {
Expand All @@ -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();
}
Expand All @@ -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();
}
Expand Down
18 changes: 13 additions & 5 deletions packages/@react-aria/datepicker/src/useDateSegment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +33,7 @@ export interface DateSegmentAria {
*/
export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: RefObject<HTMLElement | null>): DateSegmentAria {
let enteredKeys = useRef('');
let {locale} = useLocale();
let {locale, direction} = useLocale();
let displayNames = useDisplayNames();
let {ariaLabel, ariaLabelledBy, ariaDescribedBy, focusManager} = hookData.get(state)!;

Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}
};
Expand All @@ -41,6 +41,16 @@ PlaceholderFocus.parameters = {
}
};

export const PlaceholderFocusRTL = () => <DateField label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusRTL.parameters = {
chromaticProvider: {
locales: ['he-IL'],
scales: ['medium'],
colorSchemes: ['light'],
express: false
}
};

export const PlaceholderFocusExpress = () => <DateField label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}
};
Expand All @@ -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,
Expand All @@ -54,6 +66,16 @@ export const Placeholder = () => <DatePicker label="Date" placeholderValue={date
export const PlaceholderFocus = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocus.parameters = focusParams;

export const PlaceholderFocusRTL = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusRTL.parameters = {
chromaticProvider: {
locales: ['ar-EG'],
scales: ['medium'],
colorSchemes: ['light'],
express: false
}
};

export const PlaceholderFocusExpress = () => <DatePicker label="Date" placeholderValue={date} autoFocus />;
PlaceholderFocusExpress.parameters = {
chromaticProvider: {
Expand Down Expand Up @@ -110,10 +132,18 @@ export const OpenPlaceholder = () => <DatePicker label="Date" placeholderValue={
OpenPlaceholder.parameters = openParams;
OpenPlaceholder.decorators = openDecorators;

export const OpenPlaceholderRTL = () => <DatePicker label="Date" placeholderValue={date} isOpen shouldFlip={false} />;
OpenPlaceholderRTL.parameters = openParamsRTL;
OpenPlaceholderRTL.decorators = openDecorators;

export const OpenValue = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} />;
OpenValue.parameters = openParams;
OpenValue.decorators = openDecorators;

export const OpenValueRTL = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} />;
OpenValueRTL.parameters = openParamsRTL;
OpenValueRTL.decorators = openDecorators;

export const OpenTime = () => <DatePicker label="Date" value={dateTime} isOpen shouldFlip={false} />;
OpenTime.parameters = openParams;
OpenTime.decorators = openDecorators;
Expand Down Expand Up @@ -147,6 +177,50 @@ OpenExpress.parameters = {
};
OpenExpress.decorators = openDecorators;

export const OpenLTRInteractions = () => <DatePicker label="Date" value={date} />;
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 = () => <DatePicker label="Date" value={date} />;
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 = () => <DatePicker label="Date" value={date} isOpen shouldFlip={false} maxVisibleMonths={3} />;
MultipleMonths.parameters = openParams;
MultipleMonths.decorators = [Story => <div style={{height: 550, width: 1000}}><Story /></div>];
Expand Down
Loading

1 comment on commit e228ed8

@rspbot
Copy link

@rspbot rspbot commented on e228ed8 Jan 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.