Skip to content

Commit db62b88

Browse files
committed
feat(overlay): manage focus throwing and tab trapping
1 parent 31f43aa commit db62b88

File tree

28 files changed

+1442
-181
lines changed

28 files changed

+1442
-181
lines changed

.storybook/theme.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default create({
55
brandTitle: 'Spectrum Web Components',
66
brandUrl: 'https://opensource.adobe.com/spectrum-web-components',
77
brandImage:
8-
'https://opensource.adobe.com/spectrum-css/static/adobe_logo-2.svg',
8+
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0b3I6IEFkb2JlIElsbHVzdHJhdG9yIDI0LjEuMiwgU1ZHIEV4cG9ydCBQbHVnLUluIC4gU1ZHIFZlcnNpb246IDYuMDAgQnVpbGQgMCkgIC0tPgo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4IgoJIHZpZXdCb3g9IjAgMCAzMCAyNiIgc3R5bGU9ImVuYWJsZS1iYWNrZ3JvdW5kOm5ldyAwIDAgMzAgMjY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj4KPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCS5zdDB7ZmlsbDojRkEwRjAwO30KPC9zdHlsZT4KPGc+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjE5LDAgMzAsMCAzMCwyNiAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjExLjEsMCAwLDAgMCwyNiAJIi8+Cgk8cG9seWdvbiBjbGFzcz0ic3QwIiBwb2ludHM9IjE1LDkuNiAyMi4xLDI2IDE3LjUsMjYgMTUuNCwyMC44IDEwLjIsMjAuOCAJIi8+CjwvZz4KPC9zdmc+Cg==',
99
});

__snapshots__/Dropdown.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
#### `loads`
44

55
```html
6-
<sp-menu role="listbox">
6+
<sp-menu
7+
role="listbox"
8+
tabindex="0"
9+
>
710
<sp-menu-item
811
data-js-focus-visible=""
912
role="option"

documentation/src/components/side-nav-search.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,10 @@ class SearchComponent extends LitElement {
5151
}
5252
}
5353

54-
private openPopover() {
54+
private async openPopover() {
5555
if (!this.popover) return;
5656

57-
this.closeOverlay = Overlay.open(this, 'click', this.popover, {
57+
this.closeOverlay = await Overlay.open(this, 'click', this.popover, {
5858
placement: 'bottom',
5959
});
6060
}

karma.conf.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ module.exports = (config) => {
5454
coverageIstanbulReporter: {
5555
thresholds: {
5656
global: {
57-
statements: 97,
58-
branches: 90,
59-
functions: 97,
60-
lines: 97,
57+
statements: 98,
58+
branches: 93,
59+
functions: 98,
60+
lines: 98,
6161
},
6262
},
6363
},

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"storybook:start": "start-storybook",
5151
"storybook:stories:build": "tsc --build .storybook/tsconfig.json",
5252
"storybook:stories:watch": "tsc --build .storybook/tsconfig.json -w",
53+
"prestorybook:build": "yarn prestorybook",
5354
"storybook:build": "yarn storybook:stories:build && build-storybook",
5455
"docs:analyze": "wca analyze 'packages/*/src/index.ts' --format json --outFile documentation/custom-elements.json",
5556
"postdocs:analyze": "node ./scripts/add-custom-properties.js --src='documentation/custom-elements.json'",

packages/banner/test/banner.test.d.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/dialog/src/dialog-wrapper.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ import {
1616
TemplateResult,
1717
property,
1818
CSSResultArray,
19+
query,
1920
} from 'lit-element';
2021
import { ifDefined } from 'lit-html/directives/if-defined';
2122

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

2425
import styles from './dialog-wrapper.css.js';
26+
import { Dialog } from './dialog.js';
2527

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

84-
private dismiss(): void {
85-
if (!this.dismissible) {
86-
return;
86+
@query('sp-dialog')
87+
private dialog!: Dialog;
88+
89+
public focus(): void {
90+
/* istanbul ignore else */
91+
if (this.shadowRoot) {
92+
const firstFocusable = this.shadowRoot.querySelector(
93+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
94+
) as LitElement;
95+
if (firstFocusable) {
96+
/* istanbul ignore else */
97+
if (firstFocusable.updateComplete) {
98+
firstFocusable.updateComplete.then(() =>
99+
firstFocusable.focus()
100+
);
101+
} else {
102+
firstFocusable.focus();
103+
}
104+
this.removeAttribute('tabindex');
105+
} else {
106+
this.dialog.focus();
107+
}
108+
} else {
109+
super.focus();
87110
}
111+
}
112+
113+
private dismiss(): void {
88114
this.close();
89115
}
90116

packages/dialog/src/dialog.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
CSSResultArray,
1717
TemplateResult,
1818
property,
19+
query,
1920
} from 'lit-element';
2021

2122
import '@spectrum-web-components/button';
@@ -39,6 +40,9 @@ export class Dialog extends LitElement {
3940
return [styles, alertMediumStyles, crossLargeStyles];
4041
}
4142

43+
@query('.content')
44+
private contentElement!: HTMLDivElement;
45+
4246
@property({ type: Boolean, reflect: true })
4347
public error = false;
4448

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

64+
public focus(): void {
65+
/* istanbul ignore else */
66+
if (this.shadowRoot) {
67+
const firstFocusable = this.shadowRoot.querySelector(
68+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
69+
) as LitElement;
70+
/* istanbul ignore else */
71+
if (firstFocusable) {
72+
/* istanbul ignore else */
73+
if (firstFocusable.updateComplete) {
74+
firstFocusable.updateComplete.then(() =>
75+
firstFocusable.focus()
76+
);
77+
} else {
78+
firstFocusable.focus();
79+
}
80+
this.removeAttribute('tabindex');
81+
}
82+
} else {
83+
super.focus();
84+
}
85+
}
86+
6087
public close(): void {
6188
this.open = false;
6289
this.dispatchEvent(
@@ -102,8 +129,8 @@ export class Dialog extends LitElement {
102129
`
103130
: html``}
104131
</div>
105-
<div class="content" tabindex="0">
106-
<slot></slot>
132+
<div class="content">
133+
<slot @slotchange=${this.onContentSlotChange}></slot>
107134
</div>
108135
${!this.mode || this.hasFooter
109136
? html`
@@ -119,4 +146,33 @@ export class Dialog extends LitElement {
119146
: html``}
120147
`;
121148
}
149+
150+
private shouldManageTabOrderForScrolling = (): void => {
151+
const { offsetHeight, scrollHeight } = this.contentElement;
152+
if (offsetHeight < scrollHeight) {
153+
this.contentElement.tabIndex = 0;
154+
} else {
155+
this.contentElement.removeAttribute('tabindex');
156+
}
157+
};
158+
159+
protected onContentSlotChange(): void {
160+
this.shouldManageTabOrderForScrolling();
161+
}
162+
163+
public connectedCallback(): void {
164+
super.connectedCallback();
165+
window.addEventListener(
166+
'resize',
167+
this.shouldManageTabOrderForScrolling
168+
);
169+
}
170+
171+
public disconnectedCallback(): void {
172+
window.removeEventListener(
173+
'resize',
174+
this.shouldManageTabOrderForScrolling
175+
);
176+
super.disconnectedCallback();
177+
}
122178
}

