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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/showcase/src/stories/Menu.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from '@ferrlabs/ui-ng';
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { expect, userEvent, waitFor, within } from 'storybook/test';

const meta: Meta<MenuComponent> = {
title: 'Overlays/Menu',
Expand Down Expand Up @@ -50,3 +51,52 @@ type Story = StoryObj<MenuComponent>;
export const Default: Story = {};

export const AlignEnd: Story = { args: { align: 'end' } };

const keyCodes: Record<string, number> = {
Escape: 27,
End: 35,
Home: 36,
ArrowUp: 38,
ArrowDown: 40,
};

function press(key: string): void {
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', {
key,
keyCode: keyCodes[key],
bubbles: true,
cancelable: true,
}),
);
}

export const KeyboardNavigation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const overlay = within(canvasElement.ownerDocument.body);
const trigger = canvas.getByRole('button', { name: 'Actions' });

trigger.focus();
await userEvent.keyboard('{Enter}');
const first = await overlay.findByRole('menuitem', { name: 'Trigger now' });
await waitFor(() => expect(first).toHaveFocus());

press('ArrowDown');
await expect(overlay.getByRole('menuitem', { name: 'Trigger with overrides' })).toHaveFocus();

press('End');
await expect(overlay.getByRole('menuitem', { name: 'Delete agent' })).toHaveFocus();

press('ArrowDown');
await expect(first).toHaveFocus();

press('Home');
press('ArrowUp');
await expect(overlay.getByRole('menuitem', { name: 'Delete agent' })).toHaveFocus();

press('Escape');
await waitFor(() => expect(overlay.queryByRole('menu')).toBeNull());
await expect(trigger).toHaveFocus();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ interface BrandRow {
<button
type="button"
class="flr-bd__trigger"
[attr.aria-haspopup]="'menu'"
[attr.aria-expanded]="open()"
[title]="triggerTitle()"
(click)="open.set(!open())"
Expand All @@ -71,7 +70,7 @@ interface BrandRow {
</button>

@if (open()) {
<div class="flr-bd__panel" role="menu">
<div class="flr-bd__panel">
@if (!collapsed()) {
<div class="mono flr-bd__eyebrow">Switch app</div>
}
Expand All @@ -86,7 +85,6 @@ interface BrandRow {
[class.flr-bd__app--current]="row.app.id === current()"
[class.flr-bd__app--collapsed]="collapsed()"
[href]="row.app.href"
role="menuitem"
[attr.target]="row.app.newTab ? '_blank' : null"
[attr.rel]="row.app.newTab ? 'noreferrer noopener' : null"
[attr.aria-current]="row.app.id === current() ? 'page' : null"
Expand Down
69 changes: 67 additions & 2 deletions packages/ui-ng/src/lib/menu/menu.component.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
contentChildren,
ElementRef,
inject,
Injector,
input,
model,
output,
viewChild,
ViewEncapsulation,
} from '@angular/core';
import { FocusKeyManager, type FocusableOption } from '@angular/cdk/a11y';
import { type ConnectedPosition, OverlayModule } from '@angular/cdk/overlay';

const FOCUSABLE = 'button, a[href], input, select, textarea, [tabindex]';

export type MenuAlign = 'start' | 'end';
export type MenuDirection = 'down' | 'up';

Expand Down Expand Up @@ -42,14 +49,16 @@ export type MenuDirection = 'down' | 'up';
[cdkConnectedOverlayHasBackdrop]="true"
cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
(backdropClick)="open.set(false)"
(detach)="open.set(false)"
(attach)="onAttach()"
(detach)="onDetach()"
>
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus -->
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
<div
class="flr-menu__panel"
role="menu"
[style.min-width.px]="minWidth()"
(click)="open.set(false)"
(keydown)="onKeydown($event)"
>
<ng-content select="[flr-menu-content]" />
</div>
Expand Down Expand Up @@ -82,6 +91,7 @@ export class MenuComponent {
readonly open = model(false);

private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);

constructor() {
afterRenderEffect(() => {
Expand Down Expand Up @@ -117,6 +127,53 @@ export class MenuComponent {
protected toggle(): void {
this.open.set(!this.open());
}

private readonly items = contentChildren(MenuItemComponent, { descendants: true });

private readonly optionsByItem = new WeakMap<MenuItemComponent, FocusableOption>();

private readonly options = computed<FocusableOption[]>(() =>
this.items().map((item) => {
const option = this.optionsByItem.get(item) ?? {
focus: () => item.focus(),
get disabled() {
return item.disabled();
},
};
this.optionsByItem.set(item, option);
return option;
}),
);

private readonly keyManager = new FocusKeyManager(this.options, this.injector)
.withWrap()
.withHomeAndEnd()
.withTypeAhead()
.skipPredicate((item) => item.disabled === true);

protected onAttach(): void {
afterNextRender(() => this.keyManager.setFirstItemActive(), { injector: this.injector });
}

protected onDetach(): void {
this.open.set(false);
const active = document.activeElement;
if (!active || active === document.body) {
const trigger = this.host.nativeElement.querySelector<HTMLElement>('[flr-menu-trigger]');
const target = trigger?.matches(FOCUSABLE)
? trigger
: trigger?.querySelector<HTMLElement>(FOCUSABLE);
target?.focus();
}
}

protected onKeydown(event: KeyboardEvent): void {
if (event.key === 'Tab') {
this.open.set(false);
return;
}
this.keyManager.onKeydown(event);
}
Comment thread
ferrfleet-interne[bot] marked this conversation as resolved.
}

@Component({
Expand All @@ -130,6 +187,7 @@ export class MenuComponent {
[class.flr-menu-item--danger]="destructive()"
[href]="href()"
role="menuitem"
#control
>
<ng-container [ngTemplateOutlet]="body" />
</a>
Expand All @@ -139,6 +197,7 @@ export class MenuComponent {
class="flr-menu-item"
[class.flr-menu-item--danger]="destructive()"
role="menuitem"
#control
[disabled]="disabled()"
(click)="onClick()"
>
Expand Down Expand Up @@ -233,6 +292,12 @@ export class MenuItemComponent {

readonly selected = output<void>();

private readonly control = viewChild<ElementRef<HTMLElement>>('control');

focus(): void {
this.control()?.nativeElement.focus();
}

protected onClick(): void {
if (this.disabled()) return;
this.selected.emit();
Expand Down
Loading