Skip to content

Commit

Permalink
feat(modal): add more autofocus options (#1324)
Browse files Browse the repository at this point in the history
* fix(modal): prevent close button auto focus

* feat(modal): extend autofocus option

* feat(modal): add autofocus example

* feat(modal): add autofocus example

* feat(modal): add autofocus test

* fix(modal): make initial focused element visible

* fix(modal): make initial focused element visible

* fix(modal): make initial focus work on voiceover

* fix(modal): make initital focus work on voiceover

* fix(modal): fallback focus to dialog

* fix(modal): add note for aria-label on voiceover
  • Loading branch information
vt-allianz authored and GitHub Enterprise committed Oct 22, 2024
1 parent 5866d47 commit a2eb516
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 29 deletions.
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<button
nxButton="small"
class="nx-margin-right-xs"
(click)="openFromTemplate('dialog')"
>
dialog (false)
</button>
<button
nxButton="small"
class="nx-margin-right-xs"
(click)="openFromTemplate('first-tabbable')"
>
first-tabbable (true) - default
</button>
<button
nxButton="small"
class="nx-margin-right-xs"
(click)="openFromTemplate('first-heading')"
>
first-heading
</button>
<button
nxButton="small"
class="nx-margin-right-xs"
(click)="openFromTemplate('.custom-content')"
>
custom selector
</button>

<ng-template #template>
<div nxModalContent>
<h3 nxHeadline="subsection-medium" class="nx-modal-margin-bottom">
Autofocus when the modal is opened
</h3>
<p nxCopytext="small" class="nx-modal-margin-bottom">
Elements inside this modal will receive focus based on the value of
the autofocus property.
</p>
<p class="custom-content">Custom selector</p>
</div>
<div nxModalActions>
<button
nxModalClose
class="nx-margin-bottom-0 nx-margin-right-xs"
nxButton="small secondary"
type="button"
>
Cancel
</button>
<button
nxModalClose
class="nx-margin-bottom-0"
nxButton="small"
type="button"
>
Proceed
</button>
</div>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
ViewChild,
} from '@angular/core';
import { NxButtonComponent } from '@aposin/ng-aquila/button';
import { NxCopytextComponent } from '@aposin/ng-aquila/copytext';
import { NxHeadlineComponent } from '@aposin/ng-aquila/headline';
import {
AutoFocusTarget,
NxDialogService,
NxModalActionsDirective,
NxModalCloseDirective,
NxModalContentDirective,
} from '@aposin/ng-aquila/modal';