packages/dialog/test/dialog-wrapper.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ import { spy } from 'sinon';
1515

1616
import '..';
1717
import { Dialog, DialogWrapper } from '..';
18-
import { Button } from '@spectrum-web-components/button';
18+
import '@spectrum-web-components/underlay';
19+
import { Underlay } from '@spectrum-web-components/underlay';
20+
import { Button, ActionButton } from '@spectrum-web-components/button';
1921
import {
2022
wrapperLabeledHero,
2123
wrapperDismissible,
2224
wrapperButtons,
2325
wrapperFullscreen,
26+
wrapperButtonsUnderlay,
2427
} from '../stories/dialog-wrapper.stories.js';
2528

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

4649
await expect(el).to.be.accessible();
4750
});
51+
it('loads with underlay and no headline accessibly', async () => {
52+
const el = await fixture<DialogWrapper>(wrapperButtonsUnderlay());
53+
await elementUpdated(el);
54+
el.headline = '';
55+
await elementUpdated(el);
56+
expect(el).to.be.accessible();
57+
});
58+
it('dismisses via clicking the underlay', async () => {
59+
const el = await fixture<DialogWrapper>(wrapperButtonsUnderlay());
60+
await elementUpdated(el);
61+
expect(el.open).to.be.true;
62+
el.dismissible = true;
63+
const root = el.shadowRoot ? el.shadowRoot : el;
64+
const underlay = root.querySelector('sp-underlay') as Underlay;
65+
underlay.click();
66+
await elementUpdated(el);
67+
expect(el.open).to.be.false;
68+
});
4869
it('dismisses', async () => {
4970
const el = await fixture<DialogWrapper>(wrapperDismissible());
5071

@@ -58,6 +79,50 @@ describe('Dialog Wrapper', () => {
5879
await elementUpdated(el);
5980
expect(el.open).to.be.false;
6081
});
82+
it('manages entry focus - dismissible', async () => {
83+
const el = await fixture<DialogWrapper>(wrapperDismissible());
84+
85+
await elementUpdated(el);
86+
expect(el.open).to.be.true;
87+
expect(document.activeElement, 'no focused').to.not.equal(el);
88+
89+
const root = el.shadowRoot ? el.shadowRoot : el;
90+
const dialog = root.querySelector('sp-dialog') as Dialog;
91+
const dialogRoot = dialog.shadowRoot ? dialog.shadowRoot : dialog;
92+
const dismissButton = dialogRoot.querySelector(
93+
'.close-button'
94+
) as ActionButton;
95+
96+
el.focus();
97+
await elementUpdated(el);
98+
expect(document.activeElement, 'focused generally').to.equal(el);
99+
expect(
100+
(dismissButton.getRootNode() as Document).activeElement,
101+
'focused specifically'
102+
).to.equal(dismissButton);
103+
104+
dismissButton.click();
105+
await elementUpdated(el);
106+
expect(el.open).to.be.false;
107+
});
108+
it('manages entry focus - buttons', async () => {
109+
const el = await fixture<DialogWrapper>(wrapperButtons());
110+
111+
await elementUpdated(el);
112+
expect(el.open).to.be.true;
113+
expect(document.activeElement, 'no focused').to.not.equal(el);
114+
115+
const root = el.shadowRoot ? el.shadowRoot : el;
116+
const button = root.querySelector('sp-button') as Button;
117+
118+
el.focus();
119+
await elementUpdated(el);
120+
expect(document.activeElement, 'focused generally').to.equal(el);
121+
expect(
122+
(button.getRootNode() as Document).activeElement,
123+
'focused specifically'
124+
).to.equal(button);
125+
});
61126
it('dispatches `confirm`, `cancel` and `secondary`', async () => {
62127
const confirmSpy = spy();
63128
const cancelSpy = spy();

packages/dropdown/src/dropdown.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ import {
4040
MenuItem,
4141
MenuItemQueryRoleEventDetail,
4242
} from '@spectrum-web-components/menu-item';
43-
import { Placement } from '@spectrum-web-components/overlay';
43+
import { Placement, Overlay } from '@spectrum-web-components/overlay';
44+
import '@spectrum-web-components/popover';
4445

4546
/**
4647
* @slot label - The placeholder content for the dropdown
@@ -167,12 +168,14 @@ export class DropdownBase extends Focusable {
167168
if (event.code !== 'ArrowDown') {
168169
return;
169170
}
171+
event.preventDefault();
170172
/* istanbul ignore if */
171173
if (!this.optionsMenu) {
172174
return;
173175
}
174176
this.open = true;
175177
}
178+
176179
public setValueFromItem(item: MenuItem): void {
177180
const oldSelectedItemText = this.selectedItemText;
178181
const oldValue = this.value;
@@ -198,7 +201,6 @@ export class DropdownBase extends Focusable {
198201
}
199202
item.selected = true;
200203
this.open = false;
201-
this.focus();
202204
}
203205

204206
public toggle(): void {
@@ -257,30 +259,16 @@ export class DropdownBase extends Focusable {
257259
if (menuWidth) {
258260
this.popover.style.setProperty('width', menuWidth);
259261
}
260-
const Overlay = await Promise.all([
261-
import('@spectrum-web-components/overlay'),
262-
import('@spectrum-web-components/popover'),
263-
]).then(
264-
([module]) =>
265-
(module as typeof import('@spectrum-web-components/overlay'))
266-
.Overlay
267-
);
268-
this.closeOverlay = Overlay.open(this.button, 'click', this.popover, {
269-
placement: this.placement,
270-
});
271-
requestAnimationFrame(() => {
272-
/* istanbul ignore else */
273-
if (this.optionsMenu) {
274-
/* Trick :focus-visible polyfill into thinking keyboard based focus */
275-
this.dispatchEvent(
276-
new KeyboardEvent('keydown', {
277-
code: 'Tab',
278-
})
279-
);
280-
this.optionsMenu.focus();
262+
this.closeOverlay = await Overlay.open(
263+
this.button,
264+
'inline',
265+
this.popover,
266+
{
267+
placement: this.placement,
268+
receivesFocus: 'auto',
281269
}
282-
this.menuStateResolver();
283-
});
270+
);
271+
this.menuStateResolver();
284272
}
285273

286274
private closeMenu(): void {

0 commit comments

Comments
 (0)