Skip to content

Commit eecb117

Browse files
committed
fix(menu): improve keyboard navigation
Since this component relies on MDC, there is already some inbuilt keyboard interactions support for arrow keys. We had some additional customized keyboard navigation support for the cases when there is a searcher. But they didn't work well-enough for keyboard users. For instance, the breadcrumbs were completely ignored. User could press the DOWN key to set the focus on menu items, but they could never set the focus back to the input field again by pressing UP for instance, to keep modifying their keywords.
1 parent eb6f975 commit eecb117

File tree

1 file changed

+206
-29
lines changed

1 file changed

+206
-29
lines changed

src/components/menu/menu.tsx

Lines changed: 206 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export class Menu {
195195
private list: HTMLLimelMenuListElement;
196196
private searchInput: HTMLLimelInputFieldElement;
197197
private portalId: string;
198+
private breadcrumbs: HTMLLimelBreadcrumbsElement;
198199
private triggerElement: HTMLSlotElement;
199200
private selectedMenuItem?: MenuItem;
200201

@@ -322,11 +323,13 @@ export class Menu {
322323

323324
return (
324325
<limel-breadcrumbs
326+
ref={this.setBreadcrumbsElement}
325327
style={{
326328
'border-bottom': 'solid 1px rgb(var(--contrast-500))',
327329
'flex-shrink': '0',
328330
}}
329331
onSelect={this.handleBreadcrumbsSelect}
332+
onKeyDown={this.handleBreadcrumbsKeyDown}
330333
items={breadcrumbsItems}
331334
/>
332335
);
@@ -445,8 +448,8 @@ export class Menu {
445448
};
446449

447450
// Key handler for the input search field
448-
// Will change focus to the first/last item in the dropdown
449-
// list to enable selection with the keyboard
451+
// Will change focus to breadcrumbs (if present) or the first/last item
452+
// in the dropdown list to enable selection with the keyboard
450453
private handleInputKeyDown = (event: KeyboardEvent) => {
451454
const isForwardTab =
452455
event.key === TAB &&
@@ -460,37 +463,71 @@ export class Menu {
460463
return;
461464
}
462465

463-
if (!this.list) {
464-
return;
465-
}
466-
467466
event.stopPropagation();
468467
event.preventDefault();
469468

470469
if (isForwardTab || isDown) {
471-
const listItems =
472-
this.list.shadowRoot.querySelectorAll<HTMLElement>(
473-
'.mdc-deprecated-list-item'
474-
);
475-
const listElement = listItems[0];
476-
listElement?.focus();
470+
if (this.focusBreadcrumbs()) {
471+
return;
472+
}
473+
474+
this.focusFirstListItem();
477475

478476
return;
479477
}
480478

481479
if (isUp) {
482-
const listItems =
483-
this.list.shadowRoot.querySelectorAll<HTMLElement>(
484-
'.mdc-deprecated-list-item'
485-
);
486-
const listElement = [...listItems].at(-1);
487-
listElement?.focus();
480+
// Focus the last list item (wrapping behavior)
481+
this.focusLastListItem();
488482
}
489483
};
490484

491-
// Key handler for the menu list
485+
// Key handler for the menu list (capture phase)
486+
// Handles Up arrow on first item and Down arrow on last item
487+
// Must run in capture phase to intercept before MDC Menu wraps focus
488+
// Only intercepts when there's a search input or breadcrumbs to navigate to
489+
private readonly handleListKeyDownCapture = (event: KeyboardEvent) => {
490+
const isUp = event.key === ARROW_UP;
491+
const isDown = event.key === ARROW_DOWN;
492+
493+
if (!isUp && !isDown) {
494+
return;
495+
}
496+
497+
// Up on first item: go to breadcrumbs or search input (if they exist)
498+
if (isUp && this.isFirstListItemFocused()) {
499+
// Try to focus breadcrumbs first
500+
if (this.focusBreadcrumbs()) {
501+
event.stopPropagation();
502+
event.preventDefault();
503+
504+
return;
505+
}
506+
507+
// Then try search input
508+
if (this.searchInput) {
509+
event.stopPropagation();
510+
event.preventDefault();
511+
this.searchInput.focus();
512+
}
513+
514+
// If neither exists, let MDC Menu handle wrap-around
515+
return;
516+
}
517+
518+
// Down on last item: go to search input (if it exists)
519+
if (isDown && this.isLastListItemFocused() && this.searchInput) {
520+
event.stopPropagation();
521+
event.preventDefault();
522+
this.searchInput.focus();
523+
}
524+
525+
// If no search input, let MDC Menu handle wrap-around
526+
};
527+
528+
// Key handler for the menu list (bubble phase)
492529
// Will change focus to the search field if using shift+tab
493-
// And can go forward/back with righ/left arrow keys
530+
// And can go forward/back with right/left arrow keys
494531
private handleMenuKeyDown = (event: KeyboardEvent) => {
495532
const isBackwardTab =
496533
event.key === TAB &&
@@ -499,7 +536,6 @@ export class Menu {
499536
event.shiftKey;
500537

501538
const isLeft = event.key === ARROW_LEFT;
502-
503539
const isRight = event.key === ARROW_RIGHT;
504540

505541
if (!isBackwardTab && !isLeft && !isRight) {
@@ -510,7 +546,11 @@ export class Menu {
510546
event.stopPropagation();
511547
event.preventDefault();
512548
this.searchInput?.focus();
513-
} else if (!this.gridLayout) {
549+
550+
return;
551+
}
552+
553+
if (!this.gridLayout && (isLeft || isRight)) {
514554
const currentItem = this.getCurrentItem();
515555

516556
event.stopPropagation();
@@ -523,20 +563,60 @@ export class Menu {
523563
}
524564
};
525565

566+
// Key handler for breadcrumbs
567+
// Up arrow: focus search input
568+
// Down arrow: focus first list item
569+
private handleBreadcrumbsKeyDown = (event: KeyboardEvent) => {
570+
const isUp = event.key === ARROW_UP;
571+
const isDown = event.key === ARROW_DOWN;
572+
573+
if (!isUp && !isDown) {
574+
return;
575+
}
576+
577+
event.stopPropagation();
578+
event.preventDefault();
579+
580+
if (isUp) {
581+
this.searchInput?.focus();
582+
583+
return;
584+
}
585+
586+
if (isDown) {
587+
this.focusFirstListItem();
588+
}
589+
};
590+
526591
private clearSearch = () => {
527592
this.searchValue = '';
528593
this.searchResults = null;
529594
this.loadingSubItems = false;
530595
};
531596

532-
private getCurrentItem = (): MenuItem => {
533-
const activeItem = this.list?.shadowRoot?.querySelector(
534-
'[role="menuitem"][tabindex="0"]'
597+
private readonly getCurrentItem = (): MenuItem => {
598+
let menuElement =
599+
(this.list?.shadowRoot?.activeElement as HTMLElement | null) ??
600+
null;
601+
602+
if (menuElement && menuElement.getAttribute('role') !== 'menuitem') {
603+
menuElement = menuElement.closest<HTMLElement>('[role="menuitem"]');
604+
}
605+
606+
if (!menuElement) {
607+
menuElement = this.list?.shadowRoot?.querySelector<HTMLElement>(
608+
'[role="menuitem"][tabindex="0"]'
609+
);
610+
}
611+
612+
const dataIndex = Number.parseInt(
613+
menuElement?.dataset.index ?? '0',
614+
10
535615
);
536-
const attrIndex = activeItem?.attributes?.getNamedItem('data-index');
537-
const dataIndex = Number.parseInt(attrIndex?.value || '0', 10);
538616

539-
return this.visibleItems[dataIndex] as MenuItem;
617+
const item = this.visibleItems[dataIndex];
618+
619+
return (item ?? this.visibleItems[0]) as MenuItem;
540620
};
541621

542622
private goForward = (currentItem: MenuItem) => {
@@ -673,7 +753,23 @@ export class Menu {
673753
}
674754

675755
private setListElement = (element: HTMLLimelMenuListElement) => {
756+
if (this.list) {
757+
this.list.removeEventListener(
758+
'keydown',
759+
this.handleListKeyDownCapture,
760+
true
761+
);
762+
}
763+
676764
this.list = element;
765+
766+
if (this.list) {
767+
this.list.addEventListener(
768+
'keydown',
769+
this.handleListKeyDownCapture,
770+
true
771+
);
772+
}
677773
};
678774

679775
private setFocus = () => {
@@ -702,7 +798,88 @@ export class Menu {
702798
this.searchInput = element;
703799
};
704800

705-
private focusMenuItem = () => {
801+
private readonly setBreadcrumbsElement = (
802+
element: HTMLLimelBreadcrumbsElement
803+
) => {
804+
this.breadcrumbs = element;
805+
};
806+
807+
/**
808+
* Focuses the first focusable element inside breadcrumbs.
809+
* Returns true if breadcrumbs exist and were focused,
810+
* false otherwise.
811+
*/
812+
private readonly focusBreadcrumbs = (): boolean => {
813+
if (!this.breadcrumbs) {
814+
return false;
815+
}
816+
817+
const focusableElement =
818+
this.breadcrumbs.shadowRoot?.querySelector<HTMLElement>(
819+
'button, a'
820+
);
821+
if (focusableElement) {
822+
focusableElement.focus();
823+
824+
return true;
825+
}
826+
827+
return false;
828+
};
829+
830+
private readonly focusFirstListItem = () => {
831+
const listItems = this.getListItems();
832+
const firstItem = listItems?.[0];
833+
firstItem?.focus();
834+
};
835+
836+
private readonly focusLastListItem = () => {
837+
const listItems = this.getListItems();
838+
const lastItem = listItems?.at(-1);
839+
lastItem?.focus();
840+
};
841+
842+
private readonly isFirstListItemFocused = (): boolean => {
843+
const listItems = this.getListItems();
844+
if (!listItems) {
845+
return false;
846+
}
847+
848+
const firstItem = listItems[0];
849+
const activeElement = this.list.shadowRoot?.activeElement;
850+
851+
return firstItem === activeElement;
852+
};
853+
854+
private readonly isLastListItemFocused = (): boolean => {
855+
const listItems = this.getListItems();
856+
if (!listItems) {
857+
return false;
858+
}
859+
860+
const lastItem = listItems.at(-1);
861+
const activeElement = this.list.shadowRoot?.activeElement;
862+
863+
return lastItem === activeElement;
864+
};
865+
866+
private readonly getListItems = (): HTMLElement[] | null => {
867+
if (!this.list) {
868+
return null;
869+
}
870+
871+
const items = this.list.shadowRoot?.querySelectorAll<HTMLElement>(
872+
'.mdc-deprecated-list-item'
873+
);
874+
875+
if (!items?.length) {
876+
return null;
877+
}
878+
879+
return [...items];
880+
};
881+
882+
private readonly focusMenuItem = () => {
706883
if (!this.list) {
707884
return;
708885
}

0 commit comments

Comments
 (0)