From 66190b47320b13bfb8e068c3d0a21683c7512f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Oest=20Balmer?= Date: Thu, 12 Dec 2024 12:43:53 +0100 Subject: [PATCH] Menu: enable keyboard interaction and improve semantics (#3703) Co-authored-by: RasmusKjeldgaard Co-authored-by: Jakob Engelbrecht --- .../menu-example/examples/advanced.ts | 33 +- .../menu-example/examples/customButton.ts | 16 +- .../menu-example/examples/customPlacement.ts | 9 +- .../examples/menu-example/examples/default.ts | 19 -- .../examples/menu-example/examples/portal.ts | 11 +- .../menu-example/examples/portalConfig.ts | 11 +- .../menu-example/examples/selectable.ts | 15 +- .../menu-example/menu-example.component.html | 2 - .../menu-example/menu-example.module.ts | 2 - .../menu-showcase.component.html | 77 ++++- .../menu-showcase.component.scss | 19 +- .../designsystem/item/src/item.component.scss | 1 + .../designsystem/menu/src/menu.component.html | 15 +- .../menu/src/menu.component.spec.ts | 306 ++++++++++++++++-- libs/designsystem/menu/src/menu.component.ts | 253 ++++++++++++++- 15 files changed, 659 insertions(+), 130 deletions(-) delete mode 100644 apps/cookbook/src/app/examples/menu-example/examples/default.ts diff --git a/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts b/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts index cd549d8ecf..5027f1cb8a 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/advanced.ts @@ -3,10 +3,29 @@ import { Component } from '@angular/core'; const config = { selector: 'cookbook-menu-advanced-example', template: ` - + + + + + Friend Throw + + + + -

Title

- + + + Ice Curling + + +
+ + + + + Allow Cheats + +
`, }; @@ -17,12 +36,4 @@ const config = { }) export class MenuAdvancedExampleComponent { template: string = config.template; - - public actionClicked(): void { - console.log('Action clicked'); - } - - public toggled(): void { - console.log('Toggle changed'); - } } diff --git a/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts b/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts index da6136ab47..a57d97661a 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/customButton.ts @@ -11,7 +11,13 @@ const config = { -

Action 1

+

Stone

+
+ +

Rick

+
+ +

Gooey

`, }; @@ -22,12 +28,4 @@ const config = { }) export class MenuCustomButtonExampleComponent { template: string = config.template; - - public actionClicked(): void { - console.log('Action clicked'); - } - - public toggled(): void { - console.log('Toggle changed'); - } } diff --git a/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts b/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts index 90b7260bf8..1e1e7491e1 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/customPlacement.ts @@ -4,9 +4,14 @@ const config = { selector: 'cookbook-menu-custom-placement-example', template: ` -

Action 1

+

Stone

+
+ +

Rick

+
+ +

Gooey

- ...
`, }; diff --git a/apps/cookbook/src/app/examples/menu-example/examples/default.ts b/apps/cookbook/src/app/examples/menu-example/examples/default.ts deleted file mode 100644 index 91472590fb..0000000000 --- a/apps/cookbook/src/app/examples/menu-example/examples/default.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Component } from '@angular/core'; - -const config = { - selector: 'cookbook-menu-default-example', - template: ` - -

Action 1

-
-
-`, -}; - -@Component({ - selector: config.selector, - template: config.template, -}) -export class MenuDefaultExampleComponent { - template: string = config.template; -} diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portal.ts b/apps/cookbook/src/app/examples/menu-example/examples/portal.ts index c0a192e7b9..c84ff0a691 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/portal.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/portal.ts @@ -5,11 +5,14 @@ const config = { template: ` - -

Action 1

+ +

Stone

- -

Action 2

+ +

Rick

+
+ +

Gooey

`, diff --git a/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts b/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts index e8d8584cd0..8d221d0690 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/portalConfig.ts @@ -4,11 +4,14 @@ import { OutletSelector, PortalOutletConfig } from '@kirbydesign/designsystem/sh const config = { selector: 'cookbook-menu-portal-config-example', template: ` - -

Action 1

+ +

Stone

- -

Action 2

+ +

Rick

+
+ +

Gooey

`, codeSnippet: `public outletConfig: PortalOutletConfig = { diff --git a/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts b/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts index b126b945a3..81dd1a06d7 100644 --- a/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts +++ b/apps/cookbook/src/app/examples/menu-example/examples/selectable.ts @@ -4,11 +4,14 @@ import { ToastConfig, ToastController } from '@kirbydesign/designsystem/toast'; const config = { selector: 'cookbook-menu-selectable-example', template: ` - -

Action 1

+ +

Stone

- -

Action 2

+ +

Rick

+
+ +

Gooey

`, }; @@ -22,9 +25,9 @@ export class MenuSelectableExampleComponent { constructor(private toastController: ToastController) {} - actionClicked(action: string) { + actionClicked(hero: string) { const config: ToastConfig = { - message: `${action} was selected.`, + message: `${hero} was selected as your Hero.`, messageType: 'success', durationInMs: 1500, }; diff --git a/apps/cookbook/src/app/examples/menu-example/menu-example.component.html b/apps/cookbook/src/app/examples/menu-example/menu-example.component.html index cc37540ad4..4347f1e853 100644 --- a/apps/cookbook/src/app/examples/menu-example/menu-example.component.html +++ b/apps/cookbook/src/app/examples/menu-example/menu-example.component.html @@ -1,6 +1,4 @@

Menu

-

Simple

-

Selectable items

Advanced item

diff --git a/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts b/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts index 74bac44f40..00d39d2e3b 100644 --- a/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts +++ b/apps/cookbook/src/app/examples/menu-example/menu-example.module.ts @@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { KirbyModule } from '@kirbydesign/designsystem'; -import { MenuDefaultExampleComponent } from './examples/default'; import { MenuAdvancedExampleComponent } from './examples/advanced'; import { MenuSelectableExampleComponent } from './examples/selectable'; import { PortalInListWrapperComponent as MenuPortalInListWrapperComponent } from './examples/portal-in-list-wrapper'; @@ -13,7 +12,6 @@ import { MenuPortalConfigExampleComponent } from '~/app/examples/menu-example/ex const COMPONENT_DECLARATIONS = [ MenuPortalInListWrapperComponent, - MenuDefaultExampleComponent, MenuAdvancedExampleComponent, MenuSelectableExampleComponent, MenuCustomButtonExampleComponent, diff --git a/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html index d95e81abab..513160584e 100644 --- a/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html +++ b/apps/cookbook/src/app/showcase/menu-showcase/menu-showcase.component.html @@ -26,15 +26,6 @@

Examples

-

Basic

-
-
- - - -
-
-

Selectable items

This example demonstrates how to set custom actions on the elements in the menu.

@@ -48,7 +39,7 @@

Selectable items

Advanced items

Since the Menu accepts Items, it is possible to customize these. The example below demonstrates - how to add a toggle to an Item in the list. + how to add a checkbox and a toggle to an Item in the list.

@@ -275,6 +266,72 @@

+

Accessibility

+

+ The Menu implements the + + Menu Button Pattern + + and the + + Menu and Menubar Pattern + + from ARIA Authoring Practices Guide. +

+

Keyboard support

+

+ The component has full keyboard support for opening the menu and navigating between and selecting + items in the menu. +

+

The following keys can be used to open the menu when the menu button has focus:

+ +
    +
  • + Enter, Space and opens menu and moves focus to first menu item. +
  • +
  • + opens menu and moves focus to last menu item. +
  • +
+

The following keys can be used to navigate and select within the menu:

+ +
    +
  • + Enter and Space activates the menu item and closes the menu. +
  • +
  • + Escape closes the menu. +
  • +
  • + moves focus to the previous menu item. If focus is on the first menu item, moves focus to the last menu item. +
  • +
  • + moves focus to the next menu item. If focus is on the last menu item, moves focus to the first menu item. +
  • +
  • + Home moves focus to the first menu item. +
  • +
  • + End moves focus to the last menu item. +
  • +
  • + A-Z moves focus to the next menu item whose label starts with the typed character. +
  • +
  • + Tab closes the menu and moves focus to the next element in the tab sequence outside the control. +
  • +
+ + +

Properties:

*:first-child { - display: block; - margin-bottom: utils.size('s'); - max-width: 550px; -} - -.page-example { - display: flex; - justify-content: space-between; - margin-top: utils.size('xl'); - margin-bottom: utils.size('xl'); -} +@use '../showcase.shared'; diff --git a/libs/designsystem/item/src/item.component.scss b/libs/designsystem/item/src/item.component.scss index 86040628ea..930d761598 100644 --- a/libs/designsystem/item/src/item.component.scss +++ b/libs/designsystem/item/src/item.component.scss @@ -141,6 +141,7 @@ ion-item { } :host-context(kirby-dropdown) ion-item, +:host-context(kirby-menu) ion-item, :host-context(kirby-popover) ion-item { --min-height: #{utils.$dropdown-item-height}; } diff --git a/libs/designsystem/menu/src/menu.component.html b/libs/designsystem/menu/src/menu.component.html index 9175d23207..d59e1d0676 100644 --- a/libs/designsystem/menu/src/menu.component.html +++ b/libs/designsystem/menu/src/menu.component.html @@ -2,18 +2,23 @@ diff --git a/libs/designsystem/menu/src/menu.component.spec.ts b/libs/designsystem/menu/src/menu.component.spec.ts index 3827126616..a672a818ca 100644 --- a/libs/designsystem/menu/src/menu.component.spec.ts +++ b/libs/designsystem/menu/src/menu.component.spec.ts @@ -6,6 +6,8 @@ import { FloatingDirective } from '@kirbydesign/designsystem/shared/floating'; import { CardModule } from '@kirbydesign/designsystem/card'; import { ToggleComponent } from '@kirbydesign/designsystem/toggle'; import { ItemModule } from '@kirbydesign/designsystem/item'; +import { TestHelper } from '@kirbydesign/designsystem/testing'; +import { CheckboxComponent } from '@kirbydesign/designsystem/checkbox'; import { MenuComponent } from './menu.component'; describe('MenuComponent', () => { @@ -13,17 +15,20 @@ describe('MenuComponent', () => { let buttonElement: HTMLButtonElement; let card: Element; let buttonIcon: IconComponent; + let items: NodeListOf; const createHost = createHostFactory({ component: MenuComponent, - imports: [IconModule, CardModule, ItemModule], - declarations: [ - FloatingDirective, - MockComponent(ButtonComponent), - MockComponent(ToggleComponent), + imports: [ + IconModule, + CardModule, + ItemModule, + TestHelper.ionicModuleForTest, + ToggleComponent, + CheckboxComponent, ], + declarations: [FloatingDirective, MockComponent(ButtonComponent)], }); - describe('by default', () => { beforeEach(() => { spectator = createHost(``, {}); @@ -82,6 +87,15 @@ describe('MenuComponent', () => { it('should have type="button" attribute', () => { expect(buttonElement).toHaveAttribute('type', 'button'); }); + + it('should add aria attributes to default button', () => { + expect(buttonElement.getAttribute('aria-controls')).toEqual(card.id); + expect(buttonElement.getAttribute('aria-haspopup')).toEqual('true'); + }); + + it('should add aria attributes to menu for default button', () => { + expect(card.getAttribute('aria-labelledby')).toEqual(buttonElement.id); + }); }); describe('button-icon', () => { @@ -132,11 +146,33 @@ describe('MenuComponent', () => { }); }); + describe('menu card', () => { + beforeEach(async () => { + spectator = createHost( + ` + +

Action 1

+
+
`, + {} + ); + card = spectator.query('kirby-card'); + }); + + it('should have role=menu', () => { + expect(card.getAttribute('role')).toEqual('menu'); + }); + + it('should have items with role=menuitem', () => { + expect(card.querySelector('kirby-item').getAttribute('role')).toEqual('menuitem'); + }); + }); + describe('interaction', () => { beforeEach(() => { spectator = createHost( ` - +

Action 1

`, @@ -220,32 +256,21 @@ describe('MenuComponent', () => { {} ); buttonIcon = spectator.query(IconComponent); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); }); it('should render a custom button if provided', () => { expect(buttonIcon).toHaveAttribute('name', 'menu-outline'); }); - }); - describe('advanced items', () => { - let toggle: ToggleComponent; - beforeEach(() => { - spectator = createHost( - ` - - -

Title

- -
-
`, - {} - ); - buttonElement = spectator.query('button'); - toggle = spectator.query(ToggleComponent); + it('should add aria attributes to custom button', () => { + expect(buttonElement.getAttribute('aria-controls')).toEqual(card.id); + expect(buttonElement.getAttribute('aria-haspopup')).toEqual('true'); }); - it('should render an advanced kirby item, with interactive elements inside', () => { - expect(toggle).toBeTruthy(); + it('should add aria attributes to menu for custom button', () => { + expect(card.getAttribute('aria-labelledby')).toEqual(buttonElement.id); }); }); @@ -253,7 +278,7 @@ describe('MenuComponent', () => { beforeEach(() => { spectator = createHost( ` - +

Action 1

`, @@ -277,10 +302,10 @@ describe('MenuComponent', () => { beforeEach(() => { spectator = createHost( ` - -

Action 1

-
-
`, + +

Action 1

+
+ `, {} ); @@ -297,4 +322,225 @@ describe('MenuComponent', () => { expect(card).toHaveComputedStyle({ display: 'block' }); }); }); + + describe('keyboard interaction', () => { + describe('with selectable items', () => { + beforeEach(async () => { + spectator = createHost( + ` + +

First Action

+
+ +

Second Action

+
+ +

Second Action 2

+
+ +

Third Action

+
+
`, + {} + ); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + items = card.querySelectorAll('ion-item'); + await TestHelper.whenReady(items); + }); + + it('should add wrapping button to ion-item by default', () => { + items.forEach((item) => expect(item.shadowRoot.querySelector('button')).toExist()); + }); + + it('should set focus on first item when opened by enter', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus to native button within item', () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + + expect(document.activeElement.shadowRoot.activeElement).toEqual( + items[0].shadowRoot.querySelector('button') + ); + }); + + it('should set focus on first item when opened by arrow down', () => { + spectator.keyboard.pressKey('ArrowDown', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus on first item when opened by space', async () => { + spectator.keyboard.pressKey(' ', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus on last item when opened by arrow up', async () => { + spectator.keyboard.pressKey('ArrowUp', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[items.length - 1]); + }); + + it('should set focus to next item when navigating by arrow down', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('ArrowDown', card, 'keydown'); + + expect(document.activeElement).toEqual(items[1]); + }); + + it('should set focus to previous item when navigating by arrow up', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('ArrowDown', card, 'keydown'); + spectator.keyboard.pressKey('ArrowUp', card, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus to first item when focus is on the last item and navigating by arrow down', async () => { + spectator.keyboard.pressKey('ArrowUp', buttonElement, 'keydown'); + expect(document.activeElement).toEqual(items[items.length - 1]); + + spectator.keyboard.pressKey('ArrowDown', card, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus to last item when focus is on the first item and navigating by arrow up', async () => { + spectator.keyboard.pressKey('ArrowDown', buttonElement, 'keydown'); + expect(document.activeElement).toEqual(items[0]); + + spectator.keyboard.pressKey('ArrowUp', card, 'keydown'); + + expect(document.activeElement).toEqual(items[items.length - 1]); + }); + + it('should set focus to last item when navigating by end', async () => { + spectator.keyboard.pressKey('ArrowDown', buttonElement, 'keydown'); + spectator.keyboard.pressKey('End', card, 'keydown'); + + expect(document.activeElement).toEqual(items[items.length - 1]); + }); + + it('should set focus to first item when navigating by home', async () => { + spectator.keyboard.pressKey('ArrowUp', buttonElement, 'keydown'); + spectator.keyboard.pressKey('Home', card, 'keydown'); + + expect(document.activeElement).toEqual(items[0]); + }); + + it('should set focus to trigger button when selecting item', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + + const focusedNativeButton = document.activeElement.shadowRoot.activeElement; + + spectator.click(focusedNativeButton); //Using click instead of enter here since browsers natively interprete enter as a click event + expect(document.activeElement).toEqual(buttonElement); + }); + + it('should set focus to trigger button when pressing escape', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + + spectator.keyboard.pressKey('Escape', card, 'keydown'); //Using click instead of enter here since browsers natively interprete enter as a click event + expect(document.activeElement).toEqual(buttonElement); + }); + + it('should set focus to "third action" when pressing "t"', () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('t', card, 'keydown'); + + expect(document.activeElement).toEqual(items[3]); + }); + + it('should set focus to "second action" when pressing "s"', () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + + expect(document.activeElement).toEqual(items[1]); + }); + + it('should set focus to "second action 2" when pressing "s" twice', () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + + expect(document.activeElement).toEqual(items[2]); + }); + + it('should return focus to "second action" when pressing "s" three times', () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + spectator.keyboard.pressKey('s', card, 'keydown'); + + expect(document.activeElement).toEqual(items[1]); + }); + }); + describe('with interactive element inside items', () => { + describe('keyboard interaction', () => { + beforeEach(async () => { + spectator = createHost( + ` + + + + + + + `, + {} + ); + buttonElement = spectator.query('button'); + card = spectator.query('kirby-card'); + items = card.querySelectorAll('ion-item'); + await TestHelper.whenReady(items); + }); + + it('should set focus on first interactive element inside item when opened by enter', async () => { + spectator.keyboard.pressKey('Enter', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[0].querySelector('ion-checkbox')); + }); + + it('should set focus on last interactive element inside item when opened by arrow up', async () => { + spectator.keyboard.pressKey('ArrowUp', buttonElement, 'keydown'); + + expect(document.activeElement).toEqual(items[1].querySelector('ion-toggle')); + }); + + it('should set focus on next interactive element inside item when navigating by arrow down', async () => { + spectator.keyboard.pressKey('ArrowDown', buttonElement, 'keydown'); + spectator.keyboard.pressKey('ArrowDown', card, 'keydown'); + + expect(document.activeElement).toEqual(items[1].querySelector('ion-toggle')); + }); + }); + + describe('accessibility', () => { + beforeEach(async () => { + spectator = createHost( + ` + + + + + + + `, + {} + ); + card = spectator.query('kirby-card'); + items = card.querySelectorAll('kirby-item'); + }); + + it('should add role="menuitemcheckbox" to items with toggle or checkbox', () => { + expect(items[0].getAttribute('role')).toEqual('menuitemcheckbox'); + expect(items[1].getAttribute('role')).toEqual('menuitemcheckbox'); + }); + }); + }); + }); }); diff --git a/libs/designsystem/menu/src/menu.component.ts b/libs/designsystem/menu/src/menu.component.ts index 21e9675beb..6774637fb6 100644 --- a/libs/designsystem/menu/src/menu.component.ts +++ b/libs/designsystem/menu/src/menu.component.ts @@ -1,20 +1,24 @@ import { CommonModule } from '@angular/common'; import { + AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, + ContentChildren, ElementRef, + HostListener, Input, NgZone, OnDestroy, + QueryList, Renderer2, ViewChild, } from '@angular/core'; import { Placement } from '@floating-ui/dom'; -import { ItemModule } from '@kirbydesign/designsystem/item'; +import { ItemComponent, ItemModule } from '@kirbydesign/designsystem/item'; import { CardModule } from '@kirbydesign/designsystem/card'; import { IconModule } from '@kirbydesign/designsystem/icon'; import { AttentionLevel, ButtonComponent, ButtonSize } from '@kirbydesign/designsystem/button'; @@ -25,6 +29,7 @@ import { TriggerEvent, } from '@kirbydesign/designsystem/shared/floating'; import { EventListenerDisposeFn } from '@kirbydesign/designsystem/types'; +import { UniqueIdGenerator } from '@kirbydesign/designsystem/helpers'; @Component({ selector: 'kirby-menu', @@ -34,9 +39,12 @@ import { EventListenerDisposeFn } from '@kirbydesign/designsystem/types'; styleUrls: ['./menu.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MenuComponent implements AfterViewInit, OnDestroy { +export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy { + readonly menuId: string = UniqueIdGenerator.scopedTo('kirby-menu').next(); + triggerButtonId: string = UniqueIdGenerator.scopedTo('kirby-menu-trigger-button').next(); + constructor( - private cdf: ChangeDetectorRef, + private cdr: ChangeDetectorRef, private elementRef: ElementRef, private zone: NgZone, private renderer: Renderer2 @@ -75,21 +83,192 @@ export class MenuComponent implements AfterViewInit, OnDestroy { public buttonContainerElement: ElementRef | undefined; @ViewChild('defaultButton', { read: ElementRef }) - public defaultButtonElement: ElementRef | undefined; + public defaultButtonElement: ElementRef | undefined; @ContentChild(ButtonComponent, { read: ElementRef }) public userProvidedButton: - | ElementRef + | ElementRef | undefined; @ViewChild(FloatingDirective) - private floatingDirective: FloatingDirective; + private floatingMenu: FloatingDirective; - public FloatingOffset: typeof FloatingOffset = FloatingOffset; + @ContentChildren(ItemComponent, { read: ElementRef }) public kirbyItems: QueryList< + ElementRef + >; + + @ContentChildren(ItemComponent) public kirbyItemComponents: QueryList; + public floatingMenuIsShown: boolean = false; + public FloatingOffset: typeof FloatingOffset = FloatingOffset; private scrollListenerDisposeFn: EventListenerDisposeFn; + private focusedIndex = -1; + + @HostListener('keydown', ['$event']) + _onKeydown(event: KeyboardEvent) { + if (this.kirbyItems.length === 0) { + console.warn('[Kirby] No items found within menu'); + return; + } + if (this.floatingMenuIsShown) { + this.handleKeyDownForOpenedMenu(event); + } else { + this.handleKeyDownForClosedMenu(event); + } + } + + private preventDefaultAndStopImmediatePropagation(event: KeyboardEvent) { + event.stopImmediatePropagation(); + event.preventDefault(); + } + + private getFirstInteractiveElement(el: HTMLIonItemElement) { + return el.querySelector( + 'ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled])' + ); + } + + private handleKeyDownForClosedMenu(event: KeyboardEvent) { + const key = event.key; + switch (key) { + case ' ': + case 'Enter': + case 'ArrowDown': + this.preventDefaultAndStopImmediatePropagation(event); + this.focusedIndex = 0; + this.floatingMenu.show(); + this.focusItem(); + break; + case 'ArrowUp': + this.preventDefaultAndStopImmediatePropagation(event); + this.focusedIndex = this.kirbyItems.length - 1; + this.floatingMenu.show(); + this.focusItem(); + break; + } + } + + private isPrintableCharacter(key: string) { + return key.length === 1 && key.match(/\S/); + } + + private handleKeyDownForOpenedMenu(event: KeyboardEvent) { + const key = event.key; + + switch (key) { + case 'ArrowDown': + this.preventDefaultAndStopImmediatePropagation(event); + if (this.focusedIndex === this.kirbyItems.length - 1) { + this.focusedIndex = 0; + } else { + this.focusedIndex++; + } + this.focusItem(); + break; + case 'ArrowUp': + this.preventDefaultAndStopImmediatePropagation(event); + if (this.focusedIndex === 0) { + this.focusedIndex = this.kirbyItems.length - 1; + } else { + this.focusedIndex--; + } + this.focusItem(); + break; + case 'Home': { + this.preventDefaultAndStopImmediatePropagation(event); + if (this.focusedIndex > 0) { + this.focusedIndex = 0; + this.focusItem(); + } + break; + } + case 'End': { + this.preventDefaultAndStopImmediatePropagation(event); + if (this.focusedIndex < this.kirbyItems.length - 1) { + this.focusedIndex = this.kirbyItems.length - 1; + this.focusItem(); + } + break; + } + case 'Escape': + this.preventDefaultAndStopImmediatePropagation(event); + if (this.closeOnEscapeKey) { + this.floatingMenu.hide(); + } + break; + case 'Tab': + this.floatingMenu.hide(); + break; + default: { + if (this.isPrintableCharacter(key)) { + this.preventDefaultAndStopImmediatePropagation(event); + const foundItemIndex = this.getIndexOfItemByFirstCharacter(key); + if (foundItemIndex > -1) { + this.focusedIndex = foundItemIndex; + this.focusItem(); + } + } + } + } + } + + private getIndexOfItemByFirstCharacter(char: string) { + return this.getIndexByFirstMatchingStartString( + char, + this.kirbyItems.map((item) => item.nativeElement.innerText), + this.focusedIndex + 1 + ); + } + + private getIndexByFirstMatchingStartString( + searchString: string, + words: string[], + startIndex: number + ): number { + searchString = searchString.toLowerCase(); + + const wordsStartingWithMatchString = words + .map((word, index) => { + return { word: word.toLowerCase(), index }; + }) + .filter((match) => match.word.startsWith(searchString)); + + if (wordsStartingWithMatchString.length === 0) { + return -1; + } + + const firstWordStartingWithChar = wordsStartingWithMatchString[0]; + const nextWordStartingWithChar = wordsStartingWithMatchString.find( + (wordAndIndex) => wordAndIndex.index >= startIndex + ); + + return nextWordStartingWithChar?.index ?? firstWordStartingWithChar.index; + } + + focusItem() { + const itemToBeFocused = this.kirbyItems.get(this.focusedIndex); + const ionItem = itemToBeFocused.nativeElement.querySelector('ion-item'); + + // Look for interactive element within ion-item like toggle or checkbox and set focus if found + const firstInteractiveElementWithinItem = this.getFirstInteractiveElement(ionItem); + if (typeof firstInteractiveElementWithinItem?.['setFocus'] === 'function') { + firstInteractiveElementWithinItem['setFocus'](); + } else { + this.focusSelectableItem(ionItem); + } + } + + private focusSelectableItem(ionItem: HTMLIonItemElement) { + const nativeButton: HTMLButtonElement = + ionItem.shadowRoot.querySelector('button:not([disabled])'); + nativeButton?.focus(); + } + + getTriggerButton(): HTMLButtonElement { + return (this.userProvidedButton ?? this.defaultButtonElement).nativeElement; + } public ngAfterViewInit(): void { - this.cdf.detectChanges(); // Sets the updated reference for kirby-floating + this.cdr.detectChanges(); // Sets the updated reference for kirby-floating this.zone.runOutsideAngular(() => { /* @@ -97,11 +276,67 @@ export class MenuComponent implements AfterViewInit, OnDestroy { * avoid a change detection cycle for every scroll-event fired */ this.scrollListenerDisposeFn = this.renderer.listen(document, 'ionScroll', () => { - this.floatingDirective.hide(); + this.floatingMenu.hide(); }); }); } + ngAfterContentInit(): void { + this.setRoleAttributeForAllItems(); + this.setUserProvidedButtonAriaAttributes(); + this.ensureSelectableOnItems(); + } + + ensureSelectableOnItems() { + this.kirbyItemComponents.forEach((itemComponent) => { + if (itemComponent.selectable === undefined) { + itemComponent.selectable = true; + } + }); + } + + private setRoleAttributeForAllItems() { + this.kirbyItems.forEach((item) => { + this.setRoleAttributeForItem(item.nativeElement); + }); + } + + private setRoleAttributeForItem(item: HTMLElement) { + let menuItemRole = 'menuitem'; + if (item.matches(':has(kirby-toggle, kirby-checkbox)')) { + menuItemRole = 'menuitemcheckbox'; + } else if (item.matches(':has(kirby-radio)')) { + menuItemRole = 'menuitemradio'; + } + this.renderer.setAttribute(item, 'role', menuItemRole); + } + + menuVisibilityChanged(menuIsShown: boolean) { + this.floatingMenuIsShown = menuIsShown; + this.renderer.setAttribute(this.getTriggerButton(), 'aria-expanded', menuIsShown.toString()); + if (!menuIsShown) { + this.focusedIndex = -1; + this.getTriggerButton().focus(); + } + } + + private setUserProvidedButtonAriaAttributes() { + if (!this.userProvidedButton) return; + + const button = this.userProvidedButton.nativeElement; + if (button.id) { + this.triggerButtonId = button.id; + } else { + this.renderer.setAttribute(button, 'id', this.triggerButtonId); + } + if (!button.getAttribute('aria-controls')) { + this.renderer.setAttribute(button, 'aria-controls', this.menuId); + } + if (!button.getAttribute('aria-haspopup')) { + this.renderer.setAttribute(button, 'aria-haspopup', 'true'); + } + } + ngOnDestroy(): void { this.scrollListenerDisposeFn?.(); }