Skip to content

Commit

Permalink
feat(overlay): manage focus throwing and tab trapping
Browse files Browse the repository at this point in the history
  • Loading branch information
Westbrook committed Jul 13, 2020
1 parent 68b2523 commit d086c1d
Show file tree
Hide file tree
Showing 24 changed files with 1,304 additions and 89 deletions.
2 changes: 1 addition & 1 deletion .storybook/theme.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ export default create({
brandTitle: 'Spectrum Web Components',
brandUrl: 'https://opensource.adobe.com/spectrum-web-components',
brandImage:
'https://opensource.adobe.com/spectrum-css/static/adobe_logo-2.svg',
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI0LjEuMiwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAzMCAyNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzAgMjY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkEwRjAwO30KPC9zdHlsZT4KPGc+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjE5LDAgMzAsMCAzMCwyNiAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjExLjEsMCAwLDAgMCwyNiAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjE1LDkuNiAyMi4xLDI2IDE3LjUsMjYgMTUuNCwyMC44IDEwLjIsMjAuOCAJIi8+CjwvZz4KPC9zdmc+Cg==',
});
5 changes: 4 additions & 1 deletion __snapshots__/Dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
#### `loads`

```html
<sp-menu role="listbox">
<sp-menu
role="listbox"
tabindex="0"
>
<sp-menu-item
data-js-focus-visible=""
role="option"
Expand Down
8 changes: 4 additions & 4 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ module.exports = (config) => {
coverageIstanbulReporter: {
thresholds: {
global: {
statements: 97,
branches: 92,
functions: 97,
lines: 97,
statements: 98,
branches: 93,
functions: 98,
lines: 98,
},
},
},
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"storybook:start": "start-storybook",
"storybook:stories:build": "tsc --build .storybook/tsconfig.json",
"storybook:stories:watch": "tsc --build .storybook/tsconfig.json -w",
"prestorybook:build": "yarn prestorybook",
"storybook:build": "yarn storybook:stories:build && build-storybook",
"docs:analyze": "wca analyze 'packages/**/sp-*.ts' --format json --outFile documentation/custom-elements.json",
"postdocs:analyze": "node ./scripts/add-custom-properties.js --src='documentation/custom-elements.json'",
Expand Down
60 changes: 58 additions & 2 deletions packages/dialog/src/Dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CSSResultArray,
TemplateResult,
property,
query,
} from 'lit-element';

import '@spectrum-web-components/button/sp-action-button.js';
Expand All @@ -39,6 +40,9 @@ export class Dialog extends LitElement {
return [styles, alertMediumStyles, crossLargeStyles];
}

@query('.content')
private contentElement!: HTMLDivElement;

@property({ type: Boolean, reflect: true })
public error = false;

Expand All @@ -57,6 +61,29 @@ export class Dialog extends LitElement {
@property({ type: String, reflect: true })
public size?: 'small' | 'medium' | 'large' | 'alert';

public focus(): void {
/* istanbul ignore else */
if (this.shadowRoot) {
const firstFocusable = this.shadowRoot.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as LitElement;
/* istanbul ignore else */
if (firstFocusable) {
/* istanbul ignore else */
if (firstFocusable.updateComplete) {
firstFocusable.updateComplete.then(() =>
firstFocusable.focus()
);
} else {
firstFocusable.focus();
}
this.removeAttribute('tabindex');
}
} else {
super.focus();
}
}

public close(): void {
this.open = false;
this.dispatchEvent(
Expand Down Expand Up @@ -102,8 +129,8 @@ export class Dialog extends LitElement {
`
: html``}
</div>
<div class="content" tabindex="0">
<slot></slot>
<div class="content">
<slot @slotchange=${this.onContentSlotChange}></slot>
</div>
${!this.mode || this.hasFooter
? html`
Expand All @@ -119,4 +146,33 @@ export class Dialog extends LitElement {
: html``}
`;
}

