feat(switch): component to toggle a property on or off

This commit is contained in:
Alex Ghiu 2019-10-15 11:10:17 +02:00 committed by CubLion
parent 8223b4ad3d
commit 8e4360220b
12 changed files with 523 additions and 1 deletions

29
packages/switch/README.md Normal file
View file

@ -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
<lion-input-switch name="airplaneMode" label="Airplane mode" checked></lion-input-switch>
```

2
packages/switch/index.js Normal file
View file

@ -0,0 +1,2 @@
export { LionInputSwitch } from './src/LionInputSwitch.js';
export { LionButtonSwitch } from './src/LionButtonSwitch.js';

View file

@ -0,0 +1,3 @@
import { LionButtonSwitch } from './src/LionButtonSwitch.js';
customElements.define('lion-button-switch', LionButtonSwitch);

View file

@ -0,0 +1,3 @@
import { LionInputSwitch } from './src/LionInputSwitch.js';
customElements.define('lion-input-switch', LionInputSwitch);

View file

@ -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"
}
}

View file

@ -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`
<div class="button-switch__track"></div>
<div class="button-switch__thumb"></div>
`;
}
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}`);
}
}
}

View file

@ -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`
<div class="input-switch__container">
<slot name="label"></slot>
<slot name="help-text"></slot>
<slot name="feedback"></slot>
</div>
<slot name="input"></slot>
`;
}
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;
}
}

View file

@ -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`
<lion-input-switch label="Label" help-text="Help text"> </lion-input-switch>
`,
)
.add(
'Disabled',
() => html`
<lion-input-switch label="Disabled label" disabled> </lion-input-switch>
`,
)
.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`
<lion-form id="postsForm" @submit="${this.submit}">
<form>
<lion-input-switch name="emailAddress" label="Share email address">
</lion-input-switch>
<lion-input-switch name="subjectField" label="Show subject field" checked>
</lion-input-switch>
<lion-input-switch name="characterCount" label="Character count">
</lion-input-switch>
<lion-input-switch
name="newsletterCheck"
label="* Subscribe to newsletter"
.infoValidators="${[isTrueValidator()]}"
>
</lion-input-switch>
<button type="submit">
Submit
</button>
</form>
</lion-form>
`;
}
submit() {
const form = this.shadowRoot.querySelector('#postsForm');
if (form.errorState === false) {
console.log(form.serializeGroup());
}
}
},
);
}
return html`
<lion-switch-validation-demo></lion-switch-validation-demo>
`;
});

View file

@ -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`
<lion-button-switch></lion-button-switch>
`);
});
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');
});
});
});

View file

@ -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`
<lion-input-switch></lion-input-switch>
`);
expect(el.querySelector('[slot="input"]')).not.to.be.null;
});
it('should sync its "disabled" state to child button', async () => {
const el = await fixture(html`
<lion-input-switch disabled></lion-input-switch>
`);
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`
<lion-input-switch></lion-input-switch>
`);
const checkedEl = await fixture(html`
<lion-input-switch checked></lion-input-switch>
`);
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`
<lion-input-switch></lion-input-switch>
`);
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`
<lion-input-switch .choiceValue=${'foo'}></lion-input-switch>
`);
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',
});
});
});

View file

@ -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';

View file

@ -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==