diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index bde4adbfa745..c169e01b73e4 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -8,6 +8,7 @@ import { afterRenderEffect, + booleanAttribute, computed, contentChildren, Directive, @@ -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()', @@ -75,11 +78,18 @@ export class MenuTrigger { /** 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 = new MenuTriggerPattern({ textDirection: this.textDirection, element: computed(() => this._elementRef.nativeElement), menu: computed(() => this.menu()?._pattern), + disabled: () => this.disabled(), }); constructor() { @@ -122,6 +132,8 @@ export class MenuTrigger { '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)', @@ -162,11 +174,14 @@ export class Menu { readonly id = input(inject(_IdGenerator).getId('ng-menu-', true)); /** Whether the menu should wrap its items. */ - readonly wrap = input(true); + readonly wrap = input(true, {transform: booleanAttribute}); /** The delay in seconds before the typeahead buffer is cleared. */ readonly typeaheadDelay = input(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 | MenuItem | undefined>(undefined); @@ -185,11 +200,14 @@ export class Menu { /** 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(); - /** The delay in milliseconds before expanding sub-menus on hover. */ - readonly expansionDelay = input(150); // Arbitrarily chosen. + /** The delay in seconds before expanding sub-menus on hover. */ + readonly expansionDelay = input(0.1); // Arbitrarily chosen. constructor() { this._pattern = new MenuPattern({ @@ -228,7 +246,7 @@ export class Menu { }); afterRenderEffect(() => { - if (!this._pattern.hasBeenFocused()) { + if (!this._pattern.hasBeenFocused() && this._items().length) { untracked(() => this._pattern.setDefaultState()); } }); @@ -255,6 +273,9 @@ export class Menu { 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)', @@ -275,6 +296,12 @@ export class MenuBar { /** 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; @@ -282,7 +309,7 @@ export class MenuBar { readonly values = model([]); /** Whether the menu should wrap its items. */ - readonly wrap = input(true); + readonly wrap = input(true, {transform: booleanAttribute}); /** The delay in seconds before the typeahead buffer is cleared. */ readonly typeaheadDelay = input(0.5); diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index d526c25dc533..6fcd8dfde595 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -13,8 +13,9 @@ import {ModifierKeys} from '@angular/cdk/testing'; // Test types type TestMenuItem = MenuItemPattern & { - disabled: WritableSignal; - submenu: WritableSignal | undefined>; + inputs: { + disabled: WritableSignal; + }; }; // Keyboard event helpers @@ -43,6 +44,7 @@ function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) { textDirection: signal(opts?.textDirection || 'ltr'), element, menu: submenu, + disabled: signal(false), }); return trigger; } @@ -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( @@ -106,6 +109,7 @@ function getMenuPattern( selectionMode: signal('explicit'), element: signal(document.createElement('div')), expansionDelay: signal(0), + disabled: signal(false), }); items.set( @@ -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'); diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index b7658bd6d85e..d61e9ffbae9b 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -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 extends Omit, V>, 'disabled'> { +export interface MenuBarInputs extends ListInputs, V> { /** The menu items contained in the menu. */ items: SignalLike[]>; @@ -24,8 +24,7 @@ export interface MenuBarInputs extends Omit, V> } /** The inputs for the MenuPattern class. */ -export interface MenuInputs - extends Omit, V>, 'values' | 'disabled'> { +export interface MenuInputs extends Omit, V>, 'values'> { /** The unique ID of the menu. */ id: SignalLike; @@ -55,6 +54,9 @@ export interface MenuTriggerInputs { /** The text direction of the menu bar. */ textDirection: SignalLike<'ltr' | 'rtl'>; + + /** Whether the menu trigger is disabled. */ + disabled: SignalLike; } /** The inputs for the MenuItemPattern class. */ @@ -74,6 +76,9 @@ export class MenuPattern { /** 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)); @@ -92,6 +97,9 @@ export class MenuPattern { /** 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(); @@ -166,14 +174,13 @@ export class MenuPattern { this.listBehavior = new List, 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}); } } @@ -225,7 +232,7 @@ export class MenuPattern { this._closeTimeout = setTimeout(() => { item.close(); this._closeTimeout = undefined; - }, this.inputs.expansionDelay()); + }, this.inputs.expansionDelay() * 1000); } } @@ -236,7 +243,7 @@ export class MenuPattern { this._openTimeout = setTimeout(() => { item.open(); this._openTimeout = undefined; - }, this.inputs.expansionDelay()); + }, this.inputs.expansionDelay() * 1000); } /** Handles mouseout events for the menu. */ @@ -445,6 +452,9 @@ export class MenuBarPattern { /** Controls list behavior for the menu items. */ listBehavior: List, 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'; @@ -467,6 +477,9 @@ export class MenuBarPattern { /** 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() @@ -482,7 +495,7 @@ export class MenuBarPattern { }); constructor(readonly inputs: MenuBarInputs) { - this.listBehavior = new List, V>({...inputs, disabled: () => false}); + this.listBehavior = new List, V>(inputs); } /** Sets the default state for the menubar. */ @@ -598,6 +611,9 @@ export class MenuTriggerPattern { /** 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() @@ -614,12 +630,16 @@ export class MenuTriggerPattern { /** 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. */ @@ -681,7 +701,7 @@ export class MenuItemPattern implements ListItem { id: SignalLike; /** Whether the menu item is disabled. */ - disabled: SignalLike; + disabled = () => this.inputs.parent()?.disabled() || this.inputs.disabled(); /** The search term for the menu item. */ searchTerm: SignalLike; @@ -731,7 +751,6 @@ export class MenuItemPattern implements ListItem { 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()); @@ -739,6 +758,10 @@ export class MenuItemPattern implements ListItem { /** Opens the submenu. */ open(opts?: {first?: boolean; last?: boolean}) { + if (this.disabled()) { + return; + } + this._expanded.set(true); if (opts?.first) { diff --git a/src/components-examples/aria/menu/index.ts b/src/components-examples/aria/menu/index.ts index eb1cc8bc0c2c..1aacab273cf2 100644 --- a/src/components-examples/aria/menu/index.ts +++ b/src/components-examples/aria/menu/index.ts @@ -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'; diff --git a/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.html b/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.html new file mode 100644 index 000000000000..5a97e99fa8cb --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.html @@ -0,0 +1,324 @@ +
+
File
+ +
+ +
+ article + New + ⌘N +
+ +
+ folder + Open + ⌘O +
+ +
+ file_copy + Make a copy +
+ + + +
+ person_add + Share + arrow_right +
+ +
+ +
+ person_add + Share with others +
+ +
+ public + Publish to web +
+
+
+ +
+ download + Download +
+ +
+ print + Print +
+ + + +
+ edit + Rename +
+ +
+ delete + Move to trash +
+
+
+ +
Edit
+ +
+ +
+ undo + Undo + ⌘Z +
+ +
+ redo + Redo + ⌘Y +
+ + + +
+ content_cut + Cut + ⌘X +
+ +
+ content_copy + Copy + ⌘C +
+ +
+ content_paste + Paste + ⌘V +
+ + + +
+ find_replace + Find and replace + ⇧⌘H +
+
+
+ +
View
+ +
+ +
+ check + Show print layout +
+ +
+ check + Show ruler +
+ + + +
+ Zoom in + ⌘+ +
+ +
+ Zoom out + ⌘- +
+ + + +
+ Full screen +
+
+
+ +
Insert
+ +
+ +
+ image + Image + arrow_right +
+ +
+ +
+ upload + Upload from computer +
+ +
+ search + Search the web +
+ +
+ link + By URL +
+
+
+ +
+ table_chart + Table +
+ +
+ insert_chart + Chart + arrow_right +
+ +
+ +
+ bar_chart + Bar +
+ +
+ insert_chart + Column +
+ +
+ show_chart + Line +
+ +
+ pie_chart + Pie +
+
+
+ +
+ horizontal_rule + Horizontal line +
+
+
+ +
Format
+ +
+ +
+ format_bold + Text + arrow_right +
+ +
+ +
+ format_bold + Bold + ⌘B +
+ +
+ format_italic + Italic + ⌘I +
+ +
+ format_underlined + Underline + ⌘U +
+ +
+ strikethrough_s + Strikethrough + ⇧⌘X +
+ + + +
+ Size + arrow_right +
+ +
+ +
+ Increase font size + ⇧⌘. +
+ +
+ Decrease font size + ⇧⌘, +
+
+
+
+
+ +
+ format_align_justify + Paragraph styles + arrow_right +
+ +
+ +
Normal text
+
Heading 1
+
Heading 2
+
+
+ +
+ format_indent_increase + Align & indent + arrow_right +
+ +
+ +
+ format_align_left + Align left +
+ +
+ format_align_center + Align center +
+ +
+ format_align_right + Align right +
+ +
+ format_align_justify + Justify +
+
+
+
+
+
diff --git a/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.ts b/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.ts new file mode 100644 index 000000000000..9d8b7ce12e9e --- /dev/null +++ b/src/components-examples/aria/menu/menu-bar-disabled/menu-bar-disabled-example.ts @@ -0,0 +1,29 @@ +import {Component} from '@angular/core'; +import { + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemShortcut, + SimpleMenuItemText, +} from '../simple-menu'; +import {MenuContent} from '@angular/aria/menu'; + +/** @title Disabled menu bar example. */ +@Component({ + selector: 'menu-bar-disabled-example', + templateUrl: 'menu-bar-disabled-example.html', + styleUrl: '../menu-example.css', + imports: [ + SimpleMenu, + SimpleMenuBar, + SimpleMenuBarItem, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + SimpleMenuItemShortcut, + MenuContent, + ], +}) +export class MenuBarDisabledExample {} diff --git a/src/components-examples/aria/menu/menu-example.css b/src/components-examples/aria/menu/menu-example.css index 945ebe261fff..6aaf76c9d91a 100644 --- a/src/components-examples/aria/menu/menu-example.css +++ b/src/components-examples/aria/menu/menu-example.css @@ -43,6 +43,13 @@ border-radius: var(--mat-sys-corner-extra-small); } +.example-menu-item[aria-disabled='true'], +.example-menu-trigger[aria-disabled='true'], +.example-menu-bar-item[aria-disabled='true'] { + cursor: default; + opacity: 0.38; +} + .example-menu-heading { display: block; font-weight: bold; @@ -84,11 +91,6 @@ outline: 2px solid var(--mat-sys-primary); } -.example-menu-item[aria-disabled='true'] { - cursor: default; - opacity: 0.38; -} - .example-icon { opacity: 0.875; font-size: 1.25rem; diff --git a/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.html b/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.html new file mode 100644 index 000000000000..9f835ce855c4 --- /dev/null +++ b/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.html @@ -0,0 +1,65 @@ +
+ + SECURITY + +
+
+ lock_open + Change password +
+ +
+ security_key + Two-factor authentication +
+ +
+ refresh + Reset + arrow_right +
+ +
+ +
+ email + Email address +
+ +
+ phone + Phone number +
+ +
+ vpn_key + Password +
+
+
+
+ + + + HELP + +
+
+ help + Support +
+ +
+ feedback + Feedback +
+
+ + + +
+ logout + Logout +
+
+
diff --git a/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.ts b/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.ts new file mode 100644 index 000000000000..8de16e276329 --- /dev/null +++ b/src/components-examples/aria/menu/menu-standalone-disabled/menu-standalone-disabled-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {Menu, MenuContent} from '@angular/aria/menu'; +import {SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +/** + * @title Disabled standalone menu example. + */ +@Component({ + selector: 'menu-standalone-disabled-example', + templateUrl: 'menu-standalone-disabled-example.html', + styleUrl: '../menu-example.css', + imports: [Menu, MenuContent, SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText], +}) +export class MenuStandaloneDisabledExample {} diff --git a/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.html b/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.html new file mode 100644 index 000000000000..571e8cf0dfad --- /dev/null +++ b/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.html @@ -0,0 +1,67 @@ + + +
+ +
+ mark_email_read + Mark as read +
+ +
+ snooze + Snooze +
+ + + +
+ category + Categorize + arrow_right +
+ +
+ +
+ label_important + Mark as important +
+ +
+ star + Star +
+ +
+ label + Label +
+
+
+ + + +
+ archive + Archive +
+ +
+ report + Report spam +
+ +
+ delete + Delete +
+
+
diff --git a/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.ts b/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.ts new file mode 100644 index 000000000000..e8eb4322e67b --- /dev/null +++ b/src/components-examples/aria/menu/menu-trigger-disabled/menu-trigger-disabled-example.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; +import {MenuTrigger, MenuContent} from '@angular/aria/menu'; +import {SimpleMenu, SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +/** @title Menu trigger example. */ +@Component({ + selector: 'menu-trigger-disabled-example', + templateUrl: 'menu-trigger-disabled-example.html', + styleUrl: '../menu-example.css', + imports: [ + MenuContent, + MenuTrigger, + SimpleMenu, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + ], +}) +export class MenuTriggerDisabledExample {} diff --git a/src/components-examples/aria/menu/simple-menu.ts b/src/components-examples/aria/menu/simple-menu.ts index 98930a997a5d..4c0e5e25fbc7 100644 --- a/src/components-examples/aria/menu/simple-menu.ts +++ b/src/components-examples/aria/menu/simple-menu.ts @@ -54,14 +54,14 @@ export class SimpleMenu { @Directive({ selector: '[ng-menu-bar]', - hostDirectives: [{directive: MenuBar}], + hostDirectives: [{directive: MenuBar, inputs: ['disabled']}], host: {class: 'example-menu-bar'}, }) export class SimpleMenuBar {} @Directive({ selector: '[ng-menu-bar-item]', - hostDirectives: [{directive: MenuItem, inputs: ['value', 'submenu']}], + hostDirectives: [{directive: MenuItem, inputs: ['value', 'submenu', 'disabled']}], host: {class: 'example-menu-bar-item'}, }) export class SimpleMenuBarItem { diff --git a/src/dev-app/aria-menu/menu-demo.html b/src/dev-app/aria-menu/menu-demo.html index 0c9cacad17ac..fd8e433f3a13 100644 --- a/src/dev-app/aria-menu/menu-demo.html +++ b/src/dev-app/aria-menu/menu-demo.html @@ -10,16 +10,31 @@

Menu Bar RTL Example

+
+

Disabled Menu Bar Example

+ +
+

Menu Trigger Example

+
+

Disabled Menu Trigger Example

+ +
+

Standalone Menu Example

+
+

Disabled Standalone Menu Example

+ +
+

Context Menu Example

diff --git a/src/dev-app/aria-menu/menu-demo.ts b/src/dev-app/aria-menu/menu-demo.ts index fb7ce444eca9..b9ec74d10240 100644 --- a/src/dev-app/aria-menu/menu-demo.ts +++ b/src/dev-app/aria-menu/menu-demo.ts @@ -10,9 +10,12 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co import { MenuBarExample, MenuBarRTLExample, + MenuBarDisabledExample, MenuContextExample, MenuTriggerExample, MenuStandaloneExample, + MenuStandaloneDisabledExample, + MenuTriggerDisabledExample, } from '@angular/components-examples/aria/menu'; @Component({ @@ -23,9 +26,12 @@ import { imports: [ MenuBarExample, MenuBarRTLExample, + MenuBarDisabledExample, MenuContextExample, MenuTriggerExample, + MenuTriggerDisabledExample, MenuStandaloneExample, + MenuStandaloneDisabledExample, ], }) export class MenuDemo {}