Skip to content

Commit 6c4f026

Browse files
authored
fix: skip items without path in breadcrumbs overlay keyboard navigation (#11925)
1 parent ef3b16a commit 6c4f026

3 files changed

Lines changed: 115 additions & 2 deletions

File tree

‎packages/breadcrumbs/spec/web-component-spec.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,8 @@ Internal behavior:
196196
- **Light-DOM projection via `slot="overlay"`.** Rather than an `OverlayMixin` `.renderer` callback or a `_rendererRoot` override, collapsed items are projected through the overlay's default slot while staying in the breadcrumbs' light DOM (see "Overlay management" above). This keeps `<vaadin-breadcrumbs-overlay>` free of rendering plumbing and lets global page CSS reach the links.
197197
- **Inherited overlay behavior.** Top-layer rendering, outside-click and Escape closing, focus restoration, and stacking all come from `OverlayMixin`; positioning relative to the overflow button is driven by the `positionTarget` property from `PositionMixin`, matching `<vaadin-combo-box-overlay>` and `<vaadin-avatar-group-overlay>`. None of these are re-implemented here.
198198
- **Keyboard interaction within the open overlay.**
199-
- **Focus on open** — focus stays on the overflow button when the overlay opens (via overflow-button click, Enter, or Space). The first ArrowDown / ArrowUp keypress while the overlay is open moves focus into the overlay: ArrowDown lands on the first non-disabled overlay item, ArrowUp lands on the last non-disabled overlay item.
200-
- **Arrow keys** — once focus is inside the overlay, Up/Down arrows move focus between adjacent links (Home/End jump to first/last). Disabled items are skipped — arrow keys, Home, and End land on the nearest non-disabled item in the requested direction. The overlay reads as a menu visually, so menu-style keyboard navigation is the primary way to traverse its items.
199+
- **Focus on open** — focus stays on the overflow button when the overlay opens (via overflow-button click, Enter, or Space). The first ArrowDown / ArrowUp keypress while the overlay is open moves focus into the overlay: ArrowDown lands on the first focusable overlay item, ArrowUp lands on the last focusable overlay item. Disabled items and items without a `path` are not focusable.
200+
- **Arrow keys** — once focus is inside the overlay, Up/Down arrows move focus between adjacent links (Home/End jump to first/last). Non-focusable items — disabled items and items without a `path` — are skipped: arrow keys, Home, and End land on the nearest focusable item in the requested direction. The overlay reads as a menu visually, so menu-style keyboard navigation is the primary way to traverse its items.
201201
- **Tab / Shift+Tab** — closes the overlay. `restore-focus-on-close` returns focus to the overflow button, from which the native Tab / Shift+Tab traversal then moves focus to the next / previous focusable trail item in document order.
202202
- **Escape** — closes the overlay and returns focus to the overflow button.
203203

@@ -275,6 +275,10 @@ For consistency with the other Vaadin overlay-with-overflow component, `<vaadin-
275275

276276
Arrow / Home / End navigation in the overflow overlay is implemented via `KeyboardDirectionMixin` from `@vaadin/a11y-base`, the same primitive that powers `<vaadin-context-menu-list-box>`, `<vaadin-menu-bar>`, and `<vaadin-accordion>`. The mixin's `_isItemFocusable(item)` defaults to `!item.hasAttribute('disabled')` and is honored by every focus-moving keystroke, so disabled items are skipped uniformly. The alternative — letting focus land on a disabled item — would conflict with the inner link's `tabindex="-1"`, the host's `aria-disabled="true"`, and the suppressed click contract, and would diverge from the menu-style navigation users encounter everywhere else in the library.
277277

278+
**Q: Why does arrow-key navigation also skip overlay items without a `path`?**
279+
280+
An item without a `path` renders as a `<span part="nolink">` rather than an `<a part="link">` — there is no focusable element inside it. Letting the arrow keys land on such an item would either trap focus on a non-interactive node or silently no-op the keystroke, both of which break the menu-style navigation contract. `<vaadin-breadcrumbs>` therefore overrides `_isItemFocusable` to treat items where `path == null` as non-focusable, so arrow / Home / End navigation skips them the same way it skips disabled items.
281+
278282
**Q: Why does the overflow overlay not move focus to the first item when it opens?**
279283

280284
To keep pointer-driven opens from showing a focus outline on an overlay item the user did not request. Auto-focusing the first item on open meant a click on the overflow button immediately drew a focus ring around the first overlay link, then forced ArrowDown to land on the *second* item rather than the first — both surprising. The chosen behavior matches `<vaadin-menu-bar>`: clicking the trigger opens the overlay with focus still on the trigger button, and the first ArrowDown / ArrowUp keystroke is what hands focus to an item (ArrowDown to the first focusable, ArrowUp to the last). Keyboard users press Enter/Space to open and then their first arrow key reaches the expected item; pointer users see no premature focus ring; assistive tech still associates the open overlay with the trigger via `aria-expanded` and `aria-haspopup`.

‎packages/breadcrumbs/src/vaadin-breadcrumbs.js‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,20 @@ class Breadcrumbs extends KeyboardDirectionMixin(
355355
return [...this.querySelectorAll('vaadin-breadcrumbs-item[slot="overlay"]')];
356356
}
357357

358+
/**
359+
* Override the method inherited from `KeyboardDirectionMixin` to also
360+
* skip overlay items that have no `path` and therefore render as a
361+
* non-interactive `<span>` instead of a focusable link.
362+
*
363+
* @param {Element} item
364+
* @return {boolean}
365+
* @protected
366+
* @override
367+
*/
368+
_isItemFocusable(item) {
369+
return super._isItemFocusable(item) && item.path != null;
370+
}
371+
358372
/**
359373
* Override the method inherited from `KeyboardDirectionMixin` to make
360374
* `breadcrumbs.focus()` lands on the root item. When the root item is

‎packages/breadcrumbs/test/overflow.test.js‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,101 @@ describe('overflow', () => {
429429
});
430430
});
431431

432+
describe('non-link items', () => {
433+
beforeEach(async () => {
434+
// Collapse all items to the overlay
435+
breadcrumbs.style.maxWidth = '200px';
436+
await nextResize(breadcrumbs);
437+
});
438+
439+
it('should skip the first item without path on first ArrowDown', async () => {
440+
items[0].path = null;
441+
442+
button.focus();
443+
button.click();
444+
await oneEvent(overlay, 'vaadin-overlay-open');
445+
446+
await sendKeys({ press: 'ArrowDown' });
447+
await nextRender();
448+
449+
expectFocusedItem(items[1]);
450+
});
451+
452+
it('should skip the last item without path on first ArrowUp', async () => {
453+
items[4].path = null;
454+
455+
button.focus();
456+
button.click();
457+
await oneEvent(overlay, 'vaadin-overlay-open');
458+
459+
await sendKeys({ press: 'ArrowUp' });
460+
await nextRender();
461+
462+
expectFocusedItem(items[3]);
463+
});
464+
465+
it('should skip item without path on subsequent ArrowDown', async () => {
466+
items[1].path = null;
467+
468+
button.focus();
469+
button.click();
470+
await oneEvent(overlay, 'vaadin-overlay-open');
471+
472+
await sendKeys({ press: 'ArrowDown' });
473+
await nextRender();
474+
475+
await sendKeys({ press: 'ArrowDown' });
476+
await nextRender();
477+
478+
expectFocusedItem(items[2]);
479+
});
480+
481+
it('should skip item without path on subsequent ArrowUp', async () => {
482+
items[3].path = null;
483+
484+
button.focus();
485+
button.click();
486+
await oneEvent(overlay, 'vaadin-overlay-open');
487+
488+
await sendKeys({ press: 'End' });
489+
await nextRender();
490+
491+
await sendKeys({ press: 'ArrowUp' });
492+
await nextRender();
493+
494+
expectFocusedItem(items[2]);
495+
});
496+
497+
it('should focus the previous focusable item on End when last has no path', async () => {
498+
items[4].path = null;
499+
500+
button.focus();
501+
button.click();
502+
await oneEvent(overlay, 'vaadin-overlay-open');
503+
504+
await sendKeys({ press: 'End' });
505+
await nextRender();
506+
507+
expectFocusedItem(items[3]);
508+
});
509+
510+
it('should focus the next focusable item on Home when first has no path', async () => {
511+
items[0].path = null;
512+
513+
button.focus();
514+
button.click();
515+
await oneEvent(overlay, 'vaadin-overlay-open');
516+
517+
await sendKeys({ press: 'End' });
518+
await nextRender();
519+
520+
await sendKeys({ press: 'Home' });
521+
await nextRender();
522+
523+
expectFocusedItem(items[1]);
524+
});
525+
});
526+
432527
describe('focus()', () => {
433528
it('should focus the overflow button when the root item is disabled', async () => {
434529
breadcrumbs.style.maxWidth = '600px';

0 commit comments

Comments
 (0)