Skip to content

Commit 50afba9

Browse files
authored
chore: remove unstable from column menus (#8248)
1 parent eccc080 commit 50afba9

File tree

4 files changed

+208
-5
lines changed

4 files changed

+208
-5
lines changed

packages/@react-aria/test-utils/src/table.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ interface TableToggleSortOpts {
2525
*/
2626
interactionType?: UserOpts['interactionType']
2727
}
28+
interface TableColumnHeaderActionOpts extends TableToggleSortOpts {
29+
/**
30+
* The index of the column header action to trigger.
31+
*/
32+
action: number
33+
}
2834
interface TableRowActionOpts extends GridRowActionOpts {}
2935

3036
export class TableTester {
@@ -184,6 +190,92 @@ export class TableTester {
184190
}
185191
}
186192

193+
/**
194+
* Triggers an action for the specified table column menu. Defaults to using the interaction type set on the table tester.
195+
*/
196+
async triggerColumnHeaderAction(opts: TableColumnHeaderActionOpts): Promise<void> {
197+
let {
198+
column,
199+
interactionType = this._interactionType,
200+
action
201+
} = opts;
202+
203+
let columnheader;
204+
if (typeof column === 'number') {
205+
columnheader = this.columns[column];
206+
} else if (typeof column === 'string') {
207+
columnheader = within(this.rowGroups[0]).getByText(column);
208+
while (columnheader && !/columnheader/.test(columnheader.getAttribute('role'))) {
209+
columnheader = columnheader.parentElement;
210+
}
211+
} else {
212+
columnheader = column;
213+
}
214+
215+
let menuButton = within(columnheader).queryByRole('button');
216+
if (menuButton) {
217+
// TODO: Focus management is all kinda of messed up if I just use .focus and Space to open the sort menu. Seems like
218+
// the focused key doesn't get properly set to the desired column header. Have to do this strange flow where I focus the
219+
// column header except if the active element is already the menu button within the column header
220+
if (interactionType === 'keyboard' && document.activeElement !== menuButton) {
221+
await pressElement(this.user, columnheader, interactionType);
222+
} else {
223+
await pressElement(this.user, menuButton, interactionType);
224+
}
225+
226+
await waitFor(() => {
227+
if (menuButton.getAttribute('aria-controls') == null) {
228+
throw new Error('No aria-controls found on table column dropdown menu trigger element.');
229+
} else {
230+
return true;
231+
}
232+
});
233+
234+
let menuId = menuButton.getAttribute('aria-controls');
235+
await waitFor(() => {
236+
if (!menuId || document.getElementById(menuId) == null) {
237+
throw new Error(`Table column header menu with id of ${menuId} not found in document.`);
238+
} else {
239+
return true;
240+
}
241+
});
242+
243+
if (menuId) {
244+
let menu = document.getElementById(menuId);
245+
if (menu) {
246+
await pressElement(this.user, within(menu).getAllByRole('menuitem')[action], interactionType);
247+
248+
await waitFor(() => {
249+
if (document.contains(menu)) {
250+
throw new Error('Expected table column menu listbox to not be in the document after selecting an option');
251+
} else {
252+
return true;
253+
}
254+
});
255+
}
256+
}
257+
258+
// Handle cases where the table may transition in response to the row selection/deselection
259+
if (!this._advanceTimer) {
260+
throw new Error('No advanceTimers provided for table transition.');
261+
}
262+
263+
await act(async () => {
264+
await this._advanceTimer?.(200);
265+
});
266+
267+
await waitFor(() => {
268+
if (document.activeElement !== menuButton) {
269+
throw new Error(`Expected the document.activeElement to be the table column menu button but got ${document.activeElement}`);
270+
} else {
271+
return true;
272+
}
273+
});
274+
} else {
275+
throw new Error('No menu button found on table column header.');
276+
}
277+
}
278+
187279
/**
188280
* Triggers the action for the specified table row. Defaults to using the interaction type set on the table tester.
189281
*/

packages/@react-spectrum/s2/src/TableView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ export interface ColumnProps extends RACColumnProps {
520520
/** The content to render as the column header. */
521521
children: ReactNode,
522522
/** Menu fragment to be rendered inside the column header's menu. */
523-
UNSTABLE_menuItems?: ReactNode
523+
menuItems?: ReactNode
524524
}
525525

526526
/**
@@ -530,7 +530,7 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef
530530
let {isQuiet} = useContext(InternalTableContext);
531531
let {allowsResizing, children, align = 'start'} = props;
532532
let domRef = useDOMRef(ref);
533-
let isMenu = allowsResizing || !!props.UNSTABLE_menuItems;
533+
let isMenu = allowsResizing || !!props.menuItems;
534534

535535

536536
return (
@@ -543,7 +543,7 @@ export const Column = forwardRef(function Column(props: ColumnProps, ref: DOMRef
543543
{isFocusVisible && <CellFocusRing />}
544544
{isMenu ?
545545
(
546-
<ColumnWithMenu isColumnResizable={allowsResizing} menuItems={props.UNSTABLE_menuItems} allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} align={align}>
546+
<ColumnWithMenu isColumnResizable={allowsResizing} menuItems={props.menuItems} allowsSorting={allowsSorting} sortDirection={sortDirection} sort={sort} startResize={startResize} align={align}>
547547
{children}
548548
</ColumnWithMenu>
549549
) : (

packages/@react-spectrum/s2/stories/TableView.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ const DynamicTableWithCustomMenus = (args: any) => (
194194
width={150}
195195
minWidth={150}
196196
isRowHeader={column.isRowHeader}
197-
UNSTABLE_menuItems={
197+
menuItems={
198198
<>
199199
<MenuSection>
200200
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
@@ -247,7 +247,7 @@ const DynamicSortableTableWithCustomMenus = (args: any) => {
247247
width={150}
248248
minWidth={150}
249249
isRowHeader={column.isRowHeader}
250-
UNSTABLE_menuItems={
250+
menuItems={
251251
<>
252252
<MenuSection>
253253
<MenuItem onAction={action('filter')}><Filter /><Text slot="label">Filter</Text></MenuItem>
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
jest.mock('@react-aria/live-announcer');
14+
jest.mock('@react-aria/utils/src/scrollIntoView');
15+
import {act, render} from '@react-spectrum/test-utils-internal';
16+
import {
17+
Cell,
18+
Column,
19+
MenuItem,
20+
MenuSection,
21+
Row,
22+
TableBody,
23+
TableHeader,
24+
TableView,
25+
Text
26+
} from '../src';
27+
import Filter from '../s2wf-icons/S2_Icon_Filter_20_N.svg';
28+
import React from 'react';
29+
import {User} from '@react-aria/test-utils';
30+
31+
// @ts-ignore
32+
window.getComputedStyle = (el) => el.style;
33+
34+
describe('TableView', () => {
35+
let offsetWidth, offsetHeight;
36+
let testUtilUser = new User({advanceTimer: jest.advanceTimersByTime});
37+
beforeAll(function () {
38+
offsetWidth = jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 400);
39+
offsetHeight = jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 200);
40+
jest.useFakeTimers();
41+
});
42+
43+
afterAll(function () {
44+
offsetWidth.mockReset();
45+
offsetHeight.mockReset();
46+
});
47+
48+
afterEach(() => {
49+
act(() => {jest.runAllTimers();});
50+
});
51+
52+
let columns = [
53+
{name: 'Foo', id: 'foo', isRowHeader: true},
54+
{name: 'Bar', id: 'bar'},
55+
{name: 'Baz', id: 'baz'},
56+
{name: 'Yah', id: 'yah'}
57+
];
58+
59+
let items = [
60+
{id: 1, foo: 'Foo 1', bar: 'Bar 1', baz: 'Baz 1', yah: 'Yah long long long 1'},
61+
{id: 2, foo: 'Foo 2', bar: 'Bar 2', baz: 'Baz 2', yah: 'Yah long long long 2'},
62+
{id: 3, foo: 'Foo 3', bar: 'Bar 3', baz: 'Baz 3', yah: 'Yah long long long 3'},
63+
{id: 4, foo: 'Foo 4', bar: 'Bar 4', baz: 'Baz 4', yah: 'Yah long long long 4'},
64+
{id: 5, foo: 'Foo 5', bar: 'Bar 5', baz: 'Baz 5', yah: 'Yah long long long 5'},
65+
{id: 6, foo: 'Foo 6', bar: 'Bar 6', baz: 'Baz 6', yah: 'Yah long long long 6'},
66+
{id: 7, foo: 'Foo 7', bar: 'Bar 7', baz: 'Baz 7', yah: 'Yah long long long 7'},
67+
{id: 8, foo: 'Foo 8', bar: 'Bar 8', baz: 'Baz 8', yah: 'Yah long long long 8'},
68+
{id: 9, foo: 'Foo 9', bar: 'Bar 9', baz: 'Baz 9', yah: 'Yah long long long 9'},
69+
{id: 10, foo: 'Foo 10', bar: 'Bar 10', baz: 'Baz 10', yah: 'Yah long long long 10'}
70+
];
71+
72+
it('should render custom menus', async () => {
73+
let onAction = jest.fn();
74+
let {getByRole} = render(
75+
<TableView aria-label="Dynamic table">
76+
<TableHeader columns={columns}>
77+
{(column) => (
78+
<Column
79+
width={150}
80+
minWidth={150}
81+
isRowHeader={column.isRowHeader}
82+
menuItems={
83+
<>
84+
<MenuSection>
85+
<MenuItem onAction={onAction}><Filter /><Text slot="label">Filter</Text></MenuItem>
86+
</MenuSection>
87+
<MenuSection>
88+
<MenuItem><Text slot="label">Hide column</Text></MenuItem>
89+
<MenuItem><Text slot="label">Manage columns</Text></MenuItem>
90+
</MenuSection>
91+
</>
92+
}>{column.name}</Column>
93+
)}
94+
</TableHeader>
95+
<TableBody items={items}>
96+
{item => (
97+
<Row id={item.id} columns={columns}>
98+
{(column) => {
99+
return <Cell>{item[column.id]}</Cell>;
100+
}}
101+
</Row>
102+
)}
103+
</TableBody>
104+
</TableView>
105+
);
106+
107+
let tableTester = testUtilUser.createTester('Table', {root: getByRole('grid')});
108+
await tableTester.triggerColumnHeaderAction({column: 1, action: 0, interactionType: 'keyboard'});
109+
expect(onAction).toHaveBeenCalledTimes(1);
110+
});
111+
});

0 commit comments

Comments
 (0)