Skip to content

Commit

Permalink
feat(modal): add [ngbAutofocus] option (#2737)
Browse files Browse the repository at this point in the history
Closes #938
Closes #2718
Closes #2728
  • Loading branch information
Benoit Charbonnier authored and pkozlowski-opensource committed Oct 5, 2018
1 parent c585124 commit 10fd5e4
Show file tree
Hide file tree
Showing 8 changed files with 199 additions and 29 deletions.
17 changes: 17 additions & 0 deletions demo/src/app/components/modal/demos/focus/modal-focus.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<p>First focusable element within the modal window will receive focus upon opening.
This could be configured to focus any other element by adding an <code>ngbAutofocus</code> attribute on it.</p>

<pre class="language-html"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>button</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation">=</span><span class="token punctuation">"</span>button<span class="token punctuation">"</span></span> <span class="token attr-name">ngbAutofocus</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation">=</span><span class="token punctuation">"</span>btn btn-danger<span class="token punctuation">"</span></span>
<span class="token attr-name">(click)</span><span class="token attr-value"><span class="token punctuation">=</span><span class="token punctuation">"</span>modal.close(<span class="token punctuation">'</span>Ok click<span class="token punctuation">'</span>)<span class="token punctuation">"</span></span><span class="token punctuation">&gt;</span></span>Ok<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>button</span><span class="token punctuation">&gt;</span></span></code></pre>

<br />
<button class="btn btn-outline-primary mr-2" (click)="open('focusFirst')">
<div>Open confirm modal</div>
<div class="text-dark" aria-hidden="true"><small>&times; button will be focused</small></div>
</button>

<button class="btn btn-outline-primary" (click)="open('autofocus')">
<div>Open confirm modal with `ngbAutofocus`</div>
<div class="text-dark" aria-hidden="true"><small>Ok button will be focused</small></div>
</button>

72 changes: 72 additions & 0 deletions demo/src/app/components/modal/demos/focus/modal-focus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Component } from '@angular/core';
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap';

@Component({
selector: 'ngbd-modal-confirm',
template: `
<div class="modal-header">
<h4 class="modal-title" id="modal-title">Profile deletion</h4>
<button type="button" class="close" aria-describedby="modal-title" (click)="modal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><strong>Are you sure you want to delete <span class="text-primary">"John Doe"</span> profile?</strong></p>
<p>All information associated to this user profile will be permanently deleted.
<span class="text-danger">This operation can not be undone.</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="modal.dismiss('cancel click')">Cancel</button>
<button type="button" class="btn btn-danger" (click)="modal.close('Ok click')">Ok</button>
</div>
`
})
export class NgbdModalConfirm {
constructor(public modal: NgbActiveModal) {}
}

@Component({
selector: 'ngbd-modal-confirm-autofocus',
template: `
<div class="modal-header">
<h4 class="modal-title" id="modal-title">Profile deletion</h4>
<button type="button" class="close" aria-label="Close button" aria-describedby="modal-title" (click)="modal.dismiss('Cross click')">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p><strong>Are you sure you want to delete <span class="text-primary">"John Doe"</span> profile?</strong></p>
<p>All information associated to this user profile will be permanently deleted.
<span class="text-danger">This operation can not be undone.</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="modal.dismiss('cancel click')">Cancel</button>
<button type="button" ngbAutofocus class="btn btn-danger" (click)="modal.close('Ok click')">Ok</button>
</div>
`
})
export class NgbdModalConfirmAutofocus {
constructor(public modal: NgbActiveModal) {}
}

const MODALS = {
focusFirst: NgbdModalConfirm,
autofocus: NgbdModalConfirmAutofocus
};

