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?.();
}