private shouldManageTabOrderForScrolling = (): void => {
const { offsetHeight, scrollHeight } = this.contentElement;
if (offsetHeight < scrollHeight) {
this.contentElement.tabIndex = 0;
} else {
this.contentElement.removeAttribute('tabindex');
}
};

protected onContentSlotChange(): void {
this.shouldManageTabOrderForScrolling();
}

public connectedCallback(): void {
super.connectedCallback();
window.addEventListener(
'resize',
this.shouldManageTabOrderForScrolling
);
}

public disconnectedCallback(): void {
window.removeEventListener(
'resize',
this.shouldManageTabOrderForScrolling
);
super.disconnectedCallback();
}
}
29 changes: 29 additions & 0 deletions packages/dialog/src/DialogWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
TemplateResult,
property,
CSSResultArray,
query,
} from 'lit-element';
import { ifDefined } from 'lit-html/directives/if-defined';

Expand All @@ -24,6 +25,7 @@ import '@spectrum-web-components/button/sp-button.js';

import '../sp-dialog.js';
import styles from './dialog-wrapper.css.js';
import { Dialog } from './dialog.js';

/**
* @element sp-dialog-wrapper
Expand Down Expand Up @@ -83,6 +85,33 @@ export class DialogWrapper extends LitElement {
@property({ type: Boolean })
public underlay = false;

@query('sp-dialog')
private dialog!: Dialog;

public focus(): void {
/* istanbul ignore else */
if (this.shadowRoot) {
const firstFocusable = this.shadowRoot.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
) as LitElement;
if (firstFocusable) {
/* istanbul ignore else */
if (firstFocusable.updateComplete) {
firstFocusable.updateComplete.then(() =>
firstFocusable.focus()
);
} else {
firstFocusable.focus();
}
this.removeAttribute('tabindex');
} else {
this.dialog.focus();
}
} else {
super.focus();
}
}

private dismiss(): void {
this.close();
}
Expand Down
48 changes: 46 additions & 2 deletions packages/dialog/test/dialog-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ import { spy } from 'sinon';

import '../sp-dialog-wrapper.js';
import { Dialog, DialogWrapper } from '../';
import { Button } from '@spectrum-web-components/button';
import { Button, ActionButton } from '@spectrum-web-components/button';
import { Underlay } from '@spectrum-web-components/underlay';
import {
wrapperLabeledHero,
wrapperDismissible,
wrapperButtons,
wrapperFullscreen,
wrapperButtonsUnderlay,
} from '../stories/dialog-wrapper.stories.js';
import { Underlay } from '@spectrum-web-components/underlay';

