diff --git a/.changeset/giant-scissors-grab.md b/.changeset/giant-scissors-grab.md new file mode 100644 index 000000000..c51520580 --- /dev/null +++ b/.changeset/giant-scissors-grab.md @@ -0,0 +1,10 @@ +--- +'@lion/ui/checkbox-group': patch +'@lion/ui/core': patch +'@lion/ui/drawer': patch +'@lion/ui/form-core': patch +'@lion/ui/input-tel': patch +'@lion/ui/input-tel-dropdown': patch +--- + +replaced import('lit-element') with import('lit') to fix tests, fixed test for SlotMixin diff --git a/.changeset/hot-poets-shout.md b/.changeset/hot-poets-shout.md new file mode 100644 index 000000000..eb26bdbee --- /dev/null +++ b/.changeset/hot-poets-shout.md @@ -0,0 +1,5 @@ +--- +'@lion/ui/drawer': patch +--- + +implemented lion-drawer diff --git a/docs/components/drawer/index.md b/docs/components/drawer/index.md new file mode 100644 index 000000000..a00890323 --- /dev/null +++ b/docs/components/drawer/index.md @@ -0,0 +1,3 @@ +# Drawer ||20 + +-> go to Overview diff --git a/docs/components/drawer/overview.md b/docs/components/drawer/overview.md new file mode 100644 index 000000000..75c31f73e --- /dev/null +++ b/docs/components/drawer/overview.md @@ -0,0 +1,68 @@ +# Drawer >> Overview ||10 + +A combination of a button (the invoker) and a chunk of 'extra content'. This web component can be extended with an +animation to disclose the extra content. + +There are three slots available respectively; `invoker` to specify the +drawer's invoker, `headline` for the optional headline and `content` for the content of the drawer. + +Through the `position` property, the drawer can be placed on the `top`, `left` or `right` of the viewport. + +```js script +import { html } from '@mdjs/mdjs-preview'; +import '@lion/ui/define/lion-drawer.js'; +import '@lion/ui/define/lion-icon.js'; +import { icons } from '@lion/ui/icon.js'; +import { demoStyle } from './src/demoStyle.js'; + +icons.addIconResolver('lion', (iconset, name) => { + switch (iconset) { + case 'misc': + return import('../icon/assets/iconset-misc.js').then(module => module[name]); + default: + throw new Error(`Unknown iconset ${iconset}`); + } +}); +``` + +```js preview-story +export const main = () => html` + +
+ + +

Headline

+
Hello! This is the content of the drawer
+ +
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` + +## Installation + +```bash +npm i --save @lion/ui +``` + +```js +import { LionDrawer } from '@lion/ui/drawer.js'; +// or +import '@lion/ui/define/lion-drawer.js'; +``` diff --git a/docs/components/drawer/src/demoStyle.js b/docs/components/drawer/src/demoStyle.js new file mode 100644 index 000000000..763a89c60 --- /dev/null +++ b/docs/components/drawer/src/demoStyle.js @@ -0,0 +1,71 @@ +import { css } from 'lit'; + +export const demoStyle = css` + .demo-container { + height: 400px; + display: flex; + flex-direction: row; + } + + .demo-container > div { + padding: 8px; + background-color: #f6f8fa; + } + + lion-drawer { + height: 400px; + } + + button { + all: revert !important; + border: 2px solid #000000; + background-color: rgb(239, 239, 239); + } + + .demo-container-top { + height: 400px; + display: flex; + flex-direction: column; + } + + .demo-container-top > div { + padding: 8px; + height: 100%; + background-color: #f6f8fa; + } + + .demo-container-top lion-drawer { + height: auto; + width: 100%; + } + + .demo-container-right { + height: 400px; + display: flex; + flex-direction: row-reverse; + } + + .demo-container-right > div { + padding: 8px; + background-color: #f6f8fa; + } + + .demo-container-right lion-drawer { + height: 400px; + } + + .demo-container-opened { + height: 400px; + display: flex; + flex-direction: row; + } + + .demo-container-opened > div { + padding: 8px; + background-color: #f6f8fa; + } + + .demo-container-opened lion-drawer { + height: 400px; + } +`; diff --git a/docs/components/drawer/use-cases.md b/docs/components/drawer/use-cases.md new file mode 100644 index 000000000..4ca44531d --- /dev/null +++ b/docs/components/drawer/use-cases.md @@ -0,0 +1,194 @@ +# Drawer >> Use Cases ||20 + +```js script +import { html } from '@mdjs/mdjs-preview'; +import { icons } from '@lion/ui/icon.js'; +import '@lion/ui/define/lion-drawer.js'; +import '@lion/ui/define/lion-icon.js'; +import { demoStyle } from './src/demoStyle.js'; + +icons.addIconResolver('lion', (iconset, name) => { + switch (iconset) { + case 'misc': + return import('../icon/assets/iconset-misc.js').then(module => module[name]); + default: + throw new Error(`Unknown iconset ${iconset}`); + } +}); +``` + +## Positioning + +### Default left + +By default, the drawer is positioned on the left side of the viewport. + +With the `position` property it can be positioned at the top or on the right of the screen. + +### Top + +```js preview-story +export const top = () => html` + +
+ + +

