Skip to content

Commit 9d3ff05

Browse files
authored
[select][menu] Better handle dynamic and non-string items (#1861)
1 parent e4647f7 commit 9d3ff05

File tree

11 files changed

+212
-43
lines changed

11 files changed

+212
-43
lines changed

packages/react/src/menu/popup/MenuPopup.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export const MenuPopup = React.forwardRef(function MenuPopup(
103103
(parent.type === 'menubar' && lastOpenChangeReason !== 'outside-press')
104104
}
105105
initialFocus={parent.type === 'menu' ? -1 : 0}
106+
restoreFocus
106107
>
107108
{renderElement()}
108109
</FloatingFocusManager>

packages/react/src/menubar/Menubar.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,9 @@ describe('<Menubar />', () => {
316316

317317
// First submenu item should be focused
318318
const submenuItem = screen.getByTestId('share-item-1');
319-
expect(submenuItem).toHaveFocus();
319+
await waitFor(() => {
320+
expect(submenuItem).toHaveFocus();
321+
});
320322
});
321323

322324
it.skipIf(isJSDOM)('should close the menu with Escape key', async () => {

packages/react/src/select/item-text/SelectItemText.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ const InnerSelectItemText = React.memo(
2424
(node: HTMLElement | null) => {
2525
// Wait for the DOM indices to be set.
2626
queueMicrotask(() => {
27-
if (selected || (selectedItemTextRef.current === null && indexRef.current === 0)) {
27+
const hasNoSelectedItemText =
28+
selectedItemTextRef.current === null || !selectedItemTextRef.current.isConnected;
29+
if (selected || (hasNoSelectedItemText && indexRef.current === 0)) {
2830
selectedItemTextRef.current = node;
2931
}
3032
});

packages/react/src/select/popup/SelectPopup.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ export const SelectPopup = React.forwardRef(function SelectPopup(
8585
dangerouslySetInnerHTML={html}
8686
/>
8787
)}
88-
<FloatingFocusManager context={positioner.context} modal={false} disabled={!mounted}>
88+
<FloatingFocusManager
89+
context={positioner.context}
90+
modal={false}
91+
disabled={!mounted}
92+
restoreFocus
93+
>
8994
{element}
9095
</FloatingFocusManager>
9196
</React.Fragment>

packages/react/src/select/popup/useSelectPopup.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,18 @@ export function useSelectPopup(): useSelectPopup.ReturnValue {
2020
valueRef,
2121
selectedItemTextRef,
2222
popupRef,
23-
scrollUpArrowVisible,
24-
scrollDownArrowVisible,
25-
setScrollUpArrowVisible,
26-
setScrollDownArrowVisible,
2723
keyboardActiveRef,
2824
floatingRootContext,
2925
} = useSelectRootContext();
3026
const { setActiveIndex } = useSelectIndexContext();
31-
const { alignItemWithTriggerActive, setControlledItemAnchor } = useSelectPositionerContext();
27+
const {
28+
alignItemWithTriggerActive,
29+
setControlledItemAnchor,
30+
scrollUpArrowVisible,
31+
scrollDownArrowVisible,
32+
setScrollUpArrowVisible,
33+
setScrollDownArrowVisible,
34+
} = useSelectPositionerContext();
3235

3336
const initialHeightRef = React.useRef(0);
3437
const reachedMaxHeightRef = React.useRef(false);

packages/react/src/select/positioner/SelectPositioner.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { SelectPositionerContext } from './SelectPositionerContext';
1010
import { InternalBackdrop } from '../../utils/InternalBackdrop';
1111
import { inertValue } from '../../utils/inertValue';
1212
import { useRenderElement } from '../../utils/useRenderElement';
13+
import { clearPositionerStyles } from '../popup/utils';
14+
import { useSelectIndexContext } from '../root/SelectIndexContext';
15+
import { useEventCallback } from '../../utils/useEventCallback';
1316

1417
/**
1518
* Positions the select menu popup.
@@ -42,20 +45,24 @@ export const SelectPositioner = React.forwardRef(function SelectPositioner(
4245
const {
4346
open,
4447
mounted,
48+
positionerElement,
4549
setPositionerElement,
4650
listRef,
4751
labelsRef,
4852
floatingRootContext,
4953
modal,
5054
touchModality,
51-
scrollUpArrowVisible,
52-
setScrollUpArrowVisible,
53-
scrollDownArrowVisible,
54-
setScrollDownArrowVisible,
5555
alignItemWithTriggerActiveRef,
56+
valuesRef,
57+
value,
58+
setLabel,
5659
} = useSelectRootContext();
60+
const { setSelectedIndex } = useSelectIndexContext();
5761

62+
const [scrollUpArrowVisible, setScrollUpArrowVisible] = React.useState(false);
63+
const [scrollDownArrowVisible, setScrollDownArrowVisible] = React.useState(false);
5864
const [controlledItemAnchor, setControlledItemAnchor] = React.useState(alignItemWithTrigger);
65+
5966
const alignItemWithTriggerActive = mounted && controlledItemAnchor && !touchModality;
6067

6168
React.useImperativeHandle(alignItemWithTriggerActiveRef, () => alignItemWithTriggerActive);
@@ -114,12 +121,58 @@ export const SelectPositioner = React.forwardRef(function SelectPositioner(
114121
alignItemWithTriggerActive,
115122
controlledItemAnchor,
116123
setControlledItemAnchor,
124+
scrollUpArrowVisible,
125+
setScrollUpArrowVisible,
126+
scrollDownArrowVisible,
127+
setScrollDownArrowVisible,
117128
}),
118-
[positioner, alignItemWithTriggerActive, controlledItemAnchor],
129+
[
130+
positioner,
131+
alignItemWithTriggerActive,
132+
controlledItemAnchor,
133+
scrollUpArrowVisible,
134+
scrollDownArrowVisible,
135+
],
119136
);
120137

138+
const prevMapSizeRef = React.useRef(0);
139+
140+
const onMapChange = useEventCallback((map: Map<Element, { index?: number | null } | null>) => {
141+
if (map.size === 0 && prevMapSizeRef.current === 0) {
142+
return;
143+
}
144+
145+
if (valuesRef.current.length === 0) {
146+
return;
147+
}
148+
149+
const prevSize = prevMapSizeRef.current;
150+
prevMapSizeRef.current = map.size;
151+
152+
if (map.size === prevSize) {
153+
return;
154+
}
155+
156+
if (value !== null) {
157+
const valueIndex = valuesRef.current.indexOf(value);
158+
if (valueIndex === -1) {
159+
setSelectedIndex(null);
160+
setLabel('');
161+
}
162+
}
163+
164+
if (open && alignItemWithTriggerActive) {
165+
setScrollDownArrowVisible(false);
166+
setScrollUpArrowVisible(false);
167+
168+
if (positionerElement) {
169+
clearPositionerStyles(positionerElement, { height: '' });
170+
}
171+
}
172+
});
173+
121174
return (
122-
<CompositeList elementsRef={listRef} labelsRef={labelsRef}>
175+
<CompositeList elementsRef={listRef} labelsRef={labelsRef} onMapChange={onMapChange}>
123176
<SelectPositionerContext.Provider value={contextValue}>
124177
{mounted && modal && <InternalBackdrop inert={inertValue(!open)} />}
125178
{element}

packages/react/src/select/positioner/SelectPositionerContext.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ export type SelectPositionerContext = useSelectPositioner.ReturnValue & {
55
alignItemWithTriggerActive: boolean;
66
controlledItemAnchor: boolean;
77
setControlledItemAnchor: React.Dispatch<React.SetStateAction<boolean>>;
8+
scrollUpArrowVisible: boolean;
9+
setScrollUpArrowVisible: React.Dispatch<React.SetStateAction<boolean>>;
10+
scrollDownArrowVisible: boolean;
11+
setScrollDownArrowVisible: React.Dispatch<React.SetStateAction<boolean>>;
812
};
913

1014
export const SelectPositionerContext = React.createContext<SelectPositionerContext | null>(null);

packages/react/src/select/root/SelectRoot.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,38 @@ describe('<Select.Root />', () => {
131131
'',
132132
);
133133
});
134+
135+
it('should not update the internal value if the controlled value prop does not change', async () => {
136+
const onValueChange = spy();
137+
await render(
138+
<Select.Root value="a" onValueChange={onValueChange}>
139+
<Select.Trigger data-testid="trigger">
140+
<Select.Value>a</Select.Value>
141+
</Select.Trigger>
142+
<Select.Portal>
143+
<Select.Positioner>
144+
<Select.Popup>
145+
<Select.Item value="a">a</Select.Item>
146+
<Select.Item value="b">b</Select.Item>
147+
</Select.Popup>
148+
</Select.Positioner>
149+
</Select.Portal>
150+
</Select.Root>,
151+
);
152+
153+
const trigger = screen.getByTestId('trigger');
154+
expect(trigger).to.have.text('a');
155+
156+
fireEvent.click(trigger);
157+
await flushMicrotasks();
158+
159+
const optionB = screen.getByRole('option', { name: 'b' });
160+
fireEvent.click(optionB);
161+
await flushMicrotasks();
162+
163+
expect(onValueChange.callCount).to.equal(0);
164+
expect(trigger).to.have.text('a');
165+
});
134166
});
135167

136168
describe('prop: onValueChange', () => {
@@ -1272,5 +1304,68 @@ describe('<Select.Root />', () => {
12721304

12731305
expect(screen.queryByRole('option', { name: 'Share' })).toHaveFocus();
12741306
});
1307+
1308+
it('unselects the selected item if removed', async () => {
1309+
function DynamicMenu() {
1310+
const [items, setItems] = React.useState(['a', 'b', 'c']);
1311+
const [selectedItem, setSelectedItem] = React.useState('a');
1312+
1313+
return (
1314+
<div>
1315+
<button
1316+
onClick={() => {
1317+
setItems((prev) => prev.filter((item) => item !== 'a'));
1318+
}}
1319+
>
1320+
Remove
1321+
</button>
1322+
1323+
<button
1324+
onClick={() => {
1325+
setItems(['a', 'b', 'c']);
1326+
}}
1327+
>
1328+
Add
1329+
</button>
1330+
<div data-testid="value">{selectedItem}</div>
1331+
1332+
<Select.Root value={selectedItem} onValueChange={setSelectedItem}>
1333+
<Select.Trigger>Toggle</Select.Trigger>
1334+
<Select.Portal>
1335+
<Select.Positioner>
1336+
<Select.Popup>
1337+
{items.map((item) => (
1338+
<Select.Item key={item} value={item}>
1339+
{item}
1340+
</Select.Item>
1341+
))}
1342+
</Select.Popup>
1343+
</Select.Positioner>
1344+
</Select.Portal>
1345+
</Select.Root>
1346+
</div>
1347+
);
1348+
}
1349+
1350+
const { user } = await renderFakeTimers(<DynamicMenu />);
1351+
1352+
const trigger = screen.getByText('Toggle');
1353+
1354+
await act(async () => {
1355+
trigger.focus();
1356+
});
1357+
await user.keyboard('{ArrowDown}');
1358+
1359+
expect(screen.queryByRole('option', { name: 'a' })).to.have.attribute('data-selected');
1360+
expect(screen.getByTestId('value')).to.have.text('a');
1361+
1362+
fireEvent.click(screen.getByText('Remove'));
1363+
1364+
expect(screen.queryByRole('option', { name: 'b' })).not.to.have.attribute('data-selected');
1365+
1366+
fireEvent.click(screen.getByText('Add'));
1367+
1368+
expect(screen.queryByRole('option', { name: 'a' })).to.have.attribute('data-selected');
1369+
});
12751370
});
12761371
});

packages/react/src/select/root/SelectRootContext.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,6 @@ export interface SelectRootContext {
2525
setTriggerElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
2626
positionerElement: HTMLElement | null;
2727
setPositionerElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
28-
scrollUpArrowVisible: boolean;
29-
setScrollUpArrowVisible: React.Dispatch<React.SetStateAction<boolean>>;
30-
scrollDownArrowVisible: boolean;
31-
setScrollDownArrowVisible: React.Dispatch<React.SetStateAction<boolean>>;
3228
listRef: React.MutableRefObject<Array<HTMLElement | null>>;
3329
popupRef: React.MutableRefObject<HTMLDivElement | null>;
3430
triggerProps: HTMLProps;

0 commit comments

Comments
 (0)