Skip to content

Commit 6d957f1

Browse files
authored
fix(menu-button): close behavior and ensure menu overlays (#21184)
* fix(menu-button): parity close behavior, ensure menu overlays * fix(menu-button): close menu on item activation and blur * test: add tests
1 parent f7f4794 commit 6d957f1

File tree

8 files changed

+321
-123
lines changed

8 files changed

+321
-123
lines changed

packages/web-components/src/components/menu-button/__tests__/__snapshots__/menu-button-test.snap.js

Lines changed: 6 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@ snapshots['cds-menu-button should render and match snapshot'] =
66
kind="primary"
77
label="Actions"
88
menu-alignment="bottom"
9+
menu-background-token="layer"
910
size="lg"
1011
tab-index="0"
1112
>
1213
<cds-menu
1314
size="lg"
14-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
15+
style=""
1516
>
1617
<cds-menu-item
1718
label="First action"
@@ -44,12 +45,13 @@ snapshots[
4445
kind="primary"
4546
label="Test"
4647
menu-alignment="bottom"
48+
menu-background-token="layer"
4749
size="lg"
4850
tab-index="0"
4951
>
5052
<cds-menu
5153
size="lg"
52-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
54+
style=""
5355
>
5456
<cds-menu-item
5557
label="First action"
@@ -78,12 +80,13 @@ snapshots[
7880
kind="primary"
7981
label="Nested"
8082
menu-alignment="bottom"
83+
menu-background-token="layer"
8184
size="lg"
8285
tab-index="0"
8386
>
8487
<cds-menu
8588
size="lg"
86-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
89+
style=""
8790
>
8891
<cds-menu-item
8992
aria-expanded="true"
@@ -121,110 +124,3 @@ snapshots['cds-menu-button should support xs size'] = `<cds-button
121124
</slot>
122125
`;
123126
/* end snapshot cds-menu-button should support xs size */
124-
snapshots['cds-menu-button should render and match snapshot'] =
125-
`<cds-menu-button
126-
kind="primary"
127-
label="Actions"
128-
menu-alignment="bottom"
129-
menu-background-token="layer"
130-
size="lg"
131-
tab-index="0"
132-
>
133-
<cds-menu
134-
size="lg"
135-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
136-
>
137-
<cds-menu-item
138-
label="First action"
139-
role="menuitem"
140-
tabindex="0"
141-
>
142-
</cds-menu-item>
143-
<cds-menu-item
144-
label="Second action"
145-
role="menuitem"
146-
tabindex="0"
147-
>
148-
</cds-menu-item>
149-
<cds-menu-item
150-
aria-disabled="true"
151-
disabled=""
152-
label="Third action"
153-
role="menuitem"
154-
tabindex="-1"
155-
>
156-
</cds-menu-item>
157-
</cds-menu>
158-
</cds-menu-button>
159-
`;
160-
/* end snapshot cds-menu-button should render and match snapshot */
161-
162-
snapshots[
163-
'cds-menu-button Children/slots and special menu content Snapshot variants should render with divider and danger and match snapshot'
164-
] = `<cds-menu-button
165-
kind="primary"
166-
label="Test"
167-
menu-alignment="bottom"
168-
menu-background-token="layer"
169-
size="lg"
170-
tab-index="0"
171-
>
172-
<cds-menu
173-
size="lg"
174-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
175-
>
176-
<cds-menu-item
177-
label="First action"
178-
role="menuitem"
179-
tabindex="0"
180-
>
181-
</cds-menu-item>
182-
<cds-menu-item-divider role="separator">
183-
</cds-menu-item-divider>
184-
<cds-menu-item
185-
class="cds--menu-item--danger"
186-
kind="danger"
187-
label="Danger"
188-
role="menuitem"
189-
tabindex="0"
190-
>
191-
</cds-menu-item>
192-
</cds-menu>
193-
</cds-menu-button>
194-
`;
195-
/* end snapshot cds-menu-button Children/slots and special menu content Snapshot variants should render with divider and danger and match snapshot */
196-
197-
snapshots[
198-
'cds-menu-button Children/slots and special menu content Snapshot variants should render with nested menu and match snapshot'
199-
] = `<cds-menu-button
200-
kind="primary"
201-
label="Nested"
202-
menu-alignment="bottom"
203-
menu-background-token="layer"
204-
size="lg"
205-
tab-index="0"
206-
>
207-
<cds-menu
208-
size="lg"
209-
style="inset-inline-start: 8px; inset-inline-end: initial; inset-block-start: 8px;"
210-
>
211-
<cds-menu-item
212-
aria-expanded="true"
213-
aria-haspopup="true"
214-
label="Export as"
215-
role="menuitem"
216-
tabindex="0"
217-
>
218-
<cds-menu-item-group slot="submenu">
219-
<cds-menu-item
220-
label="PDF"
221-
role="menuitem"
222-
tabindex="0"
223-
>
224-
</cds-menu-item>
225-
</cds-menu-item-group>
226-
</cds-menu-item>
227-
</cds-menu>
228-
</cds-menu-button>
229-
`;
230-
/* end snapshot cds-menu-button Children/slots and special menu content Snapshot variants should render with nested menu and match snapshot */

packages/web-components/src/components/menu-button/__tests__/menu-button-test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,58 @@ describe('cds-menu-button', function () {
280280
});
281281
});
282282

283+
describe('internal close helpers', () => {
284+
it('should close the menu and optionally restore focus', async () => {
285+
const el = await fixture(menuButton);
286+
const originalFocusTrigger = el._focusTrigger;
287+
let focused = false;
288+
289+
el._focusTrigger = () => {
290+
focused = true;
291+
};
292+
293+
el._open = true;
294+
el._closeMenu({ restoreFocus: true });
295+
expect(el._open).to.be.false;
296+
expect(focused).to.be.true;
297+
298+
focused = false;
299+
el._open = true;
300+
el._closeMenu({ restoreFocus: false });
301+
expect(el._open).to.be.false;
302+
expect(focused).to.be.false;
303+
304+
el._focusTrigger = originalFocusTrigger;
305+
});
306+
307+
it('should respect menu closed events', async () => {
308+
const el = await fixture(menuButton);
309+
const menu = el.querySelector('cds-menu');
310+
311+
const closeCalls = [];
312+
const originalCloseMenu = el._closeMenu;
313+
el._closeMenu = (options) => {
314+
closeCalls.push(options);
315+
};
316+
317+
el._open = true;
318+
el._handleMenuClosed({
319+
target: menu,
320+
detail: { triggerEventType: 'click' },
321+
});
322+
expect(closeCalls[0]).to.deep.equal({ restoreFocus: true });
323+
324+
el._open = true;
325+
el._handleMenuClosed({
326+
target: menu,
327+
detail: { triggerEventType: 'focusout' },
328+
});
329+
expect(closeCalls[1]).to.deep.equal({ restoreFocus: false });
330+
331+
el._closeMenu = originalCloseMenu;
332+
});
333+
});
334+
283335
// Note: Focus management in WC is limited by Shadow DOM, so this suite covers basic trigger behavior only
284336
describe('Keyboard and focus interaction', () => {
285337
it('should receive focus and open with Space/Enter', async () => {

packages/web-components/src/components/menu-button/menu-button.ts

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,6 @@ class CDSMenuButton extends HostListenerMixin(LitElement) {
3636
@query(`${prefix}-button`)
3737
_triggerNode!: CDSButton;
3838

39-
@query(`${prefix}-menu`)
40-
_menuNode!: CDSMenu;
41-
4239
@property({ type: Boolean, reflect: true })
4340
private _open = false;
4441

@@ -93,11 +90,35 @@ class CDSMenuButton extends HostListenerMixin(LitElement) {
9390
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452
9491
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
9592
private _handleClick = (event: Event) => {
93+
const { _triggerNode: trigger } = this;
94+
if (!trigger) {
95+
return;
96+
}
97+
9698
const path = event.composedPath();
97-
if (path.includes(this._triggerNode)) {
98-
this._open = !this._open;
99+
if (path.includes(trigger)) {
100+
if (this._open) {
101+
this._closeMenu({ restoreFocus: true });
102+
} else {
103+
this._open = true;
104+
}
99105
} else if (this._open) {
100-
this._open = false;
106+
this._closeMenu();
107+
}
108+
};
109+
110+
@HostListener('mousedown')
111+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452
112+
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
113+
private _handleMousedown = (event: MouseEvent) => {
114+
const { _triggerNode: trigger } = this;
115+
if (!trigger) {
116+
return;
117+
}
118+
119+
const path = event.composedPath();
120+
if (path.includes(trigger)) {
121+
event.preventDefault();
101122
}
102123
};
103124

@@ -107,10 +128,46 @@ class CDSMenuButton extends HostListenerMixin(LitElement) {
107128
private _handleBlur = ({ relatedTarget }: FocusEvent) => {
108129
// Close the menu if the focus moves outside the menu button or menu
109130
if (!this.contains(relatedTarget as Node)) {
110-
this._open = false;
131+
this._closeMenu();
111132
}
112133
};
113134

135+
@HostListener('keydown')
136+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452
137+
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
138+
private _handleKeydown = (event: KeyboardEvent) => {
139+
if (!this._open || event.key !== 'Escape') {
140+
return;
141+
}
142+
143+
const { _triggerNode: trigger } = this;
144+
if (!trigger) {
145+
return;
146+
}
147+
148+
const path = event.composedPath();
149+
if (path.includes(trigger)) {
150+
event.stopPropagation();
151+
event.preventDefault();
152+
this._closeMenu({ restoreFocus: true });
153+
}
154+
};
155+
156+
@HostListener(`${prefix}-menu-closed`)
157+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- https://github.com/carbon-design-system/carbon/issues/20452
158+
// @ts-ignore: The decorator refers to this method but TS thinks this method is not referred to
159+
private _handleMenuClosed = (
160+
event: CustomEvent<{ triggerEventType?: string }>
161+
) => {
162+
const menu = this.querySelector(`${prefix}-menu`);
163+
if (!menu || event.target !== menu || !this._open) {
164+
return;
165+
}
166+
167+
const shouldRestoreFocus = event.detail?.triggerEventType !== 'focusout';
168+
this._closeMenu({ restoreFocus: shouldRestoreFocus });
169+
};
170+
114171
updated(changedProperties) {
115172
const menu = this.querySelector(`${prefix}-menu`) as CDSMenu;
116173

@@ -164,6 +221,28 @@ class CDSMenuButton extends HostListenerMixin(LitElement) {
164221
}
165222

166223
static styles = styles;
224+
225+
private _closeMenu({
226+
restoreFocus = false,
227+
}: { restoreFocus?: boolean } = {}) {
228+
if (!this._open) {
229+
return;
230+
}
231+
232+
this._open = false;
233+
234+
if (restoreFocus) {
235+
this._focusTrigger();
236+
}
237+
}
238+
239+
private _focusTrigger() {
240+
if (!this._triggerNode || typeof this._triggerNode.focus !== 'function') {
241+
return;
242+
}
243+
244+
this._triggerNode.focus();
245+
}
167246
}
168247

169248
export default CDSMenuButton;

packages/web-components/src/components/menu/__tests__/menu-item-test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,22 @@ describe('cds-menu-item', () => {
191191
// Restore original method
192192
el._handleClick = originalHandleClick;
193193
});
194+
195+
it('should dispatch a close request when activated without a submenu', async () => {
196+
const el = await fixture(
197+
html`<cds-menu-item label="Test Item"></cds-menu-item>`
198+
);
199+
200+
const events = [];
201+
el.addEventListener('cds-menu-close-root-request', (event) => {
202+
events.push(event);
203+
});
204+
205+
el.dispatchEvent(new MouseEvent('click', { bubbles: true }));
206+
207+
expect(events.length).to.equal(1);
208+
expect(events[0].detail.triggerEvent).to.be.instanceOf(MouseEvent);
209+
});
194210
});
195211

196212
describe('submenu positioning', () => {

0 commit comments

Comments
 (0)