Skip to content

Commit ec65d2a

Browse files
fix(apollo-react): improve Toolbox keyboard nav with Space, Tab, and index restore
1 parent c9eeb27 commit ec65d2a

2 files changed

Lines changed: 67 additions & 17 deletions

File tree

packages/apollo-react/src/canvas/components/Toolbox/SearchBox.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ interface SearchBoxProps {
1010
placeholder?: string;
1111
inputRef?: React.RefObject<HTMLInputElement | null>;
1212
onNavigationKeyDown?: (e: React.KeyboardEvent) => void;
13+
navigateToFirstItem?: () => void;
1314
activeDescendantId?: string;
1415
}
1516

@@ -20,6 +21,7 @@ export const SearchBox = memo(function SearchBox({
2021
placeholder = 'Search...',
2122
inputRef: externalInputRef,
2223
onNavigationKeyDown,
24+
navigateToFirstItem,
2325
activeDescendantId,
2426
}: SearchBoxProps) {
2527
const internalRef = useRef<HTMLInputElement>(null);
@@ -29,6 +31,17 @@ export const SearchBox = memo(function SearchBox({
2931
inputRef.current?.focus();
3032
}, [inputRef]);
3133

34+
const handleClearButtonKeyDown = (e: React.KeyboardEvent) => {
35+
if (e.key === 'Tab') {
36+
e.preventDefault();
37+
inputRef.current?.focus();
38+
39+
if (!e.shiftKey) {
40+
navigateToFirstItem?.();
41+
}
42+
}
43+
};
44+
3245
return (
3346
<StyledSearchForm
3447
autoComplete="off"
@@ -54,7 +67,12 @@ export const SearchBox = memo(function SearchBox({
5467
aria-activedescendant={activeDescendantId}
5568
/>
5669
{value && (
57-
<button type="button" className="searchbox-clear" onClick={clear}>
70+
<button
71+
type="button"
72+
className="searchbox-clear"
73+
onClick={clear}
74+
onKeyDown={handleClearButtonKeyDown}
75+
>
5876
<ApIcon name="close" size="16px" />
5977
</button>
6078
)}

packages/apollo-react/src/canvas/components/Toolbox/Toolbox.tsx

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export function Toolbox<T>({
129129
const navigationStack = useNavigationStack<{
130130
items: ListItem<T>[];
131131
parentItem: ListItem<T> | null;
132+
activeIndex: number;
132133
}>();
133134

134135
const [activeIndex, setActiveIndex] = useState(SEARCH_BAR_INDEX);
@@ -175,13 +176,19 @@ export function Toolbox<T>({
175176
[listRef]
176177
);
177178

179+
const navigateToFirstItem = useCallback(() => {
180+
const renderedItems = listViewRef.current?.renderedItems ?? [];
181+
const firstIndex = getFirstSelectableIndex(renderedItems);
182+
if (firstIndex !== SEARCH_BAR_INDEX) navigateToIndex(firstIndex);
183+
}, [navigateToIndex]);
184+
178185
const clearSearch = useCallback(() => {
179186
setSearch('');
180187
setSearchedItems([]);
181188
setSearchLoading(false);
182189
setIsSearchingInitialItems(true);
183-
setActiveIndex(SEARCH_BAR_INDEX);
184-
}, []);
190+
navigateToIndex(SEARCH_BAR_INDEX);
191+
}, [navigateToIndex]);
185192

186193
const handleSearch = useCallback(
187194
async (query: string) => {
@@ -218,7 +225,6 @@ export function Toolbox<T>({
218225

219226
const handleBackTransition = useCallback(() => {
220227
startTransition('back');
221-
setActiveIndex(SEARCH_BAR_INDEX);
222228

223229
const previousState = navigationStack.pop();
224230

@@ -227,12 +233,17 @@ export function Toolbox<T>({
227233
setCurrentParentItem(previousState.data.parentItem);
228234
}
229235

230-
if (isSearching) {
231-
clearSearch();
232-
}
236+
// Reset search state without calling clearSearch (which navigates to search bar).
237+
setSearch('');
238+
setSearchedItems([]);
239+
setSearchLoading(false);
240+
setIsSearchingInitialItems(true);
241+
242+
const restoredIndex = previousState?.data.activeIndex ?? SEARCH_BAR_INDEX;
243+
navigateToIndex(restoredIndex);
233244

234245
onBack?.();
235-
}, [navigationStack, isSearching, onBack, clearSearch, startTransition]);
246+
}, [navigationStack, onBack, startTransition, navigateToIndex]);
236247

237248
const handleItemSelect = useCallback(
238249
async (item: ListItem<T>) => {
@@ -247,7 +258,11 @@ export function Toolbox<T>({
247258
: item.children;
248259
navigationStack.push({
249260
title: currentParentItem?.name || title,
250-
data: { items, parentItem: currentParentItem },
261+
data: {
262+
items,
263+
parentItem: currentParentItem,
264+
activeIndex: isSearching ? SEARCH_BAR_INDEX : activeIndex,
265+
},
251266
});
252267
setItems(nestedItems);
253268
setCurrentParentItem(item);
@@ -256,7 +271,17 @@ export function Toolbox<T>({
256271
startTransition('forward');
257272
setChildrenLoading(false);
258273
},
259-
[navigationStack, currentParentItem, title, items, clearSearch, startTransition, onItemSelect]
274+
[
275+
navigationStack,
276+
currentParentItem,
277+
title,
278+
items,
279+
activeIndex,
280+
isSearching,
281+
clearSearch,
282+
startTransition,
283+
onItemSelect,
284+
]
260285
);
261286

262287
useEffect(() => {
@@ -303,6 +328,7 @@ export function Toolbox<T>({
303328
data: {
304329
items: newInitialItems,
305330
parentItem: null,
331+
activeIndex: stackItem.data.activeIndex,
306332
},
307333
};
308334
}
@@ -320,6 +346,7 @@ export function Toolbox<T>({
320346
data: {
321347
items: updatedItems,
322348
parentItem: updatedParentItem,
349+
activeIndex: stackItem.data.activeIndex,
323350
},
324351
};
325352
});
@@ -356,15 +383,13 @@ export function Toolbox<T>({
356383

357384
const navigateDown = () => {
358385
if (activeIndex === SEARCH_BAR_INDEX) {
359-
const firstIndex = getFirstSelectableIndex(renderedItems);
360-
if (firstIndex !== SEARCH_BAR_INDEX) navigateToIndex(firstIndex);
386+
navigateToFirstItem();
361387
} else {
362388
const nextIndex = getNextSelectableIndex(renderedItems, activeIndex, 'down');
363389
if (nextIndex !== SEARCH_BAR_INDEX) {
364390
navigateToIndex(nextIndex);
365391
} else {
366-
const firstIndex = getFirstSelectableIndex(renderedItems);
367-
if (firstIndex !== SEARCH_BAR_INDEX) navigateToIndex(firstIndex);
392+
navigateToFirstItem();
368393
}
369394
}
370395
};
@@ -384,8 +409,9 @@ export function Toolbox<T>({
384409
navigateUp();
385410
break;
386411
}
412+
case ' ':
387413
case 'Enter': {
388-
if (activeIndex === SEARCH_BAR_INDEX) break;
414+
if (activeIndex === SEARCH_BAR_INDEX) return;
389415

390416
const renderItem = renderedItems[activeIndex];
391417
if (renderItem?.type === 'item') {
@@ -411,6 +437,11 @@ export function Toolbox<T>({
411437
break;
412438
}
413439
case 'Tab': {
440+
// When on the search bar with text, defer behavior handling to SearchBox
441+
if (activeIndex === SEARCH_BAR_INDEX && search.length > 0 && !e.shiftKey) {
442+
break;
443+
}
444+
414445
e.preventDefault();
415446
if (e.shiftKey) {
416447
navigateUp();
@@ -423,8 +454,7 @@ export function Toolbox<T>({
423454
if (activeIndex === SEARCH_BAR_INDEX) break;
424455

425456
e.preventDefault();
426-
const firstIndex = getFirstSelectableIndex(renderedItems);
427-
if (firstIndex !== SEARCH_BAR_INDEX) navigateToIndex(firstIndex);
457+
navigateToFirstItem();
428458
break;
429459
}
430460
case 'End': {
@@ -443,6 +473,7 @@ export function Toolbox<T>({
443473
activeIndex,
444474
navigationStack.canGoBack,
445475
navigateToIndex,
476+
navigateToFirstItem,
446477
handleItemSelect,
447478
handleBackTransition,
448479
]
@@ -500,6 +531,7 @@ export function Toolbox<T>({
500531
placeholder="Search"
501532
inputRef={searchInputRef}
502533
onNavigationKeyDown={handleNavigationKeyDown}
534+
navigateToFirstItem={navigateToFirstItem}
503535
activeDescendantId={activeDescendantId}
504536
/>
505537

0 commit comments

Comments
 (0)