From 463ee264b452fd72fdb67f03bf12460b07dfb98b Mon Sep 17 00:00:00 2001 From: Jay Stothard Date: Fri, 6 Feb 2026 18:51:44 +0000 Subject: [PATCH 1/5] fix(focus): handle single radio button in FocusScope (#9587) * fix(focus): handle single radio button in FocusScope Fixes #9569 form.elements.namedItem() returns an Element (not RadioNodeList) when there is exactly one element with that name. This caused TypeError when trying to spread a non-iterable Element. The fix checks if namedItem() returns a single Element before spreading, handling all three possible return types per the DOM spec: - RadioNodeList (iterable) for 2+ elements with the same name - Element (NOT iterable) for exactly 1 element - null for no elements * Update packages/@react-aria/focus/src/FocusScope.tsx * Update packages/@react-aria/focus/src/FocusScope.tsx * Add getOwnerWindow import to FocusScope --------- Co-authored-by: Robert Snow --- packages/@react-aria/focus/src/FocusScope.tsx | 41 +++++++++++++------ .../@react-aria/focus/test/FocusScope.test.js | 26 ++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/@react-aria/focus/src/FocusScope.tsx b/packages/@react-aria/focus/src/FocusScope.tsx index ab7c4c7f067..0a0dc1310f4 100644 --- a/packages/@react-aria/focus/src/FocusScope.tsx +++ b/packages/@react-aria/focus/src/FocusScope.tsx @@ -15,6 +15,7 @@ import { getActiveElement, getEventTarget, getOwnerDocument, + getOwnerWindow, isAndroid, isChrome, isFocusable, @@ -295,23 +296,37 @@ function shouldContainFocus(scopeRef: ScopeRef) { return true; } -function isTabbableRadio(element: HTMLInputElement) { - if (element.checked) { - return true; - } - let radios: HTMLInputElement[] = []; +function getRadiosInGroup(element: HTMLInputElement): HTMLInputElement[] { if (!element.form) { - radios = ([...getOwnerDocument(element).querySelectorAll(`input[type="radio"][name="${CSS.escape(element.name)}"]`)] as HTMLInputElement[]).filter(radio => !radio.form); - } else { - let radioList = element.form?.elements?.namedItem(element.name) as RadioNodeList; - radios = [...(radioList ?? [])] as HTMLInputElement[]; + // Radio buttons outside a form - query the document + return Array.from( + getOwnerDocument(element).querySelectorAll( + `input[type="radio"][name="${CSS.escape(element.name)}"]` + ) + ).filter(radio => !radio.form); } - if (!radios) { - return false; + + // namedItem returns RadioNodeList (iterable) for 2+ elements, but a single Element for exactly 1. + // https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection/namedItem + const radioList = element.form.elements.namedItem(element.name); + let ownerWindow = getOwnerWindow(element); + if (radioList instanceof ownerWindow.RadioNodeList) { + return Array.from(radioList).filter( + (el): el is HTMLInputElement => el instanceof ownerWindow.HTMLInputElement + ); } - let anyChecked = radios.some(radio => radio.checked); + if (radioList instanceof ownerWindow.HTMLInputElement) { + return [radioList]; + } + return []; +} - return !anyChecked; +function isTabbableRadio(element: HTMLInputElement): boolean { + if (element.checked) { + return true; + } + const radios = getRadiosInGroup(element); + return radios.length > 0 && !radios.some(radio => radio.checked); } function useFocusContainment(scopeRef: RefObject, contain?: boolean) { diff --git a/packages/@react-aria/focus/test/FocusScope.test.js b/packages/@react-aria/focus/test/FocusScope.test.js index ef0b104c084..38648c12635 100644 --- a/packages/@react-aria/focus/test/FocusScope.test.js +++ b/packages/@react-aria/focus/test/FocusScope.test.js @@ -1538,6 +1538,32 @@ describe('FocusScope', function () { expect(document.activeElement).toBe(getByTestId('button1')); }); + it('handles forms with a single radio button without crashing', async function () { + // Regression test for https://github.com/adobe/react-spectrum/issues/9569 + // form.elements.namedItem() returns Element (not RadioNodeList) for single elements + function Test() { + return ( + + +
+ + +
+ +
+ ); + } + + let {getByTestId, getByRole} = render(); + let radio = getByRole('radio'); + await user.tab(); + expect(document.activeElement).toBe(getByTestId('button1')); + await user.tab(); + expect(document.activeElement).toBe(radio); + await user.tab(); + expect(document.activeElement).toBe(getByTestId('button2')); + }); + describe('nested focus scopes', function () { it('should make child FocusScopes the active scope regardless of DOM structure', function () { function ChildComponent(props) { From 2f1e79f2b1695949c555035a6841ea0391102e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sonsu/=EC=9D=B4=EC=84=B1=EC=88=98?= <127682098+sonsu-lee@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:39:18 +0900 Subject: [PATCH 2/5] fix: correct aria-posinset to use 1-based index in virtualized Menu (#9615) * fix: correct aria-posinset to use 1-based index in virtualized Menu * Have Menu set isVirtualized automatically, add test and story --------- Co-authored-by: Robert Snow --- packages/@react-aria/menu/src/useMenuItem.ts | 3 +- .../@react-aria/menu/test/useMenu.test.tsx | 73 +++++++++++++++- packages/react-aria-components/src/Menu.tsx | 5 +- .../stories/Menu.stories.tsx | 33 +++++++- .../test/VirtualizedMenu.test.tsx | 84 +++++++++++++++++++ 5 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 packages/react-aria-components/test/VirtualizedMenu.test.tsx diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index a8302c8c745..7e161eec2b6 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -188,7 +188,8 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re } if (isVirtualized) { - ariaProps['aria-posinset'] = item?.index; + let index = Number(item?.index); + ariaProps['aria-posinset'] = Number.isNaN(index) ? undefined : index + 1; ariaProps['aria-setsize'] = getItemCount(state.collection); } diff --git a/packages/@react-aria/menu/test/useMenu.test.tsx b/packages/@react-aria/menu/test/useMenu.test.tsx index aafb79204c3..f99047417d3 100644 --- a/packages/@react-aria/menu/test/useMenu.test.tsx +++ b/packages/@react-aria/menu/test/useMenu.test.tsx @@ -10,12 +10,14 @@ * governing permissions and limitations under the License. */ + import {AriaMenuProps, useMenu, useMenuItem} from '../'; import {Item} from '@react-stately/collections'; +import {Key} from '@react-types/shared'; import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; +import {TreeState, useTreeState} from '@react-stately/tree'; import userEvent from '@testing-library/user-event'; -import {useTreeState} from '@react-stately/tree'; function Menu(props: AriaMenuProps & {onSelect: () => void}) { // Create menu state based on the incoming props @@ -51,6 +53,41 @@ function MenuItem({item, state, onAction}) { ); } +interface VirtualizedMenuItemProps { + item: {key: Key, rendered: React.ReactNode, index?: number}, + state: TreeState, + onAction?: (key: Key) => void +} + +function VirtualizedMenuItem({item, state, onAction}: VirtualizedMenuItemProps) { + let ref = React.useRef(null); + let {menuItemProps} = useMenuItem( + {key: item.key, onAction, isVirtualized: true}, + state, + ref + ); + + return ( +
  • + {item.rendered} +
  • + ); +} + +function VirtualizedMenu(props: AriaMenuProps) { + let state = useTreeState(props); + let ref = React.useRef(null); + let {menuProps} = useMenu(props, state, ref); + + return ( +
      + {[...state.collection].map((item) => ( + + ))} +
    + ); +} + describe('useMenuTrigger', function () { let user; beforeAll(() => { @@ -75,3 +112,37 @@ describe('useMenuTrigger', function () { expect(onSelect).toHaveBeenCalledTimes(1); }); }); + +describe('useMenuItem with isVirtualized', function () { + it('sets correct aria-posinset (1-based) for virtualized menu items', () => { + let {getAllByRole} = render( + + One + Two + Three + + ); + + let items = getAllByRole('menuitem'); + // aria-posinset should be 1-based (1, 2, 3), not 0-based (0, 1, 2) + expect(items[0]).toHaveAttribute('aria-posinset', '1'); + expect(items[1]).toHaveAttribute('aria-posinset', '2'); + expect(items[2]).toHaveAttribute('aria-posinset', '3'); + }); + + it('sets correct aria-setsize for virtualized menu items', () => { + let {getAllByRole} = render( + + One + Two + Three + + ); + + let items = getAllByRole('menuitem'); + // aria-setsize should match the total number of items + expect(items[0]).toHaveAttribute('aria-setsize', '3'); + expect(items[1]).toHaveAttribute('aria-setsize', '3'); + expect(items[2]).toHaveAttribute('aria-setsize', '3'); + }); +}); diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 91cb0727e4a..c6ab3cfb6aa 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -431,12 +431,13 @@ export const MenuItem = /*#__PURE__*/ createLeafComponent(ItemNode, function Men let state = useContext(MenuStateContext)!; let ref = useObjectRef(forwardedRef); let selectionManager = useContext(SelectionManagerContext)!; - + let {isVirtualized} = useContext(CollectionRendererContext); let {menuItemProps, labelProps, descriptionProps, keyboardShortcutProps, ...states} = useMenuItem({ ...props, id, key: item.key, - selectionManager + selectionManager, + isVirtualized: isVirtualized }, state, ref); let {hoverProps, isHovered} = useHover({ diff --git a/packages/react-aria-components/stories/Menu.stories.tsx b/packages/react-aria-components/stories/Menu.stories.tsx index 55810f87160..6c824451ca5 100644 --- a/packages/react-aria-components/stories/Menu.stories.tsx +++ b/packages/react-aria-components/stories/Menu.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Header, Heading, Input, Keyboard, Label, Menu, MenuItemProps, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, SubmenuTriggerProps, Text, TextField} from 'react-aria-components'; +import {Button, Header, Heading, Input, Keyboard, Label, ListLayout, Menu, MenuItemProps, MenuSection, MenuTrigger, Popover, Separator, SubmenuTrigger, SubmenuTriggerProps, Text, TextField, Virtualizer} from 'react-aria-components'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {MyMenuItem} from './utils'; import React, {JSX} from 'react'; @@ -444,7 +444,7 @@ function MenuItemWithCustomElement(props: MenuItemProps) { // Otherwise we'd need another way to set the expected element type. return ( 'href' in domProps ? :
    } /> ); } @@ -453,3 +453,32 @@ function RouterLink(props: React.AnchorHTMLAttributes) { // eslint-disable-next-line jsx-a11y/anchor-has-content return {e.preventDefault(); console.log('click');}})} />; } + +let items = Array.from({length: 600}, (_, index) => { + // Return the object structure for each element + return { + id: index + 1, + name: `Object ${index + 1}`, + value: Math.random() + }; +}); +export const VirtualizedExample: MenuStory = () => { + return ( + + + + + + {(item) => { + return {item.name}; + }} + + + + + ); +}; diff --git a/packages/react-aria-components/test/VirtualizedMenu.test.tsx b/packages/react-aria-components/test/VirtualizedMenu.test.tsx new file mode 100644 index 00000000000..37579b9f142 --- /dev/null +++ b/packages/react-aria-components/test/VirtualizedMenu.test.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2022 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 {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button, ListLayout, Menu, MenuItem, MenuTrigger, Popover, Virtualizer} from '..'; +import React from 'react'; +import {User} from '@react-aria/test-utils'; +import userEvent from '@testing-library/user-event'; + +let items = Array.from({length: 50}, (_, index) => { + // Return the object structure for each element + return { + id: index + 1, + name: `Object ${index + 1}`, + value: Math.random() + }; +}); +const VirtualizedExample = () => { + return ( + + + + + + {(item) => { + return {item.name}; + }} + + + + + ); +}; + +// @ts-ignore +window.getComputedStyle = (el) => el.style; + +describe('virtualized menu', () => { + let user; + let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime}); + beforeAll(function () { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + + afterEach(() => { + act(() => {jest.runAllTimers();}); + }); + it('should support virtualized menu', async () => { + jest.restoreAllMocks(); // don't mock scrollTop for this test + jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100); + jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 100); + + let {getAllByRole, container} = render(); + let tester = testUtilUser.createTester('Menu', {user, root: container}); + tester.setInteractionType('mouse'); + await tester.open(); + let items = getAllByRole('menuitem'); + let menu = tester.menu; + expect(menu).toBeInTheDocument(); + expect(items[0]).toHaveAttribute('aria-posinset', '1'); + expect(items[0]).toHaveAttribute('aria-setsize', '50'); + expect(items[items.length - 1]).toHaveAttribute('aria-posinset', items.length.toString()); + expect(items.length).toBeLessThan(50); + + await user.keyboard('{End}'); + items = getAllByRole('menuitem'); + expect(items.length).toBeLessThan(50); + expect(items[items.length - 1]).toHaveAttribute('aria-posinset', '50'); + }); +}); From fb50ecbc7e3dab09e05e289469a793aba3c1a5b2 Mon Sep 17 00:00:00 2001 From: XiaoYan Li Date: Sat, 7 Feb 2026 04:42:15 +0800 Subject: [PATCH 3/5] fix(RAC): move native input inside the combobox div (#9554) We have some form binding logic that depends on native form elements being inside the ref element. The current implementation of ComboBox breaks this design. This also makes it more difficult to access the native element for certain reasons (unless we add an extra div wrapper outside). I suggest changing the rendering structure of ComboBox to be consistent with Select, rendering the native form element inside the div instead of as a sibling. --- packages/react-aria-components/src/ComboBox.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx index 91c5cfe1208..0fe80d7c2c4 100644 --- a/packages/react-aria-components/src/ComboBox.tsx +++ b/packages/react-aria-components/src/ComboBox.tsx @@ -241,8 +241,10 @@ function ComboBoxInner({props, collection, comboBoxRef: ref}: data-open={state.isOpen || undefined} data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} - data-required={props.isRequired || undefined} /> - {name && formValue === 'key' && } + data-required={props.isRequired || undefined}> + {renderProps.children} + {name && formValue === 'key' && } + ); } From 5abd94d4cf2990959bbae7771f1de19c9aa163b0 Mon Sep 17 00:00:00 2001 From: Daniel Lu Date: Fri, 6 Feb 2026 12:53:13 -0800 Subject: [PATCH 4/5] fix: apply WHCM S2 table text color (#9589) --- packages/@react-spectrum/s2/src/TableView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/src/TableView.tsx b/packages/@react-spectrum/s2/src/TableView.tsx index b5e66fd84a2..df243e855c7 100644 --- a/packages/@react-spectrum/s2/src/TableView.tsx +++ b/packages/@react-spectrum/s2/src/TableView.tsx @@ -955,7 +955,8 @@ const cell = style({ ...commonCellStyles, color: { default: baseColor('neutral-subdued'), - isSelected: baseColor('neutral') + isSelected: baseColor('neutral'), + forcedColors: 'ButtonText' }, paddingY: centerPadding(), minHeight: { From 4ba9f1dbc32a079073352a7b0ba6fa095c66c5b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Albert=20=F0=9F=87=AC=F0=9F=87=AD?= Date: Fri, 6 Feb 2026 22:46:19 +0000 Subject: [PATCH 5/5] fix: allow tooltip to show on hover when Button has isPending (#9460) (#9619) * fix: allow tooltip to show on hover when Button has isPending (#9460) * add story --------- Co-authored-by: Reid Barber --- packages/react-aria-components/src/Button.tsx | 6 ++-- .../stories/Button.stories.tsx | 28 ++++++++++++++++++- .../test/Tooltip.test.js | 19 +++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx index d43d789db59..33887f318f0 100644 --- a/packages/react-aria-components/src/Button.tsx +++ b/packages/react-aria-components/src/Button.tsx @@ -168,11 +168,13 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop ); }); +// Events to preserve when isPending is true (for tooltips and other overlays) +const PRESERVED_EVENT_PATTERN = /Focus|Blur|Hover|Pointer(Enter|Leave|Over|Out)|Mouse(Enter|Leave|Over|Out)/; + function useDisableInteractions(props, isPending) { - // Don't allow interaction while isPending is true if (isPending) { for (const key in props) { - if (key.startsWith('on') && !(key.includes('Focus') || key.includes('Blur'))) { + if (key.startsWith('on') && !PRESERVED_EVENT_PATTERN.test(key)) { props[key] = undefined; } } diff --git a/packages/react-aria-components/stories/Button.stories.tsx b/packages/react-aria-components/stories/Button.stories.tsx index 47ffbe8d1b1..32f6f09ea66 100644 --- a/packages/react-aria-components/stories/Button.stories.tsx +++ b/packages/react-aria-components/stories/Button.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, ProgressBar, Text} from 'react-aria-components'; +import {Button, ProgressBar, Text, Tooltip, TooltipTrigger} from 'react-aria-components'; import {mergeProps} from '@react-aria/utils'; import {Meta, StoryObj} from '@storybook/react'; import React, {useEffect, useRef, useState} from 'react'; @@ -39,6 +39,13 @@ export const PendingButton: ButtonStory = { } }; +export const PendingButtonTooltip: ButtonStory = { + render: (args) => , + args: { + children: 'Press me, then hover again to see tooltip' + } +}; + function PendingButtonExample(props) { let [isPending, setPending] = useState(false); @@ -84,6 +91,25 @@ function PendingButtonExample(props) { ); } +function PendingButtonTooltipExample(props) { + return ( + + + + Tooltip should appear on hover + + + ); +} + export const RippleButtonExample: ButtonStory = { render: () => ( Press me diff --git a/packages/react-aria-components/test/Tooltip.test.js b/packages/react-aria-components/test/Tooltip.test.js index e13ecf87b10..01c6cffe26c 100644 --- a/packages/react-aria-components/test/Tooltip.test.js +++ b/packages/react-aria-components/test/Tooltip.test.js @@ -61,6 +61,25 @@ describe('Tooltip', () => { expect(arrow).toHaveStyle('position: absolute'); }); + it('shows on hover when button has isPending', async () => { + let {getByRole} = render( + + + Tooltip content + + ); + + let button = getByRole('button'); + + fireEvent.mouseMove(document.body); + await user.hover(button); + act(() => jest.runAllTimers()); + + let tooltip = getByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + expect(tooltip).toHaveTextContent('Tooltip content'); + }); + it('shows on focus', async () => { let {getByRole} = renderTooltip(); let button = getByRole('button');