Skip to content

Commit 483d787

Browse files
Rajdeep ChandraRajdeep Chandra
authored andcommitted
fix(menu): multi-select keyboard selection and esc key closing
1 parent 2811b9e commit 483d787

File tree

3 files changed

+171
-12
lines changed

3 files changed

+171
-12
lines changed

packages/menu/src/Menu.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,30 +103,30 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
103103

104104
/**
105105
* Minimum vertical movement (in pixels) required to trigger scrolling detection.
106-
*
106+
*
107107
* This threshold is consistent with other components in the design system:
108108
* - Card component uses 10px for click vs. drag detection
109109
* - Menu component uses 10px for scroll vs. selection detection
110-
*
110+
*
111111
* The 10px threshold is carefully chosen to:
112112
* - Allow for natural finger tremor and accidental touches
113113
* - Distinguish between intentional scroll gestures and taps
114114
* - Provide consistent behavior across the platform
115-
*
115+
*
116116
* @see {@link packages/card/src/Card.ts} for similar threshold usage
117117
*/
118118
private scrollThreshold = 10; // pixels
119119

120120
/**
121121
* Maximum time (in milliseconds) for a movement to be considered scrolling.
122-
*
122+
*
123123
* This threshold is consistent with other timing values in the design system:
124124
* - Longpress duration: 300ms (ActionButton, LongpressController)
125125
* - Scroll detection: 300ms (Menu component)
126-
*
126+
*
127127
* Quick movements within this timeframe are likely intentional scrolls,
128128
* while slower movements are more likely taps or selections.
129-
*
129+
*
130130
* @see {@link packages/action-button/src/ActionButton.ts} for longpress duration
131131
* @see {@link packages/overlay/src/LongpressController.ts} for longpress duration
132132
*/
@@ -576,7 +576,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
576576
* Resets the scrolling state after a short delay (100ms) to allow for
577577
* any final touch events to be processed. This delay prevents immediate
578578
* state changes that could interfere with the selection logic.
579-
*
579+
*
580580
* The 100ms delay is consistent with the design system's approach to
581581
* touch event handling and ensures that any final touch events or
582582
* gesture recognition can complete before the scrolling state is reset.
@@ -886,8 +886,9 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) {
886886
}
887887
if (key === ' ' || key === 'Enter') {
888888
event.preventDefault();
889+
// The click() method already triggers selection via handlePointerBasedSelection
890+
// so we don't need to call selectOrToggleItem again here.
889891
root?.focusElement?.click();
890-
if (root) this.selectOrToggleItem(root);
891892
return;
892893
}
893894
this.navigateBetweenRelatedMenus(event as MenuItemKeydownEvent);

