Skip to content
This repository has been archived by the owner on Dec 8, 2022. It is now read-only.

Modal tab trap #911

Merged
merged 14 commits into from
Jul 21, 2017
Merged
Show file tree
Hide file tree
Changes from 13 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
13 changes: 13 additions & 0 deletions src/app/components/modal/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@
Specifies a size for the modal. The valid options are <code>small</code>,
<code>medium</code>, and <code>large</code>.
</sky-demo-page-property>

<sky-demo-page-property
propertyName="ariaLabelledBy?"
isOptional="true"
>
Sets the <code>aria-labelledby</code> attribute for the modal dialog for accessibility support. The value should be an id (without the leading <code>#</code>) pointing to the element that labels your modal. Typically, this will be a header element. If not provided, this will default to the content of the <code>sky-modal-header</code> component.
</sky-demo-page-property>
<sky-demo-page-property
propertyName="ariaDescribedBy?"
isOptional="true"
>
Sets the <code>aria-describedby</code> attribute for the modal dialog for accessibility support. The value should be an id (without the leading <code>#</code>) pointing to the element that describes your modal. Typically, this will be the text on your modal, but does not include something the user would interact with, like buttons or a form. If not provided, this will default to the content of the <code>sky-modal-content</code> component.
</sky-demo-page-property>
</sky-demo-page-properties>

<sky-demo-page-properties sectionHeading="Modal component properties">
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/modal/modal-demo-form.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
{{title}}
</sky-modal-header>
<sky-modal-content>
<div id="docs-modal-content">
This modal can have content!
</div>
<label>
Value A
<input type="text" [(ngModel)]="context.valueA">
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/modal/modal-demo.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export class SkyModalDemoComponent {
options.size = 'large';
}

options.ariaDescribedBy = 'docs-modal-content';

let modalInstance = this.modal.open(SkyModalDemoFormComponent, options);

modalInstance.closed.subscribe((result: SkyModalCloseArgs) => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/learn/get-started/8.-unit-test-modals/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@
useValue: new MockModalHostService()
},
{
provide: SkyModalConfiguation,
provide: SkyModalConfiguration,
useValue: new MockModalConfiguration()
}
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<sky-modal>
<sky-modal-header>Test</sky-modal-header>
<sky-modal-content>Content <input id="autofocus-el" autofocus/></sky-modal-content>
</sky-modal>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';

@Component({
selector: 'sky-test-cmp',
templateUrl: './modal-autofocus.component.fixture.html'
})
export class ModalAutofocusTestComponent {

}
13 changes: 11 additions & 2 deletions src/modules/modal/fixtures/modal-fixtures.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,28 @@ import { CommonModule } from '@angular/common';
import { SkyModalModule } from '../modal.module';
import { ModalTestComponent } from './modal.component.fixture';
import { ModalWithValuesTestComponent } from './modal-with-values.component.fixture';
import { ModalAutofocusTestComponent } from './modal-autofocus.component.fixture';
import { ModalFooterTestComponent } from './modal-footer.component.fixture';
import { ModalNoHeaderTestComponent } from './modal-no-header.component.fixture';

@NgModule({
declarations: [
ModalTestComponent,
ModalWithValuesTestComponent
ModalWithValuesTestComponent,
ModalAutofocusTestComponent,
ModalFooterTestComponent,
ModalNoHeaderTestComponent
],
imports: [
CommonModule,
SkyModalModule
],
entryComponents: [
ModalTestComponent,
ModalWithValuesTestComponent
ModalWithValuesTestComponent,
ModalAutofocusTestComponent,
ModalFooterTestComponent,
ModalNoHeaderTestComponent
]
})
export class SkyModalFixturesModule { }
13 changes: 13 additions & 0 deletions src/modules/modal/fixtures/modal-footer.component.fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<sky-modal>
<sky-modal-header>Test</sky-modal-header>
<sky-modal-content>Content <input /></sky-modal-content>
<sky-modal-footer>
<button
type="button"
class="sky-btn sky-btn-primary"
>
Close
</button>
</sky-modal-footer>

</sky-modal>
9 changes: 9 additions & 0 deletions src/modules/modal/fixtures/modal-footer.component.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';

@Component({
selector: 'sky-test-cmp',
templateUrl: './modal-footer.component.fixture.html'
})
export class ModalFooterTestComponent {

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<sky-modal>
<sky-modal-content>Content</sky-modal-content>
</sky-modal>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';

@Component({
selector: 'sky-test-cmp',
templateUrl: './modal-no-header.component.fixture.html'
})
export class ModalNoHeaderTestComponent {

}
4 changes: 4 additions & 0 deletions src/modules/modal/modal-adapter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ export class SkyModalAdapterService {
document.body.classList.remove(modalClass);
}
}

public getModalOpener(): HTMLElement {
return <HTMLElement>document.activeElement;
}
}
74 changes: 74 additions & 0 deletions src/modules/modal/modal-component-adapter.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import {
Injectable,
ElementRef
} from '@angular/core';
/* tslint:disable */
let tabbableSelector = 'a[href], area[href], input:not([disabled]):not([tabindex=\'-1\']), ' +
'button:not([disabled]):not([tabindex=\'-1\']),select:not([disabled]):not([tabindex=\'-1\']), textarea:not([disabled]):not([tabindex=\'-1\']), ' +
'iframe, object, embed, *[tabindex]:not([tabindex=\'-1\']), *[contenteditable=true]';
/* tslint:enable */

@Injectable()
export class SkyModalComponentAdapterService {
Expand Down Expand Up @@ -31,4 +36,73 @@ export class SkyModalComponentAdapterService {

}
}

public loadFocusElementList(modalEl: ElementRef): Array<HTMLElement> {
let elements: Array<HTMLElement>
= Array.prototype.slice.call(modalEl.nativeElement.querySelectorAll(tabbableSelector));

return elements.filter((element) => {
return this.isVisible(element);
});
}

public isFocusInFirstItem(event: KeyboardEvent, list: Array<HTMLElement>): boolean {
/* istanbul ignore next */
/* sanity check */
let eventTarget = event.target || event.srcElement;
return list.length > 0 && eventTarget === list[0];
}

public isFocusInLastItem(event: KeyboardEvent, list: Array<HTMLElement>): boolean {
/* istanbul ignore next */
/* sanity check */
let eventTarget = event.target || event.srcElement;
return list.length > 0 && eventTarget === list[list.length - 1];
}

public isModalFocused(event: KeyboardEvent, modalEl: ElementRef): boolean {
/* istanbul ignore next */
/* sanity check */
let eventTarget = event.target || event.srcElement;
return modalEl &&
eventTarget === modalEl.nativeElement.querySelector('.sky-modal-dialog');
}

public focusLastElement(list: Array<HTMLElement>): boolean {
if (list.length > 0) {
list[list.length - 1].focus();
return true;
}
return false;
}

public focusFirstElement(list: Array<HTMLElement>): boolean {
if (list.length > 0) {
list[0].focus();
return true;
}
return false;
}

public modalOpened(modalEl: ElementRef): void {
/* istanbul ignore else */
/* handle the case where somehow there is a focused element already in the modal */
if (!(document.activeElement && modalEl.nativeElement.contains(document.activeElement))) {
let inputWithAutofocus = modalEl.nativeElement.querySelector('[autofocus]');

if (inputWithAutofocus) {
inputWithAutofocus.focus();
} else {
let focusEl: HTMLElement = modalEl.nativeElement.querySelector('.sky-modal-dialog');
focusEl.focus();

}
}
}

private isVisible(element: HTMLElement) {
return !!(element.offsetWidth ||
element.offsetHeight ||
element.getClientRects().length);
}
}
6 changes: 4 additions & 2 deletions src/modules/modal/modal-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { Injectable } from '@angular/core';
@Injectable()
export class SkyModalConfiguration {

public fullPage: boolean;
public size: string;
public fullPage?: boolean;
public size?: string;
public ariaDescribedBy?: string;
public ariaLabelledBy?: string;

constructor() {
this.fullPage = this.fullPage;
Expand Down
6 changes: 6 additions & 0 deletions src/modules/modal/modal-host.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class SkyModalHostComponent {
let factory = this.resolver.resolveComponentFactory(component);
let hostService = new SkyModalHostService();
let adapter = this.adapter;
let modalOpener: HTMLElement = adapter.getModalOpener();

params.providers.push({
provide: SkyModalHostService,
Expand All @@ -69,6 +70,11 @@ export class SkyModalHostComponent {
function closeModal() {
hostService.destroy();
adapter.setPageScroll(SkyModalHostService.openModalCount > 0);
/* istanbul ignore else */
/* sanity check */
if (modalOpener && modalOpener.focus) {
modalOpener.focus();
}
modalComponentRef.destroy();
}

Expand Down
5 changes: 4 additions & 1 deletion src/modules/modal/modal-host.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ describe('Modal host service', () => {
let componentService = new SkyModalComponentAdapterService();
let component = new SkyModalComponent(
service,
{ fullPage: false, size: 'medium' },
{
fullPage: false,
size: 'medium'
},
{ nativeElement: {} },
componentService
);
Expand Down
4 changes: 4 additions & 0 deletions src/modules/modal/modal-host.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export class SkyModalHostService {
return SkyModalHostService.BASE_Z_INDEX + SkyModalHostService.modalHosts.length * 10;
}

public static get topModal(): SkyModalHostService {
return SkyModalHostService.modalHosts[SkyModalHostService.modalHosts.length - 1];
}

private static modalHosts: SkyModalHostService[] = [];

public close = new EventEmitter<SkyModalComponent>();
Expand Down
9 changes: 7 additions & 2 deletions src/modules/modal/modal.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
<!--<div @modalState="modalState">-->

<div
class="sky-modal-dialog"
role="dialog"
tabindex="-1"
[attr.aria-describedby]="ariaDescribedBy"
[attr.aria-labelledby]="ariaLabelledBy"
(window:resize)="windowResize()"
>
<div class="sky-modal"
Expand All @@ -19,7 +24,7 @@
}">

<div class="sky-modal-header" [hidden]="!headerContent || !headerContent.children || headerContent.children.length < 1">
<div class="sky-modal-header-content" #headerContent>
<div [attr.id]="modalHeaderId" class="sky-modal-header-content" #headerContent>
<ng-content select="sky-modal-header"></ng-content>
</div>
<div class="sky-modal-header-buttons">
Expand All @@ -31,7 +36,7 @@
</div>

</div>
<div class="sky-modal-content">
<div [attr.id]="modalContentId" class="sky-modal-content">
<ng-content select="sky-modal-content"></ng-content>
</div>
<div class="sky-modal-footer">
Expand Down
Loading