Skip to content

Commit 4cc6b04

Browse files
authored
feat(cdk-experimental/menu): allow configuration of typeahead and menu position (#24600)
* feat(cdk-experimental/menu): allow configuration of typeahead and menu position Addresses several TODOs that were left after the original implementation * fixup! feat(cdk-experimental/menu): allow configuration of typeahead and menu position * fixup! feat(cdk-experimental/menu): allow configuration of typeahead and menu position
1 parent ff12d09 commit 4cc6b04

File tree

12 files changed

+148
-80
lines changed

12 files changed

+148
-80
lines changed

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

Lines changed: 13 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
EventEmitter,
1212
Inject,
1313
Injectable,
14-
InjectionToken,
1514
Injector,
1615
Input,
1716
OnDestroy,
@@ -27,6 +26,7 @@ import {
2726
Overlay,
2827
OverlayConfig,
2928
OverlayRef,
29+
STANDARD_DROPDOWN_BELOW_POSITIONS,
3030
} from '@angular/cdk/overlay';
3131
import {Portal, TemplatePortal} from '@angular/cdk/portal';
3232
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
@@ -36,6 +36,14 @@ import {MENU_STACK, MenuStack} from './menu-stack';
3636
import {isClickInsideMenuOverlay} from './menu-item-trigger';
3737
import {MENU_TRIGGER, MenuTrigger} from './menu-trigger';
3838

39+
// In cases where the first menu item in the context menu is a trigger the submenu opens on a
40+
// hover event. We offset the context menu 2px by default to prevent this from occurring.
41+
const CONTEXT_MENU_POSITIONS = STANDARD_DROPDOWN_BELOW_POSITIONS.map(position => {
42+
const offsetX = position.overlayX === 'start' ? 2 : -2;
43+
const offsetY = position.overlayY === 'top' ? 2 : -2;
44+
return {...position, offsetX, offsetY};
45+
});
46+
3947
/** Tracks the last open context menu trigger across the entire application. */
4048
@Injectable({providedIn: 'root'})
4149
export class ContextMenuTracker {
@@ -54,20 +62,6 @@ export class ContextMenuTracker {
5462
}
5563
}
5664

57-
/** Configuration options passed to the context menu. */
58-
export type ContextMenuOptions = {
59-
/** The opened menus X coordinate offset from the triggering position. */
60-
offsetX: number;
61-
62-
/** The opened menus Y coordinate offset from the triggering position. */
63-
offsetY: number;
64-
};
65-
66-
/** Injection token for the ContextMenu options object. */
67-
export const CDK_CONTEXT_MENU_DEFAULT_OPTIONS = new InjectionToken<ContextMenuOptions>(
68-
'cdk-context-menu-default-options',
69-
);
70-
7165
/** The coordinates of where the context menu should open. */
7266
export type ContextMenuCoordinates = {x: number; y: number};
7367

@@ -83,9 +77,6 @@ export type ContextMenuCoordinates = {x: number; y: number};
8377
'(contextmenu)': '_openOnContextMenu($event)',
8478
},
8579
providers: [
86-
// In cases where the first menu item in the context menu is a trigger the submenu opens on a
87-
// hover event. Offsetting the opened context menu by 2px prevents this from occurring.
88-
{provide: CDK_CONTEXT_MENU_DEFAULT_OPTIONS, useValue: {offsetX: 2, offsetY: 2}},
8980
{provide: MENU_TRIGGER, useExisting: CdkContextMenuTrigger},
9081
{provide: MENU_STACK, useClass: MenuStack},
9182
],
@@ -95,6 +86,9 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy {
9586
@Input('cdkContextMenuTriggerFor')
9687
private _menuTemplateRef: TemplateRef<unknown>;
9788

89+
/** A list of preferred menu positions to be used when constructing the `FlexibleConnectedPositionStrategy` for this trigger's menu. */
90+
@Input('cdkMenuPosition') menuPosition: ConnectedPosition[];
91+
9892
/** Emits when the attached menu is requested to open. */
9993
@Output('cdkContextMenuOpened') readonly opened: EventEmitter<void> = new EventEmitter();
10094

@@ -129,7 +123,6 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy {
129123
private readonly _overlay: Overlay,
130124
private readonly _contextMenuTracker: ContextMenuTracker,
131125
@Inject(MENU_STACK) menuStack: MenuStack,
132-
@Inject(CDK_CONTEXT_MENU_DEFAULT_OPTIONS) private readonly _options: ContextMenuOptions,
133126
@Optional() private readonly _directionality?: Directionality,
134127
) {
135128
super(injector, menuStack);
@@ -233,22 +226,7 @@ export class CdkContextMenuTrigger extends MenuTrigger implements OnDestroy {
233226
return this._overlay
234227
.position()
235228
.flexibleConnectedTo(coordinates)
236-
.withDefaultOffsetX(this._options.offsetX)
237-
.withDefaultOffsetY(this._options.offsetY)
238-
.withPositions(this._getOverlayPositions());
239-
}
240-
241-
/**
242-
* Determine and return where to position the opened menu relative to the mouse location.
243-
*/
244-
private _getOverlayPositions(): ConnectedPosition[] {
245-
// TODO: this should be configurable through the injected context menu options
246-
return [
247-
{originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top'},
248-
{originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top'},
249-
{originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom'},
250-
{originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom'},
251-
];
229+
.withPositions(this.menuPosition ?? CONTEXT_MENU_POSITIONS);
252230
}
253231

254232
/**

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,7 +539,6 @@ class TriggersWithSameMenuSameMenuBar {
539539
@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
540540
}
541541

542-
// TODO uncomment once we figure out why this is failing in Ivy
543542
@Component({
544543
template: `
545544
<div cdkMenuBar>

src/cdk-experimental/menu/menu-item-trigger.ts

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
Overlay,
3030
OverlayConfig,
3131
OverlayRef,
32+
STANDARD_DROPDOWN_ADJACENT_POSITIONS,
33+
STANDARD_DROPDOWN_BELOW_POSITIONS,
3234
} from '@angular/cdk/overlay';
3335
import {DOWN_ARROW, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, UP_ARROW} from '@angular/cdk/keycodes';
3436
import {fromEvent, merge, Subject} from 'rxjs';
@@ -90,6 +92,9 @@ export class CdkMenuItemTrigger extends MenuTrigger implements OnDestroy {
9092
@Input('cdkMenuTriggerFor')
9193
_menuTemplateRef?: TemplateRef<unknown>;
9294

95+
/** A list of preferred menu positions to be used when constructing the `FlexibleConnectedPositionStrategy` for this trigger's menu. */
96+
@Input('cdkMenuPosition') menuPosition: ConnectedPosition[];
97+
9398
/** Emits when the attached menu is requested to open */
9499
@Output('cdkMenuOpened') readonly opened: EventEmitter<void> = new EventEmitter();
95100

@@ -287,20 +292,12 @@ export class CdkMenuItemTrigger extends MenuTrigger implements OnDestroy {
287292

288293
/** Determine and return where to position the opened menu relative to the menu item */
289294
private _getOverlayPositions(): ConnectedPosition[] {
290-
// TODO: use a common positioning config from (possibly) cdk/overlay
291-
return !this._parentMenu || this._parentMenu.orientation === 'horizontal'
292-
? [
293-
{originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'},
294-
{originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'},
295-
{originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'},
296-
{originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom'},
297-
]
298-
: [
299-
{originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top'},
300-
{originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom'},
301-
{originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top'},
302-
{originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom'},
303-
];
295+
return (
296+
this.menuPosition ??
297+
(!this._parentMenu || this._parentMenu.orientation === 'horizontal'
298+
? STANDARD_DROPDOWN_BELOW_POSITIONS
299+
: STANDARD_DROPDOWN_ADJACENT_POSITIONS)
300+
);
304301
}
305302

306303
/**

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('MenuItem', () => {
9090
fixture.detectChanges();
9191

9292
menuItem = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem);
93+
return fixture;
9394
}
9495

9596
it('should get the text for a simple menu item with no nested or wrapped elements', () => {
@@ -98,15 +99,21 @@ describe('MenuItem', () => {
9899
});
99100

100101
it('should get the text for menu item with a single nested mat icon component', () => {
101-
createComponent(MenuItemWithIcon);
102+
const fixture = createComponent(MenuItemWithIcon);
103+
expect(menuItem.getLabel()).toEqual('unicorn Click me!');
104+
fixture.componentInstance.typeahead = 'Click me!';
105+
fixture.detectChanges();
102106
expect(menuItem.getLabel()).toEqual('Click me!');
103107
});
104108

105109
it(
106110
'should get the text for menu item with single nested component with the material ' +
107111
'icon class',
108112
() => {
109-
createComponent(MenuItemWithIconClass);
113+
const fixture = createComponent(MenuItemWithIconClass);
114+
expect(menuItem.getLabel()).toEqual('unicorn Click me!');
115+
fixture.componentInstance.typeahead = 'Click me!';
116+
fixture.detectChanges();
110117
expect(menuItem.getLabel()).toEqual('Click me!');
111118
},
112119
);
@@ -120,7 +127,10 @@ describe('MenuItem', () => {
120127
'should get the text for a menu item with nested icon, nested icon class and nested ' +
121128
'wrapping elements',
122129
() => {
123-
createComponent(MenuItemWithMultipleNestings);
130+
const fixture = createComponent(MenuItemWithMultipleNestings);
131+
expect(menuItem.getLabel()).toEqual('unicorn Click menume!');
132+
fixture.componentInstance.typeahead = 'Click me!';
133+
fixture.detectChanges();
124134
expect(menuItem.getLabel()).toEqual('Click me!');
125135
},
126136
);
@@ -134,22 +144,27 @@ class SingleMenuItem {}
134144

135145
@Component({
136146
template: `
137-
<button cdkMenuItem>
147+
<button cdkMenuItem [typeahead]="typeahead">
138148
<mat-icon>unicorn</mat-icon>
139149
Click me!
140150
</button>
141151
`,
142152
})
143-
class MenuItemWithIcon {}
153+
class MenuItemWithIcon {
154+
typeahead: string;
155+
}
156+
144157
@Component({
145158
template: `
146-
<button cdkMenuItem>
159+
<button cdkMenuItem [typeahead]="typeahead">
147160
<div class="material-icons">unicorn</div>
148161
Click me!
149162
</button>
150163
`,
151164
})
152-
class MenuItemWithIconClass {}
165+
class MenuItemWithIconClass {
166+
typeahead: string;
167+
}
153168

154169
@Component({
155170
template: ` <button cdkMenuItem><b>Click</b> me!</button> `,
@@ -158,7 +173,7 @@ class MenuItemWithBoldElement {}
158173

159174
@Component({
160175
template: `
161-
<button cdkMenuItem>
176+
<button cdkMenuItem [typeahead]="typeahead">
162177
<div>
163178
<div class="material-icons">unicorn</div>
164179
<div>
@@ -170,7 +185,9 @@ class MenuItemWithBoldElement {}
170185
</button>
171186
`,
172187
})
173-
class MenuItemWithMultipleNestings {}
188+
class MenuItemWithMultipleNestings {
189+
typeahead: string;
190+
}
174191

175192
@Component({
176193
selector: 'mat-icon',

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,6 @@ import {FocusNext, MENU_STACK, MenuStack} from './menu-stack';
3030
import {FocusableElement} from './pointer-focus-tracker';
3131
import {MENU_AIM, MenuAim, Toggler} from './menu-aim';
3232

33-
// TODO refactor this to be configurable allowing for custom elements to be removed
34-
/** Removes all icons from within the given element. */
35-
function removeIcons(element: Element) {
36-
for (const icon of Array.from(element.querySelectorAll('mat-icon, .material-icons'))) {
37-
icon.remove();
38-
}
39-
}
40-
4133
/**
4234
* Directive which provides the ability for an element to be focused and navigated to using the
4335
* keyboard when residing in a CdkMenu, CdkMenuBar, or CdkMenuGroup. It performs user defined
@@ -71,6 +63,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler,
7163
}
7264
private _disabled = false;
7365

66+
/**
67+
* The text used to locate this item during menu typeahead. If not specified,
68+
* the `textContent` of the item will be used.
69+
*/
70+
@Input() typeahead: string;
71+
7472
/**
7573
* If this MenuItem is a regular MenuItem, outputs when it is triggered by a keyboard or mouse
7674
* event.
@@ -173,12 +171,7 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, Toggler,
173171

174172
/** Get the label for this element which is required by the FocusableOption interface. */
175173
getLabel(): string {
176-
// TODO cloning the tree may be expensive; implement a better method
177-
// we know that the current node is an element type
178-
const clone = this._elementRef.nativeElement.cloneNode(true) as Element;
179-
removeIcons(clone);
180-
181-
return clone.textContent?.trim() || '';
174+
return this.typeahead || this._elementRef.nativeElement.textContent?.trim() || '';
182175
}
183176

184177
/**

src/cdk/overlay/position/flexible-connected-position-strategy.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1288,3 +1288,17 @@ function getRoundedBoundingClientRect(clientRect: Dimensions): Dimensions {
12881288
height: Math.floor(clientRect.height),
12891289
};
12901290
}
1291+
1292+
export const STANDARD_DROPDOWN_BELOW_POSITIONS: ConnectedPosition[] = [
1293+
{originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top'},
1294+
{originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom'},
1295+
{originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top'},
1296+
{originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom'},
1297+
];
1298+
1299+
export const STANDARD_DROPDOWN_ADJACENT_POSITIONS: ConnectedPosition[] = [
1300+
{originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top'},
1301+
{originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom'},
1302+
{originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top'},
1303+
{originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom'},
1304+
];

src/cdk/overlay/public-api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@ export {
2727
ConnectedPosition,
2828
FlexibleConnectedPositionStrategy,
2929
FlexibleConnectedPositionStrategyOrigin,
30+
STANDARD_DROPDOWN_ADJACENT_POSITIONS,
31+
STANDARD_DROPDOWN_BELOW_POSITIONS,
3032
} from './position/flexible-connected-position-strategy';

src/dev-app/cdk-experimental-menu/cdk-menu-demo.css

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,8 @@
1717
border: solid 2px black;
1818
padding: 6px;
1919
}
20+
21+
demo-custom-position {
22+
display: block;
23+
margin-top: 20px;
24+
}

src/dev-app/cdk-experimental-menu/cdk-menu-demo.html

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,19 +99,55 @@ <h4>Inner Context</h4>
9999
wampa darth mara. Lando kessel wampa sidious.
100100
</div>
101101
</div>
102+
</div>
103+
104+
<div class="example-menu-container">
105+
<h3>Custom Menu Position Example</h3>
106+
<p>The menu and context menu below should open centered on the trigger</p>
107+
108+
<p><button [cdkMenuTriggerFor]="someMenu" [cdkMenuPosition]="customPosition">Some Menu</button></p>
109+
110+
<div class="example-context" [cdkContextMenuTriggerFor]="outer" [cdkMenuPosition]="customPosition">
111+
<h4>Custom Context Menu Position (Centered on Cursor)</h4>
112+
113+
Lucas ipsum dolor sit amet maul jade jawa ben wookiee binks lando jinn baba tatooine. Jade biggs
114+
padmé sebulba cade dagobah. Baba lars mothma yoda. Bothan calrissian c-3p0 maul fisto lando
115+
obi-wan. Skywalker solo darth bothan droid obi-wan ahsoka. Maul solo obi-wan calrissian antilles
116+
yavin chewbacca lando. Mustafar ponda kit jango. C-3p0 skywalker baba grievous moff. Hutt ben
117+
darth solo skywalker bothan skywalker maul organa. Grievous cade antilles utapau skywalker
118+
grievous antilles chewbacca.
119+
</div>
120+
121+
<ng-template #someMenu>
122+
<div cdkMenu class="example-menu">
123+
<button cdkMenuItem>Some Option 1</button>
124+
<button cdkMenuItem [cdkMenuTriggerFor]="someSubMenu">Some Option 2 ></button>
125+
<button cdkMenuItem>Some Option 3</button>
126+
<button cdkMenuItem>Some Option 4</button>
127+
</div>
128+
</ng-template>
129+
130+
<ng-template #someSubMenu>
131+
<div cdkMenu class="example-menu">
132+
<button cdkMenuItem>Some Option 1</button>
133+
<button cdkMenuItem>Some Option 2</button>
134+
<button cdkMenuItem>Some Option 3</button>
135+
<button cdkMenuItem>Some Option 4</button>
136+
</div>
137+
</ng-template>
102138

103139
<ng-template #outer>
104140
<div cdkMenu class="example-menu" id="outer_menu">
105-
<button id="undo_button" cdkMenuItem>Undo</button>
106-
<button id="redo_button" cdkMenuItem>Redo</button>
141+
<button cdkMenuItem>Undo</button>
142+
<button cdkMenuItem>Redo</button>
107143
</div>
108144
</ng-template>
109145

110146
<ng-template #inner>
111147
<div cdkMenu class="example-menu" id="inner_menu">
112-
<button id="cut_button" cdkMenuItem>Cut</button>
113-
<button id="copy_button" cdkMenuItem>Copy</button>
114-
<button id="paste_button" cdkMenuItem>Paste</button>
148+
<button cdkMenuItem>Cut</button>
149+
<button cdkMenuItem>Copy</button>
150+
<button cdkMenuItem>Paste</button>
115151
</div>
116152
</ng-template>
117153
</div>

0 commit comments

Comments
 (0)