Skip to content

Commit 44f94cf

Browse files
committed
Merge branch 'main' into fix-all-document-active-element
# Conflicts: # packages/@react-aria/numberfield/src/useNumberField.ts # packages/@react-aria/utils/src/useViewportSize.ts # packages/@react-spectrum/menu/src/ContextualHelpTrigger.tsx # packages/@react-spectrum/menu/src/SubmenuTrigger.tsx # packages/@react-spectrum/menu/src/useOverlayPosition.ts
2 parents b8f1e17 + 0efd2d4 commit 44f94cf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+698
-1470
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,10 @@
187187
"postcss": "^8.4.24",
188188
"postcss-custom-properties": "^13.2.0",
189189
"postcss-import": "^15.1.0",
190-
"react": "^19.1.0",
191-
"react-dom": "^19.1.0",
190+
"react": "^19.2.0",
191+
"react-dom": "^19.2.0",
192192
"react-frame-component": "^5.0.0",
193-
"react-test-renderer": "^19.1.0",
193+
"react-test-renderer": "^19.2.0",
194194
"recast": "^0.23",
195195
"recursive-readdir": "^2.2.2",
196196
"regenerator-runtime": "0.13.3",

packages/@react-aria/calendar/src/useCalendarCell.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
174174
onPressStart(e) {
175175
if (state.isReadOnly) {
176176
state.setFocusedDate(date);
177+
state.setFocused(true);
177178
return;
178179
}
179180

@@ -186,12 +187,14 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
186187
if (isSameDay(date, state.highlightedRange.start)) {
187188
state.setAnchorDate(state.highlightedRange.end);
188189
state.setFocusedDate(date);
190+
state.setFocused(true);
189191
state.setDragging(true);
190192
isRangeBoundaryPressed.current = true;
191193
return;
192194
} else if (isSameDay(date, state.highlightedRange.end)) {
193195
state.setAnchorDate(state.highlightedRange.start);
194196
state.setFocusedDate(date);
197+
state.setFocused(true);
195198
state.setDragging(true);
196199
isRangeBoundaryPressed.current = true;
197200
return;
@@ -204,6 +207,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
204207

205208
state.selectDate(date);
206209
state.setFocusedDate(date);
210+
state.setFocused(true);
207211
isAnchorPressed.current = true;
208212
};
209213

@@ -227,6 +231,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
227231
if (!('anchorDate' in state) && !state.isReadOnly) {
228232
state.selectDate(date);
229233
state.setFocusedDate(date);
234+
state.setFocused(true);
230235
}
231236
},
232237
onPressUp(e) {
@@ -240,6 +245,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
240245
if ('anchorDate' in state && touchDragTimerRef.current) {
241246
state.selectDate(date);
242247
state.setFocusedDate(date);
248+
state.setFocused(true);
243249
}
244250

245251
if ('anchorDate' in state) {
@@ -252,6 +258,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
252258
// When releasing a drag or pressing the end date of a range, select it.
253259
state.selectDate(date);
254260
state.setFocusedDate(date);
261+
state.setFocused(true);
255262
} else if (e.pointerType === 'keyboard' && !state.anchorDate) {
256263
// For range selection, auto-advance the focused date by one if using keyboard.
257264
// This gives an indication that you're selecting a range rather than a single date.
@@ -264,11 +271,13 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
264271
}
265272
if (!state.isInvalid(nextDay)) {
266273
state.setFocusedDate(nextDay);
274+
state.setFocused(true);
267275
}
268276
} else if (e.pointerType === 'virtual') {
269277
// For screen readers, just select the date on click.
270278
state.selectDate(date);
271279
state.setFocusedDate(date);
280+
state.setFocused(true);
272281
}
273282
}
274283
}
@@ -316,6 +325,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
316325
onFocus() {
317326
if (!isDisabled) {
318327
state.setFocusedDate(date);
328+
state.setFocused(true);
319329
}
320330
},
321331
tabIndex,

packages/@react-aria/collections/src/Document.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -475,12 +475,10 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
475475

476476
/** Finalizes the collection update, updating all nodes and freezing the collection. */
477477
getCollection(): C {
478-
// If in a subscription update, return a clone of the existing collection.
479-
// This ensures React will queue a render. React will call getCollection again
480-
// during render, at which point all the updates will be complete and we can return
481-
// the new collection.
478+
// If in a subscription update, return return the existing collection.
479+
// React will call getCollection again during render, at which point all the updates will be complete.
482480
if (this.inSubscription) {
483-
return this.collection.clone();
481+
return this.collection;
484482
}
485483

486484
// Reset queuedRender to false when getCollection is called during render.
@@ -540,9 +538,18 @@ export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extend
540538
// we reset queuedRender back to false.
541539
this.queuedRender = true;
542540
this.inSubscription = true;
541+
542+
// Clone the collection to ensure that React queues a render. It will call getCollection again
543+
// during render, at which point all the updates will be complete and we can return
544+
// the new collection.
545+
if (!this.isSSR) {
546+
this.collection = this.collection.clone();
547+
}
548+
543549
for (let fn of this.subscriptions) {
544550
fn();
545551
}
552+
546553
this.inSubscription = false;
547554
}
548555

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getActiveElement,
1616
getEventTarget,
1717
getOwnerDocument,
18+
getOwnerWindow,
1819
isAndroid,
1920
isChrome,
2021
isFocusable,
@@ -295,23 +296,37 @@ function shouldContainFocus(scopeRef: ScopeRef) {
295296
return true;
296297
}
297298

