Skip to content

Commit 0845efa

Browse files
committed
[Select] Fix handling dynamic items
1 parent 53c4ae8 commit 0845efa

File tree

9 files changed

+156
-33
lines changed

9 files changed

+156
-33
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export const MenuPopup = React.forwardRef(function MenuPopup(
9898
modal={false}
9999
disabled={!mounted}
100100
initialFocus={nested ? -1 : 0}
101+
restoreFocus
101102
>
102103
{renderElement()}
103104
</FloatingFocusManager>

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
{renderElement()}
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
{renderElement()}

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: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,5 +1238,68 @@ describe('<Select.Root />', () => {
12381238

12391239
expect(screen.queryByRole('option', { name: 'Share' })).toHaveFocus();
12401240
});
1241+
1242+
it('unselects the selected item if removed', async () => {
1243+
function DynamicMenu() {
1244+
const [items, setItems] = React.useState(['a', 'b', 'c']);
1245+
const [selectedItem, setSelectedItem] = React.useState('a');
1246+
1247+
return (
1248+
<div>
1249+
<button
1250+
onClick={() => {
1251+
setItems((prev) => prev.filter((item) => item !== 'a'));
1252+
}}
1253+
>
1254+
Remove
1255+
</button>
1256+
1257+
<button
1258+
onClick={() => {
1259+
setItems(['a', 'b', 'c']);
1260+
}}
1261+
>
1262+
Add
1263+
</button>
1264+
<div data-testid="value">{selectedItem}</div>
1265+
1266+
<Select.Root value={selectedItem} onValueChange={setSelectedItem}>
1267+
<Select.Trigger>Toggle</Select.Trigger>
1268+
<Select.Portal>
1269+
<Select.Positioner>
1270+
<Select.Popup>
1271+
{items.map((item) => (
1272+
<Select.Item key={item} value={item}>
1273+
{item}
1274+
</Select.Item>
1275+
))}
1276+
</Select.Popup>
1277+
</Select.Positioner>
1278+
</Select.Portal>
1279+
</Select.Root>
1280+
</div>
1281+
);
1282+
}
1283+
1284+
const { user } = await renderFakeTimers(<DynamicMenu />);
1285+
1286+
const trigger = screen.getByText('Toggle');
1287+
1288+
await act(async () => {
1289+
trigger.focus();
1290+
});
1291+
await user.keyboard('{ArrowDown}');
1292+
1293+
expect(screen.queryByRole('option', { name: 'a' })).to.have.attribute('data-selected');
1294+
expect(screen.getByTestId('value')).to.have.text('a');
1295+
1296+
fireEvent.click(screen.getByText('Remove'));
1297+
1298+
expect(screen.queryByRole('option', { name: 'b' })).not.to.have.attribute('data-selected');
1299+
1300+
fireEvent.click(screen.getByText('Add'));
1301+
1302+
expect(screen.queryByRole('option', { name: 'a' })).to.have.attribute('data-selected');
1303+
});
12411304
});
12421305
});

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
getRootTriggerProps: (props?: GenericHTMLProps) => GenericHTMLProps;

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,6 @@ export function useSelectRoot<T>(params: useSelectRoot.Parameters<T>): useSelect
103103
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null);
104104
const [label, setLabel] = React.useState('');
105105
const [touchModality, setTouchModality] = React.useState(false);
106-
const [scrollUpArrowVisible, setScrollUpArrowVisible] = React.useState(false);
107-
const [scrollDownArrowVisible, setScrollDownArrowVisible] = React.useState(false);
108106

109107
const { mounted, setMounted, transitionStatus } = useTransitionStatus(open);
110108

@@ -183,7 +181,7 @@ export function useSelectRoot<T>(params: useSelectRoot.Parameters<T>): useSelect
183181
clearErrors(name);
184182

185183
const index = valuesRef.current.indexOf(nextValue);
186-
setSelectedIndex(index);
184+
setSelectedIndex(index === -1 ? null : index);
187185
setLabel(labelsRef.current[index] ?? '');
188186
});
189187

@@ -194,15 +192,21 @@ export function useSelectRoot<T>(params: useSelectRoot.Parameters<T>): useSelect
194192
hasRegisteredRef.current = true;
195193
}
196194

197-
const stringValue = typeof value === 'string' || value === null ? value : JSON.stringify(value);
198-
const index = suppliedIndex ?? valuesRef.current.indexOf(stringValue);
195+
const index = suppliedIndex ?? valuesRef.current.indexOf(value);
199196
const hasIndex = index !== -1;
200197

201198
if (hasIndex || value === null) {
202199
setSelectedIndex(hasIndex ? index : null);
203200
setLabel(hasIndex ? (labelsRef.current[index] ?? '') : '');
204-
} else if (value) {
205-
warn(`The value \`${stringValue}\` is not present in the select items.`);
201+
return;
202+
}
203+
204+
if (process.env.NODE_ENV !== 'production') {
205+
if (value) {
206+
const stringValue =
207+
typeof value === 'string' || value === null ? value : JSON.stringify(value);
208+
warn(`The value \`${stringValue}\` is not present in the select items.`);
209+
}
206210
}
207211
});
208212

@@ -294,10 +298,6 @@ export function useSelectRoot<T>(params: useSelectRoot.Parameters<T>): useSelect
294298
setTriggerElement,
295299
positionerElement,
296300
setPositionerElement,
297-
scrollUpArrowVisible,
298-
setScrollUpArrowVisible,
299-
scrollDownArrowVisible,
300-
setScrollDownArrowVisible,
301301
value,
302302
setValue,
303303
open,
@@ -336,8 +336,6 @@ export function useSelectRoot<T>(params: useSelectRoot.Parameters<T>): useSelect
336336
readOnly,
337337
triggerElement,
338338
positionerElement,
339-
scrollUpArrowVisible,
340-
scrollDownArrowVisible,
341339
value,
342340
setValue,
343341
open,

packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ export const SelectScrollArrow = React.forwardRef(function SelectScrollArrow(
1818
) {
1919
const { render, className, direction, keepMounted = false, ...elementProps } = componentProps;
2020

21+
const { popupRef, listRef } = useSelectRootContext();
2122
const {
22-
popupRef,
23+
side,
24+
alignItemWithTriggerActive,
2325
scrollUpArrowVisible,
2426
scrollDownArrowVisible,
2527
setScrollUpArrowVisible,
2628
setScrollDownArrowVisible,
27-
listRef,
28-
} = useSelectRootContext();
29-
const { side, alignItemWithTriggerActive } = useSelectPositionerContext();
29+
} = useSelectPositionerContext();
3030
const { setActiveIndex } = useSelectIndexContext();
3131

3232
const visible = direction === 'up' ? scrollUpArrowVisible : scrollDownArrowVisible;

0 commit comments

Comments
 (0)