diff --git a/index.js b/index.js index 69705da8..dfd2fbd3 100644 --- a/index.js +++ b/index.js @@ -6,4 +6,5 @@ export * from './packages/toast/toast'; export * from './packages/toast/toast-container'; export * from './packages/toast/api'; export * from './packages/broadcast'; +export * from './packages/modal'; export * from './packages/utils/expand-transition'; diff --git a/package.json b/package.json index 7457ba6a..313b9058 100644 --- a/package.json +++ b/package.json @@ -79,9 +79,12 @@ "vite-plugin-html": "3.2.0" }, "dependencies": { + "@a11y/focus-trap": "^1.0.5", "@fabric-ds/core": "0.0.15", "@open-wc/testing": "3.1.6", - "html-format": "1.0.2" + "dialog-polyfill": "^0.5.6", + "html-format": "1.0.2", + "scroll-doctor": "^1.0.1" }, "publishConfig": { "access": "public" diff --git a/packages/modal/index.js b/packages/modal/index.js new file mode 100644 index 00000000..764ef235 --- /dev/null +++ b/packages/modal/index.js @@ -0,0 +1,182 @@ +import { html, css } from 'lit'; +import { fclasses, FabricElement } from '../utils'; +import { modal as c } from '@fabric-ds/css/component-classes'; +import { leftButtonSvg, rightButtonSvg } from './svgs'; +import { setup, teardown } from 'scroll-doctor'; +import dialogPolyfill from 'dialog-polyfill'; + +class FabricModal extends FabricElement { + static properties = { + open: { type: Boolean }, + left: { type: Boolean }, + right: { type: Boolean }, + }; + + static styles = css` + :host { + --f-modal-width: 640px; + --f-modal-max-height: 80%; + } + .modal { + width: var(--f-modal-width); + } + dialog { + padding: 0; + border: none !important; + /* !important used here to override polyfill CSS, if loaded */ + } + ::backdrop { + background-color: #00000059; + } + `; + + connectedCallback() { + super.connectedCallback(); + const dialog = this.renderRoot.querySelector('dialog'); + dialogPolyfill.registerDialog(dialog); + } + + get _leftButtonClasses() { + return fclasses({ + [c.transitionTitle]: true, + [c.titleButton]: true, + [c.titleButtonLeft]: true, + 'justify-self-start': true, + }); + } + + get _rightButtonClasses() { + return fclasses({ + [c.transitionTitle]: true, + [c.titleButton]: true, + [c.titleButtonRight]: true, + 'justify-self-end': true, + }); + } + + get _titleClasses() { + return fclasses({ + [c.transitionTitle]: true, + 'justify-self-center': !!this.left, + 'col-span-2': !this.left, + }); + } + + willUpdate() { + if (!this.open && this._scrollDoctorEnabled) { + // this.renderRoot.querySelector('dialog').close(); + // close dialog case + this.renderRoot.querySelector('dialog').close(); + teardown(); + this._removeSafariDialogHack(); + } + } + + updated() { + if (this.open && !this._scrollDoctorEnabled) { + // open dialog case + setup(this); + this._scrollDoctorEnabled = true; + // take note of where the focus is for later + this._activeEl = document.activeElement; + this.renderRoot.querySelector('dialog').showModal(); + this._applySafariDialogHack(); + } else if (!this.open && this._scrollDoctorEnabled) { + this._scrollDoctorEnabled = false; + this._activeEl?.focus(); + } + } + + _containerKeyDown(event) { + if (event.key === 'Escape') this._dismiss(); + } + + _containerClick(event) { + event.stopPropagation(); + } + + _dismiss(event) { + this.dispatchEvent(new CustomEvent('close')); + } + + get _leftButton() { + return html``; + } + + get _rightButton() { + return html``; + } + + _applySafariDialogHack() { + // Nasty workaround for Safari + VoiceOver to make sure surrounding content is not available for VoiceOver. + // Super important that these aria-hidden attributes are removed when the dialog is closed. + this._hiddenSurroundings = []; + ['previousElementSibling', 'nextElementSibling'].forEach((direction) => { + let el = this; + while (el !== document.body) { + if (el[direction]) { + el = el[direction]; + if (el.getAttribute('aria-hidden') !== 'true') { + this._hiddenSurroundings.push(el); + el.setAttribute('aria-hidden', 'true'); + } + } else el = el.parentNode; + } + }); + } + + _removeSafariDialogHack() { + this._hiddenSurroundings.forEach((el) => el.removeAttribute('aria-hidden')); + } + + render() { + return html` + ${this._fabricStylesheet} + +
+
+ ${this.left ? this._leftButton : ''} +
+

+ +

+
+ ${this.right ? this._rightButton : ''} +
+
+ +
+
+ +
+
+
+ `; + } +} + +if (!customElements.get('f-modal')) { + customElements.define('f-modal', FabricModal); +} + +export { FabricModal }; diff --git a/packages/modal/svgs.js b/packages/modal/svgs.js new file mode 100644 index 00000000..eb6223cc --- /dev/null +++ b/packages/modal/svgs.js @@ -0,0 +1,30 @@ +import { fclasses } from '../utils'; +import { html } from 'lit'; +import { modal as c } from '@fabric-ds/css/component-classes'; + +export const leftButtonSvg = html` + +`; + +export const rightButtonSvg = html` + + `; diff --git a/packages/modal/test.js b/packages/modal/test.js new file mode 100644 index 00000000..fb08c634 --- /dev/null +++ b/packages/modal/test.js @@ -0,0 +1,43 @@ +/* eslint-disable no-undef */ +import tap, { test, beforeEach, teardown } from 'tap'; +import { chromium } from 'playwright'; +import { addContentToPage } from '../../tests/utils/index.js'; + +tap.before(async () => { + const browser = await chromium.launch({ headless: true }); + tap.context.browser = browser; +}); + +beforeEach(async (t) => { + const { browser } = t.context; + const context = await browser.newContext(); + t.context.page = await context.newPage(); +}); + +teardown(async () => { + const { browser } = tap.context; + browser.close(); +}); + +test('Box component with no attributes is rendered on the page', async (t) => { + // GIVEN: A box component + const component = ` + +

This is a box

+
+ `; + + // WHEN: the component is added to the page + const page = await addContentToPage({ + page: t.context.page, + content: component, + }); + + // THEN: the component is visible in the DOM + const locator = await page.locator('f-box'); + t.equal((await locator.innerHTML()).trim(), '

This is a box

', 'HTML should be rendered'); + t.equal(await locator.getAttribute('bleed'), null, 'Bleed attribute should be null'); + t.equal(await locator.getAttribute('bordered'), null, 'Bordered attribute should be null'); + t.equal(await locator.getAttribute('info'), null, 'Info attribute should be null'); + t.equal(await locator.getAttribute('neutral'), null, 'Neutral attribute should be null'); +}); diff --git a/pages/components/modal.html b/pages/components/modal.html new file mode 100644 index 00000000..fd4cda40 --- /dev/null +++ b/pages/components/modal.html @@ -0,0 +1,142 @@ + + <%- include('head.html'); -%> + + + <%- include('nav.html'); -%> +
+

Modal

+

Modal...

+ +

Props

+ + + + + + + + + + + + + + + +
proptypedefault
open +
boolean
+
Opens the modal
+
false
+ +

Examples

+ Left back button + + + This is the main content +
+ + +
+
+
+
+ + This is the main content +
+ + +
+
+ +
+ Right close button + + + This is the main content +
+ + +
+
+
+
+ + This is the main content +
+ + +
+
+ +
+ Right close button with title + + +

This is a title

+ This is the main content +
+ + +
+
+
+
+ +

This is a title

+ This is the main content +
+ + +
+
+ +
+ Right close button with title and button in the default slot + + +

This is a title

+ This is the main content + +
+ + +
+
+
+
+ +

This is a title

+ This is the main content + +
+ +
+
+ +
+
+ <%- include('footer.html'); -%> +
+ + + + <%- include('scripts.html'); -%> + + diff --git a/pages/includes/nav.html b/pages/includes/nav.html index 0abc6126..04ebbce4 100644 --- a/pages/includes/nav.html +++ b/pages/includes/nav.html @@ -21,6 +21,10 @@ { "title": "Toast", "href": "/pages/components/toast.html" + }, + { + "title": "Modal", + "href": "/pages/components/modal.html" } ] }, diff --git a/vite.config.js b/vite.config.js index d78ce8a7..aae7d966 100644 --- a/vite.config.js +++ b/vite.config.js @@ -74,6 +74,11 @@ export default ({ mode }) => { template: 'pages/components/toast.html', injectOptions, }, + { + filename: 'modal.html', + template: 'pages/components/modal.html', + injectOptions, + }, { filename: 'index.html', template: 'index.html',