diff --git a/packages/switch/README.md b/packages/switch/README.md new file mode 100644 index 000000000..522c3a160 --- /dev/null +++ b/packages/switch/README.md @@ -0,0 +1,29 @@ +# Switch + +[//]: # 'AUTO INSERT HEADER PREPUBLISH' + +`lion-input-switch` is a component that is used to toggle a property or feature on or off. + +## Features + +- Get or set the checked state (boolean) - `choiceChecked()` +- Get or set the value of the choice - `choiceValue()` +- Pre-select an option by setting the `checked` boolean attribute + +## How to use + +### Installation + +```sh +npm i --save @lion/switch +``` + +```js +import '@lion/swith/lion-input-switch.js'; +``` + +### Example + +```html + +``` diff --git a/packages/switch/index.js b/packages/switch/index.js new file mode 100644 index 000000000..5bc463170 --- /dev/null +++ b/packages/switch/index.js @@ -0,0 +1,2 @@ +export { LionInputSwitch } from './src/LionInputSwitch.js'; +export { LionButtonSwitch } from './src/LionButtonSwitch.js'; diff --git a/packages/switch/lion-button-switch.js b/packages/switch/lion-button-switch.js new file mode 100644 index 000000000..6f3254fcf --- /dev/null +++ b/packages/switch/lion-button-switch.js @@ -0,0 +1,3 @@ +import { LionButtonSwitch } from './src/LionButtonSwitch.js'; + +customElements.define('lion-button-switch', LionButtonSwitch); diff --git a/packages/switch/lion-input-switch.js b/packages/switch/lion-input-switch.js new file mode 100644 index 000000000..93780c618 --- /dev/null +++ b/packages/switch/lion-input-switch.js @@ -0,0 +1,3 @@ +import { LionInputSwitch } from './src/LionInputSwitch.js'; + +customElements.define('lion-input-switch', LionInputSwitch); diff --git a/packages/switch/package.json b/packages/switch/package.json new file mode 100644 index 000000000..671b620f0 --- /dev/null +++ b/packages/switch/package.json @@ -0,0 +1,48 @@ +{ + "name": "@lion/switch", + "version": "0.0.1", + "description": "A Switch is used for switching a property or feature on and off", + "author": "ing-bank", + "homepage": "https://github.com/ing-bank/lion/", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/ing-bank/lion.git", + "directory": "packages/switch" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "switch" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "docs", + "src", + "stories", + "test", + "translations", + "*.js" + ], + "dependencies": { + "@lion/button": "^0.3.29", + "@lion/choice-input": "^0.2.39", + "@lion/core": "^0.2.1", + "@lion/field": "^0.3.3" + }, + "devDependencies": { + "@lion/form": "^0.1.68", + "@lion/localize": "^0.4.20", + "@lion/validate": "^0.2.37", + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^2.3.4", + "sinon": "^7.2.2" + } +} diff --git a/packages/switch/src/LionButtonSwitch.js b/packages/switch/src/LionButtonSwitch.js new file mode 100644 index 000000000..00f6c2b45 --- /dev/null +++ b/packages/switch/src/LionButtonSwitch.js @@ -0,0 +1,105 @@ +import { LionButton } from '@lion/button'; +import { html, css } from '@lion/core'; + +export class LionButtonSwitch extends LionButton { + static get properties() { + return { + checked: { + type: Boolean, + reflect: true, + }, + }; + } + + static get styles() { + return [ + ...super.styles, + css` + :host { + display: inline-block; + position: relative; + width: 36px; + height: 16px; + /* Override "button" styles */ + min-height: auto; + padding: 0; + } + + .btn { + height: 100%; + /* Override "button" styles */ + min-height: auto; + padding: 0; + } + + .button-switch__track { + background: #eee; + width: 100%; + height: 100%; + } + + .button-switch__thumb { + background: #ccc; + width: 50%; + height: 100%; + position: absolute; + top: 0; + } + + :host([checked]) .button-switch__thumb { + right: 0; + } + `, + ]; + } + + // eslint-disable-next-line class-methods-use-this + _renderBefore() { + return html` +
+
+ `; + } + + constructor() { + super(); + this.role = 'switch'; + this.checked = false; + this.addEventListener('click', this.__handleToggleStateChange); + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('aria-checked', `${this.checked}`); + } + + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + this.removeAttribute('type'); + } + + __handleToggleStateChange() { + if (this.disabled) { + return; + } + this.checked = !this.checked; + this.dispatchEvent( + new Event('checked-changed', { + composed: true, + bubbles: true, + }), + ); + } + + /** + * We synchronously update aria-checked to support voice over on safari. + * + * @override + */ + _requestUpdate(name, oldValue) { + super._requestUpdate(name, oldValue); + if (this.isConnected && name === 'checked') { + this.setAttribute('aria-checked', `${this.checked}`); + } + } +} diff --git a/packages/switch/src/LionInputSwitch.js b/packages/switch/src/LionInputSwitch.js new file mode 100644 index 000000000..9ba3fbe22 --- /dev/null +++ b/packages/switch/src/LionInputSwitch.js @@ -0,0 +1,61 @@ +import { html, css } from '@lion/core'; +import { LionField } from '@lion/field'; +import { ChoiceInputMixin } from '@lion/choice-input'; + +import '../lion-button-switch.js'; + +export class LionInputSwitch extends ChoiceInputMixin(LionField) { + static get styles() { + return [ + super.styles, + css` + :host([disabled]) { + color: #adadad; + } + `, + ]; + } + + get slots() { + return { + ...super.slots, + input: () => document.createElement('lion-button-switch'), + }; + } + + render() { + return html` +
+ + + +
+ + `; + } + + connectedCallback() { + super.connectedCallback(); + this.inputElement.addEventListener( + 'checked-changed', + this.__handleButtonSwitchCheckedChanged.bind(this), + ); + this._syncButtonSwitch(); + } + + updated(changedProperties) { + super.updated(changedProperties); + this._syncButtonSwitch(); + } + + __handleButtonSwitchCheckedChanged() { + // TODO: should be replaced by "_inputNode" after the next breaking change + // https://github.com/ing-bank/lion/blob/master/packages/field/src/FormControlMixin.js#L78 + this.checked = this.inputElement.checked; + } + + _syncButtonSwitch() { + this.inputElement.checked = this.checked; + this.inputElement.disabled = this.disabled; + } +} diff --git a/packages/switch/stories/index.stories.js b/packages/switch/stories/index.stories.js new file mode 100644 index 000000000..431aaa719 --- /dev/null +++ b/packages/switch/stories/index.stories.js @@ -0,0 +1,86 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { LitElement } from '@lion/core'; + +import { LocalizeMixin } from '@lion/localize'; + +import '../lion-input-switch.js'; +import '@lion/form/lion-form.js'; + +storiesOf('Forms|Switch', module) + .add( + 'All text slots', + () => html` + + `, + ) + .add( + 'Disabled', + () => html` + + `, + ) + .add('Validation', () => { + const isTrue = value => value && value.checked && value.checked === true; + const isTrueValidator = (...factoryParams) => [ + (...params) => ({ + isTrue: isTrue(...params), + }), + ...factoryParams, + ]; + const tagName = 'lion-switch-validation-demo'; + if (!customElements.get(tagName)) { + customElements.define( + tagName, + class extends LocalizeMixin(LitElement) { + static get localizeNamespaces() { + const result = [ + { + 'lion-validate+isTrue': () => + Promise.resolve({ + info: { + isTrue: 'You will not get the latest news!', + }, + }), + }, + ...super.localizeNamespaces, + ]; + return result; + } + + render() { + return html` + +
+ + + + + + + + + +
+
+ `; + } + + submit() { + const form = this.shadowRoot.querySelector('#postsForm'); + if (form.errorState === false) { + console.log(form.serializeGroup()); + } + } + }, + ); + } + return html` + + `; + }); diff --git a/packages/switch/test/lion-button-switch.test.js b/packages/switch/test/lion-button-switch.test.js new file mode 100644 index 000000000..77a9fd02b --- /dev/null +++ b/packages/switch/test/lion-button-switch.test.js @@ -0,0 +1,114 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; + +import { LionButton } from '@lion/button'; + +import '../lion-button-switch.js'; + +describe('lion-button-switch', () => { + let el; + beforeEach(async () => { + el = await fixture(html` + + `); + }); + + it('should behave like a button', () => { + expect(el instanceof LionButton).to.be.true; + }); + + it('should be focusable', () => { + expect(el.tabIndex).to.equal(0); + expect(el.getAttribute('tabindex')).to.equal('0'); + }); + + it('should not have a [type]', () => { + expect(el.hasAttribute('type')).to.be.false; + }); + + it('should have checked=false by default', () => { + expect(el.checked).to.equal(false); + expect(el.hasAttribute('checked')).to.be.false; + }); + + it('should toggle the value of "checked" on click', async () => { + expect(el.checked).to.be.false; + expect(el.hasAttribute('checked')).to.be.false; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.true; + expect(el.hasAttribute('checked')).to.be.true; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.false; + expect(el.hasAttribute('checked')).to.be.false; + }); + + it('can be disabled', async () => { + el.disabled = true; + expect(el.checked).to.be.false; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.false; + expect(el.hasAttribute('checked')).to.be.false; + el.disabled = true; + el.checked = true; + el.click(); + await el.updateComplete; + expect(el.checked).to.be.true; + expect(el.hasAttribute('checked')).to.be.true; + }); + + it('should dispatch "checked-changed" event when toggled', () => { + const handlerSpy = sinon.spy(); + el.addEventListener('checked-changed', handlerSpy); + el.click(); + el.click(); + expect(handlerSpy.callCount).to.equal(2); + const checkCall = call => { + expect(call.args).to.have.a.lengthOf(1); + const e = call.args[0]; + expect(e).to.be.an.instanceof(Event); + expect(e.bubbles).to.be.true; + expect(e.composed).to.be.true; + }; + checkCall(handlerSpy.getCall(0), true); + checkCall(handlerSpy.getCall(1), false); + }); + + it('should not dispatch "checked-changed" event if disabled', () => { + const handlerSpy = sinon.spy(); + el.disabled = true; + el.addEventListener('checked-changed', handlerSpy); + el.click(); + expect(handlerSpy.called).to.be.false; + }); + + describe('a11y', () => { + it('should manage "aria-checked"', async () => { + expect(el.hasAttribute('aria-checked')).to.be.true; + expect(el.getAttribute('aria-checked')).to.equal('false'); + + el.click(); + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('true'); + el.click(); + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('false'); + + el.checked = true; + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('true'); + el.checked = false; + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('false'); + + el.setAttribute('checked', true); + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('true'); + el.removeAttribute('checked'); + await el.updateComplete; + expect(el.getAttribute('aria-checked')).to.equal('false'); + }); + }); +}); diff --git a/packages/switch/test/lion-input-switch.test.js b/packages/switch/test/lion-input-switch.test.js new file mode 100644 index 000000000..558aeca8f --- /dev/null +++ b/packages/switch/test/lion-input-switch.test.js @@ -0,0 +1,70 @@ +import { expect, fixture, html } from '@open-wc/testing'; + +import '../lion-input-switch.js'; + +describe('lion-input-switch', () => { + it('should have default "input" element', async () => { + const el = await fixture(html` + + `); + expect(el.querySelector('[slot="input"]')).not.to.be.null; + }); + + it('should sync its "disabled" state to child button', async () => { + const el = await fixture(html` + + `); + expect(el.inputElement.disabled).to.be.true; + expect(el.inputElement.hasAttribute('disabled')).to.be.true; + el.disabled = false; + await el.updateComplete; + expect(el.inputElement.disabled).to.be.false; + expect(el.inputElement.hasAttribute('disabled')).to.be.false; + }); + + it('should sync its "checked" state to child button', async () => { + const uncheckedEl = await fixture(html` + + `); + const checkedEl = await fixture(html` + + `); + expect(uncheckedEl.inputElement.checked).to.be.false; + expect(checkedEl.inputElement.checked).to.be.true; + uncheckedEl.checked = true; + checkedEl.checked = false; + await uncheckedEl.updateComplete; + await checkedEl.updateComplete; + expect(uncheckedEl.inputElement.checked).to.be.true; + expect(checkedEl.inputElement.checked).to.be.false; + }); + + it('should sync "checked" state received from child button', async () => { + const el = await fixture(html` + + `); + const button = el.inputElement; + expect(el.checked).to.be.false; + button.click(); + expect(el.checked).to.be.true; + button.click(); + expect(el.checked).to.be.false; + }); + + it('synchronizes modelValue to checked state and vice versa', async () => { + const el = await fixture(html` + + `); + expect(el.checked).to.be.false; + expect(el.modelValue).to.deep.equal({ + checked: false, + value: 'foo', + }); + el.checked = true; + expect(el.checked).to.be.true; + expect(el.modelValue).to.deep.equal({ + checked: true, + value: 'foo', + }); + }); +}); diff --git a/stories/index.stories.js b/stories/index.stories.js index bd8ebdfa8..486827adc 100755 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -30,3 +30,4 @@ import '../packages/calendar/stories/index.stories.js'; import '../packages/option/stories/index.stories.js'; import '../packages/select-rich/stories/index.stories.js'; +import '../packages/switch/stories/index.stories.js'; diff --git a/yarn.lock b/yarn.lock index b1b7a4fa0..0fd591894 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,7 +2012,7 @@ resolved "https://registry.yarnpkg.com/@open-wc/semantic-dom-diff/-/semantic-dom-diff-0.14.2.tgz#4ad2a6fedc22992c6048729fbf7104e472f590d3" integrity sha512-+ENGbkgoruTtuNGUVLi9hCC6+IJVBM/lnFDGjKBUt7T6RyAgidBTV+GPMzpQtfHhLRasVJmcjRW8D2him8HvAA== -"@open-wc/testing-helpers@^1.2.1": +"@open-wc/testing-helpers@^1.0.0", "@open-wc/testing-helpers@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@open-wc/testing-helpers/-/testing-helpers-1.2.1.tgz#eecba5ccfe808f9667caf149e68cd80d781f28e0" integrity sha512-FZBjqM81GQc+Q8W4YdWNwwk64+PW6frCvHyeov5ivCR2K7SJXwTPRcQIi0qU/6VVRVDlQeQ39PH8oSnjnIYpvQ==