Headline

+
Hello! This is the content of the drawer
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` + +### Right + +```js preview-story +export const right = () => html` + +
+ + +

Headline

+
Hello! This is the content of the drawer
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` + +## Opened + +Add the `opened` attribute to display the drawer opened. + +```js preview-story +export const opened = () => html` + +
+ +

Headline

+
Hello! This is the content of the drawer
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` + +## Methods + +There are the following methods available to control the extra content for the drawer. + +- `toggle()`: toggle the extra content +- `show()`: show the extra content +- `hide()`: hide the extra content + +```js preview-story +export const methods = ({ shadowRoot }) => html` + +
+ + + +
+ +
+ + +

Headline

+
Hello! This is the content of the drawer
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` + +## Events + +`lion-drawer` fires an event on `invoker` click to notify the component's current state. It is useful for analytics purposes or to perform some actions while expanding and collapsing the component. + +- `@opened-changed`: triggers when drawer either gets opened or closed + +```js preview-story +export const events = ({ shadowRoot }) => html` + +
+ +
+
+ { + const collapsibleState = shadowRoot.querySelector('#collapsible-state'); + collapsibleState.innerText = `Opened: ${ev.target.opened}`; + }} + > + +

Headline

+
Hello! This is the content of the drawer
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum convallis, lorem sit amet + sollicitudin egestas, dui lectus sodales leo, quis luctus nulla metus vitae lacus. In at + imperdiet augue. Mauris mauris dolor, faucibus non nulla vel, vulputate hendrerit mauris. + Praesent dapibus leo nec libero scelerisque, ac venenatis ante tincidunt. Nulla maximus + vestibulum orci, ac viverra nisi molestie vel. Vivamus eget elit et turpis elementum tempor + ultricies at turpis. Ut pretium aliquet finibus. Duis ullamcorper ultrices velit id luctus. + Phasellus in ex luctus, interdum ex vel, eleifend dolor. Cras massa odio, sodales quis + consectetur a, blandit eu purus. Donec ut gravida libero, sed accumsan arcu. +
+
+`; +``` diff --git a/package-lock.json b/package-lock.json index 34c5b2914..9d680c4b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2880,6 +2880,28 @@ "resolved": "packages/ajax", "link": true }, + "node_modules/@lion/collapsible": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@lion/collapsible/-/collapsible-0.9.1.tgz", + "integrity": "sha512-M+62Un4HQMhTzdG885TBJL52Ji7TSNPYAg0+FQk6kb3QHdz255lWkRTp6G94w1iE1K6IsYO1g+KyibXvtEbbug==", + "dependencies": { + "@lion/core": "^0.24.0" + } + }, + "node_modules/@lion/core": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@lion/core/-/core-0.24.0.tgz", + "integrity": "sha512-hC5Fpi5U3PY0HOVycSev1jzoE8DYHFSN42s5gt6g6RlvvRYN5Pou0wtKnDOkOYf1UfjuL+T/4r8W99UFD1r/Eg==", + "dependencies": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@open-wc/scoped-elements": "^2.1.1", + "lit": "^2.0.2" + } + }, + "node_modules/@lion/drawer": { + "resolved": "packages/drawer", + "link": true + }, "node_modules/@lion/ui": { "resolved": "packages/ui", "link": true @@ -23199,7 +23221,7 @@ "license": "MIT" }, "packages-node/providence-analytics": { - "version": "0.13.0", + "version": "0.14.0", "license": "MIT", "dependencies": { "@babel/core": "^7.10.1", @@ -23358,7 +23380,7 @@ }, "packages/ajax": { "name": "@lion/ajax", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT" }, "packages/button": { @@ -23449,6 +23471,14 @@ "@lion/overlays": "^0.33.2" } }, + "packages/drawer": { + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@lion/collapsible": "^0.9.1", + "@lion/core": "^0.24.0" + } + }, "packages/fieldset": { "name": "@lion/fieldset", "version": "0.22.1", @@ -23738,7 +23768,7 @@ } }, "packages/singleton-manager": { - "version": "1.5.0", + "version": "1.6.0", "license": "MIT" }, "packages/steps": { @@ -23803,7 +23833,7 @@ "awesome-phonenumber": "^3.0.1", "ibantools": "^2.2.0", "lit": "^2.4.0", - "singleton-manager": "^1.5.0" + "singleton-manager": "^1.6.0" } }, "packages/validate-messages": { @@ -25903,6 +25933,31 @@ "@lion/ajax": { "version": "file:packages/ajax" }, + "@lion/collapsible": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@lion/collapsible/-/collapsible-0.9.1.tgz", + "integrity": "sha512-M+62Un4HQMhTzdG885TBJL52Ji7TSNPYAg0+FQk6kb3QHdz255lWkRTp6G94w1iE1K6IsYO1g+KyibXvtEbbug==", + "requires": { + "@lion/core": "^0.24.0" + } + }, + "@lion/core": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@lion/core/-/core-0.24.0.tgz", + "integrity": "sha512-hC5Fpi5U3PY0HOVycSev1jzoE8DYHFSN42s5gt6g6RlvvRYN5Pou0wtKnDOkOYf1UfjuL+T/4r8W99UFD1r/Eg==", + "requires": { + "@open-wc/dedupe-mixin": "^1.3.0", + "@open-wc/scoped-elements": "^2.1.1", + "lit": "^2.0.2" + } + }, + "@lion/drawer": { + "version": "file:packages/drawer", + "requires": { + "@lion/collapsible": "^0.9.1", + "@lion/core": "^0.24.0" + } + }, "@lion/ui": { "version": "file:packages/ui", "requires": { @@ -25913,7 +25968,7 @@ "awesome-phonenumber": "^3.0.1", "ibantools": "^2.2.0", "lit": "^2.4.0", - "singleton-manager": "^1.5.0" + "singleton-manager": "^1.6.0" } }, "@lit/reactive-element": { diff --git a/packages/drawer/docs/overview.md b/packages/drawer/docs/overview.md new file mode 100644 index 000000000..2474c9525 --- /dev/null +++ b/packages/drawer/docs/overview.md @@ -0,0 +1,3 @@ +# Lion Drawer Overview + +[=> See Source <=](../../../docs/components/drawer/overview.md) diff --git a/packages/drawer/docs/use-cases.md b/packages/drawer/docs/use-cases.md new file mode 100644 index 000000000..b1a4b2f5f --- /dev/null +++ b/packages/drawer/docs/use-cases.md @@ -0,0 +1,3 @@ +# Lion Drawer Use Cases + +[=> See Source <=](../../../docs/components/drawer/use-cases.md) diff --git a/packages/drawer/index.js b/packages/drawer/index.js new file mode 100644 index 000000000..e2fc22533 --- /dev/null +++ b/packages/drawer/index.js @@ -0,0 +1 @@ +export { LionDrawer } from './src/LionDrawer.js'; diff --git a/packages/drawer/lion-drawer.js b/packages/drawer/lion-drawer.js new file mode 100644 index 000000000..ceca5c144 --- /dev/null +++ b/packages/drawer/lion-drawer.js @@ -0,0 +1,3 @@ +import { LionDrawer } from './src/LionDrawer.js'; + +customElements.define('lion-drawer', LionDrawer); diff --git a/packages/drawer/package.json b/packages/drawer/package.json new file mode 100644 index 000000000..e2e921b21 --- /dev/null +++ b/packages/drawer/package.json @@ -0,0 +1,58 @@ +{ + "name": "@lion/drawer", + "version": "0.0.0", + "description": "Drawer that can be expanded to reveal it contents", + "license": "MIT", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/drawer" + }, + "type": "module", + "exports": { + ".": "./index.js", + "./define": "./lion-drawer.js", + "./docs/*": "./docs/*" + }, + "main": "index.js", + "module": "index.js", + "files": [ + "*.d.ts", + "*.js", + "custom-elements.json", + "docs", + "src", + "test", + "test-helpers", + "translations", + "types" + ], + "scripts": { + "custom-elements-manifest": "custom-elements-manifest analyze --litelement --exclude \"docs/**/*\" \"test-helpers/**/*\"", + "debug": "cd ../../ && npm run debug -- --group drawer", + "debug:firefox": "cd ../../ && npm run debug:firefox -- --group drawer", + "debug:webkit": "cd ../../ && npm run debug:webkit -- --group drawer", + "publish-docs": "node ../../packages-node/publish-docs/src/cli.js --github-url https://github.com/ing-bank/lion/ --git-root-dir ../../", + "prepublishOnly": "npm run publish-docs && npm run custom-elements-manifest", + "test": "cd ../../ && npm run test:browser -- --group drawer" + }, + "sideEffects": [ + "lion-drawer.js", + "./docs/styled-drawer-content.js" + ], + "dependencies": { + "@lion/collapsible": "^0.9.1", + "@lion/core": "^0.24.0" + }, + "keywords": [ + "drawer", + "lion", + "web-components" + ], + "publishConfig": { + "access": "public" + }, + "customElements": "custom-elements.json" +} diff --git a/packages/drawer/src/LionDrawer.js b/packages/drawer/src/LionDrawer.js new file mode 100644 index 000000000..346954edd --- /dev/null +++ b/packages/drawer/src/LionDrawer.js @@ -0,0 +1,217 @@ +import { html } from '@lion/core'; +import { LionCollapsible } from '@lion/collapsible'; +import { drawerStyle } from './drawerStyle.js'; + +const EVENT = { + TRANSITION_END: 'transitionend', + TRANSITION_START: 'transitionstart', +}; + +export class LionDrawer extends LionCollapsible { + static get properties() { + return { + transitioning: { + type: Boolean, + reflect: true, + }, + opened: { + type: Boolean, + reflect: true, + }, + position: { + type: String, + reflect: true, + }, + }; + } + + constructor() { + super(); + + /** @private */ + this.__toggle = () => { + this.opened = !this.opened; + }; + } + + connectedCallback() { + super.connectedCallback(); + + if (!this.hasAttribute('position')) { + this.position = 'left'; + } + + if (this._contentNode) { + this._contentNode.style.setProperty('display', ''); + } + + this.__setBoundaries(); + } + + /** + * Update aria labels on state change. + * @param {import('@lion/core').PropertyValues } changedProperties + */ + updated(changedProperties) { + if (changedProperties.has('opened')) { + this._openedChanged(); + } + } + + static get styles() { + return [drawerStyle]; + } + + __setBoundaries() { + const host = this.shadowRoot?.host; + + if (this.position === 'top') { + this.minHeight = host ? getComputedStyle(host).getPropertyValue('--min-height') : '0px'; + this.maxHeight = host ? getComputedStyle(host).getPropertyValue('--max-height') : '0px'; + this.minWidth = '0px'; + this.maxWidth = 'none'; + } else { + this.minWidth = host ? getComputedStyle(host).getPropertyValue('--min-width') : '0px'; + this.maxWidth = host ? getComputedStyle(host).getPropertyValue('--max-width') : '0px'; + this.minHeight = 'auto'; + this.maxHeight = 'fit-content'; + } + + setTimeout(() => { + const prop = this.position === 'top' ? 'width' : 'height'; + + if (this.__contentNode) { + this.__contentNode.style.setProperty(prop, ''); + } + }); + } + + /** + * Setter for position property, available values are 'top', 'left' and 'right' + * @param {String} position + */ + set position(position) { + const stale = this.position; + this._position = position; + this.setAttribute('position', position); + + this.__setBoundaries(); + this.requestUpdate('position', stale); + } + + get position() { + return this._position ?? 'left'; + } + + /** + * Trigger show animation and wait for transition to be finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @override + */ + async _showAnimation({ contentNode }) { + const min = this.position === 'top' ? this.minHeight : this.minWidth; + const max = this.position === 'top' ? this.maxHeight : this.maxWidth; + const prop = this.position === 'top' ? 'height' : 'width'; + + contentNode.style.setProperty(prop, /** @type {string} */ (min)); + await new Promise(resolve => requestAnimationFrame(() => resolve(true))); + contentNode.style.setProperty(prop, /** @type {string} */ (max)); + await this._waitForTransition({ contentNode }); + } + + /** + * Trigger hide animation and wait for transition to be finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @override + */ + async _hideAnimation({ contentNode }) { + if ( + ((this.position === 'left' || this.position === 'right') && + this._contentWidth === this.minWidth) || + (this.position === 'top' && this._contentHeight === this.minHeight) + ) { + return; + } + + const min = this.position === 'top' ? this.minHeight : this.minWidth; + const prop = this.position === 'top' ? 'height' : 'width'; + + contentNode.style.setProperty(prop, /** @type {string} */ (min)); + await this._waitForTransition({ contentNode }); + } + + /** + * Wait until the transition event is finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @returns {Promise} transition event + */ + _waitForTransition({ contentNode }) { + return new Promise(resolve => { + const transitionStarted = () => { + contentNode.removeEventListener(EVENT.TRANSITION_START, transitionStarted); + this.transitioning = true; + }; + contentNode.addEventListener(EVENT.TRANSITION_START, transitionStarted); + + const transitionEnded = () => { + contentNode.removeEventListener(EVENT.TRANSITION_END, transitionEnded); + this.transitioning = false; + resolve(); + }; + contentNode.addEventListener(EVENT.TRANSITION_END, transitionEnded); + }); + } + + /** + * @protected + */ + get __contentNode() { + return /** @type {HTMLElement} */ (this.shadowRoot?.querySelector('.container')); + } + + get _contentWidth() { + const size = this.__contentNode?.getBoundingClientRect().width || 0; + return `${size}px`; + } + + get _contentHeight() { + const size = this.__contentNode?.getBoundingClientRect().height || 0; + return `${size}px`; + } + + _openedChanged() { + this._updateContentSize(); + if (this._invokerNode) { + this._invokerNode.setAttribute('aria-expanded', `${this.opened}`); + } + + this.dispatchEvent(new CustomEvent('opened-changed')); + } + + async _updateContentSize() { + if (this.__contentNode) { + if (this.opened) { + await this._showAnimation({ contentNode: this.__contentNode }); + } else { + await this._hideAnimation({ contentNode: this.__contentNode }); + } + } + } + + render() { + return html` +
+
+ + +
+
+ +
+
+ `; + } +} diff --git a/packages/drawer/src/drawerStyle.js b/packages/drawer/src/drawerStyle.js new file mode 100644 index 000000000..c05779daf --- /dev/null +++ b/packages/drawer/src/drawerStyle.js @@ -0,0 +1,60 @@ +import { css } from '@lion/core'; + +export const drawerStyle = css` + :host { + display: block; + height: 100%; + --min-width: 72px; + --max-width: 320px; + --min-height: auto; + --max-height: fit-content; + --start-width: var(--min-width); + --start-height: 100%; + --transition-property: width; + } + + :host([position='top']) { + width: 100%; + --min-width: 0px; + --max-width: none; + --min-height: 50px; + --max-height: 200px; + --start-width: 100%; + --start-height: var(--min-height); + --transition-property: height; + } + + .container { + display: flex; + flex-direction: column; + width: var(--start-width); + height: var(--start-height); + min-width: var(--min-width); + max-width: var(--max-width); + min-height: var(--min-height); + max-height: var(--max-height); + overflow: hidden; + box-sizing: border-box; + transition: var(--transition-property) 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); + } + + .headline-container { + display: flex; + align-items: center; + flex-wrap: nowrap; + height: 16px; + } + + :host([position='right']) .headline-container { + flex-direction: row-reverse; + } + + .content-container { + overflow: hidden; + flex-grow: 1; + } + + ::slotted([slot='content']) { + width: var(--max-width); + } +`; diff --git a/packages/drawer/test/lion-drawer.test.js b/packages/drawer/test/lion-drawer.test.js new file mode 100644 index 000000000..bc71f60ab --- /dev/null +++ b/packages/drawer/test/lion-drawer.test.js @@ -0,0 +1,110 @@ +import { expect, fixture as _fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; + +import '@lion/drawer/define'; + +/** + * @typedef {import('../src/LionDrawer').LionDrawer} LionDrawer + * @typedef {import('@lion/core').TemplateResult} TemplateResult + */ +const fixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + +const template = html` + + +

Headline

+
This is the content of the drawer
+
+`; + +describe('', () => { + describe('Drawer', () => { + it('sets position to "left" by default', async () => { + const drawer = await fixture(template); + expect(drawer.position).to.equal('left'); + }); + + it('has [position] attribute which serves as styling hook', async () => { + const drawer = await fixture(template); + expect(drawer).to.have.attribute('position').equal('left'); + }); + + it('sets the minimum and maximum width when position=left', async () => { + const drawer = await fixture(template); + const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width'); + const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width'); + + expect(drawer.minWidth).to.equal(minWidth); + expect(drawer.maxWidth).to.equal(maxWidth); + }); + + it('sets the minimum and maximum width when position=right', async () => { + const drawer = await fixture(template); + drawer.position = 'right'; + await drawer.updateComplete; + + const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width'); + const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width'); + + expect(drawer.minWidth).to.equal(minWidth); + expect(drawer.maxWidth).to.equal(maxWidth); + }); + + it('sets the minimum and maximum height when position=top', async () => { + const drawer = await fixture(template); + drawer.position = 'top'; + await drawer.updateComplete; + + const minHeight = getComputedStyle(drawer).getPropertyValue('--min-height'); + const maxHeight = getComputedStyle(drawer).getPropertyValue('--max-height'); + + expect(drawer.minHeight).to.equal(minHeight); + expect(drawer.maxHeight).to.equal(maxHeight); + }); + }); + + describe('Accessibility', () => { + it('[collapsed] is a11y AXE accessible', async () => { + const drawer = await fixture(template); + await expect(drawer).to.be.accessible(); + }); + + it('[opened] is a11y AXE accessible', async () => { + const drawer = await fixture(template); + drawer.opened = true; + await expect(drawer).to.be.accessible(); + }); + + describe('Invoker', () => { + it('links id of content items to invoker via [aria-controls]', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + const content = drawerElement.querySelector('[slot=content]'); + expect(invoker?.getAttribute('aria-controls')).to.equal(content?.id); + }); + + it('adds aria-expanded="false" to invoker when its content is not expanded', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + expect(invoker).to.have.attribute('aria-expanded', 'false'); + }); + + it('adds aria-expanded="true" to invoker when its content is expanded', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + drawerElement.opened = true; + await drawerElement.updateComplete; + expect(invoker).to.have.attribute('aria-expanded', 'true'); + }); + }); + + describe('Contents', () => { + it('adds aria-labelledby referring to invoker id', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + const content = drawerElement.querySelector('[slot=content]'); + expect(content).to.have.attribute('aria-labelledby', invoker?.id); + }); + }); + }); +}); diff --git a/packages/ui/components/drawer/src/LionDrawer.js b/packages/ui/components/drawer/src/LionDrawer.js new file mode 100644 index 000000000..dc6cdcdd4 --- /dev/null +++ b/packages/ui/components/drawer/src/LionDrawer.js @@ -0,0 +1,217 @@ +import { html } from 'lit'; +import { LionCollapsible } from '@lion/ui/collapsible.js'; +import { drawerStyle } from './drawerStyle.js'; + +const EVENT = { + TRANSITION_END: 'transitionend', + TRANSITION_START: 'transitionstart', +}; + +export class LionDrawer extends LionCollapsible { + static get properties() { + return { + transitioning: { + type: Boolean, + reflect: true, + }, + opened: { + type: Boolean, + reflect: true, + }, + position: { + type: String, + reflect: true, + }, + }; + } + + constructor() { + super(); + + /** @private */ + this.__toggle = () => { + this.opened = !this.opened; + }; + } + + connectedCallback() { + super.connectedCallback(); + + if (!this.hasAttribute('position')) { + this.position = 'left'; + } + + if (this._contentNode) { + this._contentNode.style.setProperty('display', ''); + } + + this.__setBoundaries(); + } + + /** + * Update aria labels on state change. + * @param {import('@lion/core').PropertyValues } changedProperties + */ + updated(changedProperties) { + if (changedProperties.has('opened')) { + this._openedChanged(); + } + } + + static get styles() { + return [drawerStyle]; + } + + __setBoundaries() { + const host = this.shadowRoot?.host; + + if (this.position === 'top') { + this.minHeight = host ? getComputedStyle(host).getPropertyValue('--min-height') : '0px'; + this.maxHeight = host ? getComputedStyle(host).getPropertyValue('--max-height') : '0px'; + this.minWidth = '0px'; + this.maxWidth = 'none'; + } else { + this.minWidth = host ? getComputedStyle(host).getPropertyValue('--min-width') : '0px'; + this.maxWidth = host ? getComputedStyle(host).getPropertyValue('--max-width') : '0px'; + this.minHeight = 'auto'; + this.maxHeight = 'fit-content'; + } + + setTimeout(() => { + const prop = this.position === 'top' ? 'width' : 'height'; + + if (this.__contentNode) { + this.__contentNode.style.setProperty(prop, ''); + } + }); + } + + /** + * Setter for position property, available values are 'top', 'left' and 'right' + * @param {String} position + */ + set position(position) { + const stale = this.position; + this._position = position; + this.setAttribute('position', position); + + this.__setBoundaries(); + this.requestUpdate('position', stale); + } + + get position() { + return this._position ?? 'left'; + } + + /** + * Trigger show animation and wait for transition to be finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @override + */ + async _showAnimation({ contentNode }) { + const min = this.position === 'top' ? this.minHeight : this.minWidth; + const max = this.position === 'top' ? this.maxHeight : this.maxWidth; + const prop = this.position === 'top' ? 'height' : 'width'; + + contentNode.style.setProperty(prop, /** @type {string} */ (min)); + await new Promise(resolve => requestAnimationFrame(() => resolve(true))); + contentNode.style.setProperty(prop, /** @type {string} */ (max)); + await this._waitForTransition({ contentNode }); + } + + /** + * Trigger hide animation and wait for transition to be finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @override + */ + async _hideAnimation({ contentNode }) { + if ( + ((this.position === 'left' || this.position === 'right') && + this._contentWidth === this.minWidth) || + (this.position === 'top' && this._contentHeight === this.minHeight) + ) { + return; + } + + const min = this.position === 'top' ? this.minHeight : this.minWidth; + const prop = this.position === 'top' ? 'height' : 'width'; + + contentNode.style.setProperty(prop, /** @type {string} */ (min)); + await this._waitForTransition({ contentNode }); + } + + /** + * Wait until the transition event is finished. + * @param {Object} options - element node and its options + * @param {HTMLElement} options.contentNode + * @returns {Promise} transition event + */ + _waitForTransition({ contentNode }) { + return new Promise(resolve => { + const transitionStarted = () => { + contentNode.removeEventListener(EVENT.TRANSITION_START, transitionStarted); + this.transitioning = true; + }; + contentNode.addEventListener(EVENT.TRANSITION_START, transitionStarted); + + const transitionEnded = () => { + contentNode.removeEventListener(EVENT.TRANSITION_END, transitionEnded); + this.transitioning = false; + resolve(); + }; + contentNode.addEventListener(EVENT.TRANSITION_END, transitionEnded); + }); + } + + /** + * @protected + */ + get __contentNode() { + return /** @type {HTMLElement} */ (this.shadowRoot?.querySelector('.container')); + } + + get _contentWidth() { + const size = this.__contentNode?.getBoundingClientRect().width || 0; + return `${size}px`; + } + + get _contentHeight() { + const size = this.__contentNode?.getBoundingClientRect().height || 0; + return `${size}px`; + } + + _openedChanged() { + this._updateContentSize(); + if (this._invokerNode) { + this._invokerNode.setAttribute('aria-expanded', `${this.opened}`); + } + + this.dispatchEvent(new CustomEvent('opened-changed')); + } + + async _updateContentSize() { + if (this.__contentNode) { + if (this.opened) { + await this._showAnimation({ contentNode: this.__contentNode }); + } else { + await this._hideAnimation({ contentNode: this.__contentNode }); + } + } + } + + render() { + return html` +
+
+ + +
+
+ +
+
+ `; + } +} diff --git a/packages/ui/components/drawer/src/drawerStyle.js b/packages/ui/components/drawer/src/drawerStyle.js new file mode 100644 index 000000000..d55547b60 --- /dev/null +++ b/packages/ui/components/drawer/src/drawerStyle.js @@ -0,0 +1,59 @@ +import { css } from 'lit'; + +export const drawerStyle = css` + :host { + display: block; + height: 100%; + --min-width: 72px; + --max-width: 320px; + --min-height: auto; + --max-height: fit-content; + --start-width: var(--min-width); + --start-height: 100%; + --transition-property: width; + } + + :host([position='top']) { + width: 100%; + --min-width: 0px; + --max-width: none; + --min-height: 50px; + --max-height: 200px; + --start-width: 100%; + --start-height: var(--min-height); + --transition-property: height; + } + + .container { + display: flex; + flex-direction: column; + width: var(--start-width); + height: var(--start-height); + min-width: var(--min-width); + max-width: var(--max-width); + min-height: var(--min-height); + max-height: var(--max-height); + overflow: hidden; + box-sizing: border-box; + transition: var(--transition-property) 0.3s cubic-bezier(0.25, 0.1, 0.25, 1); + } + + .headline-container { + display: flex; + align-items: center; + flex-wrap: nowrap; + } + + :host([position='right']) .headline-container { + flex-direction: row-reverse; + } + + .content-container { + overflow: hidden; + flex-grow: 1; + } + + ::slotted([slot='content']) { + width: var(--max-width); + } +`; diff --git a/packages/ui/components/drawer/test/lion-drawer.test.js b/packages/ui/components/drawer/test/lion-drawer.test.js new file mode 100644 index 000000000..a57b4fde9 --- /dev/null +++ b/packages/ui/components/drawer/test/lion-drawer.test.js @@ -0,0 +1,109 @@ +import { expect, fixture as _fixture } from '@open-wc/testing'; +import { html } from 'lit/static-html.js'; +import '@lion/ui/define/lion-drawer.js'; + +/** + * @typedef {import('../src/LionDrawer').LionDrawer} LionDrawer + * @typedef {import('lit').TemplateResult} TemplateResult + */ +const fixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + +const template = html` + + +

Headline

+
This is the content of the drawer
+
+`; + +describe('', () => { + describe('Drawer', () => { + it('sets position to "left" by default', async () => { + const drawer = await fixture(template); + expect(drawer.position).to.equal('left'); + }); + + it('has [position] attribute which serves as styling hook', async () => { + const drawer = await fixture(template); + expect(drawer).to.have.attribute('position').equal('left'); + }); + + it('sets the minimum and maximum width when position=left', async () => { + const drawer = await fixture(template); + const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width'); + const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width'); + + expect(drawer.minWidth).to.equal(minWidth); + expect(drawer.maxWidth).to.equal(maxWidth); + }); + + it('sets the minimum and maximum width when position=right', async () => { + const drawer = await fixture(template); + drawer.position = 'right'; + await drawer.updateComplete; + + const minWidth = getComputedStyle(drawer).getPropertyValue('--min-width'); + const maxWidth = getComputedStyle(drawer).getPropertyValue('--max-width'); + + expect(drawer.minWidth).to.equal(minWidth); + expect(drawer.maxWidth).to.equal(maxWidth); + }); + + it('sets the minimum and maximum height when position=top', async () => { + const drawer = await fixture(template); + drawer.position = 'top'; + await drawer.updateComplete; + + const minHeight = getComputedStyle(drawer).getPropertyValue('--min-height'); + const maxHeight = getComputedStyle(drawer).getPropertyValue('--max-height'); + + expect(drawer.minHeight).to.equal(minHeight); + expect(drawer.maxHeight).to.equal(maxHeight); + }); + }); + + describe('Accessibility', () => { + it('[collapsed] is a11y AXE accessible', async () => { + const drawer = await fixture(template); + await expect(drawer).to.be.accessible(); + }); + + it('[opened] is a11y AXE accessible', async () => { + const drawer = await fixture(template); + drawer.opened = true; + await expect(drawer).to.be.accessible(); + }); + + describe('Invoker', () => { + it('links id of content items to invoker via [aria-controls]', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + const content = drawerElement.querySelector('[slot=content]'); + expect(invoker?.getAttribute('aria-controls')).to.equal(content?.id); + }); + + it('adds aria-expanded="false" to invoker when its content is not expanded', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + expect(invoker).to.have.attribute('aria-expanded', 'false'); + }); + + it('adds aria-expanded="true" to invoker when its content is expanded', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + drawerElement.opened = true; + await drawerElement.updateComplete; + expect(invoker).to.have.attribute('aria-expanded', 'true'); + }); + }); + + describe('Contents', () => { + it('adds aria-labelledby referring to invoker id', async () => { + const drawerElement = await fixture(template); + const invoker = drawerElement.querySelector('[slot=invoker]'); + const content = drawerElement.querySelector('[slot=content]'); + expect(content).to.have.attribute('aria-labelledby', invoker?.id); + }); + }); + }); +}); diff --git a/packages/ui/exports/define/lion-drawer.js b/packages/ui/exports/define/lion-drawer.js new file mode 100644 index 000000000..5ec1d68ce --- /dev/null +++ b/packages/ui/exports/define/lion-drawer.js @@ -0,0 +1,3 @@ +import { LionDrawer } from '../drawer.js'; + +customElements.define('lion-drawer', LionDrawer); diff --git a/packages/ui/exports/drawer.js b/packages/ui/exports/drawer.js new file mode 100644 index 000000000..551219947 --- /dev/null +++ b/packages/ui/exports/drawer.js @@ -0,0 +1 @@ +export { LionDrawer } from '../components/drawer/src/LionDrawer.js';