298-
function isTabbableRadio(element: HTMLInputElement) {
299-
if (element.checked) {
300-
return true;
301-
}
302-
let radios: HTMLInputElement[] = [];
299+
function getRadiosInGroup(element: HTMLInputElement): HTMLInputElement[] {
303300
if (!element.form) {
304-
radios = ([...getOwnerDocument(element).querySelectorAll(`input[type="radio"][name="${CSS.escape(element.name)}"]`)] as HTMLInputElement[]).filter(radio => !radio.form);
305-
} else {
306-
let radioList = element.form?.elements?.namedItem(element.name) as RadioNodeList;
307-
radios = [...(radioList ?? [])] as HTMLInputElement[];
301+
// Radio buttons outside a form - query the document
302+
return Array.from(
303+
getOwnerDocument(element).querySelectorAll<HTMLInputElement>(
304+
`input[type="radio"][name="${CSS.escape(element.name)}"]`
305+
)
306+
).filter(radio => !radio.form);
308307
}
309-
if (!radios) {
310-
return false;
308+
309+
// namedItem returns RadioNodeList (iterable) for 2+ elements, but a single Element for exactly 1.
310+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormControlsCollection/namedItem
311+
const radioList = element.form.elements.namedItem(element.name);
312+
let ownerWindow = getOwnerWindow(element);
313+
if (radioList instanceof ownerWindow.RadioNodeList) {
314+
return Array.from(radioList).filter(
315+
(el): el is HTMLInputElement => el instanceof ownerWindow.HTMLInputElement
316+
);
311317
}
312-
let anyChecked = radios.some(radio => radio.checked);
318+
if (radioList instanceof ownerWindow.HTMLInputElement) {
319+
return [radioList];
320+
}
321+
return [];
322+
}
313323

314-
return !anyChecked;
324+
function isTabbableRadio(element: HTMLInputElement): boolean {
325+
if (element.checked) {
326+
return true;
327+
}
328+
const radios = getRadiosInGroup(element);
329+
return radios.length > 0 && !radios.some(radio => radio.checked);
315330
}
316331

317332
function useFocusContainment(scopeRef: RefObject<Element[] | null>, contain?: boolean) {

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,32 @@ describe('FocusScope', function () {
15381538
expect(document.activeElement).toBe(getByTestId('button1'));
15391539
});
15401540

1541+
it('handles forms with a single radio button without crashing', async function () {
1542+
// Regression test for https://github.com/adobe/react-spectrum/issues/9569
1543+
// form.elements.namedItem() returns Element (not RadioNodeList) for single elements
1544+
function Test() {
1545+
return (
1546+
<FocusScope contain>
1547+
<button data-testid="button1">First button</button>
1548+
<form>
1549+
<input type="radio" id="only" name="option" value="only" />
1550+
<label htmlFor="only">Only Option</label>
1551+
</form>
1552+
<button data-testid="button2">Second button</button>
1553+
</FocusScope>
1554+
);
1555+
}
1556+
1557+
let {getByTestId, getByRole} = render(<Test />);
1558+
let radio = getByRole('radio');
1559+
await user.tab();
1560+
expect(document.activeElement).toBe(getByTestId('button1'));
1561+
await user.tab();
1562+
expect(document.activeElement).toBe(radio);
1563+
await user.tab();
1564+
expect(document.activeElement).toBe(getByTestId('button2'));
1565+
});
1566+
15411567
describe('nested focus scopes', function () {
15421568
it('should make child FocusScopes the active scope regardless of DOM structure', function () {
15431569
function ChildComponent(props) {

packages/@react-aria/form/src/useFormValidation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ function getNativeValidity(input: ValidatableElement): ValidationResult {
145145
function getFirstInvalidInput(form: HTMLFormElement): ValidatableElement | null {
146146
for (let i = 0; i < form.elements.length; i++) {
147147
let element = form.elements[i] as ValidatableElement;
148-
if (!element.validity.valid) {
148+
if (element.validity?.valid === false) {
149149
return element;
150150
}
151151
}

packages/@react-aria/interactions/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export type {HoverProps, HoverResult} from './useHover';
4141
export type {InteractOutsideProps} from './useInteractOutside';
4242
export type {KeyboardProps, KeyboardResult} from './useKeyboard';
4343
export type {PressProps, PressHookProps, PressResult} from './usePress';
44-
export type {PressEvent, PressEvents, MoveStartEvent, MoveMoveEvent, MoveEndEvent, MoveEvents, HoverEvent, HoverEvents, FocusEvents, KeyboardEvents} from '@react-types/shared';
44+
export type {PressEvent, PressEvents, LongPressEvent, MoveStartEvent, MoveMoveEvent, MoveEndEvent, MoveEvent, MoveEvents, HoverEvent, HoverEvents, FocusEvents, KeyboardEvents} from '@react-types/shared';
4545
export type {MoveResult} from './useMove';
4646
export type {LongPressProps, LongPressResult} from './useLongPress';
4747
export type {ScrollWheelProps} from './useScrollWheel';

packages/@react-aria/menu/src/useMenuItem.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
188188
}
189189

190190
if (isVirtualized) {
191-
ariaProps['aria-posinset'] = item?.index;
191+
let index = Number(item?.index);
192+
ariaProps['aria-posinset'] = Number.isNaN(index) ? undefined : index + 1;
192193
ariaProps['aria-setsize'] = getItemCount(state.collection);
193194
}
194195

packages/@react-aria/menu/test/useMenu.test.tsx

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
1314
import {AriaMenuProps, useMenu, useMenuItem} from '../';
1415
import {Item} from '@react-stately/collections';
16+
import {Key} from '@react-types/shared';
1517
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
1618
import React from 'react';
19+
import {TreeState, useTreeState} from '@react-stately/tree';
1720
import userEvent from '@testing-library/user-event';
18-
import {useTreeState} from '@react-stately/tree';
1921

2022
function Menu<T extends object>(props: AriaMenuProps<T> & {onSelect: () => void}) {
2123
// Create menu state based on the incoming props
@@ -51,6 +53,41 @@ function MenuItem({item, state, onAction}) {
5153
);
5254
}
5355

56+
interface VirtualizedMenuItemProps<T> {
57+
item: {key: Key, rendered: React.ReactNode, index?: number},
58+
state: TreeState<T>,
59+
onAction?: (key: Key) => void
60+
}
61+
62+
function VirtualizedMenuItem<T>({item, state, onAction}: VirtualizedMenuItemProps<T>) {
63+
let ref = React.useRef(null);
64+
let {menuItemProps} = useMenuItem(
65+
{key: item.key, onAction, isVirtualized: true},
66+
state,
67+
ref
68+
);
69+
70+
return (
71+
<li {...menuItemProps} ref={ref}>
72+
{item.rendered}
73+
</li>
74+
);
75+
}
76+
77+
function VirtualizedMenu<T extends object>(props: AriaMenuProps<T>) {
78+
let state = useTreeState(props);
79+
let ref = React.useRef(null);
80+
let {menuProps} = useMenu(props, state, ref);
81+
82+
return (
83+
<ul {...menuProps} ref={ref}>
84+
{[...state.collection].map((item) => (
85+
<VirtualizedMenuItem key={item.key} item={item} state={state} />
86+
))}
87+
</ul>
88+
);
89+
}
90+
5491
describe('useMenuTrigger', function () {
5592
let user;
5693
beforeAll(() => {
@@ -75,3 +112,37 @@ describe('useMenuTrigger', function () {
75112
expect(onSelect).toHaveBeenCalledTimes(1);
76113
});
77114
});
115+
116+
describe('useMenuItem with isVirtualized', function () {
117+
it('sets correct aria-posinset (1-based) for virtualized menu items', () => {
118+
let {getAllByRole} = render(
119+
<VirtualizedMenu aria-label="test menu">
120+
<Item key="1">One</Item>
121+
<Item key="2">Two</Item>
122+
<Item key="3">Three</Item>
123+
</VirtualizedMenu>
124+
);
125+
126+
let items = getAllByRole('menuitem');
127+
// aria-posinset should be 1-based (1, 2, 3), not 0-based (0, 1, 2)
128+
expect(items[0]).toHaveAttribute('aria-posinset', '1');
129+
expect(items[1]).toHaveAttribute('aria-posinset', '2');
130+
expect(items[2]).toHaveAttribute('aria-posinset', '3');
131+
});
132+
133+
it('sets correct aria-setsize for virtualized menu items', () => {
134+
let {getAllByRole} = render(
135+
<VirtualizedMenu aria-label="test menu">
136+
<Item key="1">One</Item>
137+
<Item key="2">Two</Item>
138+
<Item key="3">Three</Item>
139+
</VirtualizedMenu>
140+
);
141+
142+
let items = getAllByRole('menuitem');
143+
// aria-setsize should match the total number of items
144+
expect(items[0]).toHaveAttribute('aria-setsize', '3');
145+
expect(items[1]).toHaveAttribute('aria-setsize', '3');
146+
expect(items[2]).toHaveAttribute('aria-setsize', '3');
147+
});
148+
});

packages/@react-aria/numberfield/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"@react-aria/i18n": "^3.12.15",
3030
"@react-aria/interactions": "^3.27.0",
31+
"@react-aria/live-announcer": "^3.4.4",
3132
"@react-aria/spinbutton": "^3.7.1",
3233
"@react-aria/textfield": "^3.18.4",
3334
"@react-aria/utils": "^3.33.0",

0 commit comments

Comments
 (0)