Skip to content

Commit 8b68083

Browse files
authored
feat(cdk-experimental/menu): add support for inline menus (#20143)
Add the ability to place a menu inline (always visible and not trigered) which is typically used as a navigation menu.
1 parent afa29cf commit 8b68083

File tree

5 files changed

+77
-30
lines changed

5 files changed

+77
-30
lines changed

src/cdk-experimental/menu/menu-errors.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

src/cdk-experimental/menu/menu-stack.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,13 @@ export class MenuStack {
122122
}
123123

124124
/** Get the top most element on the stack. */
125-
peek() {
125+
peek(): MenuStackItem | undefined {
126126
return this._elements[this._elements.length - 1];
127127
}
128128
}
129+
130+
/** NoopMenuStack is a placeholder MenuStack used for inline menus. */
131+
export class NoopMenuStack extends MenuStack {
132+
/** Noop push - does not add elements to the MenuStack. */
133+
push(_: MenuStackItem) {}
134+
}

src/cdk-experimental/menu/menu.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
22
import {Component, ViewChild} from '@angular/core';
3+
import {TAB} from '@angular/cdk/keycodes';
4+
import {dispatchKeyboardEvent} from '@angular/cdk/testing/private';
35
import {By} from '@angular/platform-browser';
46
import {CdkMenu} from './menu';
57
import {CdkMenuModule} from './menu-module';
@@ -158,6 +160,41 @@ describe('Menu', () => {
158160
expect(spy).withContext('Expected initial trigger only').toHaveBeenCalledTimes(1);
159161
});
160162
});
163+
164+
describe('when configured inline', () => {
165+
let fixture: ComponentFixture<InlineMenu>;
166+
let nativeMenu: HTMLElement;
167+
let nativeMenuItems: HTMLElement[];
168+
169+
beforeEach(async(() => {
170+
TestBed.configureTestingModule({
171+
imports: [CdkMenuModule],
172+
declarations: [InlineMenu],
173+
}).compileComponents();
174+
}));
175+
176+
beforeEach(() => {
177+
fixture = TestBed.createComponent(InlineMenu);
178+
fixture.detectChanges();
179+
180+
nativeMenu = fixture.debugElement.query(By.directive(CdkMenu)).nativeElement;
181+
nativeMenuItems = fixture.debugElement
182+
.queryAll(By.directive(CdkMenuItem))
183+
.map(e => e.nativeElement);
184+
});
185+
186+
it('should have its tabindex set to 0', () => {
187+
const item = fixture.debugElement.query(By.directive(CdkMenu)).nativeElement;
188+
expect(item.getAttribute('tabindex')).toBe('0');
189+
});
190+
191+
it('should focus the first menu item when it gets tabbed in', () => {
192+
dispatchKeyboardEvent(document, 'keydown', TAB);
193+
nativeMenu.focus();
194+
195+
expect(document.querySelector(':focus')).toEqual(nativeMenuItems[0]);
196+
});
197+
});
161198
});
162199

163200
@Component({
@@ -229,3 +266,13 @@ class MenuWithConditionalGroup {
229266
@ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem;
230267
@ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel;
231268
}
269+
270+
@Component({
271+
template: `
272+
<div cdkMenu>
273+
<button cdkMenuItem>Inbox</button>
274+
<button cdkMenuItem>Starred</button>
275+
</div>
276+
`,
277+
})
278+
class InlineMenu {}

src/cdk-experimental/menu/menu.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@ import {merge, Observable} from 'rxjs';
3636
import {CdkMenuGroup} from './menu-group';
3737
import {CdkMenuPanel} from './menu-panel';
3838
import {Menu, CDK_MENU} from './menu-interface';
39-
import {throwMissingMenuPanelError} from './menu-errors';
4039
import {CdkMenuItem} from './menu-item';
41-
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
40+
import {MenuStack, MenuStackItem, FocusNext, NoopMenuStack} from './menu-stack';
4241
import {getItemPointerEntries} from './item-pointer-entries';
4342

4443
/**
@@ -52,6 +51,7 @@ import {getItemPointerEntries} from './item-pointer-entries';
5251
selector: '[cdkMenu]',
5352
exportAs: 'cdkMenu',
5453
host: {
54+
'[tabindex]': '_isInline() ? 0 : null',
5555
'role': 'menu',
5656
'class': 'cdk-menu',
5757
'[attr.aria-orientation]': 'orientation',
@@ -71,8 +71,11 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
7171
/** Event emitted when the menu is closed. */
7272
@Output() readonly closed: EventEmitter<void | 'click' | 'tab' | 'escape'> = new EventEmitter();
7373

74+
// We provide a default MenuStack implementation in case the menu is an inline menu.
75+
// For Menus part of a MenuBar nested within a MenuPanel this will be overwritten
76+
// to the correct parent MenuStack.
7477
/** Track the Menus making up the open menu stack. */
75-
_menuStack: MenuStack;
78+
_menuStack: MenuStack = new NoopMenuStack();
7679

7780
/** Handles keyboard events for the menu. */
7881
private _keyManager: FocusKeyManager<CdkMenuItem>;
@@ -124,6 +127,11 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
124127
this._subscribeToMouseManager();
125128
}
126129

130+
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
131+
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
132+
// can move this back into `host`.
133+
// tslint:disable:no-host-decorator-in-concrete
134+
@HostListener('focus')
127135
/** Place focus on the first MenuItem in the menu and set the focus origin. */
128136
focusFirstItem(focusOrigin: FocusOrigin = 'program') {
129137
this._keyManager.setFocusOrigin(focusOrigin);
@@ -181,12 +189,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
181189

182190
/** Register this menu with its enclosing parent menu panel */
183191
private _registerWithParentPanel() {
184-
const parent = this._getMenuPanel();
185-
if (parent) {
186-
parent._registerMenu(this);
187-
} else {
188-
throwMissingMenuPanelError();
189-
}
192+
this._getMenuPanel()?._registerMenu(this);
190193
}
191194

192195
/**
@@ -323,6 +326,16 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
323326
return this.orientation === 'horizontal';
324327
}
325328

329+
/**
330+
* Return true if this menu is an inline menu. That is, it does not exist in a pop-up and is
331+
* always visible in the dom.
332+
*/
333+
_isInline() {
334+
// NoopMenuStack is the default. If this menu is not inline than the NoopMenuStack is replaced
335+
// automatically.
336+
return this._menuStack instanceof NoopMenuStack;
337+
}
338+
326339
ngOnDestroy() {
327340
this._emitClosedEvent();
328341
}

src/cdk-experimental/menu/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,5 @@ export * from './menu-panel';
1717
export * from './menu-group';
1818
export * from './context-menu';
1919

20-
export * from './menu-stack';
20+
export {MenuStack, MenuStackItem} from './menu-stack';
2121
export {CDK_MENU} from './menu-interface';

0 commit comments

Comments
 (0)