Skip to content

Commit 8faa46d

Browse files
committed
feat: Improve keyboard navigation by skipping disabled and divider items in menu
1 parent 9d96fd4 commit 8faa46d

File tree

1 file changed

+125
-42
lines changed

1 file changed

+125
-42
lines changed

src/components/Menu.vue

Lines changed: 125 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,10 @@ export default defineComponent({
195195
196196
// If there are menu items, select the first one for keyboard navigation
197197
if (menuItems.value.length > 0) {
198-
// Find first non-divider item
199-
const firstItemIndex = menuItems.value.findIndex((item) => !item.divider);
198+
// Find first non-divider, non-disabled item
199+
const firstItemIndex = menuItems.value.findIndex(
200+
(item) => !item.divider && !item.disabled
201+
);
200202
if (firstItemIndex !== -1) {
201203
setActiveIndex(firstItemIndex);
202204
}
@@ -245,7 +247,14 @@ export default defineComponent({
245247
}
246248
247249
setActiveIndex(index);
248-
selectMenuItem(name, id, subMenu, false, props.onSelection);
250+
251+
// For mouse clicks, always select the item and don't toggle submenus
252+
if (!subMenu) {
253+
selectMenuItem(name, id, subMenu, false, props.onSelection);
254+
} else {
255+
// For submenu items, toggle the submenu
256+
toggleMenu(id, false);
257+
}
249258
250259
// Direct style manipulation to remove any focus borders
251260
const target = event.currentTarget as HTMLElement;
@@ -293,29 +302,98 @@ export default defineComponent({
293302
switch (keyCode) {
294303
case 'ArrowDown':
295304
if (actvIndex < len - 1) {
296-
const nextItemIsDivider = menuItems.value[actvIndex + 1]?.divider;
305+
let nextIndex = actvIndex + 1;
306+
307+
// Skip disabled items and dividers
308+
while (
309+
nextIndex < len &&
310+
(menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)
311+
) {
312+
nextIndex++;
313+
}
297314
298-
if (nextItemIsDivider) {
299-
setActiveIndex(actvIndex + 2 < len ? actvIndex + 2 : 0);
300-
} else {
301-
setActiveIndex(actvIndex + 1);
315+
// If we reached the end, loop back to the start
316+
if (nextIndex >= len) {
317+
nextIndex = 0;
318+
// Skip disabled items and dividers at the beginning
319+
while (
320+
nextIndex < actvIndex &&
321+
(menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)
322+
) {
323+
nextIndex++;
324+
}
302325
}
326+
327+
setActiveIndex(nextIndex);
303328
} else if (actvIndex === len - 1) {
304-
setActiveIndex(0);
329+
// Find first non-disabled, non-divider item
330+
let nextIndex = 0;
331+
while (
332+
nextIndex < len &&
333+
(menuItems.value[nextIndex]?.divider || menuItems.value[nextIndex]?.disabled)
334+
) {
335+
nextIndex++;
336+
}
337+
338+
// If all items are disabled, keep current selection
339+
if (nextIndex >= len) {
340+
nextIndex = actvIndex;
341+
}
342+
343+
setActiveIndex(nextIndex);
305344
}
306345
break;
307346
308347
case 'ArrowUp':
309-
const isDivider = menuItems.value[actvIndex - 1]?.divider;
310-
const nextIndex = isDivider
311-
? actvIndex - 2
312-
: actvIndex - 1 < 0
313-
? len - 1
314-
: actvIndex - 1;
315-
setActiveIndex(nextIndex);
348+
if (actvIndex > 0) {
349+
let prevIndex = actvIndex - 1;
350+
351+
// Skip disabled items and dividers
352+
while (
353+
prevIndex >= 0 &&
354+
(menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)
355+
) {
356+
prevIndex--;
357+
}
358+
359+
// If we reached the beginning, loop to the end
360+
if (prevIndex < 0) {
361+
prevIndex = len - 1;
362+
// Skip disabled items and dividers at the end
363+
while (
364+
prevIndex > actvIndex &&
365+
(menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)
366+
) {
367+
prevIndex--;
368+
}
369+
}
370+
371+
setActiveIndex(prevIndex);
372+
} else {
373+
// Find last non-disabled, non-divider item
374+
let prevIndex = len - 1;
375+
while (
376+
prevIndex >= 0 &&
377+
(menuItems.value[prevIndex]?.divider || menuItems.value[prevIndex]?.disabled)
378+
) {
379+
prevIndex--;
380+
}
381+
382+
// If all items are disabled, keep current selection
383+
if (prevIndex < 0) {
384+
prevIndex = actvIndex;
385+
}
386+
387+
setActiveIndex(prevIndex);
388+
}
316389
break;
317390
318391
case 'ArrowLeft':
392+
// Skip disabled items
393+
if (item?.disabled) {
394+
break;
395+
}
396+
319397
if (!props.flip) {
320398
props.onClose('ArrowLeft');
321399
} else if (item.subMenu) {
@@ -324,6 +402,11 @@ export default defineComponent({
324402
break;
325403
326404
case 'ArrowRight':
405+
// Skip disabled items
406+
if (item?.disabled) {
407+
break;
408+
}
409+
327410
if (!props.flip && item?.subMenu) {
328411
toggleMenu(item.id || '', true);
329412
// Focus handling for keyboard navigation
@@ -334,28 +417,28 @@ export default defineComponent({
334417
break;
335418
336419
case 'Enter':
337-
if (item?.subMenu) {
338-
toggleMenu(item.id || '', true);
339-
// Focus handling for keyboard navigation
340-
handleSubmenuOpen(item.id || '');
341-
} else {
342-
selectMenuItem(
343-
item?.name,
344-
item?.id || '',
345-
Boolean(item?.subMenu),
346-
false,
347-
props.onSelection
348-
);
349-
350-
// Announce selection for screen readers
351-
if (item?.name) {
352-
const announcement = document.createElement('div');
353-
announcement.setAttribute('aria-live', 'polite');
354-
announcement.className = 'sr-only';
355-
announcement.textContent = `Selected ${item.name}`;
356-
document.body.appendChild(announcement);
357-
setTimeout(() => document.body.removeChild(announcement), 1000);
358-
}
420+
// Skip disabled items for Enter key actions
421+
if (item?.disabled) {
422+
break;
423+
}
424+
425+
// Only select regular menu items with Enter, submenus should only use arrow keys
426+
selectMenuItem(
427+
item?.name,
428+
item?.id || '',
429+
Boolean(item?.subMenu),
430+
false,
431+
props.onSelection
432+
);
433+
434+
// Announce selection for screen readers
435+
if (item?.name) {
436+
const announcement = document.createElement('div');
437+
announcement.setAttribute('aria-live', 'polite');
438+
announcement.className = 'sr-only';
439+
announcement.textContent = `Selected ${item.name}`;
440+
document.body.appendChild(announcement);
441+
setTimeout(() => document.body.removeChild(announcement), 1000);
359442
}
360443
break;
361444
@@ -407,9 +490,9 @@ export default defineComponent({
407490
// Find the submenu container
408491
const submenu = document.querySelector(`[data-submenu-id="${submenuId}"]`) as HTMLElement;
409492
if (submenu) {
410-
// Find the first focusable item in the submenu
493+
// Find the first non-disabled focusable item in the submenu
411494
const firstItem = submenu.querySelector(
412-
'[role="menuitem"]:not([aria-disabled="true"])'
495+
'[role="menuitem"]:not([aria-disabled="true"]):not(.divider)'
413496
) as HTMLElement;
414497
if (firstItem) {
415498
firstItem.focus();
@@ -479,10 +562,10 @@ export default defineComponent({
479562
480563
const handleAfterTransitionEnter = (el: Element) => {
481564
try {
482-
// Focus the first item in the submenu
565+
// Focus the first non-disabled item in the submenu
483566
nextTick(() => {
484567
const firstItem = el.querySelector(
485-
'[role="menuitem"]:not([aria-disabled="true"])'
568+
'[role="menuitem"]:not([aria-disabled="true"]):not(.divider)'
486569
) as HTMLElement;
487570
if (firstItem) {
488571
firstItem.focus();

0 commit comments

Comments
 (0)