Skip to content

Commit 891dcb4

Browse files
committed
feat(cdk-experimental/menu): add support for inline menus
Add the ability to place a menu inline (always visible and not trigered) which is typically used as a navigation menu.
1 parent 338c02f commit 891dcb4

File tree

5 files changed

+78
-30
lines changed

5 files changed

+78
-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: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,8 @@ import {merge, Observable} from 'rxjs';
3535
import {CdkMenuGroup} from './menu-group';
3636
import {CdkMenuPanel} from './menu-panel';
3737
import {Menu, CDK_MENU} from './menu-interface';
38-
import {throwMissingMenuPanelError} from './menu-errors';
3938
import {CdkMenuItem} from './menu-item';
40-
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
39+
import {MenuStack, MenuStackItem, FocusNext, NoopMenuStack} from './menu-stack';
4140
import {getItemPointerEntries} from './item-pointer-entries';
4241

4342
/**
@@ -52,6 +51,8 @@ import {getItemPointerEntries} from './item-pointer-entries';
5251
exportAs: 'cdkMenu',
5352
host: {
5453
'(keydown)': '_handleKeyEvent($event)',
54+
'(focus)': '_focusFirstItem()',
55+
'[tabindex]': '_isInline() ? 0 : null',
5556
'role': 'menu',
5657
'class': 'cdk-menu',
5758
'[attr.aria-orientation]': 'orientation',
@@ -71,8 +72,11 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
7172
/** Event emitted when the menu is closed. */
7273
@Output() readonly closed: EventEmitter<void | 'click' | 'tab' | 'escape'> = new EventEmitter();
7374

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

7781
/** Handles keyboard events for the menu. */
7882
private _keyManager: FocusKeyManager<CdkMenuItem>;
@@ -136,6 +140,11 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
136140
this._keyManager.setLastItemActive();
137141
}
138142

143+
/** Set focus to the first menu item if this is an inline menu. */
144+
_focusFirstItem() {
145+
this._keyManager.setFirstItemActive();
146+
}
147+
139148
/** Handle keyboard events for the Menu. */
140149
_handleKeyEvent(event: KeyboardEvent) {
141150
const keyManager = this._keyManager;
@@ -176,12 +185,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
176185

177186
/** Register this menu with its enclosing parent menu panel */
178187
private _registerWithParentPanel() {
179-
const parent = this._getMenuPanel();
180-
if (parent) {
181-
parent._registerMenu(this);
182-
} else {
183-
throwMissingMenuPanelError();
184-
}
188+
this._getMenuPanel()?._registerMenu(this);
185189
}
186190

187191
/**
@@ -318,6 +322,16 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
318322
return this.orientation === 'horizontal';
319323
}
320324

325+
/**
326+
* Return true if this menu is an inline menu. That is, it does not exist in a pop-up and is
327+
* always visible in the dom.
328+
*/
329+
_isInline() {
330+
// NoopMenuStack is the default. If this menu is not inline than the NoopMenuStack is replaced
331+
// automatically.
332+
return this._menuStack instanceof NoopMenuStack;
333+
}
334+
321335
ngOnDestroy() {
322336
this._emitClosedEvent();
323337
}

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

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

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

0 commit comments

Comments
 (0)