diff --git a/README.md b/README.md index 378d78da0..9b2dd1d00 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ The accessibility column indicates whether the functionality is accessible in it | [input-datepicker](./packages/input-datepicker) | [![input-datepicker](https://img.shields.io/npm/v/@lion/input-datepicker.svg)](https://www.npmjs.com/package/@lion/input-datepicker) | Input element for dates with a datepicker | ✔️ | | [input-email](./packages/input-email) | [![input-email](https://img.shields.io/npm/v/@lion/input-email.svg)](https://www.npmjs.com/package/@lion/input-email) | Input element for e-mails | [#169][i169] | | [input-iban](./packages/input-iban) | [![input-iban](https://img.shields.io/npm/v/@lion/input-iban.svg)](https://www.npmjs.com/package/@lion/input-iban) | Input element for IBANs | [#169][i169] | +| [input-range](./packages/input-range) | [![input-range](https://img.shields.io/npm/v/@lion/input-range.svg)](https://www.npmjs.com/package/@lion/input-range) | Input element for a range of values | ✔️ | | [radio](./packages/radio) | [![radio](https://img.shields.io/npm/v/@lion/radio.svg)](https://www.npmjs.com/package/@lion/radio) | Radio from element | ✔️ | | [radio-group](./packages/radio-group) | [![radio-group](https://img.shields.io/npm/v/@lion/radio-group.svg)](https://www.npmjs.com/package/@lion/radio-group) | Group of radios | ✔️ | | [select](./packages/select) | [![select](https://img.shields.io/npm/v/@lion/select.svg)](https://www.npmjs.com/package/@lion/select) | Simple native dropdown element | ✔️ | diff --git a/packages/input-range/README.md b/packages/input-range/README.md new file mode 100644 index 000000000..ce68b08eb --- /dev/null +++ b/packages/input-range/README.md @@ -0,0 +1,32 @@ +# Input range + +[//]: # 'AUTO INSERT HEADER PREPUBLISH' + +`lion-input-range` component is based on the native range input. +Its purpose is to provide a way for users to select one value from a range of values. + +## Features + +- Based on [lion-input](../input). +- Shows `modelValue` and `unit` above the range input. +- Shows `min` and `max` value after the range input. +- Can hide the `min` and `max` value via `no-min-max-labels`. +- Makes use of [formatNumber](../localize/docs/number.md) for formatting and parsing. + +## How to use + +### Installation + +```sh +npm i --save @lion/input-range +``` + +```js +import '@lion/input-range/lion-input-range.js'; +``` + +### Example + +```html + +``` diff --git a/packages/input-range/index.js b/packages/input-range/index.js new file mode 100644 index 000000000..e0d52d1e7 --- /dev/null +++ b/packages/input-range/index.js @@ -0,0 +1 @@ +export { LionInputRange } from './src/LionInputRange.js'; diff --git a/packages/input-range/lion-input-range.js b/packages/input-range/lion-input-range.js new file mode 100644 index 000000000..f60d7d23d --- /dev/null +++ b/packages/input-range/lion-input-range.js @@ -0,0 +1,3 @@ +import { LionInputRange } from './src/LionInputRange.js'; + +customElements.define('lion-input-range', LionInputRange); diff --git a/packages/input-range/package.json b/packages/input-range/package.json new file mode 100644 index 000000000..4852eac80 --- /dev/null +++ b/packages/input-range/package.json @@ -0,0 +1,43 @@ +{ + "name": "@lion/input-range", + "version": "0.0.0", + "description": "Provide a way for users to select one value from a range of values", + "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/input-range" + }, + "scripts": { + "prepublishOnly": "../../scripts/npm-prepublish.js" + }, + "keywords": [ + "lion", + "web-components", + "input-range", + "slider" + ], + "main": "index.js", + "module": "index.js", + "files": [ + "src", + "stories", + "test", + "*.js" + ], + "dependencies": { + "@lion/core": "0.3.0", + "@lion/field": "0.6.8", + "@lion/input": "0.4.0", + "@lion/localize": "0.7.1" + }, + "devDependencies": { + "@open-wc/demoing-storybook": "^0.2.0", + "@open-wc/testing": "^2.3.4" + } +} diff --git a/packages/input-range/src/LionInputRange.js b/packages/input-range/src/LionInputRange.js new file mode 100644 index 000000000..b05ae558a --- /dev/null +++ b/packages/input-range/src/LionInputRange.js @@ -0,0 +1,129 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { LocalizeMixin, formatNumber } from '@lion/localize'; +import { FieldCustomMixin } from '@lion/field'; +import { LionInput } from '@lion/input'; +import { html, css, unsafeCSS } from '@lion/core'; + +/** + * LionInputRange: extension of lion-input + * + * @customElement `lion-input-range` + * @extends LionInput + */ +export class LionInputRange extends FieldCustomMixin(LocalizeMixin(LionInput)) { + static get properties() { + return { + min: Number, + max: Number, + unit: String, + step: { + type: Number, + reflect: true, + }, + noMinMaxLabels: { + type: Boolean, + attribute: 'no-min-max-labels', + }, + }; + } + + static rangeStyles(scope) { + return css` + /* Custom input range styling comes here, be aware that this won't work for polyfilled browsers */ + .${scope} .form-control { + width: 100%; + box-shadow: none; + outline: none; + } + `; + } + + connectedCallback() { + if (super.connectedCallback) super.connectedCallback(); + this.type = 'range'; + /* eslint-disable-next-line wc/no-self-class */ + this.classList.add(this.scopedClass); + + this.__setupStyleTag(); + } + + disconnectedCallback() { + if (super.disconnectedCallback) super.disconnectedCallback(); + this.__teardownStyleTag(); + } + + constructor() { + super(); + this.parser = modelValue => parseFloat(modelValue); + this.scopedClass = `${this.localName}-${Math.floor(Math.random() * 10000)}`; + } + + updated(changedProperties) { + super.updated(changedProperties); + + if (changedProperties.has('min')) { + this._inputNode.min = this.min; + } + + if (changedProperties.has('max')) { + this._inputNode.max = this.max; + } + + if (changedProperties.has('step')) { + this._inputNode.step = this.step; + } + } + + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + if (changedProperties.has('modelValue')) { + // TODO: find out why this hack is needed to display the initial modelValue + this.updateComplete.then(() => { + this._inputNode.value = this.modelValue; + }); + } + } + + inputGroupTemplate() { + return html` +
+ ${formatNumber(this.formattedValue)} + ${this.unit} +
+
+ ${this.inputGroupBeforeTemplate()} +
+ ${this.inputGroupPrefixTemplate()} ${this.inputGroupInputTemplate()} + ${this.inputGroupSuffixTemplate()} +
+ ${this.inputGroupAfterTemplate()} +
+ `; + } + + inputGroupInputTemplate() { + return html` +
+ + ${!this.noMinMaxLabels + ? html` +
+ ${formatNumber(this.min)} + ${formatNumber(this.max)} +
+ ` + : ''} +
+ `; + } + + __setupStyleTag() { + this.__styleTag = document.createElement('style'); + this.__styleTag.innerHTML = this.constructor.rangeStyles(unsafeCSS(this.scopedClass)); + this.insertBefore(this.__styleTag, this.childNodes[0]); + } + + __teardownStyleTag() { + this.removeChild(this.__styleTag); + } +} diff --git a/packages/input-range/stories/index.stories.js b/packages/input-range/stories/index.stories.js new file mode 100644 index 000000000..7f87dbedd --- /dev/null +++ b/packages/input-range/stories/index.stories.js @@ -0,0 +1,111 @@ +import { storiesOf, html } from '@open-wc/demoing-storybook'; +import { css } from '@lion/core'; + +import '../lion-input-range.js'; + +const rangeDemoStyle = css` + .demo-range { + max-width: 400px; + } +`; + +storiesOf('Forms | Input Range', module) + .add( + 'Default', + () => html` + + + + `, + ) + .add( + 'Help text', + () => html` + + + + `, + ) + .add( + 'Units', + () => html` + + + + `, + ) + .add( + 'With steps', + () => html` + + + + `, + ) + .add( + 'No min max labels', + () => html` + + + `, + ) + .add( + 'Disabled', + () => html` + + + + `, + ); diff --git a/packages/input-range/test/lion-input-range.test.js b/packages/input-range/test/lion-input-range.test.js new file mode 100644 index 000000000..ccb3b2e9a --- /dev/null +++ b/packages/input-range/test/lion-input-range.test.js @@ -0,0 +1,98 @@ +import { expect, fixture, nextFrame, html } from '@open-wc/testing'; + +import '../lion-input-range.js'; + +describe('', () => { + it('has a type = range', async () => { + const el = await fixture(``); + expect(el._inputNode.type).to.equal('range'); + }); + + it('contain the scoped css class for the slotted input style', async () => { + const el = await fixture(` + + `); + expect(el.classList.contains(el.scopedClass)).to.equal(true); + }); + + it('adds a style tag as the first child which contains a class selector to the element', async () => { + const el = await fixture(` + + `); + expect(el.children[0].tagName).to.equal('STYLE'); + expect(el.children[0].innerHTML).to.contain(el.scopedClass); + }); + + it('does cleanup of the style tag when moving or deleting the el', async () => { + const wrapper = await fixture(` +
+ `); + const wrapper2 = await fixture(` +
+ `); + const el = document.createElement('lion-input-range'); + wrapper.appendChild(el); + wrapper2.appendChild(el); + + expect(el.children[1].tagName).to.not.equal('STYLE'); + }); + + it('displays the modelValue and unit', async () => { + const el = await fixture(html` + + `); + expect(el.shadowRoot.querySelector('.input-range__value').innerText).to.equal('75'); + expect(el.shadowRoot.querySelector('.input-range__unit').innerText).to.equal('%'); + }); + + it('displays 2 tick labels (min and max values) by default', async () => { + const el = await fixture(``); + expect(el.shadowRoot.querySelectorAll('.input-range__limits span').length).to.equal(2); + expect(el.shadowRoot.querySelectorAll('.input-range__limits span')[0].innerText).to.equal( + el.min, + ); + expect(el.shadowRoot.querySelectorAll('.input-range__limits span')[1].innerText).to.equal( + el.max, + ); + }); + + it('update min and max attributes when min and max property change', async () => { + const el = await fixture(``); + el.min = '120'; + el.max = '220'; + await nextFrame(); + expect(el._inputNode.min).to.equal(el.min); + expect(el._inputNode.max).to.equal(el.max); + }); + + it('can hide the tick labels', async () => { + const el = await fixture( + ``, + ); + expect(el.shadowRoot.querySelectorAll('.input-group__input')[0]).dom.to.equal(` +
+ +
+ `); + }); + + it('parser method should return a value parsed into a number format', async () => { + const el = await fixture(html` + + `); + expect(el.modelValue).to.equal(150); + el._inputNode.value = '130'; + el._inputNode.dispatchEvent(new Event('input')); + expect(el.modelValue).to.equal(130); + }); + + it('is accessible', async () => { + const el = await fixture(``); + await expect(el).to.be.accessible(); + }); + + it('is accessible when disabled', async () => { + const el = await fixture(``); + await expect(el).to.be.accessible(); + }); +}); diff --git a/stories/index.stories.js b/stories/index.stories.js index 632b5a3f1..d19a466e2 100755 --- a/stories/index.stories.js +++ b/stories/index.stories.js @@ -8,6 +8,7 @@ import '../packages/input-date/stories/index.stories.js'; import '../packages/input-datepicker/stories/index.stories.js'; import '../packages/input-email/stories/index.stories.js'; import '../packages/input-iban/stories/index.stories.js'; +import '../packages/input-range/stories/index.stories.js'; import '../packages/option/stories/index.stories.js'; import '../packages/select/stories/index.stories.js'; import '../packages/fieldset/stories/index.stories.js';