Skip to content

docs: rtl date time format blog #7936

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open

docs: rtl date time format blog #7936

wants to merge 15 commits into from

Conversation

yihuiliao
Copy link
Member

Closes

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@rspbot
Copy link

rspbot commented Mar 14, 2025

@adobe adobe deleted a comment from rspbot Mar 15, 2025

To format the segments according to the user locale, we rely on the browser’s [Unicode Bidirectional Algorithm](https://unicode.org/reports/tr9/). However, we found that some of our CSS styles were interferring with algorithm's application, leading to incorrect formating. For instance, in `he-IL`, the proper numeric date format should be `DD.MM.YYYY`, but our date component was displaying `YYYY.MM.DD`. This issue varied across different RTL languages for date fields, but for time fields, we observed a consistent problem across all RTL languages where time segments were flipped, rendering `MM:HH` instead of the correct `HH:MM` format.

<RTLTimefield />
Copy link
Member Author

Choose a reason for hiding this comment

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

i have some things i'd like to fix this image so it'll get eventually get replaced but the jist is there. wanted to have something so people could see how the blog flows with this image

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

Some screen recordings have the keyboard events displayed, and some don't. Could we add them to all? otherwise it's hard to know what's going on

@LFDanLu LFDanLu moved this from 🩺 To Triage to 👀 In Review in RSP Component Milestones Mar 19, 2025
@snowystinger snowystinger self-assigned this Mar 19, 2025
@LFDanLu LFDanLu self-assigned this Mar 20, 2025
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Mainly stylistic suggestions and small typos/grammar, feel free to push back on any of them! Otherwise great work, loved the break down of the numerous issues and the fixes you found when investigating this


<Video src={keyboardVideoURL} loop autoPlay muted />

As a result, we updated the keyboard navigation in right-to-left langauges to rely on the positioning of the different segments to determine which node to receive focus for an intuitive keyboard experience.
Copy link
Member

Choose a reason for hiding this comment

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

I'm not entirely sure what this fix entails, mind clarifying?

@rspbot
Copy link

rspbot commented Mar 21, 2025

@rspbot
Copy link

rspbot commented Mar 22, 2025

@rspbot
Copy link

rspbot commented Mar 22, 2025

snowystinger
snowystinger previously approved these changes Mar 24, 2025
Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

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

videos are much easier for me to follow now

Copy link
Member

@devongovett devongovett left a comment

Choose a reason for hiding this comment

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

This is a great deep dive! I wonder if it might help to have a short section at the start that gives a high level overview of RTL languages and how support for it is commonly implemented (mirroring, bidirectional text with mixed rtl/ltr, etc). I have a feeling that most developers are not familiar with it, and we get pretty deep into the weeds very quickly here, so we might need to explain some stuff more. Then later on, don't assume devs know what <bdo> is, what these different unicode control characters are, etc. The links are helpful, but most people won't click them so we might need just a bit more explanation of our own.

</div>
```

Through much trial and error, we discovered that appplying the [left-to-right embedding (LRE) Unicode](https://unicode.org/reports/tr9/#Explicit_Directional_Embeddings) on the date segments allowed us to to treat the text as embedded left-to-right while preserving the right-to-left mark on the separators, ensuring that Arabic dates display in the correct format. While we could have added Unicode to the segments like we did with the time fields, we opted for the [equivalent CSS](https://unicode.org/reports/tr9/#Markup_And_Formatting) approach instead to avoid modifying the DOM. This CSS is applied on date segments with placeholder or actual values to avoid the behavior discussed earlier with shifting segments. Through additional testing, we found that we should only apply left-to-right embedding on numeric values. If the value was displayed as text (e.g. "November" instead of "11") we did not apply this CSS.
Copy link
Member

Choose a reason for hiding this comment

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

We might need to show how it was rendering in the incorrect cases again here. I lost track of which problem this was solving when reading it. If I remember correctly it was so that the segments were ordered in LTR but the contents of the individual segments were RTL (so as not to mess up the placeholder text)?

@rspbot
Copy link

rspbot commented Apr 18, 2025

@rspbot
Copy link

rspbot commented Apr 21, 2025

@rspbot
Copy link

rspbot commented Apr 21, 2025

@rspbot
Copy link

rspbot commented Apr 22, 2025

@rspbot
Copy link

rspbot commented Apr 22, 2025

## API Changes

react-aria-components

/react-aria-components:DropZone

 DropZone {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode | ((DropZoneRenderProps & {
     defaultChildren: ReactNode | undefined
 })) => ReactNode
   className?: string | ((DropZoneRenderProps & {
     defaultClassName: string | undefined
 })) => string
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   isDisabled?: boolean
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   onHoverChange?: (boolean) => void
   onHoverStart?: (HoverEvent) => void
   slot?: string | null
   style?: CSSProperties | ((DropZoneRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:Tree

 Tree <T extends {}> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | ({}) => ReactNode
   className?: string | ((TreeRenderProps & {
     defaultClassName: string | undefined
 })) => string
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
-  keyboardDelegate?: KeyboardDelegate
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onScroll?: (UIEvent<Element>) => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionBehavior?: SelectionBehavior
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   style?: CSSProperties | ((TreeRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:DropZoneProps

 DropZoneProps {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children?: ReactNode | ((DropZoneRenderProps & {
     defaultChildren: ReactNode | undefined
 })) => ReactNode
   className?: string | ((DropZoneRenderProps & {
     defaultClassName: string | undefined
 })) => string
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   isDisabled?: boolean
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   onHoverChange?: (boolean) => void
   onHoverStart?: (HoverEvent) => void
   slot?: string | null
   style?: CSSProperties | ((DropZoneRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

/react-aria-components:TreeProps

 TreeProps <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   className?: string | ((TreeRenderProps & {
     defaultClassName: string | undefined
 })) => string
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
-  dragAndDropHooks?: DragAndDropHooks
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   items?: Iterable<T>
-  keyboardDelegate?: KeyboardDelegate
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onScroll?: (UIEvent<Element>) => void
   onSelectionChange?: (Selection) => void
   selectedKeys?: 'all' | Iterable<Key>
   selectionBehavior?: SelectionBehavior
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   style?: CSSProperties | ((TreeRenderProps & {
     defaultStyle: CSSProperties
 })) => CSSProperties | undefined
 }

@react-aria/dnd

/@react-aria/dnd:DroppableCollectionOptions

 DroppableCollectionOptions {
   acceptedDragTypes?: 'all' | Array<string | symbol> = 'all'
   dropTargetDelegate: DropTargetDelegate
   getDropOperation?: (DropTarget, DragTypes, Array<DropOperation>) => DropOperation
   keyboardDelegate: KeyboardDelegate
   onDrop?: (DroppableCollectionDropEvent) => void
   onDropEnter?: (DroppableCollectionEnterEvent) => void
   onDropExit?: (DroppableCollectionExitEvent) => void
   onInsert?: (DroppableCollectionInsertDropEvent) => void
   onItemDrop?: (DroppableCollectionOnItemDropEvent) => void
-  onKeyDown?: KeyboardEvents['onKeyDown']
   onReorder?: (DroppableCollectionReorderEvent) => void
   onRootDrop?: (DroppableCollectionRootDropEvent) => void
   shouldAcceptItemDrop?: (ItemDropTarget, DragTypes) => boolean
 }

/@react-aria/dnd:DropOptions

 DropOptions {
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   getDropOperationForPoint?: (DragTypes, Array<DropOperation>, number, number) => DropOperation
   hasDropButton?: boolean
   isDisabled?: boolean
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   ref: RefObject<FocusableElement | null>

@react-spectrum/dropzone

/@react-spectrum/dropzone:DropZone

 DropZone {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   bottom?: Responsive<DimensionValue>
   children: ReactNode
   end?: Responsive<DimensionValue>
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isFilled?: boolean
   isHidden?: Responsive<boolean>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   order?: Responsive<number>
   replaceMessage?: string
   right?: Responsive<DimensionValue>
   slot?: string | null
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

/@react-spectrum/dropzone:SpectrumDropZoneProps

 SpectrumDropZoneProps {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   bottom?: Responsive<DimensionValue>
   children: ReactNode
   end?: Responsive<DimensionValue>
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isFilled?: boolean
   isHidden?: Responsive<boolean>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   order?: Responsive<number>
   replaceMessage?: string
   right?: Responsive<DimensionValue>
   slot?: string | null
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

@react-spectrum/s2

/@react-spectrum/s2:DropZone

 DropZone {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   id?: string
   isFilled?: boolean
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   replaceMessage?: string
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:TreeView

 TreeView {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isDetached?: boolean
   isEmphasized?: boolean
   items?: Iterable<T>
-  keyboardDelegate?: KeyboardDelegate
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:DropZoneProps

 DropZoneProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   children: ReactNode
   getDropOperation?: (DragTypes, Array<DropOperation>) => DropOperation
   id?: string
   isFilled?: boolean
   onDrop?: (DropEvent) => void
-  onDropActivate?: (DropActivateEvent) => void
   onDropEnter?: (DropEnterEvent) => void
   onDropExit?: (DropExitEvent) => void
   onDropMove?: (DropMoveEvent) => void
   replaceMessage?: string
   slot?: string | null
   styles?: StylesPropWithHeight
 }

/@react-spectrum/s2:TreeViewProps

 TreeViewProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   autoFocus?: boolean | FocusStrategy
   children?: ReactNode | (T) => ReactNode
   defaultExpandedKeys?: Iterable<Key>
   defaultSelectedKeys?: 'all' | Iterable<Key>
   dependencies?: ReadonlyArray<any>
   disabledBehavior?: DisabledBehavior = 'all'
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   escapeKeyBehavior?: 'clearSelection' | 'none' = 'clearSelection'
   expandedKeys?: Iterable<Key>
   id?: string
   isDetached?: boolean
   isEmphasized?: boolean
   items?: Iterable<T>
-  keyboardDelegate?: KeyboardDelegate
   onAction?: (Key) => void
   onExpandedChange?: (Set<Key>) => any
   onSelectionChange?: (Selection) => void
   renderEmptyState?: (TreeEmptyStateRenderProps) => ReactNode
   selectionMode?: SelectionMode
   shouldSelectOnPressUp?: boolean
   slot?: string | null
   styles?: StylesPropWithHeight
 }

@react-stately/data

/@react-stately/data:TreeData

 TreeData <T extends {}> {
   append: (Key | null, Array<{}>) => void
   getItem: (Key) => TreeNode<{}> | undefined
   insert: (Key | null, number, Array<{}>) => void
   insertAfter: (Key, Array<{}>) => void
   insertBefore: (Key, Array<{}>) => void
   items: Array<TreeNode<{}>>
   move: (Key, Key | null, number) => void
-  moveAfter: (Key, Iterable<Key>) => void
-  moveBefore: (Key, Iterable<Key>) => void
   prepend: (Key | null, Array<{}>) => void
   remove: (Array<Key>) => void
   removeSelectedItems: () => void
   selectedKeys: Set<Key>
   update: (Key, {}) => void
 }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: 👀 In Review
Development

Successfully merging this pull request may close these issues.

5 participants