/**
* @title Modal autofocus Example
*/
@Component({
selector: 'modal-autofocus-example',
standalone: true,
imports: [
NxButtonComponent,
NxModalContentDirective,
NxHeadlineComponent,
NxCopytextComponent,
NxModalActionsDirective,
NxModalCloseDirective,
],
templateUrl: './modal-autofocus-example.html',
styleUrl: './modal-autofocus-example.css',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ModalAutofocusExampleComponent {
@ViewChild('template') templateRef!: TemplateRef<any>;

constructor(private readonly dialogService: NxDialogService) {}

openFromTemplate(focus: AutoFocusTarget | string): void {
this.dialogService.open(this.templateRef, {
autoFocus: focus,
showCloseIcon: true,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { NxInputModule } from '@aposin/ng-aquila/input';
import { NxModalModule } from '@aposin/ng-aquila/modal';
import { NxPopoverModule } from '@aposin/ng-aquila/popover';

import { ModalAutofocusExampleComponent } from './modal-autofocus/modal-autofocus-example';
import { ModalBasicExampleComponent } from './modal-basic/modal-basic-example';
import { ModalClosingExampleComponent } from './modal-closing/modal-closing-example';
import { ModalClosingBehaviourExampleComponent } from './modal-closing-behaviour/modal-closing-behaviour-example';
Expand All @@ -23,6 +24,7 @@ import { ModalUnsavedExampleComponent } from './modal-unsaved/modal-unsaved-exam
import { ModalWithDirectionExampleComponent } from './modal-with-direction/modal-with-direction-example';

const EXAMPLES = [
ModalAutofocusExampleComponent,
ModalStatusExampleComponent,
ModalUnsavedExampleComponent,
ModalBasicExampleComponent,
Expand Down Expand Up @@ -55,6 +57,7 @@ const EXAMPLES = [
export class ModalExamplesModule {
static components() {
return {
'modal-autofocus': ModalAutofocusExampleComponent,
'modal-status': ModalStatusExampleComponent,
'modal-unsaved': ModalUnsavedExampleComponent,
'modal-basic': ModalBasicExampleComponent,
Expand Down
4 changes: 3 additions & 1 deletion projects/ng-aquila/src/modal/dialog/modal-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export type NxModalRole = 'dialog' | 'alertdialog';

export type NxModalAppearance = 'expert' | 'default';

export type AutoFocusTarget = 'dialog' | 'first-tabbable' | 'first-heading';

/** Possible overrides for a modal's position. */
export interface NxDialogPosition {
/** Override for the modal's top position. */
Expand Down Expand Up @@ -96,7 +98,7 @@ export class NxModalConfig<D = any> {
ariaLabel?: string | null = null;

/** Whether the modal should focus the first focusable element on open. */
autoFocus?: boolean = true;
autoFocus?: boolean | AutoFocusTarget | string = true;

/**
* Whether the modal should restore focus to the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<ng-template cdkPortalOutlet></ng-template>
@if (_config.showCloseIcon) {
<button #closeButton [attr.aria-label]="_config.closeIconButtonLabel" (click)="_closeButtonClick()" class="nx-modal__close" type="button">
<nx-icon name="close" aria-hidden="true"></nx-icon>
</button>
}

<ng-template cdkPortalOutlet></ng-template>
85 changes: 67 additions & 18 deletions projects/ng-aquila/src/modal/dialog/modal-container.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AnimationEvent } from '@angular/animations';
import { FocusMonitor, FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y';
import { FocusMonitor, FocusTrap, FocusTrapFactory, InteractivityChecker } from '@angular/cdk/a11y';
import { _getFocusedElementPierceShadowDom } from '@angular/cdk/platform';
import { BasePortalOutlet, CdkPortalOutlet, ComponentPortal, DomPortal, TemplatePortal } from '@angular/cdk/portal';
import { DOCUMENT } from '@angular/common';
Expand All @@ -13,6 +13,7 @@ import {
EmbeddedViewRef,
EventEmitter,
Inject,
NgZone,
OnDestroy,
OnInit,
Optional,
Expand Down Expand Up @@ -102,6 +103,8 @@ export class NxModalContainer extends BasePortalOutlet implements AfterViewInit,
/** The modal configuration. */
readonly _config: NxModalConfig,
private readonly _focusMonitor: FocusMonitor,
private readonly _interactivityChecker: InteractivityChecker,
protected _ngZone: NgZone,
) {
super();
this._ariaLabelledBy = _config.ariaLabelledBy || null;
Expand Down Expand Up @@ -164,31 +167,79 @@ export class NxModalContainer extends BasePortalOutlet implements AfterViewInit,

/** Moves the focus inside the focus trap. */
private _trapFocus() {
const element = this._elementRef.nativeElement;
const dialog = this._elementRef.nativeElement;

if (!this._focusTrap) {
this._focusTrap = this._focusTrapFactory.create(element);
this._focusTrap = this._focusTrapFactory.create(dialog);
}

// If we were to attempt to focus immediately, then the content of the modal would not yet be
// ready in instances where change detection has to run first. To deal with this, we simply
// wait for the microtask queue to be empty.
if (this._config.autoFocus) {
this._focusTrap.focusInitialElementWhenReady();
} else {
const activeElement = this._document?.activeElement;
const activeElement = this._document?.activeElement;

switch (this._config.autoFocus) {
// Otherwise ensure that focus is on the modal container. It's possible that a different
// component tried to move focus while the open animation was running. See:
// https://github.com/angular/components/issues/16215. Note that we only want to do this
// if the focus isn't inside the modal already, because it's possible that the consumer
// turned off `autoFocus` in order to move focus themselves.
if (activeElement !== element && !element.contains(activeElement)) {
element.focus();
}
case false:
case 'dialog':
if (activeElement !== dialog && !dialog.contains(activeElement)) {
dialog.focus();
}
return;
// If we were to attempt to focus immediately, then the content of the modal would not yet be
// ready in instances where change detection has to run first. To deal with this, we simply
// wait for the microtask queue to be empty.
case true:
case 'first-tabbable':
this._focusTrap.focusInitialElementWhenReady().then(() => {
const focused = dialog?.querySelector('.cdk-focused') as HTMLElement;
if (focused) {
// make focus style appear because it only show on focus vis keyboard
this._focusMonitor.focusVia(focused, 'keyboard');
}
});
break;
case 'first-heading':
this._focusByCssSelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
break;
default:
this._focusByCssSelector(this._config.autoFocus!);
break;
}

// fallback, if no tabbable nor selector then focus on dialog.
const hasTabbable = dialog.contains(document.activeElement);
if (!hasTabbable) {
dialog.focus();
}
}

private _focusByCssSelector(selector: string, options?: FocusOptions) {
const elementToFocus = this._elementRef.nativeElement.querySelector(selector) as HTMLElement | null;
if (elementToFocus) {
this._forceFocus(elementToFocus, options);
}
}

private _forceFocus(element: HTMLElement, options?: FocusOptions) {
if (!this._interactivityChecker.isFocusable(element)) {
element.tabIndex = -1;
// The tabindex attribute should be removed to avoid navigating to that element again
this._ngZone.runOutsideAngular(() => {
const callback = () => {
element.removeEventListener('blur', callback);
element.removeEventListener('mousedown', callback);
element.removeAttribute('tabindex');
};

element.addEventListener('blur', callback);
element.addEventListener('mousedown', callback);
});
}
element.focus(options);
}

/** Restores focus to the element that was focused before the modal opened. */
private _restoreFocus() {
const toFocus = this._elementFocusedBeforeDialogWasOpened;
Expand Down Expand Up @@ -218,13 +269,11 @@ export class NxModalContainer extends BasePortalOutlet implements AfterViewInit,
return;
}
this._elementFocusedBeforeDialogWasOpened = _getFocusedElementPierceShadowDom();

// Note that there is no focus method when rendering on the server.
if (this._elementRef.nativeElement.focus) {
// Move focus onto the modal immediately in order to prevent the user from accidentally
// opening multiple modals at the same time. Needs to be async, because the element
// may not be focusable immediately.
Promise.resolve().then(() => this._elementRef.nativeElement.focus());
// prevent the user from accidentally
// opening multiple modals at the same time.
this._elementFocusedBeforeDialogWasOpened?.blur();
}
}

Expand Down
54 changes: 53 additions & 1 deletion projects/ng-aquila/src/modal/dialog/modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,9 +1122,21 @@ describe('NxDialog', () => {

afterEach(() => document.body.removeChild(overlayContainerElement));

it('should focus the first tabbable element of the dialog on open as default', fakeAsync(() => {
dialog.open(PizzaMsg, {
viewContainerRef: testViewContainerRef,
});

viewContainerFixture.detectChanges();
flushMicrotasks();

expect(document.activeElement!.tagName).withContext('Expected first tabbable element (input) in the dialog to be focused.').toBe('INPUT');
}));

it('should focus the first tabbable element of the dialog on open', fakeAsync(() => {
dialog.open(PizzaMsg, {
viewContainerRef: testViewContainerRef,
autoFocus: 'first-tabbable',
});

viewContainerFixture.detectChanges();
Expand All @@ -1145,6 +1157,46 @@ describe('NxDialog', () => {
expect(document.activeElement!.tagName).not.toBe('INPUT');
}));

it('should focus dialog when set autofocus to dialog ', fakeAsync(() => {
dialog.open(PizzaMsg, {
viewContainerRef: testViewContainerRef,
autoFocus: 'dialog',
});
const container = overlayContainerElement.querySelector('nx-modal-container')!;

viewContainerFixture.detectChanges();
flushMicrotasks();
expect(document.activeElement).withContext('Expected dialog to be focused.').toBe(container);
}));

it('should focus the first heading element of the dialog on open', fakeAsync(() => {
dialog.open(PizzaMsg, {
viewContainerRef: testViewContainerRef,
autoFocus: 'first-heading',
});

viewContainerFixture.detectChanges();
flushMicrotasks();

const firstHeader = overlayContainerElement.querySelector('h1') as HTMLInputElement;

expect(document.activeElement).withContext('Expected first heading element in the dialog to be focused.').toBe(firstHeader);
}));

it('should focus the custom element of the dialog on open', fakeAsync(() => {
dialog.open(PizzaMsg, {
viewContainerRef: testViewContainerRef,
autoFocus: '.custom',
});

viewContainerFixture.detectChanges();
flushMicrotasks();

const customElement = overlayContainerElement.querySelector('.custom') as HTMLInputElement;

expect(document.activeElement).withContext('Expected custom element in the dialog to be focused.').toBe(customElement);
}));

it('should re-focus trigger element when dialog closes', fakeAsync(() => {
// Create a element that has focus before the dialog is opened.
const button = document.createElement('button');
Expand Down Expand Up @@ -1690,7 +1742,7 @@ class ComponentWithTemplateRef {

/** Simple component for testing ComponentPortal. */
@Component({
template: '<p>Pizza</p> <input> <button>Close</button>',
template: '<h1>Header</h1><p>Pizza</p><div class="custom">custom</div><input> <button>Close</button>',
standalone: true,
})
class PizzaMsg {
Expand Down
11 changes: 5 additions & 6 deletions projects/ng-aquila/src/modal/modal.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@
<div class="nx-modal__backdrop" (click)="clickOutsideModal()">
<div class="nx-modal__position">
<div class="nx-modal__container" (click)="cancelClick($event)" [@scaleUpDown]>
@if (showCloseIcon) {
<button #closeButton (click)="closeButtonClick()" [attr.aria-label]="closeButtonLabel" class="nx-modal__close" type="button">
<nx-icon name="close" aria-hidden="true"></nx-icon>
</button>
}

<div class="nx-modal__content-wrapper" cdkScrollable>
@if (body) {
<ng-container *ngTemplateOutlet="body"></ng-container>
} @else {
<ng-content></ng-content>
}
</div>
@if (showCloseIcon) {
<button #closeButton (click)="closeButtonClick()" [attr.aria-label]="closeButtonLabel" class="nx-modal__close" type="button">
<nx-icon name="close" aria-hidden="true"></nx-icon>
</button>
}
</div>
</div>
</div>
Expand Down
Loading

0 comments on commit a2eb516

Please sign in to comment.