packages/menu/src/MenuItem.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -583,10 +583,9 @@ export class MenuItem extends LikeAnchor(
583583
const openSubmenuKey =
584584
this.hasSubmenu && !this.open && [' ', 'Enter'].includes(key);
585585
if (target === this) {
586-
if (
587-
['ArrowLeft', 'ArrowRight', 'Escape'].includes(key) ||
588-
openSubmenuKey
589-
)
586+
// Only prevent default for arrow keys and submenu-related keys.
587+
// Don't prevent Escape - let it bubble to close overlays.
588+
if (['ArrowLeft', 'ArrowRight'].includes(key) || openSubmenuKey)
590589
event.preventDefault();
591590
this.dispatchEvent(
592591
new MenuItemKeydownEvent({ root: this, event: event })

packages/menu/stories/menu.stories.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,162 @@ InputsWithMenu.parameters = {
591591
// Disables Chromatic's snapshotting on a global level
592592
chromatic: { disableSnapshot: true },
593593
};
594+
595+
export const menuWithMultipleSelection = (): TemplateResult => {
596+
import('@spectrum-web-components/picker-button/sp-picker-button.js');
597+
import('@spectrum-web-components/overlay/sp-overlay.js');
598+
import('@spectrum-web-components/popover/sp-popover.js');
599+
600+
function handleKeydown(event: KeyboardEvent): void {
601+
const logEl = document.querySelector('.debug-log');
602+
if (logEl) {
603+
const time = new Date().toLocaleTimeString();
604+
const info = `[${time}] Key: "${event.key}", Target: ${(event.target as HTMLElement).tagName}, defaultPrevented: ${event.defaultPrevented}`;
605+
logEl.textContent = info + '\n' + logEl.textContent;
606+
}
607+
}
608+
609+
setTimeout(() => {
610+
const menu = document.querySelector(
611+
'sp-menu[selects="multiple"]'
612+
) as Menu;
613+
const overlay = document.querySelector('sp-overlay');
614+
if (menu && overlay) {
615+
menu.addEventListener('keydown', handleKeydown, { capture: true });
616+
console.log('Debug: Menu found, attaching keydown listener');
617+
console.log('Debug: Menu selects=', menu.getAttribute('selects'));
618+
console.log('Debug: Menu childItems=', menu.childItems);
619+
620+
// Log focus changes
621+
menu.addEventListener('focusin', () => {
622+
console.log(
623+
'🎯 FOCUSIN on menu, activeElement:',
624+
document.activeElement
625+
);
626+
});
627+
628+
// Also listen on document for all keydown events
629+
document.addEventListener(
630+
'keydown',
631+
(event: KeyboardEvent) => {
632+
if (event.key === ' ' || event.key === 'Enter') {
633+
console.log(
634+
'⌨️ Space/Enter pressed, activeElement:',
635+
document.activeElement?.tagName
636+
);
637+
}
638+
if (event.key === 'Escape') {
639+
console.log(
640+
'🚪 Escape pressed, overlay open:',
641+
overlay.open,
642+
'activeElement:',
643+
document.activeElement?.tagName
644+
);
645+
}
646+
},
647+
{ capture: true }
648+
);
649+
650+
// Listen for overlay events
651+
overlay.addEventListener('sp-opened', () => {
652+
console.log('✅ Overlay opened');
653+
});
654+
overlay.addEventListener('sp-closed', () => {
655+
console.log('❌ Overlay closed');
656+
});
657+
}
658+
}, 1000);
659+
660+
return html`
661+
<style>
662+
.multi-select-demo {
663+
display: flex;
664+
flex-direction: column;
665+
gap: 16px;
666+
padding: 20px;
667+
}
668+
.status {
669+
padding: 12px;
670+
background: var(--spectrum-gray-100);
671+
border-radius: 4px;
672+
font-family: monospace;
673+
font-size: 12px;
674+
}
675+
.instructions {
676+
padding: 12px;
677+
background: var(--spectrum-blue-100);
678+
border-radius: 4px;
679+
font-size: 14px;
680+
}
681+
.debug-log {
682+
padding: 12px;
683+
background: var(--spectrum-yellow-100);
684+
border-radius: 4px;
685+
font-family: monospace;
686+
font-size: 11px;
687+
max-height: 150px;
688+
overflow-y: auto;
689+
white-space: pre-wrap;
690+
}
691+
</style>
692+
<div class="multi-select-demo">
693+
<div class="instructions">
694+
<strong>Instructions:</strong>
695+
<br />
696+
1. Click the picker button below to open the menu
697+
<br />
698+
2. Use
699+
<kbd>Arrow Up/Down</kbd>
700+
to navigate
701+
<br />
702+
3. Press
703+
<kbd>Space</kbd>
704+
or
705+
<kbd>Enter</kbd>
706+
to toggle selection
707+
<br />
708+
4. Press
709+
<kbd>Escape</kbd>
710+
to close
711+
<br />
712+
5. Watch the debug log below for keyboard events
713+
</div>
714+
715+
<sp-picker-button id="multi-select-button">
716+
<span slot="label">Select multiple options</span>
717+
</sp-picker-button>
718+
719+
<sp-overlay
720+
trigger="multi-select-button@click"
721+
placement="bottom-start"
722+
>
723+
<sp-popover>
724+
<sp-menu
725+
selects="multiple"
726+
label="Select options"
727+
@change=${(event: Event) => {
728+
console.log(
729+
'Selected:',
730+
(event.target as Menu).selected
731+
);
732+
}}
733+
>
734+
<sp-menu-item value="option1">Option 1</sp-menu-item>
735+
<sp-menu-item value="option2">Option 2</sp-menu-item>
736+
<sp-menu-item value="option3">Option 3</sp-menu-item>
737+
</sp-menu>
738+
</sp-popover>
739+
</sp-overlay>
740+
741+
<div class="debug-log">Debug log will appear here...</div>
742+
</div>
743+
`;
744+
};
745+
746+
menuWithMultipleSelection.parameters = {
747+
chromatic: { disableSnapshot: true },
748+
};
749+
750+
menuWithMultipleSelection.story = {
751+
name: 'Picker Button with Multiple Selection',
752+
};

0 commit comments

Comments
 (0)