Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import {
afterRenderEffect,
booleanAttribute,
computed,
contentChildren,
Directive,
Expand Down Expand Up @@ -47,6 +48,8 @@ import {Directionality} from '@angular/cdk/bidi';
host: {
'class': 'ng-menu-trigger',
'[attr.tabindex]': '_pattern.tabIndex()',
'[attr.disabled]': '!softDisabled() && _pattern.disabled() ? true : null',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.aria-haspopup]': 'hasPopup()',
'[attr.aria-expanded]': 'expanded()',
'[attr.aria-controls]': '_pattern.menu()?.id()',
Expand Down Expand Up @@ -75,11 +78,18 @@ export class MenuTrigger<V> {
/** Whether the menu trigger has a popup. */
readonly hasPopup = computed(() => this._pattern.hasPopup());

/** Whether the menu trigger is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** Whether the menu trigger is soft disabled. */
readonly softDisabled = input(true, {transform: booleanAttribute});

/** The menu trigger ui pattern instance. */
_pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
textDirection: this.textDirection,
element: computed(() => this._elementRef.nativeElement),
menu: computed(() => this.menu()?._pattern),
disabled: () => this.disabled(),
});

constructor() {
Expand Down Expand Up @@ -122,6 +132,8 @@ export class MenuTrigger<V> {
'role': 'menu',
'class': 'ng-menu',
'[attr.id]': '_pattern.id()',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.tabindex]': 'tabIndex()',
'[attr.data-visible]': 'isVisible()',
'(keydown)': '_pattern.onKeydown($event)',
'(mouseover)': '_pattern.onMouseOver($event)',
Expand Down Expand Up @@ -162,11 +174,14 @@ export class Menu<V> {
readonly id = input<string>(inject(_IdGenerator).getId('ng-menu-', true));

/** Whether the menu should wrap its items. */
readonly wrap = input<boolean>(true);
readonly wrap = input(true, {transform: booleanAttribute});

/** The delay in seconds before the typeahead buffer is cleared. */
readonly typeaheadDelay = input<number>(0.5); // Picked arbitrarily.

/** Whether the menu is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** A reference to the parent menu item or menu trigger. */
readonly parent = signal<MenuTrigger<V> | MenuItem<V> | undefined>(undefined);

Expand All @@ -185,11 +200,14 @@ export class Menu<V> {
/** Whether the menu is visible. */
readonly isVisible = computed(() => this._pattern.isVisible());

/** The tab index of the menu. */
readonly tabIndex = computed(() => this._pattern.tabIndex());

/** A callback function triggered when a menu item is selected. */
onSelect = output<V>();

/** The delay in milliseconds before expanding sub-menus on hover. */
readonly expansionDelay = input<number>(150); // Arbitrarily chosen.
/** The delay in seconds before expanding sub-menus on hover. */
readonly expansionDelay = input<number>(0.1); // Arbitrarily chosen.

constructor() {
this._pattern = new MenuPattern({
Expand Down Expand Up @@ -228,7 +246,7 @@ export class Menu<V> {
});

afterRenderEffect(() => {
if (!this._pattern.hasBeenFocused()) {
if (!this._pattern.hasBeenFocused() && this._items().length) {
untracked(() => this._pattern.setDefaultState());
}
});
Expand All @@ -255,6 +273,9 @@ export class Menu<V> {
host: {
'role': 'menubar',
'class': 'ng-menu-bar',
'[attr.disabled]': '!softDisabled() && _pattern.disabled() ? true : null',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.tabindex]': '_pattern.tabIndex()',
'(keydown)': '_pattern.onKeydown($event)',
'(mouseover)': '_pattern.onMouseOver($event)',
'(click)': '_pattern.onClick($event)',
Expand All @@ -275,14 +296,20 @@ export class MenuBar<V> {
/** A reference to the menubar element. */
readonly element: HTMLElement = this._elementRef.nativeElement;

/** Whether the menubar is disabled. */
readonly disabled = input(false, {transform: booleanAttribute});

/** Whether the menubar is soft disabled. */
readonly softDisabled = input(true, {transform: booleanAttribute});

/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
readonly textDirection = inject(Directionality).valueSignal;

/** The values of the menu. */
readonly values = model<V[]>([]);

/** Whether the menu should wrap its items. */
readonly wrap = input<boolean>(true);
readonly wrap = input(true, {transform: booleanAttribute});

/** The delay in seconds before the typeahead buffer is cleared. */
readonly typeaheadDelay = input<number>(0.5);
Expand Down
10 changes: 7 additions & 3 deletions src/aria/private/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {ModifierKeys} from '@angular/cdk/testing';

// Test types
type TestMenuItem = MenuItemPattern<string> & {
disabled: WritableSignal<boolean>;
submenu: WritableSignal<MenuPattern<string> | undefined>;
inputs: {
disabled: WritableSignal<boolean>;
};
};

// Keyboard event helpers
Expand Down Expand Up @@ -43,6 +44,7 @@ function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) {
textDirection: signal(opts?.textDirection || 'ltr'),
element,
menu: submenu,
disabled: signal(false),
});
return trigger;
}
Expand All @@ -63,6 +65,7 @@ function getMenuBarPattern(values: string[], opts?: {textDirection: 'ltr' | 'rtl
softDisabled: signal(true),
focusMode: signal('activedescendant'),
element: signal(document.createElement('div')),
disabled: signal(false),
});

items.set(
Expand Down Expand Up @@ -106,6 +109,7 @@ function getMenuPattern(
selectionMode: signal('explicit'),
element: signal(document.createElement('div')),
expansionDelay: signal(0),
disabled: signal(false),
});

items.set(
Expand Down Expand Up @@ -275,7 +279,7 @@ describe('Standalone Menu Pattern', () => {

it('should not select a disabled item', () => {
const items = menu.inputs.items() as TestMenuItem[];
items[1].disabled.set(true);
items[1].inputs.disabled.set(true);
menu.inputs.activeItem.set(items[1]);
menu.inputs.onSelect = jasmine.createSpy('onSelect');

Expand Down
47 changes: 35 additions & 12 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {SignalLike} from '../behaviors/signal-like/signal-like';
import {List, ListInputs, ListItem} from '../behaviors/list/list';

/** The inputs for the MenuBarPattern class. */
export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, 'disabled'> {
export interface MenuBarInputs<V> extends ListInputs<MenuItemPattern<V>, V> {
/** The menu items contained in the menu. */
items: SignalLike<MenuItemPattern<V>[]>;

Expand All @@ -24,8 +24,7 @@ export interface MenuBarInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>
}

/** The inputs for the MenuPattern class. */
export interface MenuInputs<V>
extends Omit<ListInputs<MenuItemPattern<V>, V>, 'values' | 'disabled'> {
export interface MenuInputs<V> extends Omit<ListInputs<MenuItemPattern<V>, V>, 'values'> {
/** The unique ID of the menu. */
id: SignalLike<string>;

Expand Down Expand Up @@ -55,6 +54,9 @@ export interface MenuTriggerInputs<V> {

/** The text direction of the menu bar. */
textDirection: SignalLike<'ltr' | 'rtl'>;

/** Whether the menu trigger is disabled. */
disabled: SignalLike<boolean>;
}

/** The inputs for the MenuItemPattern class. */
Expand All @@ -74,6 +76,9 @@ export class MenuPattern<V> {
/** The role of the menu. */
role = () => 'menu';

/** Whether the menu is disabled. */
disabled = () => this.inputs.disabled();

/** Whether the menu is visible. */
isVisible = computed(() => (this.inputs.parent() ? !!this.inputs.parent()?.expanded() : true));

Expand All @@ -92,6 +97,9 @@ export class MenuPattern<V> {
/** Timeout used to close sub-menus on hover out. */
_closeTimeout: any;

/** The tab index of the menu. */
tabIndex = () => this.listBehavior.tabIndex();

/** Whether the menu should be focused on mouse over. */
shouldFocus = computed(() => {
const root = this.root();
Expand Down Expand Up @@ -166,14 +174,13 @@ export class MenuPattern<V> {
this.listBehavior = new List<MenuItemPattern<V>, V>({
...inputs,
values: signal([]),
disabled: () => false,
});
}

/** Sets the default state for the menu. */
setDefaultState() {
if (!this.inputs.parent()) {
this.inputs.activeItem.set(this.inputs.items()[0]);
this.listBehavior.goto(this.inputs.items()[0], {focusElement: false});
}
}

Expand Down Expand Up @@ -225,7 +232,7 @@ export class MenuPattern<V> {
this._closeTimeout = setTimeout(() => {
item.close();
this._closeTimeout = undefined;
}, this.inputs.expansionDelay());
}, this.inputs.expansionDelay() * 1000);
}
}

Expand All @@ -236,7 +243,7 @@ export class MenuPattern<V> {
this._openTimeout = setTimeout(() => {
item.open();
this._openTimeout = undefined;
}, this.inputs.expansionDelay());
}, this.inputs.expansionDelay() * 1000);
}

/** Handles mouseout events for the menu. */
Expand Down Expand Up @@ -445,6 +452,9 @@ export class MenuBarPattern<V> {
/** Controls list behavior for the menu items. */
listBehavior: List<MenuItemPattern<V>, V>;

/** The tab index of the menu. */
tabIndex = () => this.listBehavior.tabIndex();

/** The key used to navigate to the next item. */
private _nextKey = computed(() => {
return this.inputs.textDirection() === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
Expand All @@ -467,6 +477,9 @@ export class MenuBarPattern<V> {
/** Whether the menubar has been focused. */
hasBeenFocused = signal(false);

/** Whether the menubar is disabled. */
disabled = () => this.inputs.disabled();

/** Handles keyboard events for the menu. */
keydownManager = computed(() => {
return new KeyboardEventManager()
Expand All @@ -482,7 +495,7 @@ export class MenuBarPattern<V> {
});

constructor(readonly inputs: MenuBarInputs<V>) {
this.listBehavior = new List<MenuItemPattern<V>, V>({...inputs, disabled: () => false});
this.listBehavior = new List<MenuItemPattern<V>, V>(inputs);
}

/** Sets the default state for the menubar. */
Expand Down Expand Up @@ -598,6 +611,9 @@ export class MenuTriggerPattern<V> {
/** The tab index of the menu trigger. */
tabIndex = computed(() => (this.expanded() && this.menu()?.inputs.activeItem() ? -1 : 0));

/** Whether the menu trigger is disabled. */
disabled = () => this.inputs.disabled();

/** Handles keyboard events for the menu trigger. */
keydownManager = computed(() => {
return new KeyboardEventManager()
Expand All @@ -614,12 +630,16 @@ export class MenuTriggerPattern<V> {

/** Handles keyboard events for the menu trigger. */
onKeydown(event: KeyboardEvent) {
this.keydownManager().handle(event);
if (!this.inputs.disabled()) {
this.keydownManager().handle(event);
}
}

/** Handles click events for the menu trigger. */
onClick() {
this.expanded() ? this.close() : this.open({first: true});
if (!this.inputs.disabled()) {
this.expanded() ? this.close() : this.open({first: true});
}
}

/** Handles focusin events for the menu trigger. */
Expand Down Expand Up @@ -681,7 +701,7 @@ export class MenuItemPattern<V> implements ListItem<V> {
id: SignalLike<string>;

/** Whether the menu item is disabled. */
disabled: SignalLike<boolean>;
disabled = () => this.inputs.parent()?.disabled() || this.inputs.disabled();

/** The search term for the menu item. */
searchTerm: SignalLike<string>;
Expand Down Expand Up @@ -731,14 +751,17 @@ export class MenuItemPattern<V> implements ListItem<V> {
this.id = inputs.id;
this.value = inputs.value;
this.element = inputs.element;
this.disabled = inputs.disabled;
this.submenu = this.inputs.submenu;
this.searchTerm = inputs.searchTerm;
this.selectable = computed(() => !this.submenu());
}

/** Opens the submenu. */
open(opts?: {first?: boolean; last?: boolean}) {
if (this.disabled()) {
return;
}

this._expanded.set(true);

if (opts?.first) {
Expand Down
3 changes: 3 additions & 0 deletions src/components-examples/aria/menu/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export {MenuBarExample} from './menu-bar/menu-bar-example';
export {MenuBarRTLExample} from './menu-bar-rtl/menu-bar-rtl-example';
export {MenuBarDisabledExample} from './menu-bar-disabled/menu-bar-disabled-example';
export {MenuContextExample} from './menu-context/menu-context-example';
export {MenuTriggerExample} from './menu-trigger/menu-trigger-example';
export {MenuTriggerDisabledExample} from './menu-trigger-disabled/menu-trigger-disabled-example';
export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example';
export {MenuStandaloneDisabledExample} from './menu-standalone-disabled/menu-standalone-disabled-example';
Loading
Loading