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 Jun 23, 2020
1 parent 31f43aa commit 6a83090
Show file tree
Hide file tree
Showing 28 changed files with 1,442 additions and 181 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
4 changes: 2 additions & 2 deletions documentation/src/components/side-nav-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ class SearchComponent extends LitElement {
}
}

private openPopover() {
private async openPopover() {
if (!this.popover) return;

this.closeOverlay = Overlay.open(this, 'click', this.popover, {
this.closeOverlay = await Overlay.open(this, 'click', this.popover, {
placement: 'bottom',
});
}
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: 90,
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 @@ -50,6 +50,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/*/src/index.ts' --format json --outFile documentation/custom-elements.json",
"postdocs:analyze": "node ./scripts/add-custom-properties.js --src='documentation/custom-elements.json'",
Expand Down
1 change: 0 additions & 1 deletion packages/banner/test/banner.test.d.ts

This file was deleted.

32 changes: 29 additions & 3 deletions packages/dialog/src/dialog-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import {
TemplateResult,
property,
CSSResultArray,
query,
} from 'lit-element';
import { ifDefined } from 'lit-html/directives/if-defined';

import '@spectrum-web-components/underlay';

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

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

private dismiss(): void {
if (!this.dismissible) {
return;
@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
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';
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();
}
}
67 changes: 66 additions & 1 deletion packages/dialog/test/dialog-wrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,15 @@ import { spy } from 'sinon';

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

describe('Dialog Wrapper', () => {
Expand All @@ -45,6 +48,24 @@ describe('Dialog Wrapper', () => {

await expect(el).to.be.accessible();
});
it('loads with underlay and no headline accessibly', async () => {
const el = await fixture<DialogWrapper>(wrapperButtonsUnderlay());
await elementUpdated(el);
el.headline = '';
await elementUpdated(el);
expect(el).to.be.accessible();
});
it('dismisses via clicking the underlay', async () => {
const el = await fixture<DialogWrapper>(wrapperButtonsUnderlay());
await elementUpdated(el);
expect(el.open).to.be.true;
el.dismissible = true;
const root = el.shadowRoot ? el.shadowRoot : el;
const underlay = root.querySelector('sp-underlay') as Underlay;
underlay.click();
await elementUpdated(el);
expect(el.open).to.be.false;
});
it('dismisses', async () => {
const el = await fixture<DialogWrapper>(wrapperDismissible());

Expand All @@ -58,6 +79,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
38 changes: 13 additions & 25 deletions packages/dropdown/src/dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import {
MenuItem,
MenuItemQueryRoleEventDetail,
} from '@spectrum-web-components/menu-item';
import { Placement } from '@spectrum-web-components/overlay';
import { Placement, Overlay } from '@spectrum-web-components/overlay';
import '@spectrum-web-components/popover';

/**
* @slot label - The placeholder content for the dropdown
Expand Down Expand Up @@ -167,12 +168,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 @@ -198,7 +201,6 @@ export class DropdownBase extends Focusable {
}
item.selected = true;
this.open = false;
this.focus();
}

public toggle(): void {
Expand Down Expand Up @@ -257,30 +259,16 @@ export class DropdownBase extends Focusable {
if (menuWidth) {
this.popover.style.setProperty('width', menuWidth);
}
const Overlay = await Promise.all([
import('@spectrum-web-components/overlay'),
import('@spectrum-web-components/popover'),
]).then(
([module]) =>
(module as typeof import('@spectrum-web-components/overlay'))
.Overlay
);
this.closeOverlay = Overlay.open(this.button, 'click', this.popover, {
placement: this.placement,
});
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.closeOverlay = await Overlay.open(
this.button,
'inline',
this.popover,
{
placement: this.placement,
receivesFocus: 'auto',
}
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
Loading

0 comments on commit 6a83090

Please sign in to comment.