@Component({
selector: 'ngbd-modal-focus',
templateUrl: './modal-focus.html'
})
export class NgbdModalFocus {
withAutofocus = `<button type="button" ngbAutofocus class="btn btn-danger"
(click)="modal.close('Ok click')">Ok</button>`;

constructor(private _modalService: NgbModal) {}

open(name: string) {
this._modalService.open(MODALS[name]);
}
}
39 changes: 32 additions & 7 deletions demo/src/app/components/modal/modal.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import { NgbdApiPage } from '../shared/api-page/api.component';
import { NgbdExamplesPage } from '../shared/examples-page/examples.component';
import { NgbdModalBasic } from './demos/basic/modal-basic';
import { NgbdModalComponent, NgbdModalContent } from './demos/component/modal-component';
import { NgbdModalConfig } from './demos/config/modal-config';
import { NgbdModalConfirm, NgbdModalConfirmAutofocus, NgbdModalFocus } from './demos/focus/modal-focus';
import { NgbdModalOptions } from './demos/options/modal-options';
import { NgbdModal1Content, NgbdModal2Content, NgbdModalStacked } from './demos/stacked/modal-stacked';
import { NgbdModalConfig } from './demos/config/modal-config';

const DEMO_DIRECTIVES = [NgbdModalBasic, NgbdModalComponent, NgbdModalOptions, NgbdModalStacked, NgbdModalConfig];
const DEMO_DIRECTIVES = [
NgbdModalBasic,
NgbdModalComponent,
NgbdModalOptions,
NgbdModalStacked,
NgbdModalConfig,
NgbdModalFocus
];

