diff --git a/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js b/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js index d7653d3f550..185ddc5a0fa 100644 --- a/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js +++ b/packages/@adobe/react-spectrum/test/actiongroup/ActionGroup.test.js @@ -193,7 +193,7 @@ describe('ActionGroup', function () { ${'(up/down arrows, ltr + horizontal) ActionGroup'} | ${{locale: 'de-DE'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowDown, result: btnBehavior.forward}, {action: pressArrowUp, result: btnBehavior.backward}, {action: pressArrowUp, result: btnBehavior.backward}]} ${'(up/down arrows, rtl + horizontal) ActionGroup'} | ${{locale: 'ar-AE'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowDown, result: btnBehavior.forward}, {action: pressArrowUp, result: btnBehavior.backward}, {action: pressArrowUp, result: btnBehavior.backward}]} ${'(left/right arrows, ltr + vertical) ActionGroup'} | ${{locale: 'de-DE', orientation: 'vertical'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowRight, result: btnBehavior.forward}, {action: pressArrowLeft, result: btnBehavior.backward}, {action: pressArrowLeft, result: btnBehavior.backward}]} - ${'(left/right arrows, rtl + vertical) ActionGroup'} | ${{locale: 'ar-AE', orientation: 'vertical'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowRight, result: btnBehavior.forward}, {action: pressArrowLeft, result: btnBehavior.backward}, {action: pressArrowLeft, result: btnBehavior.backward}]} + ${'(left/right arrows, rtl + vertical) ActionGroup'} | ${{locale: 'ar-AE', orientation: 'vertical'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowRight, result: btnBehavior.backward}, {action: pressArrowLeft, result: btnBehavior.forward}, {action: pressArrowLeft, result: btnBehavior.forward}]} ${'(up/down arrows, ltr + vertical) ActionGroup'} | ${{locale: 'de-DE', orientation: 'vertical'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowDown, result: btnBehavior.forward}, {action: pressArrowUp, result: btnBehavior.backward}, {action: pressArrowUp, result: btnBehavior.backward}]} ${'(up/down arrows, rtl + vertical) ActionGroup'} | ${{locale: 'ar-AE', orientation: 'vertical'}} | ${[{action: tab, result: () => expectedButtonIndices.button1Focused}, {action: pressArrowDown, result: btnBehavior.forward}, {action: pressArrowUp, result: btnBehavior.backward}, {action: pressArrowUp, result: btnBehavior.backward}]} `( diff --git a/packages/@react-spectrum/ai/chromatic/PromptField.stories.tsx b/packages/@react-spectrum/ai/chromatic/PromptField.stories.tsx new file mode 100644 index 00000000000..17c92e442c2 --- /dev/null +++ b/packages/@react-spectrum/ai/chromatic/PromptField.stories.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {AttachFileMenuItem, InsertMenuButton, PromptFieldVoiceButton} from '../src/PromptField'; +import {Button} from '@react-spectrum/s2/Button'; +import { + Header, + Heading, + Menu, + MenuItem, + MenuSection, + MenuTrigger, + Text +} from '@react-spectrum/s2/Menu'; +import Keyboard from '@react-spectrum/s2/icons/Keyboard'; +import {LinkButton} from '@react-spectrum/s2/LinkButton'; +import ListMultiSelect from '@react-spectrum/s2/icons/ListMultiSelect'; +import type {Meta, StoryObj} from '@storybook/react'; +import { + PromptField, + PromptFieldSubmitButton, + PromptFieldToolbar, + PromptTokenField +} from '@react-spectrum/ai'; +import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; + +const meta: Meta = { + parameters: { + chromaticProvider: { + disableAnimations: true, + colorSchemes: ['light', 'dark'], + locales: ['en-US'] + } + }, + title: 'AI Chromatic/PromptField' +}; + +export default meta; + +type Story = StoryObj; + +function ToolbarButtons() { + return ( +
+ + + Plan mode + + + + + Normal + + + +
+ Transcript view +
+ Normal +
+
+
+ + + Terms and conditions + +
+ ); +} + +export const ToolbarButtonsStory: Story = { + render: () => ( +
+ + + +
+ + + + +
+
+ + +
+
+
+ + + +
+ + + + +
+
+ + +
+
+
+ + + +
+ + + + +
+
+ + +
+
+
+
+ ) +}; diff --git a/packages/@react-spectrum/ai/src/PromptField.tsx b/packages/@react-spectrum/ai/src/PromptField.tsx index 83958e2f52a..3aed8e1fa58 100644 --- a/packages/@react-spectrum/ai/src/PromptField.tsx +++ b/packages/@react-spectrum/ai/src/PromptField.tsx @@ -10,11 +10,11 @@ * governing permissions and limitations under the License. */ -import {ActionButton} from '@react-spectrum/s2/ActionButton'; +import {ActionButton, ActionButtonContext} from '@react-spectrum/s2/ActionButton'; import Attach from '@react-spectrum/s2/icons/Attach'; import {Attachment, AttachmentList, AttachmentListProps} from './AttachmentList'; import {Autocomplete} from 'react-aria-components/Autocomplete'; -import {Button} from '@react-spectrum/s2/Button'; +import {Button, ButtonContext} from '@react-spectrum/s2/Button'; import {Cell} from './loader/data'; import {CenterBaseline} from '@react-spectrum/s2/CenterBaseline'; import {color, css, space, style, StyleString} from '@react-spectrum/s2/style' with {type: 'macro'}; @@ -39,6 +39,7 @@ import {Image, Text} from '@react-spectrum/s2/Card'; import intlMessages from '../intl/*.json'; import {isFileDropItem, useDrop} from 'react-aria-components/useDrop'; import {Link} from '@react-spectrum/s2/Link'; +import {LinkButtonContext} from '@react-spectrum/s2/LinkButton'; import {Menu, MenuItem, MenuItemProps, MenuTrigger} from '@react-spectrum/s2/Menu'; import Microphone from '@react-spectrum/s2/icons/Microphone'; import {PixelLoader} from './loader/react'; @@ -53,10 +54,11 @@ import { } from 'react-stately/useTokenFieldState'; import {PromptFieldContainer} from './PromptFieldContainer'; import {PromptFocusContext} from './Chat'; +import {Provider} from 'react-aria-components/slots'; import Send from '@react-spectrum/s2/icons/ArrowUpSend'; import {setTokenFieldSelection} from 'react-aria/useTokenField'; import Stop from '@react-spectrum/s2/icons/StopProcessing'; -import {ToggleButton} from '@react-spectrum/s2/ToggleButton'; +import {ToggleButton, ToggleButtonContext} from '@react-spectrum/s2/ToggleButton'; import { Token, TokenField, @@ -337,28 +339,36 @@ export const PromptField = forwardRef(function PromptField( onAddAttachments, onRemoveAttachments }}> -
- - {children} - -

- {stringFormatter.format('promptfield.aiDisclaimer')}{' '} - - {stringFormatter.format('promptfield.aiUserGuidlines')} - -

-
+ +
+ + {children} + +

+ {stringFormatter.format('promptfield.aiDisclaimer')}{' '} + + {stringFormatter.format('promptfield.aiUserGuidlines')} + +

+
+
); }); @@ -520,7 +530,7 @@ export function PromptTokenField(props: PromptTokenFieldProps) { }, transition: 'default', transitionDuration: 350, - paddingStart: 4, + paddingStart: space(5), width: 'full', '--loader-color': { type: 'color', diff --git a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx index 0b12e55106a..615ea091caf 100644 --- a/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx +++ b/packages/@react-spectrum/ai/src/PromptFieldContainer.tsx @@ -350,16 +350,7 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { data-variant={variant} data-state={isGenerating ? 'generating' : 'idle'} data-focused={isFocused || undefined} - className={ - outerBorder + - // outline for WHCM - style({ - outlineStyle: 'solid', - outlineColor: 'transparent', - outlineWidth: 1, - containerType: 'inline-size' - }) - } + className={outerBorder + style({containerType: 'inline-size'})} style={{ ...props.style, // @ts-ignore @@ -400,7 +391,20 @@ export function PromptFieldContainer(props: PropFieldContainerProps) { style({ borderRadius: '[24px]', position: 'relative', - overflow: 'clip' + overflow: 'clip', + outlineStyle: 'solid', + outlineColor: { + default: 'transparent', // for WHCM + ':has([contenteditable][data-focus-visible])': { + default: 'transparent', + '@media (prefers-contrast: more)': 'gray-1000', + forcedColors: 'Highlight' + } + }, + outlineWidth: { + default: 1, + ':has([contenteditable][data-focus-visible])': 2 + } }), styles ) diff --git a/packages/@react-spectrum/ai/src/loader/data.ts b/packages/@react-spectrum/ai/src/loader/data.ts index bde62acf5cd..a71c99406f9 100644 --- a/packages/@react-spectrum/ai/src/loader/data.ts +++ b/packages/@react-spectrum/ai/src/loader/data.ts @@ -13,9 +13,9 @@ export interface Cell { cx: number; cy: number; - // Retained from the source data; no longer affects rendering. - outer: boolean; stagger: number; + adjustX?: number; + adjustY?: number; } type StaggerMode = 'individual' | 'grouped' | 'by-row'; @@ -30,18 +30,18 @@ interface BuildOptions { // ai-logo: original 12-cell diamond layout with hand-tuned timings. // ───────────────────────────────────────────────────────────── export const aiLogo: Cell[] = [ - {cx: 240, cy: 360, outer: true, stagger: 0}, - {cx: 160, cy: 320, outer: true, stagger: 2}, - {cx: 320, cy: 320, outer: true, stagger: 4}, - {cx: 200, cy: 280, outer: false, stagger: 6}, - {cx: 280, cy: 280, outer: false, stagger: 8}, - {cx: 120, cy: 240, outer: true, stagger: 10}, - {cx: 360, cy: 240, outer: true, stagger: 14}, - {cx: 200, cy: 200, outer: false, stagger: 16}, - {cx: 280, cy: 200, outer: false, stagger: 18}, - {cx: 160, cy: 160, outer: true, stagger: 20}, - {cx: 320, cy: 160, outer: true, stagger: 22}, - {cx: 240, cy: 120, outer: true, stagger: 24} + {cx: 240, cy: 360, adjustY: -0.5, stagger: 0}, + {cx: 160, cy: 320, stagger: 2}, + {cx: 320, cy: 320, stagger: 4}, + {cx: 200, cy: 280, stagger: 6}, + {cx: 280, cy: 280, stagger: 8}, + {cx: 120, cy: 240, adjustX: 0.5, stagger: 10}, + {cx: 360, cy: 240, adjustX: -0.5, stagger: 14}, + {cx: 200, cy: 200, stagger: 16}, + {cx: 280, cy: 200, stagger: 18}, + {cx: 160, cy: 160, stagger: 20}, + {cx: 320, cy: 160, stagger: 22}, + {cx: 240, cy: 120, adjustY: 0.5, stagger: 24} ]; // ───────────────────────────────────────────────────────────── diff --git a/packages/@react-spectrum/ai/src/loader/react.tsx b/packages/@react-spectrum/ai/src/loader/react.tsx index 5f7436e7dcb..86ed43cb817 100644 --- a/packages/@react-spectrum/ai/src/loader/react.tsx +++ b/packages/@react-spectrum/ai/src/loader/react.tsx @@ -295,6 +295,7 @@ export function PixelLoader(props: PixelLoaderProps) { } return matrix; }, [cells]); + const isHighDPI = window.devicePixelRatio >= 2; return (
(!a && !b && !diag ? '1px' : '0px'); // Adjust position for outer cells on high DPI displays. - let xPx = x * cellSize + offset; - let yPx = y * cellSize + offset; + let xPx = x * cellSize + offset + (isHighDPI ? (c.adjustX ?? 0) : 0); + let yPx = y * cellSize + offset + (isHighDPI ? (c.adjustY ?? 0) : 0); return (
+ + + Plan mode + + + + + Normal + + + +
+ Transcript view +
+ Normal +
+
+
+ + + Terms and conditions + +
+ ); +} + interface UploadState { status: 'uploading' | 'completed'; progress?: number; @@ -508,36 +546,58 @@ function EverythingRender(args) { )} - - - - - - Commands - - item.kind === 'command')}> - {item => - item.command === '/clear' ? ( - { - setValue(new PromptFieldValue([])); - setAttachments([]); - }}> - {item.command} - {item.description} - - ) : item.command === '/compact' ? ( - - {item.command} - {item.description} - - ) : item.command === '/feedback' || item.command === '/btw' ? ( - - {item.command} - {item.description} - - ) : ( +
+ + + + + + Commands + + item.kind === 'command')}> + {item => + item.command === '/clear' ? ( + { + setValue(new PromptFieldValue([])); + setAttachments([]); + }}> + {item.command} + {item.description} + + ) : item.command === '/compact' ? ( + + {item.command} + {item.description} + + ) : item.command === '/feedback' || item.command === '/btw' ? ( + + {item.command} + {item.description} + + ) : ( + + {item.command} + {item.description} + + ) + } + + + + + + Skills + + item.kind === 'skill')}> + {item => ( {item.command} {item.description} - ) - } - - - - - - Skills - - item.kind === 'skill')}> - {item => ( - - {item.command} - {item.description} - - )} - - - - - - Reference an object - - - {item => ( - -
- {item.section} -
- - {item => ( - - {item.title} - - )} - -
- )} -
-
-
+ )} +
+
+ + + + Reference an object + + + {item => ( + +
+ {item.section} +
+ + {item => ( + + {item.title} + + )} + +
+ )} +
+
+
+ +
{/* TODO is this kind of styling expected from the user? Or should we have a slot that places the mic button next to the submit button? */}
diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index 3e0664e2593..d040ece4697 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -37,7 +37,7 @@ import {box, iconStyles} from './Checkbox'; import {centerBaseline} from './CenterBaseline'; import CheckmarkIcon from '../ui-icons/Checkmark'; import ChevronRightIcon from '../ui-icons/Chevron'; -import {ContextValue, DEFAULT_SLOT, Provider} from 'react-aria-components/slots'; +import {ContextValue, DEFAULT_SLOT, Provider, useSlottedContext} from 'react-aria-components/slots'; import { control, controlFont, @@ -747,12 +747,14 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode { placement = `${direction} ${align}` as Placement; } let holdAffordance = trigger === 'longPress'; + let actionButtonContext = useSlottedContext(ActionButtonContext) || {}; + let toggleButtonContext = useSlottedContext(ToggleButtonContext) || {}; return ( { }); }); +describe('Context propagation', () => { + it('preserves the ActionButton context from its parent', () => { + function ContextActionButton() { + let {staticColor} = useSlottedContext(ActionButtonContext) || {}; + return ( + + Menu button + + ); + } + + let {getByRole} = render( + + + + + Item + + + + ); + + expect(getByRole('button', {name: 'Menu button'})).toHaveAttribute('data-static-color', 'auto'); + }); +}); + describe('long press support', function () { let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime}); let user; diff --git a/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts b/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts index fd98151fdac..4c4bd0c5413 100644 --- a/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts +++ b/packages/dev/s2-docs/pages/react-aria/TagFieldValue.ts @@ -2,7 +2,7 @@ import {type TokenFieldSegment, TokenFieldValue} from 'react-aria-components/Tok export class TagFieldValue extends TokenFieldValue { tokenize(text: string): TokenFieldSegment[] { - let parts = text.split(/[, \n]/); + let parts = text.split(/[,\s\u200B]/); let segments: TokenFieldSegment[] = parts.map((part, i) => { if (i === parts.length - 1 || part.length === 0) { diff --git a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx index b634f87a538..f4202f5619b 100644 --- a/packages/dev/s2-docs/pages/react-aria/TokenField.mdx +++ b/packages/dev/s2-docs/pages/react-aria/TokenField.mdx @@ -23,7 +23,7 @@ export const description = 'Allows users to enter text with inline tokens such a /* PROPS */ defaultValue={TokenizingFieldValue.tokenize( 'This example automatically tokenizes #hashtags and @usernames in the text.', - /(?<=\s|^)[#@]\S+(?=\s)/g + /(?<=[\s\u200B]|^)[#@][^\s\u200B]+(?=[\s\u200B])/g )}> {segment => {segment.text}} @@ -38,7 +38,7 @@ export const description = 'Allows users to enter text with inline tokens such a /* PROPS */ defaultValue={TokenizingFieldValue.tokenize( 'This example automatically tokenizes #hashtags and @usernames in the text.', - /(?<=\s|^)[#@]\S+(?=\s)/g + /(?<=[\s\u200B]|^)[#@][^\s\u200B]+(?=[\s\u200B])/g )}> {segment => {segment.text}} @@ -90,7 +90,7 @@ import {TokenizingFieldValue} from './TokenizingFieldValue'; allowsNewlines defaultValue={TokenizingFieldValue.tokenize( 'This example automatically tokenizes #hashtags and @usernames in the text.', - /(?<=\s|^)[#@]\S+(?=\s)/g + /(?<=[\s\u200B]|^)[#@][^\s\u200B]+(?=[\s\u200B])/g )} label="Message"> {segment => {segment.text}} diff --git a/packages/react-aria-components/test/Tabs.test.js b/packages/react-aria-components/test/Tabs.test.js index 087915a5570..0f3b40a97d1 100644 --- a/packages/react-aria-components/test/Tabs.test.js +++ b/packages/react-aria-components/test/Tabs.test.js @@ -21,6 +21,7 @@ import { import {Button} from '../src/Button'; import {ComboBox} from '../src/ComboBox'; import {DialogTrigger} from '../src/Dialog'; +import {I18nProvider} from 'react-aria/I18nProvider'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {ListBox, ListBoxItem} from '../src/ListBox'; @@ -470,6 +471,41 @@ describe('Tabs', () => { expect(tabs).toHaveClass('vertical'); }); + it('allows user to change tab item selection via arrow keys with vertical tabs (rtl)', async () => { + let {getByRole} = render( + + + + A + B + C + + A + B + C + + + ); + + let tablist = getByRole('tablist'); + let tabs = within(tablist).getAllByRole('tab'); + let selectedItem = tabs[0]; + await user.tab(); + + expect(tablist).toHaveAttribute('aria-orientation', 'vertical'); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowDown}'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + await user.keyboard('{ArrowUp}'); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + + await user.keyboard('{ArrowLeft}'); + expect(tabs[1]).toHaveAttribute('aria-selected', 'true'); + await user.keyboard('{ArrowRight}'); + expect(selectedItem).toHaveAttribute('aria-selected', 'true'); + }); + it.each` interactionType ${'mouse'} diff --git a/packages/react-aria-components/test/Toolbar.test.tsx b/packages/react-aria-components/test/Toolbar.test.tsx index da033352b9b..a1c78d19de5 100644 --- a/packages/react-aria-components/test/Toolbar.test.tsx +++ b/packages/react-aria-components/test/Toolbar.test.tsx @@ -458,6 +458,51 @@ describe('Toolbar', () => { expect(screen.getByRole('button', {name: 'Align center'})).toHaveFocus(); }); + it('supports RTL with orientation vertical', async () => { + render( + + + + + + + + +
+ + + + +
+ +
+ ); + + await user.tab(); + await user.tab(); + expect(screen.getByRole('button', {name: 'Align left'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('button', {name: 'Align center'})).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(screen.getByRole('button', {name: 'Align left'})).toHaveFocus(); + + await user.keyboard('{ArrowLeft}'); + expect(screen.getByRole('button', {name: 'Align left'})).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(screen.getByRole('button', {name: 'Align left'})).toHaveFocus(); + }); + it('supports all the aria example children', async () => { render(); diff --git a/packages/react-aria/src/actiongroup/useActionGroup.ts b/packages/react-aria/src/actiongroup/useActionGroup.ts index 109024ab6e2..0febd0c210e 100644 --- a/packages/react-aria/src/actiongroup/useActionGroup.ts +++ b/packages/react-aria/src/actiongroup/useActionGroup.ts @@ -91,7 +91,9 @@ export function useActionGroup( let {direction} = useLocale(); // oxlint-disable-next-line react/react-compiler let focusManager = createFocusManager(ref); - let flipDirection = direction === 'rtl' && orientation === 'horizontal'; + // ArrowLeft/ArrowRight follow the locale's text direction regardless of orientation, so + // ArrowLeft always moves to the next item in RTL. ArrowUp/ArrowDown are never flipped. + let flipDirection = direction === 'rtl'; let {keyboardProps} = useKeyboard({ shortcuts: { ArrowRight: () => { diff --git a/packages/react-aria/src/overlays/calculatePosition.ts b/packages/react-aria/src/overlays/calculatePosition.ts index 5c82a9f0f52..65dc7ee2cc0 100644 --- a/packages/react-aria/src/overlays/calculatePosition.ts +++ b/packages/react-aria/src/overlays/calculatePosition.ts @@ -137,9 +137,10 @@ function getContainerDimensions( // The goal of the below is to get a top/left value that represents the top/left of the visual viewport with // respect to the layout viewport origin. This combined with the scrollTop/scrollLeft will allow us to calculate // coordinates/values with respect to the visual viewport or with respect to the layout viewport. + // Uses pageTop/pageLeft instead of offsetTop/offsetLeft because WebKit misreports those during pans. if (visualViewport) { - top = visualViewport.offsetTop; - left = visualViewport.offsetLeft; + top = Math.max(0, visualViewport.pageTop - (scroll.top ?? 0)); + left = Math.max(0, visualViewport.pageLeft - (scroll.left ?? 0)); } } else { ({width, height, top, left} = getOffset(containerNode, false)); diff --git a/packages/react-aria/src/overlays/useOverlayPosition.ts b/packages/react-aria/src/overlays/useOverlayPosition.ts index b50f57525de..0d1e410c048 100644 --- a/packages/react-aria/src/overlays/useOverlayPosition.ts +++ b/packages/react-aria/src/overlays/useOverlayPosition.ts @@ -10,9 +10,11 @@ * governing permissions and limitations under the License. */ +import {addEvent} from '../utils/domHelpers'; import {calculatePosition, getRect, PositionResult} from './calculatePosition'; import {DOMAttributes, RefObject} from '@react-types/shared'; import {getActiveElement, isFocusWithin} from '../utils/shadowdom/DOMFunctions'; +import {getPropagationTargets} from '../utils/shadowdom/DOMFunctions'; import {useCallback, useEffect, useRef, useState} from 'react'; import {useCloseOnScroll} from './useCloseOnScroll'; import {useLayoutEffect} from '../utils/useLayoutEffect'; @@ -368,9 +370,16 @@ export function useOverlayPosition(props: AriaPositionProps): PositionAria { visualViewport?.addEventListener('resize', onResize); visualViewport?.addEventListener('scroll', onScroll); + let cleanup = addEvent( + // @ts-expect-error + getPropagationTargets(window), + 'scroll', + onScroll + ); return () => { visualViewport?.removeEventListener('resize', onResize); visualViewport?.removeEventListener('scroll', onScroll); + cleanup(); }; }, [updatePosition]); diff --git a/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts b/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts index 04b6773c45d..f35eb8e2966 100644 --- a/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts +++ b/packages/react-aria/src/tabs/TabsKeyboardDelegate.ts @@ -25,7 +25,9 @@ export class TabsKeyboardDelegate implements KeyboardDelegate { disabledKeys: Set = new Set() ) { this.collection = collection; - this.flipDirection = direction === 'rtl' && orientation === 'horizontal'; + // getKeyLeftOf/getKeyRightOf follow the locale's text direction regardless of orientation, + // so ArrowLeft always moves to the next tab in RTL. getKeyAbove/getKeyBelow are never flipped. + this.flipDirection = direction === 'rtl'; this.disabledKeys = disabledKeys; this.tabDirection = orientation === 'horizontal'; }