Skip to content

Commit 8b3b4a3

Browse files
committed
Add tab workflow
1 parent 7cc17cf commit 8b3b4a3

1 file changed

Lines changed: 132 additions & 80 deletions

File tree

components/dash-core-components/src/fragments/Dropdown.tsx

Lines changed: 132 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,23 @@ const Dropdown = (props: DropdownProps) => {
237237

238238
// Focus first selected item or search input when dropdown opens
239239
useEffect(() => {
240-
if (!isOpen || search_value) {
240+
if (!isOpen) {
241241
return;
242242
}
243243

244244
// waiting for the DOM to be ready after the dropdown renders
245245
requestAnimationFrame(() => {
246+
// If opened with search value (auto-open on typing), focus search input
247+
if (search_value && searchable && searchInputRef.current) {
248+
searchInputRef.current.focus();
249+
// Move cursor to end of input
250+
searchInputRef.current.setSelectionRange(
251+
search_value.length,
252+
search_value.length
253+
);
254+
return;
255+
}
256+
246257
// Try to focus the first selected item (for single-select)
247258
if (!multi) {
248259
const selectedValue = sanitizedValues[0];
@@ -264,94 +275,123 @@ const Dropdown = (props: DropdownProps) => {
264275
searchInputRef.current.focus();
265276
}
266277
});
267-
}, [isOpen, multi, displayOptions]);
278+
}, [isOpen, multi, displayOptions, search_value, searchable]);
268279

269280
// Handle keyboard navigation in popover
270-
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
271-
const relevantKeys = [
272-
'ArrowDown',
273-
'ArrowUp',
274-
'PageDown',
275-
'PageUp',
276-
'Home',
277-
'End',
278-
];
279-
if (!relevantKeys.includes(e.key)) {
280-
return;
281-
}
281+
const handleKeyDown = useCallback(
282+
(e: React.KeyboardEvent) => {
283+
// Handle TAB to select first option and close dropdown
284+
if (e.key === 'Tab' && !e.shiftKey) {
285+
// If we have filtered options and search is active, select the first one
286+
if (displayOptions.length > 0) {
287+
const firstOption = displayOptions[0];
288+
if (!firstOption.disabled) {
289+
if (multi) {
290+
// For multi-select, toggle the first option if not already selected
291+
if (!sanitizedValues.includes(firstOption.value)) {
292+
updateSelection([
293+
...sanitizedValues,
294+
firstOption.value,
295+
]);
296+
}
297+
} else {
298+
// For single-select, select the first option
299+
updateSelection([firstOption.value]);
300+
}
301+
}
302+
}
303+
// Close dropdown and let TAB naturally move focus
304+
setIsOpen(false);
305+
setProps({search_value: undefined});
306+
return;
307+
}
282308

283-
// Don't interfere with the event if the user is using Home/End keys on the search input
284-
if (
285-
['Home', 'End'].includes(e.key) &&
286-
document.activeElement === searchInputRef.current
287-
) {
288-
return;
289-
}
309+
const relevantKeys = [
310+
'ArrowDown',
311+
'ArrowUp',
312+
'PageDown',
313+
'PageUp',
314+
'Home',
315+
'End',
316+
];
317+
if (!relevantKeys.includes(e.key)) {
318+
return;
319+
}
290320

291-
const focusableElements = e.currentTarget.querySelectorAll(
292-
'input[type="search"], input:not([disabled])'
293-
) as NodeListOf<HTMLElement>;
321+
// Don't interfere with the event if the user is using Home/End keys on the search input
322+
if (
323+
['Home', 'End'].includes(e.key) &&
324+
document.activeElement === searchInputRef.current
325+
) {
326+
return;
327+
}
294328

295-
// Don't interfere with the event if there aren't any options that the user can interact with
296-
if (focusableElements.length === 0) {
297-
return;
298-
}
329+
const focusableElements = e.currentTarget.querySelectorAll(
330+
'input[type="search"], input:not([disabled])'
331+
) as NodeListOf<HTMLElement>;
299332

300-
e.preventDefault();
333+
// Don't interfere with the event if there aren't any options that the user can interact with
334+
if (focusableElements.length === 0) {
335+
return;
336+
}
301337

302-
const currentIndex = Array.from(focusableElements).indexOf(
303-
document.activeElement as HTMLElement
304-
);
305-
let nextIndex = -1;
306-
307-
switch (e.key) {
308-
case 'ArrowDown':
309-
nextIndex =
310-
currentIndex < focusableElements.length - 1
311-
? currentIndex + 1
312-
: 0;
313-
break;
314-
315-
case 'ArrowUp':
316-
nextIndex =
317-
currentIndex > 0
318-
? currentIndex - 1
319-
: focusableElements.length - 1;
320-
321-
break;
322-
case 'PageDown':
323-
nextIndex = Math.min(
324-
currentIndex + 10,
325-
focusableElements.length - 1
326-
);
327-
break;
328-
case 'PageUp':
329-
nextIndex = Math.max(currentIndex - 10, 0);
330-
break;
331-
case 'Home':
332-
nextIndex = 0;
333-
break;
334-
case 'End':
335-
nextIndex = focusableElements.length - 1;
336-
break;
337-
default:
338-
break;
339-
}
338+
e.preventDefault();
340339

341-
if (nextIndex > -1) {
342-
focusableElements[nextIndex].focus();
343-
if (nextIndex === 0) {
344-
// first element is a sticky search bar, so if we are focusing
345-
// on that, also move the scroll to the top
346-
dropdownContentRef.current?.scrollTo({top: 0});
347-
} else {
348-
focusableElements[nextIndex].scrollIntoView({
349-
behavior: 'auto',
350-
block: 'nearest',
351-
});
340+
const currentIndex = Array.from(focusableElements).indexOf(
341+
document.activeElement as HTMLElement
342+
);
343+
let nextIndex = -1;
344+
345+
switch (e.key) {
346+
case 'ArrowDown':
347+
nextIndex =
348+
currentIndex < focusableElements.length - 1
349+
? currentIndex + 1
350+
: 0;
351+
break;
352+
353+
case 'ArrowUp':
354+
nextIndex =
355+
currentIndex > 0
356+
? currentIndex - 1
357+
: focusableElements.length - 1;
358+
359+
break;
360+
case 'PageDown':
361+
nextIndex = Math.min(
362+
currentIndex + 10,
363+
focusableElements.length - 1
364+
);
365+
break;
366+
case 'PageUp':
367+
nextIndex = Math.max(currentIndex - 10, 0);
368+
break;
369+
case 'Home':
370+
nextIndex = 0;
371+
break;
372+
case 'End':
373+
nextIndex = focusableElements.length - 1;
374+
break;
375+
default:
376+
break;
352377
}
353-
}
354-
}, []);
378+
379+
if (nextIndex > -1) {
380+
focusableElements[nextIndex].focus();
381+
if (nextIndex === 0) {
382+
// first element is a sticky search bar, so if we are focusing
383+
// on that, also move the scroll to the top
384+
dropdownContentRef.current?.scrollTo({top: 0});
385+
} else {
386+
focusableElements[nextIndex].scrollIntoView({
387+
behavior: 'auto',
388+
block: 'nearest',
389+
});
390+
}
391+
}
392+
},
393+
[displayOptions, multi, sanitizedValues, updateSelection]
394+
);
355395

356396
// Handle popover open/close
357397
const handleOpenChange = useCallback(
@@ -381,6 +421,18 @@ const Dropdown = (props: DropdownProps) => {
381421
if (['ArrowDown', 'Enter'].includes(e.key)) {
382422
e.preventDefault();
383423
}
424+
// Auto-open on typing: detect printable characters
425+
if (
426+
searchable &&
427+
e.key.length === 1 &&
428+
!e.ctrlKey &&
429+
!e.metaKey &&
430+
!e.altKey
431+
) {
432+
e.preventDefault();
433+
setProps({search_value: e.key});
434+
setIsOpen(true);
435+
}
384436
}}
385437
onKeyUp={e => {
386438
if (['ArrowDown', 'Enter'].includes(e.key)) {

0 commit comments

Comments
 (0)