const DEMOS = {
basic: {
Expand All @@ -26,6 +34,12 @@ const DEMOS = {
code: require('!!raw-loader!./demos/component/modal-component'),
markup: require('!!raw-loader!./demos/component/modal-component.html')
},
focus: {
title: 'Focus management',
type: NgbdModalFocus,
code: require('!!raw-loader!./demos/focus/modal-focus'),
markup: require('!!raw-loader!./demos/focus/modal-focus.html')
},
options: {
title: 'Modal with options',
type: NgbdModalOptions,
Expand Down Expand Up @@ -59,12 +73,23 @@ export const ROUTES = [
];

@NgModule({
imports: [
NgbdSharedModule,
NgbdComponentsSharedModule
imports: [NgbdSharedModule, NgbdComponentsSharedModule],
declarations: [
NgbdModalContent,
NgbdModal1Content,
NgbdModal2Content,
NgbdModalConfirm,
NgbdModalConfirmAutofocus,
...DEMO_DIRECTIVES
],
declarations: [NgbdModalContent, NgbdModal1Content, NgbdModal2Content, ...DEMO_DIRECTIVES],
entryComponents: [NgbdModalContent, NgbdModal1Content, NgbdModal2Content, ...DEMO_DIRECTIVES]
entryComponents: [
NgbdModalContent,
NgbdModal1Content,
NgbdModal2Content,
NgbdModalConfirm,
NgbdModalConfirmAutofocus,
...DEMO_DIRECTIVES
]
})
export class NgbdModalModule {
constructor(demoList: NgbdDemoList) {
Expand Down
16 changes: 9 additions & 7 deletions misc/stackblitz-gen.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const versions = getVersions();

const ENTRY_CMPTS = {
'modal-component': ['NgbdModalContent'],
'modal-stacked': ['NgbdModal1Content', 'NgbdModal2Content']
'modal-stacked': ['NgbdModal1Content', 'NgbdModal2Content'],
'modal-focus': ['NgbdModalConfirm', 'NgbdModalConfirmAutofocus']
};

function generateDemosCSS() {
Expand All @@ -29,7 +30,7 @@ function generateStackblitzContent(componentName, demoName) {
<body>
<form id="mainForm" method="post" action="${stackblitzUrl}">
<input type="hidden" name="description" value="Example usage of the ${componentName} widget from https://ng-bootstrap.github.io">
${generateTags(['Angular', 'Bootstrap', 'ng-bootstrap', capitalize(componentName)])}
${generateTags(['Angular', 'Bootstrap', 'ng-bootstrap', capitalize(componentName)])}
<input type="hidden" name="files[.angular-cli.json]" value="${he.encode(getStackblitzTemplate('.angular-cli.json'))}">
<input type="hidden" name="files[index.html]" value="${he.encode(generateIndexHtml())}">
Expand All @@ -41,7 +42,7 @@ ${generateTags(['Angular', 'Bootstrap', 'ng-bootstrap', capitalize(componentName
<input type="hidden" name="files[app/app.component.html]" value="${he.encode(generateAppComponentHtmlContent(componentName, demoName))}">
<input type="hidden" name="files[app/${fileName}.ts]" value="${he.encode(codeContent)}">
<input type="hidden" name="files[app/${fileName}.html]" value="${he.encode(markupContent)}">
<input type="hidden" name="dependencies" value="${he.encode(JSON.stringify(generateDependencies()))}">
</form>
<script>document.getElementById("mainForm").submit();</script>
Expand All @@ -60,12 +61,13 @@ function generateIndexHtml() {
<head>
<title>ng-bootstrap demo</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/${versions.bootstrap}/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.15.0/themes/prism.css" />
</head>
<body>
<my-app>loading...</my-app>
</body>
</html>`;
}

Expand All @@ -74,7 +76,7 @@ function generateAppComponentHtmlContent(componentName, demoName) {

return `
<div class="container-fluid">
<hr>
<p>
Expand Down Expand Up @@ -110,10 +112,10 @@ import { AppComponent } from './app.component';
import { ${demoImports} } from '${demoImport}';
@NgModule({
imports: [BrowserModule, FormsModule, ReactiveFormsModule, HttpClientModule, NgbModule.forRoot()],
imports: [BrowserModule, FormsModule, ReactiveFormsModule, HttpClientModule, NgbModule],
declarations: [AppComponent, ${demoImports}]${entryCmptClasses ? `,\n entryComponents: [${entryCmptClasses}],` : ','}
bootstrap: [AppComponent]
})
})
export class AppModule {}
`;
}
Expand Down
20 changes: 12 additions & 8 deletions src/modal/modal-window.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import {DOCUMENT} from '@angular/common';
import {
AfterViewInit,
Component,
Output,
ElementRef,
EventEmitter,
Input,
Inject,
ElementRef,
Input,
OnDestroy,
OnInit,
AfterViewInit,
OnDestroy
Output
} from '@angular/core';

import {getFocusableBoundaryElements} from '../util/focus-trap';
import {ModalDismissReasons} from './modal-dismiss-reasons';

@Component({
Expand Down Expand Up @@ -62,7 +63,11 @@ export class NgbModalWindow implements OnInit,

ngAfterViewInit() {
if (!this._elRef.nativeElement.contains(document.activeElement)) {
this._elRef.nativeElement['focus'].apply(this._elRef.nativeElement, []);
const autoFocusable = this._elRef.nativeElement.querySelector(`[ngbAutofocus]`) as HTMLElement;
const firstFocusable = getFocusableBoundaryElements(this._elRef.nativeElement)[0];

const elementToFocus = autoFocusable || firstFocusable || this._elRef.nativeElement;
elementToFocus.focus();
}
}

Expand All @@ -76,8 +81,7 @@ export class NgbModalWindow implements OnInit,
} else {
elementToFocus = body;
}
elementToFocus['focus'].apply(elementToFocus, []);

elementToFocus.focus();
this._elWithFocus = null;
}
}
4 changes: 2 additions & 2 deletions src/modal/modal.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import {NgModule, ModuleWithProviders} from '@angular/core';
import {ModuleWithProviders, NgModule} from '@angular/core';

import {NgbModal} from './modal';
import {NgbModalBackdrop} from './modal-backdrop';
import {NgbModalWindow} from './modal-window';
import {NgbModal} from './modal';

export {NgbModal} from './modal';
export {NgbModalConfig, NgbModalOptions} from './modal-config';
Expand Down
57 changes: 53 additions & 4 deletions src/modal/modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,14 +675,13 @@ describe('ngb-modal', () => {

describe('focus management', () => {

it('should focus modal window and return focus to previously focused element', () => {
it('should return focus to previously focused element', () => {
fixture.detectChanges();
const openButtonEl = fixture.nativeElement.querySelector('button#open');
openButtonEl.focus();
openButtonEl.click();
fixture.detectChanges();
expect(fixture.nativeElement).toHaveModal('from button');
expect(document.activeElement).toBe(document.querySelector('ngb-modal-window'));

fixture.componentInstance.close();
expect(fixture.nativeElement).not.toHaveModal();
Expand Down Expand Up @@ -727,6 +726,37 @@ describe('ngb-modal', () => {
expect(fixture.nativeElement).not.toHaveModal();
expect(document.activeElement).toBe(document.body);
});

describe('initial focus', () => {
it('should focus the proper specified element when [ngbAutofocus] is used', () => {
fixture.detectChanges();
const modal = fixture.componentInstance.openCmpt(WithAutofocusModalCmpt);
fixture.detectChanges();

expect(document.activeElement).toBe(document.querySelector('button.withNgbAutofocus'));
modal.close();
});

it('should focus the first focusable element when [ngbAutofocus] is not used', () => {
fixture.detectChanges();
const modal = fixture.componentInstance.openCmpt(WithFirstFocusableModalCmpt);
fixture.detectChanges();

expect(document.activeElement).toBe(document.querySelector('button.firstFocusable'));
modal.close();
fixture.detectChanges();
});

it('should focus modal window as a default fallback option', () => {
fixture.detectChanges();
const modal = fixture.componentInstance.open('content');
fixture.detectChanges();

expect(document.activeElement).toBe(document.querySelector('ngb-modal-window'));
modal.close();
fixture.detectChanges();
});
});
});

describe('window element ordering', () => {
Expand Down Expand Up @@ -837,6 +867,21 @@ export class WithActiveModalCmpt {
close() { this.activeModal.close('from inside'); }
}

@Component(
{selector: 'modal-autofocus-cmpt', template: `<button class="withNgbAutofocus" ngbAutofocus>Click Me</button>`})
export class WithAutofocusModalCmpt {
}

@Component({
selector: 'modal-firstfocusable-cmpt',
template: `
<button class="firstFocusable close">Close</button>
<button class="other">Other button</button>
`
})
export class WithFirstFocusableModalCmpt {
}

@Component({
selector: 'test-cmpt',
template: `
Expand Down Expand Up @@ -902,10 +947,14 @@ class TestComponent {
}

@NgModule({
declarations: [TestComponent, CustomInjectorCmpt, DestroyableCmpt, WithActiveModalCmpt],
declarations: [
TestComponent, CustomInjectorCmpt, DestroyableCmpt, WithActiveModalCmpt, WithAutofocusModalCmpt,
WithFirstFocusableModalCmpt
],
exports: [TestComponent, DestroyableCmpt],
imports: [CommonModule, NgbModalModule],
entryComponents: [CustomInjectorCmpt, DestroyableCmpt, WithActiveModalCmpt],
entryComponents:
[CustomInjectorCmpt, DestroyableCmpt, WithActiveModalCmpt, WithAutofocusModalCmpt, WithFirstFocusableModalCmpt],
providers: [SpyService]
})
class NgbModalTestModule {
Expand Down
3 changes: 2 additions & 1 deletion src/util/focus-trap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {fromEvent, Observable} from 'rxjs';
import {filter, map, takeUntil, withLatestFrom} from 'rxjs/operators';

import {Key} from '../util/key';

const FOCUSABLE_ELEMENTS_SELECTOR = [
Expand All @@ -10,7 +11,7 @@ const FOCUSABLE_ELEMENTS_SELECTOR = [
/**
* Returns first and last focusable elements inside of a given element based on specific CSS selector
*/
function getFocusableBoundaryElements(element: HTMLElement): HTMLElement[] {
export function getFocusableBoundaryElements(element: HTMLElement): HTMLElement[] {
const list: NodeListOf<HTMLElement> = element.querySelectorAll(FOCUSABLE_ELEMENTS_SELECTOR);
return [list[0], list[list.length - 1]];
}
Expand Down

0 comments on commit 10fd5e4

Please sign in to comment.