describe('Dialog Wrapper', () => {
it('loads wrapped dialog accessibly', async () => {
Expand Down Expand Up @@ -78,6 +78,50 @@ describe('Dialog Wrapper', () => {
await elementUpdated(el);
expect(el.open).to.be.false;
});
it('manages entry focus - dismissible', async () => {
const el = await fixture<DialogWrapper>(wrapperDismissible());

await elementUpdated(el);
expect(el.open).to.be.true;
expect(document.activeElement, 'no focused').to.not.equal(el);

const root = el.shadowRoot ? el.shadowRoot : el;
const dialog = root.querySelector('sp-dialog') as Dialog;
const dialogRoot = dialog.shadowRoot ? dialog.shadowRoot : dialog;
const dismissButton = dialogRoot.querySelector(
'.close-button'
) as ActionButton;

el.focus();
await elementUpdated(el);
expect(document.activeElement, 'focused generally').to.equal(el);
expect(
(dismissButton.getRootNode() as Document).activeElement,
'focused specifically'
).to.equal(dismissButton);

dismissButton.click();
await elementUpdated(el);
expect(el.open).to.be.false;
});
it('manages entry focus - buttons', async () => {
const el = await fixture<DialogWrapper>(wrapperButtons());

await elementUpdated(el);
expect(el.open).to.be.true;
expect(document.activeElement, 'no focused').to.not.equal(el);

const root = el.shadowRoot ? el.shadowRoot : el;
const button = root.querySelector('sp-button') as Button;

el.focus();
await elementUpdated(el);
expect(document.activeElement, 'focused generally').to.equal(el);
expect(
(button.getRootNode() as Document).activeElement,
'focused specifically'
).to.equal(button);
});
it('dispatches `confirm`, `cancel` and `secondary`', async () => {
const confirmSpy = spy();
const cancelSpy = spy();
Expand Down
20 changes: 5 additions & 15 deletions packages/dropdown/src/Dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,12 +169,14 @@ export class DropdownBase extends Focusable {
if (event.code !== 'ArrowDown') {
return;
}
event.preventDefault();
/* istanbul ignore if */
if (!this.optionsMenu) {
return;
}
this.open = true;
}

public setValueFromItem(item: MenuItem): void {
const oldSelectedItemText = this.selectedItemText;
const oldValue = this.value;
Expand All @@ -200,7 +202,6 @@ export class DropdownBase extends Focusable {
}
item.selected = true;
this.open = false;
this.focus();
}

public toggle(): void {
Expand Down Expand Up @@ -261,25 +262,14 @@ export class DropdownBase extends Focusable {
}
this.closeOverlay = await Overlay.open(
this.button,
'click',
'inline',
this.popover,
{
placement: this.placement,
receivesFocus: 'auto',
}
);
requestAnimationFrame(() => {
/* istanbul ignore else */
if (this.optionsMenu) {
/* Trick :focus-visible polyfill into thinking keyboard based focus */
this.dispatchEvent(
new KeyboardEvent('keydown', {
code: 'Tab',
})
);
this.optionsMenu.focus();
}
this.menuStateResolver();
});
this.menuStateResolver();
}

private closeMenu(): void {
Expand Down
11 changes: 11 additions & 0 deletions packages/dropdown/stories/dropdown.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ export const Default = (): TemplateResult => {
</sp-menu-item>
</sp-menu>
</sp-dropdown>
<p>
This is some text.
</p>
<p>
This is some text.
</p>
<p>
This is a
<a href="#">link</a>
.
</p>
`;
};

Expand Down
29 changes: 29 additions & 0 deletions packages/dropdown/test/dropdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const keyboardEvent = (code: string): KeyboardEvent =>
});
const arrowDownEvent = keyboardEvent('ArrowDown');
const arrowUpEvent = keyboardEvent('ArrowUp');
const tabEvent = keyboardEvent('Tab');

describe('Dropdown', () => {
const dropdownFixture = async (): Promise<Dropdown> => {
Expand Down Expand Up @@ -284,6 +285,34 @@ describe('Dropdown', () => {
expect(el.open).to.be.true;
expect(document.activeElement === firstItem).to.be.true;
});
it('allows tabing to close', async () => {
const el = await dropdownFixture();

await elementUpdated(el);
const firstItem = el.querySelector('sp-menu-item') as MenuItem;

el.open = true;
await elementUpdated(el);

expect(el.open).to.be.true;
el.focus();
await elementUpdated(el);
await waitUntil(() => document.activeElement === firstItem);
await waitUntil(
() => document.activeElement === firstItem,
'first item refocused'
);
expect(el.open).to.be.true;
expect(document.activeElement === firstItem).to.be.true;

firstItem.dispatchEvent(tabEvent);
await elementUpdated(el);
await waitUntil(() => !el.open);

expect(el.open, 'closes').to.be.false;
expect(document.activeElement === firstItem, 'focuses something else')
.to.be.false;
});
it('displays selected item text by default', async () => {
const focusSelectedSpy = spy();
const focusFirstSpy = spy();
Expand Down
Loading

0 comments on commit d086c1d

Please sign in to comment.