diff --git a/README.md b/README.md index 561a296e98d..0f0c7c8fcf7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ npm install @brightspace-ui/core * [Colors](components/colors/): color palette * [Dialogs](components/dialog/): generic and confirmation dialogs * [Dropdowns](components/dropdown/): dropdown openers and content containers + * [Expand Collapse](components/expand-collapse): component to create expandable and collapsible content * [Focus Trap](components/focus-trap/): generic container that traps focus * [Icons](components/icons/): iconography SVGs and web components * [Inputs](components/inputs/): text, search, select, checkbox and radio inputs diff --git a/components/expand-collapse/README.md b/components/expand-collapse/README.md new file mode 100644 index 00000000000..d00e05de036 --- /dev/null +++ b/components/expand-collapse/README.md @@ -0,0 +1,34 @@ +# Expand Collapse + +## Expand Collapse Content + +The `d2l-expand-collapse-content` element can be used to used to create expandable and collapsible content. This component only provides the logic to expand and collapse the content; controlling when and how it expands or collapses is the responsibility of the user. + +![Expand Collapse Content](./screenshots/expand-collapse-content.gif?raw=true) + +```html + + + +

My expand collapse content.

+
+``` + +**Properties:** + +- `expanded` (Boolean, default: `false`): Specifies the expanded/collapsed state of the content + +**Events:** + +- `d2l-expand-collapse-content-expand`: dispatched when the content starts to expand. The `detail` contains an `expandComplete` promise that can be waited on to determine when the content has finished expanding. +- `d2l-expand-collapse-content-collapse`: dispatched when the content starts to collapse. The `detail` contains a `collapseComplete` promise that can be waited on to determine when the content has finished collapsing. + +**Accessibility:** + +To make your usage of `d2l-expand-collapse-content` accessible, the [`aria-expanded` attribute](https://www.w3.org/TR/wai-aria/#aria-expanded) should be added to the element that controls expanding and collapsing the content with `"true"` or `"false"` to indicate that the content is expanded or collapsed. + +## Future Enhancements + +Looking for an enhancement not listed here? Create a GitHub issue! diff --git a/components/expand-collapse/demo/expand-collapse-content.html b/components/expand-collapse/demo/expand-collapse-content.html new file mode 100644 index 00000000000..05a98d38eda --- /dev/null +++ b/components/expand-collapse/demo/expand-collapse-content.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + +

Default

+ + + + +
+ + + + diff --git a/components/expand-collapse/expand-collapse-content.js b/components/expand-collapse/expand-collapse-content.js new file mode 100644 index 00000000000..387ea997a8a --- /dev/null +++ b/components/expand-collapse/expand-collapse-content.js @@ -0,0 +1,151 @@ +import { css, html, LitElement } from 'lit-element/lit-element.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; + +const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches; + +const states = { + PRECOLLAPSING: 'precollapsing', // setting up the styles so the collapse transition will run + COLLAPSING: 'collapsing', // in the process of collapsing + COLLAPSED: 'collapsed', // fully collapsed + PREEXPANDING: 'preexpanding', // setting up the styles so the expand transition will run + EXPANDING: 'expanding', // in the process of expanding + EXPANDED: 'expanded', // fully expanded +}; + +class ExpandCollapseContent extends LitElement { + + static get properties() { + return { + expanded: { type: Boolean, reflect: true }, + _height: { type: String }, + _state: { type: String } + }; + } + + static get styles() { + return css` + :host { + display: block; + } + + :host([hidden]) { + display: none; + } + + .d2l-expand-collapse-content-container { + display: none; + overflow: hidden; + transition: height 400ms cubic-bezier(0, 0.7, 0.5, 1); + } + + .d2l-expand-collapse-content-container:not([data-state="collapsed"]) { + display: block; + } + + .d2l-expand-collapse-content-container[data-state="expanded"] { + overflow: visible; + } + + /* prevent margin colapse on slotted children */ + .d2l-expand-collapse-content-inner:before, + .d2l-expand-collapse-content-inner:after { + content: ' '; + display: table; + } + + @media (prefers-reduced-motion: reduce) { + .d2l-expand-collapse-content-container { + transition: none; + } + } + `; + } + + constructor() { + super(); + this.expanded = false; + this._height = '0'; + this._isFirstUpdate = true; + this._state = states.COLLAPSED; + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('expanded')) { + this._expandedChanged(this.expanded, this._isFirstUpdate); + this._isFirstUpdate = false; + } + } + + render() { + const styles = { height: this._height }; + return html` +
+
+ +
+
+ `; + } + + async _expandedChanged(val, firstUpdate) { + const eventPromise = new Promise(resolve => this._eventPromiseResolve = resolve); + if (val) { + if (!firstUpdate) { + this.dispatchEvent(new CustomEvent( + 'd2l-expand-collapse-content-expand', + { bubbles: true, detail: { expandComplete: eventPromise } } + )); + } + if (reduceMotion || firstUpdate) { + this._state = states.EXPANDED; + this._height = 'auto'; + this._eventPromiseResolve(); + } else { + this._state = states.PREEXPANDING; + await this.updateComplete; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + if (this._state === states.PREEXPANDING) { + this._state = states.EXPANDING; + const content = this.shadowRoot.querySelector('.d2l-expand-collapse-content-inner'); + this._height = `${content.scrollHeight}px`; + } + } + } else { + if (!firstUpdate) { + this.dispatchEvent(new CustomEvent( + 'd2l-expand-collapse-content-collapse', + { bubbles: true, detail: { collapseComplete: eventPromise } } + )); + } + if (reduceMotion || firstUpdate) { + this._state = states.COLLAPSED; + this._height = '0'; + this._eventPromiseResolve(); + } else { + this._state = states.PRECOLLAPSING; + const content = this.shadowRoot.querySelector('.d2l-expand-collapse-content-inner'); + this._height = `${content.scrollHeight}px`; + await this.updateComplete; + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + if (this._state === states.PRECOLLAPSING) { + this._state = states.COLLAPSING; + this._height = '0'; + } + } + } + } + + _onTransitionEnd() { + if (this._state === states.EXPANDING) { + this._state = states.EXPANDED; + this._height = 'auto'; + this._eventPromiseResolve(); + } else if (this._state === states.COLLAPSING) { + this._state = states.COLLAPSED; + this._eventPromiseResolve(); + } + } + +} +customElements.define('d2l-expand-collapse-content', ExpandCollapseContent); diff --git a/components/expand-collapse/screenshots/expand-collapse-content.gif b/components/expand-collapse/screenshots/expand-collapse-content.gif new file mode 100644 index 00000000000..fe574050025 Binary files /dev/null and b/components/expand-collapse/screenshots/expand-collapse-content.gif differ diff --git a/components/expand-collapse/test/.eslintrc.json b/components/expand-collapse/test/.eslintrc.json new file mode 100644 index 00000000000..b3d86243e15 --- /dev/null +++ b/components/expand-collapse/test/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "brightspace/open-wc-testing-config" +} diff --git a/components/expand-collapse/test/expand-collapse-content.test.js b/components/expand-collapse/test/expand-collapse-content.test.js new file mode 100644 index 00000000000..540f8f5cb60 --- /dev/null +++ b/components/expand-collapse/test/expand-collapse-content.test.js @@ -0,0 +1,40 @@ +import '../expand-collapse-content.js'; +import { fixture, html } from '@open-wc/testing'; +import { runConstructor } from '../../../tools/constructor-test-helper.js'; + +const collapsedContentFixture = html`A message.`; +const expandedContentFixture = html`A message.`; + +describe('d2l-expand-collapse-content', () => { + + describe('constructor', () => { + + it('should construct', () => { + runConstructor('d2l-expand-collapse-content'); + }); + + }); + + describe('events', () => { + + it('should fire d2l-expand-collapse-content-expand event with complete promise', async() => { + const content = await fixture(collapsedContentFixture); + setTimeout(() => content.expanded = true); + const e = await new Promise(resolve => { + content.addEventListener('d2l-expand-collapse-content-expand', (e) => resolve(e), { once: true }); + }); + await e.detail.expandComplete; + }); + + it('should fire d2l-expand-collapse-content-collapse event with complete promise', async() => { + const content = await fixture(expandedContentFixture); + setTimeout(() => content.expanded = false); + const e = await new Promise(resolve => { + content.addEventListener('d2l-expand-collapse-content-collapse', (e) => resolve(e), { once: true }); + }); + await e.detail.collapseComplete; + }); + + }); + +}); diff --git a/components/expand-collapse/test/expand-collapse-content.visual-diff.html b/components/expand-collapse/test/expand-collapse-content.visual-diff.html new file mode 100644 index 00000000000..b5387962d76 --- /dev/null +++ b/components/expand-collapse/test/expand-collapse-content.visual-diff.html @@ -0,0 +1,46 @@ + + + + + + + + d2l-expand-collapse-content + + + + + + + +
+ + + +
+
+ + + +
+ + + diff --git a/components/expand-collapse/test/expand-collapse-content.visual-diff.js b/components/expand-collapse/test/expand-collapse-content.visual-diff.js new file mode 100644 index 00000000000..ff8906e1a02 --- /dev/null +++ b/components/expand-collapse/test/expand-collapse-content.visual-diff.js @@ -0,0 +1,30 @@ +const puppeteer = require('puppeteer'); +const VisualDiff = require('@brightspace-ui/visual-diff'); + +describe('d2l-expand-collapse-content', () => { + + const visualDiff = new VisualDiff('expand-collapse-content', __dirname); + + let browser, page; + + before(async() => { + browser = await puppeteer.launch(); + page = await visualDiff.createPage(browser, { viewport: { width: 400, height: 400 } }); + await page.goto(`${visualDiff.getBaseUrl()}/components/expand-collapse/test/expand-collapse-content.visual-diff.html`, { waitUntil: ['networkidle0', 'load'] }); + await page.bringToFront(); + }); + + after(async() => await browser.close()); + + [ + 'collapsed', + 'expanded' + ].forEach((testName) => { + it(testName, async function() { + const selector = `#${testName}`; + const rect = await visualDiff.getRect(page, selector); + await visualDiff.screenshotAndCompare(page, this.test.fullTitle(), { clip: rect }); + }); + }); + +}); diff --git a/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-collapsed.png b/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-collapsed.png new file mode 100644 index 00000000000..5061cc931e3 Binary files /dev/null and b/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-collapsed.png differ diff --git a/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-expanded.png b/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-expanded.png new file mode 100644 index 00000000000..aeba7d837d7 Binary files /dev/null and b/components/expand-collapse/test/screenshots/ci/golden/expand-collapse-content/d2l-expand-collapse-content-expanded.png differ diff --git a/index.html b/index.html index afe515bde60..aaf3093b06a 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,7 @@

Components

  • d2l-dropdown-tabs
  • +
  • d2l-expand-collapse-content
  • d2l-focus-trap
  • d2l-hierarchical-view
  • d2l-icon