Skip to content

Commit c7dc992

Browse files
committed
Fix SelectNext focus inconsistencies when mixing mouse and keyboard interaction
1 parent f3ac2d1 commit c7dc992

File tree

4 files changed

+56
-23
lines changed

4 files changed

+56
-23
lines changed

src/components/input/SelectNext.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,20 @@ function SelectOption<T>({
4646
const selected = !disabled && currentValue === value;
4747

4848
return (
49-
<Button
50-
variant="custom"
51-
classes={classnames(
52-
'w-full ring-inset rounded-none !p-0',
53-
'border-t first:border-t-0 bg-transparent',
49+
<li
50+
className={classnames(
51+
'w-full ring-inset focus:ring outline-none rounded-none cursor-pointer',
52+
'border-t first:border-t-0 transition-colors whitespace-nowrap',
5453
{ 'hover:bg-grey-1': !disabled },
5554
classes,
5655
)}
5756
onClick={() => selectValue(value)}
57+
onKeyPress={e => {
58+
if (e.code === 'Enter' || e.code === 'Space') {
59+
e.preventDefault();
60+
selectValue(value);
61+
}
62+
}}
5863
role="option"
5964
disabled={disabled}
6065
aria-selected={selected}
@@ -69,7 +74,7 @@ function SelectOption<T>({
6974
>
7075
{children({ selected, disabled })}
7176
</div>
72-
</Button>
77+
</li>
7378
);
7479
}
7580

@@ -139,7 +144,7 @@ function SelectMain<T>({
139144
const [listboxOpen, setListboxOpen] = useState(false);
140145
const closeListbox = useCallback(() => setListboxOpen(false), []);
141146
const wrapperRef = useRef<HTMLDivElement | null>(null);
142-
const listboxRef = useRef<HTMLDivElement | null>(null);
147+
const listboxRef = useRef<HTMLUListElement | null>(null);
143148
const listboxId = useId();
144149
const buttonRef = useSyncedRef(elementRef);
145150
const defaultButtonId = useId();
@@ -168,6 +173,7 @@ function SelectMain<T>({
168173
loop: false,
169174
autofocus: true,
170175
containerVisible: listboxOpen,
176+
selector: '[role="option"]',
171177
});
172178

173179
useLayoutEffect(() => {
@@ -209,7 +215,7 @@ function SelectMain<T>({
209215
</div>
210216
</Button>
211217
<SelectContext.Provider value={{ selectValue, value }}>
212-
<div
218+
<ul
213219
className={classnames(
214220
'absolute z-5 w-full max-h-80 overflow-y-auto',
215221
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
@@ -231,7 +237,7 @@ function SelectMain<T>({
231237
data-testid="select-listbox"
232238
>
233239
{children}
234-
</div>
240+
</ul>
235241
</SelectContext.Provider>
236242
</div>
237243
);

src/components/input/test/SelectNext-test.js

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,41 @@ describe('SelectNext', () => {
6868
it('changes selected value when an option is clicked', () => {
6969
const onChange = sinon.stub();
7070
const wrapper = createComponent({ onChange });
71+
const clickOption = index =>
72+
wrapper.find(`[data-testid="option-${index}"]`).simulate('click');
7173

72-
wrapper.find('[data-testid="option-3"]').simulate('click');
74+
clickOption(3);
7375
assert.calledWith(onChange.lastCall, items[2]);
7476

75-
wrapper.find('[data-testid="option-5"]').simulate('click');
77+
clickOption(5);
7678
assert.calledWith(onChange.lastCall, items[4]);
7779

78-
wrapper.find('[data-testid="option-1"]').simulate('click');
80+
clickOption(1);
7981
assert.calledWith(onChange.lastCall, items[0]);
8082
});
8183

84+
['Enter', 'Space'].forEach(code => {
85+
it(`changes selected value when ${code} is pressed in option`, () => {
86+
const onChange = sinon.stub();
87+
const wrapper = createComponent({ onChange });
88+
const pressKeyInOption = index =>
89+
wrapper
90+
.find(`[data-testid="option-${index}"]`)
91+
.getDOMNode()
92+
.closest('[role="option"]')
93+
.dispatchEvent(new KeyboardEvent('keypress', { code }));
94+
95+
pressKeyInOption(3);
96+
assert.calledWith(onChange.lastCall, items[2]);
97+
98+
pressKeyInOption(5);
99+
assert.calledWith(onChange.lastCall, items[4]);
100+
101+
pressKeyInOption(1);
102+
assert.calledWith(onChange.lastCall, items[0]);
103+
});
104+
});
105+
82106
it('marks the right item as selected', () => {
83107
const wrapper = createComponent({ value: items[2] });
84108
const isOptionSelected = id =>
@@ -177,7 +201,7 @@ describe('SelectNext', () => {
177201
wrapper
178202
.find('[data-testid="option-3"]')
179203
.getDOMNode()
180-
.closest('button')
204+
.closest('[role="option"]')
181205
.focus();
182206
toggleListbox(wrapper);
183207
wrapper.update();

src/hooks/test/use-arrow-key-navigation-test.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -380,21 +380,23 @@ describe('useArrowKeyNavigation', () => {
380380
container,
381381
),
382382
);
383-
const toggleToolbar = () => findElementByTestId('toggle').click();
383+
const toggleToolbar = () =>
384+
act(() => findElementByTestId('toggle').click());
384385

385386
// No button should be initially focused
386387
assert.equal(document.activeElement, document.body);
387388

388389
// Once we open the toolbar, the first item will be focused
389-
toggleToolbar();
390+
await toggleToolbar();
390391
await waitFor(() => document.activeElement === findElementByTestId('bold'));
391392

392393
// If we then focus another toolbar item, then close the toolbar and open it
393394
// again, that same element should be focused again
394395
pressKey('ArrowDown'); // "italic" is focused
395396
pressKey('ArrowDown'); // "underline" is focused
396-
toggleToolbar(); // Close toolbar
397-
toggleToolbar(); // Open toolbar again
397+
await toggleToolbar(); // Close toolbar
398+
await toggleToolbar(); // Open toolbar again
399+
398400
await waitFor(
399401
() => document.activeElement === findElementByTestId('underline'),
400402
);

src/hooks/use-arrow-key-navigation.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export function useArrowKeyNavigation(
9494
const lastFocusedItem = useRef<HTMLElement | null>(null);
9595

9696
useEffect(() => {
97+
if (!containerVisible) {
98+
return () => {};
99+
}
100+
97101
if (!containerRef.current) {
98102
throw new Error('Container ref not set');
99103
}
@@ -196,11 +200,7 @@ export function useArrowKeyNavigation(
196200
const initialIndex = lastFocusedItem.current
197201
? navigableElements.indexOf(lastFocusedItem.current)
198202
: 0;
199-
updateTabIndexes(
200-
navigableElements,
201-
initialIndex,
202-
containerVisible && autofocus,
203-
);
203+
updateTabIndexes(navigableElements, initialIndex, autofocus);
204204

205205
const listeners = new ListenerCollection();
206206

@@ -215,10 +215,11 @@ export function useArrowKeyNavigation(
215215
lastFocusedItem.current.focus();
216216
return;
217217
}
218+
218219
const elements = getNavigableElements();
219220
const targetIndex = elements.indexOf(event.target as HTMLElement);
220221
if (targetIndex >= 0) {
221-
updateTabIndexes(elements, targetIndex);
222+
updateTabIndexes(elements, targetIndex, autofocus);
222223
}
223224
});
224225

0 commit comments

Comments
 (0)