From 6a830904b448a4b196226eb816970ac1e65d8e12 Mon Sep 17 00:00:00 2001 From: Westbrook Johnson Date: Mon, 22 Jun 2020 20:33:00 -0400 Subject: [PATCH] feat(overlay): manage focus throwing and tab trapping --- .storybook/theme.js | 2 +- __snapshots__/Dropdown.md | 5 +- .../src/components/side-nav-search.ts | 4 +- karma.conf.js | 8 +- package.json | 1 + packages/banner/test/banner.test.d.ts | 1 - packages/dialog/src/dialog-wrapper.ts | 32 ++- packages/dialog/src/dialog.ts | 60 ++++- packages/dialog/test/dialog-wrapper.test.ts | 67 ++++- packages/dropdown/src/dropdown.ts | 38 +-- packages/dropdown/stories/dropdown.stories.ts | 11 + packages/dropdown/test/dropdown.test.ts | 136 +++++++--- packages/icon/test/icon.test.ts | 18 +- packages/icons/test/icons.test.d.ts | 1 - packages/iconset/test/iconset.test.d.ts | 2 - packages/menu/src/menu.ts | 4 + packages/overlay/src/active-overlay.ts | 34 ++- packages/overlay/src/overlay-stack.ts | 141 ++++++++-- packages/overlay/src/overlay-trigger.ts | 56 +++- packages/overlay/src/overlay-types.ts | 8 +- packages/overlay/src/overlay.ts | 11 +- packages/overlay/src/popper-arrow-rotate.ts | 4 + .../stories/overlay-story-components.ts | 83 +++++- packages/overlay/stories/overlay.stories.ts | 82 ++++++ packages/overlay/test/overlay-trigger.test.ts | 243 ++++++++++++++++-- packages/overlay/test/overlay.test.ts | 146 ++++++++++- packages/radio-group/src/radio-group.ts | 194 +++++++++++++- packages/radio-group/test/radio-group.test.ts | 231 +++++++++++++++++ 28 files changed, 1442 insertions(+), 181 deletions(-) delete mode 100644 packages/banner/test/banner.test.d.ts delete mode 100644 packages/icons/test/icons.test.d.ts delete mode 100644 packages/iconset/test/iconset.test.d.ts diff --git a/.storybook/theme.js b/.storybook/theme.js index 2ccf3a1d19..d34f804263 100644 --- a/.storybook/theme.js +++ b/.storybook/theme.js @@ -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==', }); diff --git a/__snapshots__/Dropdown.md b/__snapshots__/Dropdown.md index dad6bba03e..a83badfe5c 100644 --- a/__snapshots__/Dropdown.md +++ b/__snapshots__/Dropdown.md @@ -3,7 +3,10 @@ #### `loads` ```html - + { coverageIstanbulReporter: { thresholds: { global: { - statements: 97, - branches: 90, - functions: 97, - lines: 97, + statements: 98, + branches: 93, + functions: 98, + lines: 98, }, }, }, diff --git a/package.json b/package.json index 458ed594f9..035d1bfe0e 100644 --- a/package.json +++ b/package.json @@ -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'", diff --git a/packages/banner/test/banner.test.d.ts b/packages/banner/test/banner.test.d.ts deleted file mode 100644 index 071659fc84..0000000000 --- a/packages/banner/test/banner.test.d.ts +++ /dev/null @@ -1 +0,0 @@ -import '../'; diff --git a/packages/dialog/src/dialog-wrapper.ts b/packages/dialog/src/dialog-wrapper.ts index 1f0d7514c6..0b1cfc2be1 100644 --- a/packages/dialog/src/dialog-wrapper.ts +++ b/packages/dialog/src/dialog-wrapper.ts @@ -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 @@ -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(); } diff --git a/packages/dialog/src/dialog.ts b/packages/dialog/src/dialog.ts index 74a3931cdd..4c5dd2cdf5 100644 --- a/packages/dialog/src/dialog.ts +++ b/packages/dialog/src/dialog.ts @@ -16,6 +16,7 @@ import { CSSResultArray, TemplateResult, property, + query, } from 'lit-element'; import '@spectrum-web-components/button'; @@ -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; @@ -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( @@ -102,8 +129,8 @@ export class Dialog extends LitElement { ` : html``} -
- +
+
${!this.mode || this.hasFooter ? html` @@ -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(); + } } diff --git a/packages/dialog/test/dialog-wrapper.test.ts b/packages/dialog/test/dialog-wrapper.test.ts index 4cc4fe29e7..8c331a8a56 100644 --- a/packages/dialog/test/dialog-wrapper.test.ts +++ b/packages/dialog/test/dialog-wrapper.test.ts @@ -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', () => { @@ -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(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(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(wrapperDismissible()); @@ -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(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(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(); diff --git a/packages/dropdown/src/dropdown.ts b/packages/dropdown/src/dropdown.ts index ab0a46b7a2..c540c6d59a 100644 --- a/packages/dropdown/src/dropdown.ts +++ b/packages/dropdown/src/dropdown.ts @@ -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 @@ -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; @@ -198,7 +201,6 @@ export class DropdownBase extends Focusable { } item.selected = true; this.open = false; - this.focus(); } public toggle(): void { @@ -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 { diff --git a/packages/dropdown/stories/dropdown.stories.ts b/packages/dropdown/stories/dropdown.stories.ts index b771719c2e..67017c2b88 100644 --- a/packages/dropdown/stories/dropdown.stories.ts +++ b/packages/dropdown/stories/dropdown.stories.ts @@ -55,6 +55,17 @@ export const Default = (): TemplateResult => { +

+ This is some text. +

+

+ This is some text. +

+

+ This is a + link + . +

`; }; diff --git a/packages/dropdown/test/dropdown.test.ts b/packages/dropdown/test/dropdown.test.ts index 4c2fa1f5ce..60eb84d6f6 100644 --- a/packages/dropdown/test/dropdown.test.ts +++ b/packages/dropdown/test/dropdown.test.ts @@ -13,8 +13,10 @@ governing permissions and limitations under the License. import '../'; import { Dropdown } from '../'; import '../../menu'; +import { Menu } from '../../menu'; import '../../menu-item'; import { MenuItem } from '../../menu-item'; +import '@spectrum-web-components/shared/lib/focus-visible.js'; import { fixture, elementUpdated, @@ -22,8 +24,6 @@ import { expect, waitUntil, } from '@open-wc/testing'; -import { waitForPredicate } from '../../../test/testing-helpers'; -import '../../shared/lib/focus-visible.js'; import { spy } from 'sinon'; const keyboardEvent = (code: string): KeyboardEvent => @@ -35,42 +35,52 @@ const keyboardEvent = (code: string): KeyboardEvent => }); const arrowDownEvent = keyboardEvent('ArrowDown'); const arrowUpEvent = keyboardEvent('ArrowUp'); - -const dropdownFixture = async (): Promise => { - const el = await fixture( - html` - - - - Deselect - - - Select Inverse - - - Feather... - - - Select and Mask... - - - - Save Selection - - - Make Work Path - - - - ` - ); - await waitForPredicate(() => !!window.applyFocusVisiblePolyfill); - return el; -}; +const tabEvent = keyboardEvent('Tab'); describe('Dropdown', () => { + const dropdownFixture = async (): Promise => { + const el = await fixture( + html` + + + + Deselect + + + Select Inverse + + + Feather... + + + Select and Mask... + + + + Save Selection + + + Make Work Path + + + + ` + ); + + await waitUntil( + () => !!window.applyFocusVisiblePolyfill, + 'polyfill loaded' + ); + return el; + }; + + afterEach(async () => { + const overlays = document.querySelectorAll('active-overlay'); + overlays.forEach((overlay) => overlay.remove()); + }); + it('loads accessibly', async () => { const el = await dropdownFixture(); @@ -256,6 +266,10 @@ describe('Dropdown', () => { el.open = true; await elementUpdated(el); + await waitUntil( + () => document.activeElement === firstItem, + 'first item focused' + ); el.blur(); await elementUpdated(el); @@ -263,9 +277,40 @@ describe('Dropdown', () => { expect(el.open).to.be.true; el.focus(); await elementUpdated(el); - await waitForPredicate(() => document.activeElement === firstItem); + await waitUntil( + () => document.activeElement === firstItem, + 'first item refocused' + ); + 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(); @@ -304,8 +349,12 @@ describe('Dropdown', () => { ); await elementUpdated(el); - await waitUntil(() => el.selectedItemText === 'Select Inverse'); + await waitUntil( + () => el.selectedItemText === 'Select Inverse', + `Selected Item Text: ${el.selectedItemText}` + ); + const menu = el.querySelector('sp-menu') as Menu; const firstItem = el.querySelector( 'sp-menu-item:nth-of-type(1)' ) as MenuItem; @@ -322,11 +371,16 @@ describe('Dropdown', () => { const button = el.button as HTMLButtonElement; button.click(); - await elementUpdated(el); + await elementUpdated(menu); + await waitUntil( + () => document.activeElement === secondItem, + 'second item focused' + ); expect(focusFirstSpy.called, 'do not focus first element').to.be.false; - expect(focusSelectedSpy.calledOnce, 'focus selected element').to.be - .true; + expect(focusSelectedSpy.called, 'focused selected element').to.be.true; + expect(focusSelectedSpy.calledOnce, 'focused selected element once').to + .be.true; }); it('resets value when item not available', async () => { const el = await fixture( diff --git a/packages/icon/test/icon.test.ts b/packages/icon/test/icon.test.ts index 8a4183c933..576f9338f5 100644 --- a/packages/icon/test/icon.test.ts +++ b/packages/icon/test/icon.test.ts @@ -76,18 +76,21 @@ describe('Icon', () => { expect(icon.getAttribute('aria-label')).to.equal('Magnify'); }); - it('does not error when name is missing', () => { - const el = document.createElement('sp-icon'); + it('does not error when name is missing', async () => { + const el = await fixture( + html` + + ` + ); - document.body.appendChild(el); return elementUpdated(el); }); - it('does not error with unknown set', () => { - const el = document.createElement('sp-icon'); - el.name = 'unknown-icon'; + it('does not error with unknown set', async () => { + const el = await fixture(html` + + `); - document.body.appendChild(el); return elementUpdated(el); }); @@ -103,5 +106,6 @@ describe('Icon', () => { throw error; }).to.throw(); } + el.remove(); }); }); diff --git a/packages/icons/test/icons.test.d.ts b/packages/icons/test/icons.test.d.ts deleted file mode 100644 index 071659fc84..0000000000 --- a/packages/icons/test/icons.test.d.ts +++ /dev/null @@ -1 +0,0 @@ -import '../'; diff --git a/packages/iconset/test/iconset.test.d.ts b/packages/iconset/test/iconset.test.d.ts deleted file mode 100644 index 58b0629f08..0000000000 --- a/packages/iconset/test/iconset.test.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -import '../../icons/lib/index.js'; -import '../../icon/lib/index.js'; diff --git a/packages/menu/src/menu.ts b/packages/menu/src/menu.ts index 53c89adae6..bd9922c045 100644 --- a/packages/menu/src/menu.ts +++ b/packages/menu/src/menu.ts @@ -185,6 +185,10 @@ export class Menu extends LitElement { `; } + protected firstUpdated(): void { + this.tabIndex = 0; + } + public connectedCallback(): void { super.connectedCallback(); if (!this.hasAttribute('role')) { diff --git a/packages/overlay/src/active-overlay.ts b/packages/overlay/src/active-overlay.ts index cfbd48a674..ec70248eee 100644 --- a/packages/overlay/src/active-overlay.ts +++ b/packages/overlay/src/active-overlay.ts @@ -103,9 +103,9 @@ const stateTransition = ( }; export class ActiveOverlay extends LitElement { - public overlayContent?: HTMLElement; + public overlayContent!: HTMLElement; public overlayContentTip?: HTMLElement; - public trigger?: HTMLElement; + public trigger!: HTMLElement; private placeholder?: Comment; private popper?: Instance; @@ -141,8 +141,11 @@ export class ActiveOverlay extends LitElement { @property({ attribute: false }) public color?: Color; @property({ attribute: false }) + public receivesFocus?: 'auto'; + @property({ attribute: false }) public scale?: Scale; + public tabbingAway = false; private originalPlacement?: Placement; /** @@ -153,6 +156,18 @@ export class ActiveOverlay extends LitElement { @property({ attribute: 'data-popper-placement' }) public dataPopperPlacement?: Placement; + public focus(): void { + const firstFocusable = this.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as HTMLElement; + if (firstFocusable) { + firstFocusable.focus(); + this.removeAttribute('tabindex'); + } else { + super.focus(); + } + } + private get hasTheme(): boolean { return !!this.color || !!this.scale; } @@ -208,9 +223,17 @@ export class ActiveOverlay extends LitElement { this.state = 'visible'; }); - this.updateOverlayPosition().then(() => - this.applyContentAnimation('spOverlayFadeIn') - ); + this.tabIndex = 0; + if (this.interaction === 'modal') { + this.slot = 'open'; + } + this.updateOverlayPosition() + .then(() => this.applyContentAnimation('spOverlayFadeIn')) + .then(() => { + if (this.receivesFocus) { + this.focus(); + } + }); } private updateOverlayPopperPlacement(): void { @@ -254,6 +277,7 @@ export class ActiveOverlay extends LitElement { this.interaction = detail.interaction; this.color = detail.theme.color; this.scale = detail.theme.scale; + this.receivesFocus = detail.receivesFocus; } public dispose(): void { diff --git a/packages/overlay/src/overlay-stack.ts b/packages/overlay/src/overlay-stack.ts index c7bb65ed58..a8b61cc86a 100644 --- a/packages/overlay/src/overlay-stack.ts +++ b/packages/overlay/src/overlay-stack.ts @@ -32,10 +32,67 @@ export class OverlayStack { public constructor() { this.addEventListeners(); + this.initTabTrapping(); + } + + private tabTrapper!: HTMLElement; + private overlayHolder!: HTMLElement; + + private initTabTrapping(): void { + this.document.body.attachShadow({ mode: 'open' }); + /* istanbul ignore if */ + if (!this.document.body.shadowRoot) { + return; + } + const root = this.document.body.shadowRoot; + root.innerHTML = ` +
+ + + `; + this.tabTrapper = root.querySelector('#actual') as HTMLElement; + this.overlayHolder = root.querySelector('#holder') as HTMLElement; + this.tabTrapper.attachShadow({ mode: 'open' }); + /* istanbul ignore else */ + if (this.tabTrapper.shadowRoot) { + this.tabTrapper.shadowRoot.innerHTML = ''; + } + } + + private startTabTrapping(): void { + this.tabTrapper.tabIndex = -1; + this.overlayHolder.hidden = false; + } + + private stopTabTrapping(): void { + this.tabTrapper.removeAttribute('tabindex'); + this.overlayHolder.hidden = true; } private get document(): Document { - return this.root.ownerDocument || document; + return this.root.ownerDocument /* istanbul ignore next */ || document; } private get topOverlay(): ActiveOverlay | undefined { @@ -71,6 +128,9 @@ export class OverlayStack { if (this.findOverlayForContent(details.content)) { return false; } + if (details.interaction === 'modal') { + this.startTabTrapping(); + } if (details.delayed) { const promise = this.overlayTimer.openTimer(details.content); @@ -80,24 +140,51 @@ export class OverlayStack { } } - return new Promise((resolve) => { - requestAnimationFrame(() => { - if (details.interaction === 'click') { - this.closeAllHoverOverlays(); - } else if ( - details.interaction === 'hover' && - this.isClickOverlayActiveForTrigger(details.trigger) - ) { - // Don't show a hover popover if the click popover is already active - resolve(true); - return; - } + if (details.interaction === 'click') { + this.closeAllHoverOverlays(); + } else if ( + details.interaction === 'hover' && + this.isClickOverlayActiveForTrigger(details.trigger) + ) { + // Don't show a hover popover if the click popover is already active + return true; + } + + await import('./active-overlay.js'); + const activeOverlay = ActiveOverlay.create(details); + this.overlays.push(activeOverlay); + document.body.appendChild(activeOverlay); + let updateComplete = await activeOverlay.updateComplete; + while (updateComplete === false) { + updateComplete = await activeOverlay.updateComplete; + } + + activeOverlay.addEventListener('close', () => { + this.hideAndCloseOverlay(activeOverlay); + }); + if (details.interaction === 'inline') { + this.addOverlayEventListeners(activeOverlay); + } + if (details.receivesFocus === 'auto') { + activeOverlay.focus(); + } - const activeOverlay = ActiveOverlay.create(details); - this.overlays.push(activeOverlay); - document.body.appendChild(activeOverlay); - resolve(false); - }); + return false; + } + + public addOverlayEventListeners(activeOverlay: ActiveOverlay): void { + activeOverlay.addEventListener('keydown', (event: KeyboardEvent) => { + const { code } = event; + /* istanbul ignore if */ + if (code !== 'Tab') return; + + event.stopPropagation(); + this.closeOverlay(activeOverlay.overlayContent); + activeOverlay.tabbingAway = true; + activeOverlay.trigger.focus(); + activeOverlay.trigger.dispatchEvent( + new KeyboardEvent('keydown', event) + ); }); } @@ -122,6 +209,7 @@ export class OverlayStack { return; } + /* istanbul ignore else */ if (event.target instanceof Node) { const path = event.composedPath(); if (path.indexOf(topOverlay.overlayContent) >= 0) { @@ -142,7 +230,7 @@ export class OverlayStack { private async hideAndCloseOverlay( overlay?: ActiveOverlay, - animated = true + animated?: boolean ): Promise { if (overlay) { await overlay.hide(animated); @@ -156,6 +244,21 @@ export class OverlayStack { if (index >= 0) { this.overlays.splice(index, 1); } + if (this.overlays.length) { + const topOverlay = this.overlays[this.overlays.length - 1]; + if (topOverlay.interaction === 'modal') { + topOverlay.focus(); + } + } else { + this.stopTabTrapping(); + if ( + overlay.interaction === 'modal' || + (overlay.interaction === 'inline' && !overlay.tabbingAway) + ) { + overlay.tabbingAway = false; + overlay.trigger.focus(); + } + } } } diff --git a/packages/overlay/src/overlay-trigger.ts b/packages/overlay/src/overlay-trigger.ts index 8e29cdaaf2..3de7a67e00 100644 --- a/packages/overlay/src/overlay-trigger.ts +++ b/packages/overlay/src/overlay-trigger.ts @@ -41,6 +41,9 @@ export class OverlayTrigger extends LitElement { @property({ reflect: true }) public placement: Placement = 'bottom'; + @property() + public type?: 'inline' | 'modal'; + @property({ type: Number, reflect: true }) public offset = 6; @@ -55,9 +58,9 @@ export class OverlayTrigger extends LitElement { return html`
{ /* istanbul ignore else */ if (this.targetContent && this.clickContent) { - this.closeClickOverlay = Overlay.open( + if (this.type === 'modal') { + this.clickContent.tabIndex = 0; + } + this.closeClickOverlay = await Overlay.open( this.targetContent, - 'click', + this.type ? this.type : 'click', this.clickContent, { offset: this.offset, placement: this.placement, + receivesFocus: this.type ? 'auto' : undefined, } ); } } - public onTriggerMouseEnter(): void { + private hoverOverlayReady = Promise.resolve(); + + public async onTriggerMouseEnter(): Promise { /* istanbul ignore else */ if (this.targetContent && this.hoverContent) { - this.closeHoverOverlay = Overlay.open( + let overlayReady: () => void = () => { + return; + }; + this.hoverOverlayReady = new Promise((res) => { + overlayReady = res; + }); + this.closeHoverOverlay = await Overlay.open( this.targetContent, 'hover', this.hoverContent, @@ -104,10 +136,12 @@ export class OverlayTrigger extends LitElement { placement: this.placement, } ); + overlayReady(); } } - public onTriggerMouseLeave(): void { + public async onTriggerMouseLeave(): Promise { + await this.hoverOverlayReady; /* istanbul ignore else */ if (this.closeHoverOverlay) { this.closeHoverOverlay(); @@ -146,10 +180,6 @@ export class OverlayTrigger extends LitElement { this.closeClickOverlay(); delete this.closeClickOverlay; } - if (this.closeHoverOverlay) { - this.closeHoverOverlay(); - delete this.closeHoverOverlay; - } super.disconnectedCallback(); } } diff --git a/packages/overlay/src/overlay-types.ts b/packages/overlay/src/overlay-types.ts index f6ac54377f..55a26c500f 100644 --- a/packages/overlay/src/overlay-types.ts +++ b/packages/overlay/src/overlay-types.ts @@ -13,7 +13,12 @@ governing permissions and limitations under the License. import { ThemeData } from '@spectrum-web-components/theme'; import { Placement as PopperPlacement } from './popper'; -export type TriggerInteractions = 'click' | 'hover' | 'custom'; +export type TriggerInteractions = + | 'click' + | 'hover' + | 'custom' + | 'inline' + | 'modal'; export interface OverlayOpenDetail { content: HTMLElement; @@ -21,6 +26,7 @@ export interface OverlayOpenDetail { delayed: boolean; offset: number; placement?: Placement; + receivesFocus?: 'auto'; trigger: HTMLElement; interaction: TriggerInteractions; theme: ThemeData; diff --git a/packages/overlay/src/overlay.ts b/packages/overlay/src/overlay.ts index e939106432..6a4760f0b2 100644 --- a/packages/overlay/src/overlay.ts +++ b/packages/overlay/src/overlay.ts @@ -22,6 +22,7 @@ type OverlayOptions = { delayed?: boolean; placement?: Placement; offset?: number; + receivesFocus?: 'auto'; }; /** @@ -64,14 +65,14 @@ export class Overlay { * @param options.placement side on which to position the overlay * @returns an Overlay object which can be used to close the overlay */ - public static open( + public static async open( owner: HTMLElement, interaction: TriggerInteractions, overlayElement: HTMLElement, options: OverlayOptions - ): () => void { + ): Promise<() => void> { const overlay = new Overlay(owner, interaction, overlayElement); - overlay.open(options); + await overlay.open(options); return (): void => { overlay.close(); }; @@ -99,6 +100,7 @@ export class Overlay { delayed, offset = 0, placement = 'top', + receivesFocus, }: OverlayOptions): Promise { /* istanbul ignore if */ if (this.isOpen) return true; @@ -131,7 +133,7 @@ export class Overlay { }); this.overlayElement.dispatchEvent(queryOverlayDetailEvent); - Overlay.overlayStack.openOverlay({ + await Overlay.overlayStack.openOverlay({ content: this.overlayElement, contentTip: overlayDetailQuery.overlayContentTipElement, delayed, @@ -140,6 +142,7 @@ export class Overlay { trigger: this.owner, interaction: this.interaction, theme: queryThemeDetail, + receivesFocus, ...overlayDetailQuery, }); this.isOpen = true; diff --git a/packages/overlay/src/popper-arrow-rotate.ts b/packages/overlay/src/popper-arrow-rotate.ts index 54496c9ae3..4f891de21d 100644 --- a/packages/overlay/src/popper-arrow-rotate.ts +++ b/packages/overlay/src/popper-arrow-rotate.ts @@ -18,6 +18,7 @@ import { ModifierArguments, Modifier } from '@popperjs/core/lib/types'; function computeArrowRotateStylesFn( ref: ModifierArguments> ): undefined { + /* istanbul ignore if */ if (!ref.state.styles || !ref.state.styles.arrow) return; let rotation: number; @@ -41,6 +42,7 @@ function computeArrowRotateStylesFn( case 'right-end': rotation = 90; break; + /* istanbul ignore next */ default: return; } @@ -48,6 +50,8 @@ function computeArrowRotateStylesFn( ref.state.styles.arrow.transform += ` rotate(${rotation}deg)`; // Manage Spectrum CSS usage of negative left margin for centering. ref.state.styles.arrow.marginLeft = '0'; + // Manage Spectrum CSS usage of negative top margin for centering. + ref.state.styles.arrow.marginTop = '0'; return; } diff --git a/packages/overlay/stories/overlay-story-components.ts b/packages/overlay/stories/overlay-story-components.ts index efc9175d7c..694ede5cc5 100644 --- a/packages/overlay/stories/overlay-story-components.ts +++ b/packages/overlay/stories/overlay-story-components.ts @@ -17,10 +17,12 @@ import { TemplateResult, CSSResult, CSSResultArray, + query, } from 'lit-element'; import { Placement } from '../'; -import { Radio } from '../../radio'; +import { Button } from '../../button'; +import { RadioGroup } from '../../radio-group'; import { Overlay } from '../'; // Prevent infinite recursion in browser @@ -183,6 +185,9 @@ class RecursivePopover extends LitElement { @property({ type: Number }) private depth = 0; + @query('[slot="trigger"]') + private trigger!: Button; + public static get styles(): CSSResultArray { return [ css` @@ -191,7 +196,8 @@ class RecursivePopover extends LitElement { text-align: center; } - sp-button { + overlay-trigger { + display: inline-flex; margin-top: 11px; } `, @@ -202,37 +208,84 @@ class RecursivePopover extends LitElement { super(); this.placement = 'right'; this.depth = 0; + this.addEventListener('keydown', (event: KeyboardEvent) => { + const { code } = event; + if (code === 'Enter') { + console.log('ho', event.composedPath()); + this.trigger.click(); + } + }); + } + + public focus(): void { + if (this.shadowRoot) { + const firstFocusable = this.shadowRoot.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) as LitElement; + if (firstFocusable) { + if (firstFocusable.updateComplete) { + firstFocusable.updateComplete.then(() => + firstFocusable.focus() + ); + } else { + firstFocusable.focus(); + } + this.removeAttribute('tabindex'); + } + } else { + super.focus(); + } } public onRadioChange(event: Event): void { - const target = event.target as Radio; - this.placement = target.value as Placement; + const target = event.target as RadioGroup; + this.placement = target.selected as Placement; + } + + private captureEnter(event: KeyboardEvent): void { + const { code } = event; + if (code === 'Enter') { + event.stopPropagation(); + } } public render(): TemplateResult { return html` - - + + Top - + Right - + Bottom - + Left - - Open Popover + + + Open Popover + { + target.children[0].focus(); + }} > ${this.depth < MAX_DEPTH ? html` @@ -248,5 +301,13 @@ class RecursivePopover extends LitElement { `; } + + protected firstUpdated(): void { + if (this.tabIndex !== -1) { + this.tabIndex = 0; + } else { + this.removeAttribute('tabindex'); + } + } } customElements.define('recursive-popover', RecursivePopover); diff --git a/packages/overlay/stories/overlay.stories.ts b/packages/overlay/stories/overlay.stories.ts index 436d8b1b87..02885a2e2c 100644 --- a/packages/overlay/stories/overlay.stories.ts +++ b/packages/overlay/stories/overlay.stories.ts @@ -13,6 +13,8 @@ import { TemplateResult } from 'lit-element'; import { Placement } from '../'; import '../../button'; +import '../../dialog'; +import { DialogWrapper } from '../../dialog'; import '../../popover'; import '../../slider'; import '../../radio'; @@ -150,6 +152,85 @@ export const Default = (): TemplateResult => { `; }; +export const inline = (): TemplateResult => { + const closeEvent = new Event('close', { bubbles: true, composed: true }); + return html` + + Open + + { + event.target.dispatchEvent(closeEvent); + }} + > + Close + + + +

+ This is some text. +

+

+ This is some text. +

+

+ This is a + link + . +

+ `; +}; + +export const modal = (): TemplateResult => { + const closeEvent = new Event('close', { bubbles: true, composed: true }); + return html` + + Open + { + event.target.dispatchEvent(closeEvent); + }} + @secondary=${( + event: Event & { target: DialogWrapper } + ): void => { + event.target.dispatchEvent(closeEvent); + }} + @cancel=${(event: Event & { target: DialogWrapper }): void => { + event.target.dispatchEvent(closeEvent); + }} + @sp-overlay-closed=${( + event: Event & { target: DialogWrapper } + ): void => { + event.target.open = true; + }} + > + Content of the dialog + + +

+ This is some text. +

+

+ This is some text. +

+

+ This is a + link + . +

+ `; +}; + export const deepNesting = (): TemplateResult => { const colorOptions = { Light: 'light', @@ -166,6 +247,7 @@ export const deepNesting = (): TemplateResult => { { afterEach(async () => { let activeOverlay = document.querySelector('active-overlay'); - while (activeOverlay !== null) { + while (activeOverlay) { activeOverlay.remove(); activeOverlay = document.querySelector('active-overlay'); } + const outerTrigger = testDiv.querySelector( + '#trigger' + ) as OverlayTrigger; + if (outerTrigger) { + outerTrigger.removeAttribute('type'); + } + const innerTrigger = testDiv.querySelector( + '#inner-trigger' + ) as OverlayTrigger; + if (innerTrigger) { + innerTrigger.removeAttribute('type'); + } }); it('loads', async () => { @@ -147,6 +161,84 @@ describe('Overlay Trigger', () => { expect(isVisible(outerPopover)).to.be.true; }); + it('resizes a popover', async () => { + const button = testDiv.querySelector('#outer-button') as HTMLElement; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; + + expect(isVisible(outerPopover)).to.be.false; + + expect(button).to.exist; + button.click(); + + // Wait for the DOM node to be stolen and reparented into the overlay + await waitForPredicate( + () => !(outerPopover.parentElement instanceof OverlayTrigger) + ); + + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + expect(isVisible(outerPopover)).to.be.true; + + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('resize')); + + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + expect(isVisible(outerPopover)).to.be.true; + }); + + it('opens a popover - [type="inline"]', async () => { + const button = testDiv.querySelector('#outer-button') as HTMLElement; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; + const outerTrigger = testDiv.querySelector( + '#trigger' + ) as OverlayTrigger; + outerTrigger.type = 'inline'; + await elementUpdated(outerTrigger); + + expect(isVisible(outerPopover)).to.be.false; + + expect(button).to.exist; + button.click(); + + // Wait for the DOM node to be stolen and reparented into the overlay + await waitForPredicate( + () => !(outerPopover.parentElement instanceof OverlayTrigger) + ); + + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + expect(isVisible(outerPopover)).to.be.true; + }); + + it('opens a popover - [type="modal"]', async () => { + const button = testDiv.querySelector('#outer-button') as HTMLElement; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; + const outerTrigger = testDiv.querySelector( + '#trigger' + ) as OverlayTrigger; + outerTrigger.type = 'modal'; + await elementUpdated(outerTrigger); + + expect(isVisible(outerPopover)).to.be.false; + + expect(button).to.exist; + button.click(); + + // Wait for the DOM node to be stolen and reparented into the overlay + await waitForPredicate( + () => !(outerPopover.parentElement instanceof OverlayTrigger) + ); + + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + expect(isVisible(outerPopover)).to.be.true; + }); + it('does not open a hover popover when a click popover is open', async () => { const button = testDiv.querySelector('#outer-button') as HTMLElement; const outerPopover = testDiv.querySelector('#outer-popover') as Popover; @@ -188,16 +280,62 @@ describe('Overlay Trigger', () => { const trigger = testDiv.querySelector('#trigger') as OverlayTrigger; const root = trigger.shadowRoot ? trigger.shadowRoot : trigger; const triggerZone = root.querySelector('#trigger') as HTMLDivElement; - const styles = getComputedStyle(triggerZone); + const button = testDiv.querySelector('#outer-button') as Button; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; expect(trigger.disabled).to.be.false; - expect(styles.pointerEvents).to.equal('auto'); + button.click(); + await waitUntil( + () => !(outerPopover.parentElement instanceof OverlayTrigger), + 'outer hoverConent stolen and reparented into the overlay' + ); + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + button.click(); + await waitUntil( + () => outerPopover.parentElement instanceof OverlayTrigger, + 'outter hoverConent returned to OverlayTrigger' + ); + expect(outerPopover.parentElement).to.be.an.instanceOf(OverlayTrigger); trigger.disabled = true; await elementUpdated(trigger); expect(trigger.disabled).to.be.true; - expect(styles.pointerEvents).to.equal('none'); + expect(trigger.hasAttribute('disabled')).to.be.true; + button.click(); + await waitUntil( + () => outerPopover.parentElement instanceof OverlayTrigger, + 'outter hoverConent never left' + ); + expect(outerPopover.parentElement).to.be.an.instanceOf(OverlayTrigger); + triggerZone.dispatchEvent(new Event('mouseenter')); + await waitUntil( + () => outerPopover.parentElement instanceof OverlayTrigger, + 'outter hoverConent never left' + ); + expect(outerPopover.parentElement).to.be.an.instanceOf(OverlayTrigger); + + trigger.disabled = false; + await elementUpdated(trigger); + + expect(trigger.disabled).to.be.false; + expect(trigger.hasAttribute('disabled')).to.be.false; + button.click(); + await waitUntil( + () => !(outerPopover.parentElement instanceof OverlayTrigger), + 'outer hoverConent stolen and reparented into the overlay' + ); + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + button.click(); + await waitUntil( + () => outerPopover.parentElement instanceof OverlayTrigger, + 'outter hoverConent returned to OverlayTrigger' + ); + expect(outerPopover.parentElement).to.be.an.instanceOf(OverlayTrigger); }); it('opens a nested popover', async () => { @@ -211,9 +349,9 @@ describe('Overlay Trigger', () => { expect(button).to.exist; button.click(); - // Wait for the DOM node to be stolen and reparented into the overlay - await waitForPredicate( - () => !(outerPopover.parentElement instanceof OverlayTrigger) + await waitUntil( + () => !(outerPopover.parentElement instanceof OverlayTrigger), + 'outer hoverConent stolen and reparented into the overlay' ); expect(outerPopover.parentElement).to.not.be.an.instanceOf( @@ -228,15 +366,70 @@ describe('Overlay Trigger', () => { innerButton.click(); - // Wait for the DOM node to be stolen and reparented into the overlay - await waitForPredicate( - () => !(innerPopover.parentElement instanceof OverlayTrigger) + await waitUntil( + () => !(innerPopover.parentElement instanceof OverlayTrigger), + 'inner hoverConent stolen and reparented into the overlay' ); expect(isVisible(outerPopover)).to.be.true; expect(isVisible(innerPopover)).to.be.true; }); + it('focus previous "modal" when closing nested "modal"', async () => { + const button = testDiv.querySelector('#outer-button') as HTMLElement; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; + const innerPopover = testDiv.querySelector('#inner-popover') as Popover; + const outerTrigger = testDiv.querySelector( + '#trigger' + ) as OverlayTrigger; + const innerTrigger = testDiv.querySelector( + '#inner-trigger' + ) as OverlayTrigger; + + outerTrigger.type = 'modal'; + innerTrigger.type = 'modal'; + + expect(isVisible(outerPopover)).to.be.false; + expect(isVisible(innerPopover)).to.be.false; + + expect(button).to.exist; + button.click(); + + await waitUntil( + () => !(outerPopover.parentElement instanceof OverlayTrigger), + 'outer hoverConent stolen and reparented into the overlay' + ); + + expect(outerPopover.parentElement).to.not.be.an.instanceOf( + OverlayTrigger + ); + expect(isVisible(outerPopover)).to.be.true; + expect(isVisible(innerPopover)).to.be.false; + + const innerButton = document.querySelector( + '#inner-button' + ) as HTMLElement; + + innerButton.click(); + + await waitUntil( + () => !(innerPopover.parentElement instanceof OverlayTrigger), + 'inner hoverConent stolen and reparented into the overlay' + ); + + expect(isVisible(outerPopover)).to.be.true; + expect(isVisible(innerPopover)).to.be.true; + + pressEscape(); + + await waitUntil( + () => innerPopover.parentElement instanceof OverlayTrigger, + 'inner hoverConent returned to OverlayTrigger' + ); + + expect(document.activeElement === outerPopover).to.be.true; + }); + it('escape closes an open popover', async () => { const innerButton = testDiv.querySelector( '#inner-button' @@ -264,6 +457,11 @@ describe('Overlay Trigger', () => { expect(isVisible(outerPopover)).to.be.true; expect(isVisible(innerPopover)).to.be.true; + pressSpace(); + + expect(isVisible(outerPopover)).to.be.true; + expect(isVisible(innerPopover)).to.be.true; + pressEscape(); // Wait for the DOM node to be put back in its original place @@ -404,20 +602,21 @@ describe('Overlay Trigger', () => { expect(outerButton).to.exist; expect(hoverContent).to.exist; - expect(isVisible(hoverContent)).to.be.false; + expect(isVisible(hoverContent), 'hoverContent should not be visible').to + .be.false; const mouseEnter = new MouseEvent('mouseenter'); const mouseLeave = new MouseEvent('mouseleave'); triggerShadowDiv.dispatchEvent(mouseEnter); triggerShadowDiv.dispatchEvent(mouseLeave); - await nextFrame(); - await nextFrame(); - - expect(isVisible(hoverContent)).to.be.false; + await waitUntil( + () => isVisible(hoverContent) === false, + 'hoverContent should still not be visible' + ); }); it('acquires a `color` and `size` from `sp-theme`', async () => { - const el = await fixture(html` + const el = await fixture(html` diff --git a/packages/overlay/test/overlay.test.ts b/packages/overlay/test/overlay.test.ts index d2e43ba53e..be8f2cfca3 100644 --- a/packages/overlay/test/overlay.test.ts +++ b/packages/overlay/test/overlay.test.ts @@ -13,11 +13,19 @@ import '../'; import '../../button'; import '../../popover'; import { Popover } from '../../popover'; +import '../../dialog'; +import { Dialog } from '../../dialog'; import '../../theme'; -import { Overlay } from '../../overlay'; +import { Overlay, Placement } from '../../overlay'; import { waitForPredicate, isVisible } from '../../../test/testing-helpers'; -import { fixture, html, expect, elementUpdated } from '@open-wc/testing'; +import { + fixture, + html, + expect, + elementUpdated, + waitUntil, +} from '@open-wc/testing'; describe('Overlays', () => { let testDiv!: HTMLDivElement; @@ -33,6 +41,10 @@ describe('Overlays', () => { justify-content: center; } + #top { + margin: 100px; + } + sp-button { flex: none; } @@ -74,7 +86,67 @@ describe('Overlays', () => { await elementUpdated(testDiv); }); - it('opens a popover', async () => { + [ + 'bottom', + 'bottom-start', + 'bottom-end', + 'top', + 'top-start', + 'top-end', + 'left', + 'left-start', + 'left-end', + 'right', + 'right-start', + 'right-end', + 'none', + ].map((direction) => { + const placement = direction as Placement; + it(`opens a popover - ${placement}`, async () => { + const button = testDiv.querySelector( + '#first-button' + ) as HTMLElement; + const outerPopover = testDiv.querySelector( + '#outer-popover' + ) as Popover; + + expect(outerPopover.parentElement).to.exist; + if (outerPopover.parentElement) { + expect(outerPopover.parentElement.id).to.equal( + 'overlay-content' + ); + } + + expect(isVisible(outerPopover)).to.be.false; + + expect(button).to.exist; + + Overlay.open(button, 'click', outerPopover, { + delayed: false, + placement, + offset: 10, + }); + + // Wait for the DOM node to be stolen and reparented into the overlay + await waitForPredicate( + () => + !!( + outerPopover.parentElement && + outerPopover.parentElement.id !== 'overlay-content' + ) + ); + + expect(outerPopover.parentElement).to.exist; + if (outerPopover.parentElement) { + expect(outerPopover.parentElement.id).not.to.equal( + 'overlay-content' + ); + } + expect(isVisible(outerPopover)).to.be.true; + }); + }); + + it(`updates a popover`, async () => { const button = testDiv.querySelector('#first-button') as HTMLElement; const outerPopover = testDiv.querySelector('#outer-popover') as Popover; @@ -89,7 +161,6 @@ describe('Overlays', () => { Overlay.open(button, 'click', outerPopover, { delayed: false, - placement: 'top', offset: 10, }); @@ -102,6 +173,41 @@ describe('Overlays', () => { ) ); + expect(isVisible(outerPopover)).to.be.true; + + Overlay.update(); + + expect(isVisible(outerPopover)).to.be.true; + }); + + it(`opens a popover w/ delay`, async () => { + const button = testDiv.querySelector('#first-button') as HTMLElement; + const outerPopover = testDiv.querySelector('#outer-popover') as Popover; + + expect(outerPopover.parentElement).to.exist; + if (outerPopover.parentElement) { + expect(outerPopover.parentElement.id).to.equal('overlay-content'); + } + + expect(isVisible(outerPopover)).to.be.false; + + expect(button).to.exist; + + await Overlay.open(button, 'click', outerPopover, { + delayed: true, + offset: 10, + }); + + // Wait for the DOM node to be stolen and reparented into the overlay + await waitUntil( + () => + !!( + outerPopover.parentElement && + outerPopover.parentElement.id !== 'overlay-content' + ), + 'overlay opened' + ); + expect(outerPopover.parentElement).to.exist; if (outerPopover.parentElement) { expect(outerPopover.parentElement.id).not.to.equal( @@ -228,4 +334,36 @@ describe('Overlays', () => { expect(isVisible(customOverlay)).to.be.true; expect(isVisible(clickOverlay)).to.be.true; }); + + it('closes via events', async () => { + const el = await fixture(html` +
+ +
+ `); + + const dialog = el.querySelector('sp-dialog') as Dialog; + + Overlay.open(el, 'click', dialog, { + delayed: false, + placement: 'bottom', + offset: 10, + }); + + await waitUntil( + () => + !!dialog.parentElement && + dialog.parentElement.tagName === 'ACTIVE-OVERLAY', + 'content is stolen' + ); + + dialog.close(); + + await waitUntil( + () => + !!dialog.parentElement && + dialog.parentElement.tagName !== 'ACTIVE-OVERLAY', + 'content is returned' + ); + }); }); diff --git a/packages/radio-group/src/radio-group.ts b/packages/radio-group/src/radio-group.ts index 4e8a86b2ba..b5afec75ed 100644 --- a/packages/radio-group/src/radio-group.ts +++ b/packages/radio-group/src/radio-group.ts @@ -16,6 +16,7 @@ import { property, CSSResultArray, TemplateResult, + queryAssignedNodes, } from 'lit-element'; import radioGroupStyles from './radio-group.css.js'; @@ -31,6 +32,153 @@ export class RadioGroup extends LitElement { return [radioGroupStyles]; } + @queryAssignedNodes('') + public defaultNodes!: Node[]; + + public get buttons(): Radio[] { + return this.defaultNodes.filter( + (node) => (node as HTMLElement) instanceof Radio + ) as Radio[]; + } + + constructor() { + super(); + this.addEventListener('focusin', this.handleFocusin); + } + + public focus(): void { + if (!this.buttons.length) { + return; + } + const firstButtonNonDisabled = this.buttons.find((button) => { + if (this.selected) { + return button.checked; + } + return !button.disabled; + }); + /* istanbul ignore else */ + if (firstButtonNonDisabled) { + firstButtonNonDisabled.focus(); + } + } + + private handleFocusin = (event: FocusEvent): void => { + const target = event.target as Radio; + this.selected = target.value; + this.addEventListener('focusout', this.handleFocusout); + this.addEventListener('keydown', this.handleKeydown); + requestAnimationFrame(() => { + const firstButtonWithTabIndex = this.buttons.find( + (button) => button.tabIndex === 0 + ); + if (firstButtonWithTabIndex) { + firstButtonWithTabIndex.tabIndex = -1; + } + }); + }; + + private handleKeydown = (event: KeyboardEvent): void => { + const { code } = event; + const activeElement = (this.getRootNode() as Document) + .activeElement as Radio; + /* istanbul ignore if */ + if (!activeElement) { + return; + } + let nextIndex = this.buttons.indexOf(activeElement); + /* istanbul ignore if */ + if (nextIndex === -1) { + return; + } + const circularIndexedElement = ( + list: T[], + index: number + ): T => list[(list.length + index) % list.length]; + switch (code) { + case 'ArrowUp': + case 'ArrowLeft': { + nextIndex -= 1; + while ( + circularIndexedElement(this.buttons, nextIndex).disabled + ) { + nextIndex -= 1; + } + break; + } + case 'ArrowRight': + case 'ArrowDown': + nextIndex += 1; + while ( + circularIndexedElement(this.buttons, nextIndex).disabled + ) { + nextIndex += 1; + } + break; + case 'End': + nextIndex = this.buttons.length - 1; + while ( + circularIndexedElement(this.buttons, nextIndex).disabled + ) { + nextIndex -= 1; + } + break; + case 'Home': + nextIndex = 0; + while ( + circularIndexedElement(this.buttons, nextIndex).disabled + ) { + nextIndex += 1; + } + break; + case 'PageUp': + case 'PageDown': + const tagsSiblings = [ + ...(this.getRootNode() as Document).querySelectorAll< + RadioGroup + >('sp-radio-group'), + ]; + if (tagsSiblings.length < 2) { + return; + } + event.preventDefault(); + const currentIndex = tagsSiblings.indexOf(this); + const offset = code === 'PageUp' ? -1 : 1; + let nextRadioGroupIndex = currentIndex + offset; + let nextRadioGroup = circularIndexedElement( + tagsSiblings, + nextRadioGroupIndex + ); + while (!nextRadioGroup.buttons.length) { + nextRadioGroupIndex += offset; + nextRadioGroup = circularIndexedElement( + tagsSiblings, + nextRadioGroupIndex + ); + } + nextRadioGroup.focus(); + return; + default: + return; + } + event.preventDefault(); + circularIndexedElement(this.buttons, nextIndex).focus(); + }; + + private handleFocusout = (): void => { + const firstButtonNonDisabled = this.buttons.find((button) => { + if (this.selected) { + return button.checked; + } + return !button.disabled; + }); + /* istanbul ignore else */ + if (firstButtonNonDisabled) { + firstButtonNonDisabled.tabIndex = 0; + } + this.removeEventListener('keydown', this.handleKeydown); + this.removeEventListener('focusout', this.handleFocusout); + }; + @property({ type: String, reflect: true }) public name = ''; @@ -42,19 +190,27 @@ export class RadioGroup extends LitElement { } public set selected(value: string) { + const old = this.selected; const radio = value ? (this.querySelector(`sp-radio[value="${value}"]`) as Radio) : undefined; - this.deselectChecked(); - - if (radio) { - this._selected = value; - radio.checked = true; - } else { - // If no matching radio, selected is reset to empty string - this._selected = ''; + // If no matching radio, selected is reset to empty string + this._selected = radio ? value : ''; + const applyDefault = this.dispatchEvent( + new Event('change', { + cancelable: true, + bubbles: true, + composed: true, + }) + ); + if (!applyDefault) { + this._selected = old; + return; } + this.deselectChecked(); + if (radio) radio.checked = true; + this.requestUpdate('selected', old); } protected render(): TemplateResult { @@ -70,9 +226,25 @@ export class RadioGroup extends LitElement { // If selected already assigned, don't overwrite this.selected = this.selected || checkedRadioValue; - this.addEventListener('change', (event: Event) => { - const target = event.target as Radio; - this.selected = target.value; + this.buttons.map((button) => { + button.addEventListener('change', (event: Event) => { + event.stopPropagation(); + const target = event.target as Radio; + this.selected = target.value; + }); + }); + } + + protected updated(): void { + this.buttons.map((button, index) => { + const focusable = this.selected + ? !button.disabled && button.value === this.selected + ? '0' + : '-1' + : !button.disabled && index === 0 + ? '0' + : '-1'; + button.setAttribute('tabindex', focusable); }); } diff --git a/packages/radio-group/test/radio-group.test.ts b/packages/radio-group/test/radio-group.test.ts index 8bf56e1646..bf44019c21 100644 --- a/packages/radio-group/test/radio-group.test.ts +++ b/packages/radio-group/test/radio-group.test.ts @@ -16,6 +16,216 @@ import '../../radio'; import { Radio } from '../../radio'; import { fixture, elementUpdated, html, expect } from '@open-wc/testing'; +const keyboardEvent = (code: string): KeyboardEvent => + new KeyboardEvent('keydown', { + bubbles: true, + composed: true, + cancelable: true, + code, + key: code, + }); +const arrowUpEvent = keyboardEvent('ArrowUp'); +const arrowDownEvent = keyboardEvent('ArrowDown'); +const arrowLeftEvent = keyboardEvent('ArrowLeft'); +const arrowRightEvent = keyboardEvent('ArrowRight'); +const endEvent = keyboardEvent('End'); +const homeEvent = keyboardEvent('Home'); +const pageUpEvent = keyboardEvent('PageUp'); +const pageDownEvent = keyboardEvent('PageDown'); +const enterEvent = keyboardEvent('Enter'); + +describe('Radio Group - focus control', () => { + it('does not accept focus when empty', async () => { + const el = await fixture( + html` + + ` + ); + + await elementUpdated(el); + + expect(document.activeElement === el).to.be.false; + + el.focus(); + await elementUpdated(el); + + expect(document.activeElement === el).to.be.false; + }); + it('focuses selected before first', async () => { + const el = await fixture( + html` + + Option 1 + Option 2 + Option 3 + + ` + ); + + await elementUpdated(el); + const selected = el.querySelector('[value="second"]') as Radio; + + expect(document.activeElement === el).to.be.false; + + el.focus(); + await elementUpdated(el); + + expect(document.activeElement === selected).to.be.true; + }); + it('loads accepts keyboard events while focused', async () => { + const el = await fixture( + html` + + Options 1 + Options 2 + Options 3 + Options 4 + Options 5 + + ` + ); + + await elementUpdated(el); + + const radio1 = el.querySelector('sp-radio:nth-child(1)') as Radio; + const radio2 = el.querySelector('sp-radio:nth-child(2)') as Radio; + const radio3 = el.querySelector('sp-radio:nth-child(3)') as Radio; + const radio4 = el.querySelector('sp-radio:nth-child(4)') as Radio; + const radio5 = el.querySelector('sp-radio:nth-child(5)') as Radio; + + radio1.focus(); + await elementUpdated(el); + + el.dispatchEvent(pageUpEvent); + el.dispatchEvent(arrowRightEvent); + await elementUpdated(el); + + expect(document.activeElement === radio2).to.be.true; + + el.dispatchEvent(arrowDownEvent); + await elementUpdated(el); + + expect(document.activeElement === radio3).to.be.true; + + el.dispatchEvent(endEvent); + await elementUpdated(el); + + expect(document.activeElement === radio5).to.be.true; + + el.dispatchEvent(arrowLeftEvent); + await elementUpdated(el); + + expect(document.activeElement === radio4).to.be.true; + + el.dispatchEvent(arrowUpEvent); + await elementUpdated(el); + + expect(document.activeElement === radio3).to.be.true; + + el.dispatchEvent(homeEvent); + await elementUpdated(el); + + expect(document.activeElement === radio1).to.be.true; + + radio1.blur(); + }); + it('loads accepts keyboard events while focused', async () => { + const el = await fixture( + html` + + Option 1 + Option 2 + Option 3 + Option 4 + Option 5 + + ` + ); + + await elementUpdated(el); + + const radio2 = el.querySelector('sp-radio:nth-child(2)') as Radio; + const radio4 = el.querySelector('sp-radio:nth-child(4)') as Radio; + + radio2.focus(); + await elementUpdated(el); + + el.dispatchEvent(enterEvent); + el.dispatchEvent(endEvent); + await elementUpdated(el); + + expect(document.activeElement === radio4).to.be.true; + + el.dispatchEvent(homeEvent); + await elementUpdated(el); + + expect(document.activeElement === radio2).to.be.true; + + el.dispatchEvent(arrowUpEvent); + await elementUpdated(el); + + expect(document.activeElement === radio4).to.be.true; + + el.dispatchEvent(arrowDownEvent); + await elementUpdated(el); + + expect(document.activeElement === radio2).to.be.true; + }); + it('loads accepts "PageUp" and "PageDown" keys', async () => { + const el = await fixture( + html` +
+ + Option 1 + + + Option 2 + + + + Option 3 + Option 4 + +
+ ` + ); + + const radioGroup1 = el.querySelector( + 'sp-radio-group:nth-child(1)' + ) as RadioGroup; + const radioGroup2 = el.querySelector( + 'sp-radio-group:nth-child(2)' + ) as RadioGroup; + const radioGroup4 = el.querySelector( + 'sp-radio-group:nth-child(4)' + ) as RadioGroup; + + const radio1 = radioGroup1.querySelector('sp-radio') as Radio; + const radio2 = radioGroup2.querySelector('sp-radio') as Radio; + const radio4 = radioGroup4.querySelector( + 'sp-radio:not([disabled])' + ) as Radio; + + radio1.focus(); + radio1.dispatchEvent(pageUpEvent); + + expect(document.activeElement === radio4).to.be.true; + + radio4.dispatchEvent(pageDownEvent); + + expect(document.activeElement === radio1).to.be.true; + + radio1.dispatchEvent(pageDownEvent); + + expect(document.activeElement === radio2).to.be.true; + + radio2.dispatchEvent(pageDownEvent); + + expect(document.activeElement === radio4, 'Focuses `radio4`').to.be + .true; + }); +}); + function inputForRadio(radio: Radio): HTMLInputElement { if (!radio.shadowRoot) throw new Error('No shadowRoot'); @@ -87,6 +297,27 @@ describe('Radio Group', () => { await expect(testDiv).to.be.accessible(); }); + it('can have selection prevented', async () => { + const el = testDiv.querySelector( + 'sp-radio-group#test-default' + ) as RadioGroup; + + await elementUpdated(el); + expect(el.selected).to.equal('first'); + + el.selected = 'second'; + + await elementUpdated(el); + expect(el.selected).to.equal('second'); + + el.addEventListener('change', (event) => event.preventDefault()); + + el.selected = 'third'; + + await elementUpdated(el); + expect(el.selected).to.equal('second'); + }); + it('reflects checked radio with selected property', async () => { const radioGroup = testDiv.querySelector( 'sp-radio-group#test-default'