Skip to content

Commit

Permalink
Menu Button Keyboard support
Browse files Browse the repository at this point in the history
  • Loading branch information
Fuzzy3 committed Nov 20, 2024
1 parent 668117b commit 80a4203
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 51 deletions.
22 changes: 14 additions & 8 deletions apps/cookbook/src/app/examples/menu-example/examples/advanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import { Component } from '@angular/core';
const config = {
selector: 'cookbook-menu-advanced-example',
template: `<kirby-menu [closeOnSelect]="false">
<kirby-item [selectable]="true">
<kirby-icon name="notification" slot="start"></kirby-icon>
<h3>Notifications</h3>
<kirby-toggle slot="end"></kirby-toggle>
</kirby-item>
<kirby-item [selectable]="true">
<kirby-item selectable="true">
<kirby-icon name="person" slot="start"></kirby-icon>
<h3>Use face id</h3>
<kirby-checkbox slot="end"></kirby-checkbox>
<kirby-checkbox slot="end">
<kirby-label>
<h3>Face-id</h3>
</kirby-label>
</kirby-checkbox>
</kirby-item>
<kirby-item selectable="true">
<kirby-icon name="notification" slot="start"></kirby-icon>
<kirby-checkbox slot="end">
<kirby-label>
<h3>Notifications</h3>
</kirby-label>
</kirby-checkbox>
</kirby-item>
</kirby-menu>`,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ const config = {
>
<kirby-icon [name]="'menu-outline'"></kirby-icon>
</button>
<kirby-item>
<kirby-item [selectable]="true">
<h3>Action 1</h3>
</kirby-item>
<kirby-item [selectable]="true">
<h3>Action 2</h3>
</kirby-item>
<kirby-item [selectable]="true">
<h3>Action 3</h3>
</kirby-item>
</kirby-menu>`,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component } from '@angular/core';
const config = {
selector: 'cookbook-menu-custom-placement-example',
template: `<kirby-menu [placement]="'bottom-end'">
<kirby-item>
<kirby-item [selectable]="true">
<h3>Action 1</h3>
</kirby-item>
...
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import { Component } from '@angular/core';
const config = {
selector: 'cookbook-menu-default-example',
template: `<kirby-menu>
<kirby-item>
<kirby-item [selectable]="true">
<h3>No Action 1</h3>
</kirby-item>
<kirby-item>
<kirby-item [selectable]="true">
<h3>No Action 2</h3>
</kirby-item>
<kirby-item>
<kirby-item [selectable]="true">
<h3>No Action 3</h3>
</kirby-item>
</kirby-menu>
Expand Down
24 changes: 2 additions & 22 deletions libs/designsystem/item/src/item.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,9 @@ import {
ContentChild,
ElementRef,
HostBinding,
HostListener,
Input,
ViewChild,
} from '@angular/core';

import { IonItem } from '@ionic/angular/standalone';

import { CheckboxComponent } from '@kirbydesign/designsystem/checkbox';
import { RadioComponent } from '@kirbydesign/designsystem/radio';

Expand Down Expand Up @@ -50,15 +46,8 @@ export class ItemComponent {
private checkbox: ElementRef<HTMLElement>;
@ContentChild(RadioComponent, { static: false, read: ElementRef })
private radio: ElementRef<HTMLElement>;
@ViewChild(IonItem, { static: true })
ionItem;

@HostListener('focus')
onFocus() {
this.focusItem(this.ionItem.el);
}

constructor(public element: ElementRef) {}
constructor(public el: ElementRef) {}

// Prevent default when inside kirby-dropdown to avoid blurring dropdown:
onMouseDown(event: MouseEvent) {
Expand All @@ -70,18 +59,9 @@ export class ItemComponent {
}
}

/** Focus the internal button of the ion-item */
focusItem(item: HTMLIonItemElement) {
const root = item.shadowRoot;
const button = root.querySelector('button');
if (button) {
button.focus();
}
}

get _isIonicButton() {
// Ionic checks for slotted checkbox and radio
// and we shouldn't set the `button` prop in that scenario:
return this.selectable; // && !(this.checkbox || this.radio);
return this.selectable && !(this.checkbox || this.radio); //Add toggle
}
}
11 changes: 8 additions & 3 deletions libs/designsystem/menu/src/menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@
<ng-content select="button[kirby-button]"></ng-content>
<ng-container *ngIf="!userProvidedButton">
<button
#defaultButton
id="defaultButton"
kirby-button
[size]="buttonSize"
[disabled]="isDisabled"
type="button"
[attentionLevel]="attentionLevel"
aria-haspopup="true"
[attr.aria-expanded]="floatingMenuIsShown"
#defaultButton
[attr.aria-controls]="MENU_ID"
aria-expanded="false"
>
<kirby-icon [name]="'more'"></kirby-icon>
</button>
</ng-container>
</div>
<kirby-card
[id]="MENU_ID"
kirbyFloating
[strategy]="'fixed'"
[reference]="buttonContainerElement"
Expand All @@ -34,7 +37,9 @@
}"
class="menu-popover"
(keydown)="_onKeydown($event)"
(displayChanged)="floatingMenuIsShown = $event"
(displayChanged)="menuVisibilityChanged($event)"
role="menu"
[attr.aria-labelledby]="triggerButtonId"
>
<ng-content select="kirby-item"></ng-content>
</kirby-card>
92 changes: 79 additions & 13 deletions libs/designsystem/menu/src/menu.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ import { EventListenerDisposeFn } from '@kirbydesign/designsystem/types';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy {
readonly MENU_ID: string = 'MENU_ID';
triggerButtonId: string = 'DEFAULT_BUTTON';

constructor(
private cdr: ChangeDetectorRef,
private elementRef: ElementRef<HTMLElement>,
Expand Down Expand Up @@ -79,10 +82,10 @@ export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy
public buttonContainerElement: ElementRef<HTMLElement> | undefined;

@ViewChild('defaultButton', { read: ElementRef })
public defaultButtonElement: ElementRef<HTMLElement> | undefined;
public defaultButtonElement: ElementRef<HTMLButtonElement> | undefined;

@ContentChild(ButtonComponent, { read: ElementRef }) public userProvidedButton:
| ElementRef<HTMLElement>
| ElementRef<HTMLButtonElement>
| undefined;

@ViewChild(FloatingDirective)
Expand Down Expand Up @@ -111,6 +114,14 @@ export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy
event.preventDefault();
}

private getFirstInteractiveElement(el: HTMLIonItemElement) {
const controls = el.querySelectorAll<
HTMLIonToggleElement | HTMLIonRadioElement | HTMLIonCheckboxElement
>('ion-toggle:not([disabled]), ion-checkbox:not([disabled]), ion-radio:not([disabled])');

return controls[0] || undefined;
}

private handleOnKeyForClosedMenu(event: KeyboardEvent) {
const key = event.key;
switch (key) {
Expand Down Expand Up @@ -152,12 +163,12 @@ export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy
break;
case 'Escape':
if (this.closeOnEscapeKey) {
this.closeMenu();
this._floatingMenu.hide();
}
this.preventFurtherPropagation(event);
break;
case 'Tab':
this.closeMenu();
this._floatingMenu.hide();
break;
}
}
Expand All @@ -166,22 +177,32 @@ export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy
this.getTriggerButton().nativeElement.focus();
}

private closeMenu() {
this.resetFocus();
this._floatingMenu.hide();
this.focusTriggerButton();
}

resetFocus() {
this.focusedIndex = -1;
}

focusItem() {
const itemToBeFocused = this.kirbyItems.get(this.focusedIndex);
itemToBeFocused.nativeElement.focus();
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 (firstInteractiveElementWithinItem) {
firstInteractiveElementWithinItem['setFocus']();
} else {
this.focusSelectableItem(ionItem);
}
}

private focusSelectableItem(ionItem: HTMLIonItemElement) {
const selectableItem: HTMLButtonElement =
ionItem.shadowRoot.querySelector('button:not([disabled])');
if (selectableItem) {
selectableItem.focus();
}
}

getTriggerButton(): ElementRef<HTMLElement> {
getTriggerButton(): ElementRef<HTMLButtonElement> {
return this.userProvidedButton ?? this.defaultButtonElement;
}

Expand All @@ -200,7 +221,52 @@ export class MenuComponent implements AfterViewInit, AfterContentInit, OnDestroy
}

ngAfterContentInit(): void {
this.kirbyItems.forEach((item) => item.nativeElement.setAttribute('tabindex', '-1'));
this.setupAccessibilityForItems();
this.setupAccesibilityForUserProvidedButton();
}

private setupAccessibilityForItems() {
this.kirbyItems.forEach((item) => {
item.nativeElement.setAttribute('role', 'menuitem');
});
}

menuVisibilityChanged(menuIsShown: boolean) {
this.floatingMenuIsShown = menuIsShown;
this.getTriggerButton().nativeElement.setAttribute('aria-expanded', menuIsShown + '');
if (!menuIsShown) {
this.resetFocus();
this.focusTriggerButton();
}
}

private setupAccesibilityForUserProvidedButton() {
if (this.userProvidedButton) {
const element = this.userProvidedButton.nativeElement;

this.setupAriaHasPopup(element);
this.setupAriaControls(element);
this.setupId(element);
}
}

private setupId(button: HTMLButtonElement) {
if (!button.id) {
button.id = 'userProvidedButton';
}
this.triggerButtonId = button.id;
}

private setupAriaControls(button: HTMLButtonElement) {
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', this.MENU_ID);
}
}

private setupAriaHasPopup(button: HTMLButtonElement) {
if (!button.getAttribute('aria-haspopup')) {
button.setAttribute('aria-haspopup', 'true');
}
}

ngOnDestroy(): void {
Expand Down

0 comments on commit 80a4203

Please sign in to comment.