feat: add types for input components

This commit is contained in:
Joren Broekema 2020-09-28 19:00:59 +02:00 committed by Thomas Allmer
parent 35efc9c49e
commit cfa2daf674
15 changed files with 252 additions and 119 deletions

View file

@ -0,0 +1,9 @@
---
'@lion/input-date': minor
'@lion/input-email': minor
'@lion/input-iban': minor
'@lion/input-range': minor
'@lion/input-stepper': minor
---
Added types for all other input components except for datepicker.

View file

@ -2,8 +2,12 @@ import { IsDate } from '@lion/form-core';
import { LionInput } from '@lion/input'; import { LionInput } from '@lion/input';
import { formatDate, LocalizeMixin, parseDate } from '@lion/localize'; import { formatDate, LocalizeMixin, parseDate } from '@lion/localize';
/**
* @param {Date|number} date
*/
function isValidDate(date) { function isValidDate(date) {
// to make sure it is a valid date we use isNaN and not Number.isNaN // to make sure it is a valid date we use isNaN and not Number.isNaN
// @ts-ignore dirty hack, you're not supposed to pass Date instances to isNaN
// eslint-disable-next-line no-restricted-globals // eslint-disable-next-line no-restricted-globals
return date instanceof Date && !isNaN(date); return date instanceof Date && !isNaN(date);
} }
@ -13,8 +17,8 @@ function isValidDate(date) {
* on locale. * on locale.
* *
* @customElement lion-input-date * @customElement lion-input-date
* @extends {LionInput}
*/ */
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/40110
export class LionInputDate extends LocalizeMixin(LionInput) { export class LionInputDate extends LocalizeMixin(LionInput) {
static get properties() { static get properties() {
return { return {
@ -24,15 +28,19 @@ export class LionInputDate extends LocalizeMixin(LionInput) {
constructor() { constructor() {
super(); super();
this.parser = (value, options) => (value === '' ? undefined : parseDate(value, options)); /**
* @param {string} value
*/
this.parser = value => (value === '' ? undefined : parseDate(value));
this.formatter = formatDate; this.formatter = formatDate;
this.defaultValidators.push(new IsDate()); this.defaultValidators.push(new IsDate());
} }
/** @param {import('lit-element').PropertyValues } changedProperties */
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('locale')) { if (changedProperties.has('locale')) {
this._calculateValues(); this._calculateValues({ source: null });
} }
} }
@ -42,6 +50,9 @@ export class LionInputDate extends LocalizeMixin(LionInput) {
this.type = 'text'; this.type = 'text';
} }
/**
* @param {Date} modelValue
*/
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
serializer(modelValue) { serializer(modelValue) {
if (!isValidDate(modelValue)) { if (!isValidDate(modelValue)) {
@ -50,9 +61,12 @@ export class LionInputDate extends LocalizeMixin(LionInput) {
// modelValue is localized, so we take the timezone offset in milliseconds and subtract it // modelValue is localized, so we take the timezone offset in milliseconds and subtract it
// before converting it to ISO string. // before converting it to ISO string.
const offset = modelValue.getTimezoneOffset() * 60000; const offset = modelValue.getTimezoneOffset() * 60000;
return new Date(modelValue - offset).toISOString().slice(0, 10); return new Date(modelValue.getTime() - offset).toISOString().slice(0, 10);
} }
/**
* @param {string} serializedValue
*/
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
deserializer(serializedValue) { deserializer(serializedValue) {
return new Date(serializedValue); return new Date(serializedValue);

View file

@ -6,7 +6,6 @@ const tagString = 'lion-input-date';
describe('<lion-input-date> integrations', () => { describe('<lion-input-date> integrations', () => {
runInteractionStateMixinSuite({ runInteractionStateMixinSuite({
tagString, tagString,
suffix: tagString,
allowedModelValueTypes: [Date], allowedModelValueTypes: [Date],
}); });

View file

@ -2,9 +2,15 @@ import { html } from '@lion/core';
import { localize } from '@lion/localize'; import { localize } from '@lion/localize';
import { localizeTearDown } from '@lion/localize/test-helpers.js'; import { localizeTearDown } from '@lion/localize/test-helpers.js';
import { MaxDate } from '@lion/form-core'; import { MaxDate } from '@lion/form-core';
import { expect, fixture } from '@open-wc/testing'; import { expect, fixture as _fixture } from '@open-wc/testing';
import '../lion-input-date.js'; import '../lion-input-date.js';
/**
* @typedef {import('../src/LionInputDate').LionInputDate} LionInputDate
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult) => Promise<LionInputDate>} */ (_fixture);
describe('<lion-input-date>', () => { describe('<lion-input-date>', () => {
beforeEach(() => { beforeEach(() => {
localizeTearDown(); localizeTearDown();
@ -24,16 +30,16 @@ describe('<lion-input-date>', () => {
const el = await fixture(html`<lion-input-date></lion-input-date>`); const el = await fixture(html`<lion-input-date></lion-input-date>`);
el.modelValue = '2005/11/10'; el.modelValue = '2005/11/10';
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.a.property('IsDate'); expect(el.validationStates.error).to.have.property('IsDate');
el.modelValue = new Date('2005/11/10'); el.modelValue = new Date('2005/11/10');
expect(el.hasFeedbackFor).not.to.include('error'); expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).not.to.have.a.property('IsDate'); expect(el.validationStates.error).not.to.have.property('IsDate');
}); });
it("does not throw on invalid dates like new Date('foo')", async () => { it("does not throw on invalid dates like new Date('20.10.'), which could happen while the user types", async () => {
const el = await fixture(html`<lion-input-date></lion-input-date>`); const el = await fixture(html`<lion-input-date></lion-input-date>`);
expect(() => { expect(() => {
el.modelValue = new Date('foo'); el.modelValue = new Date('foo');
@ -48,13 +54,13 @@ describe('<lion-input-date>', () => {
></lion-input-date> ></lion-input-date>
`); `);
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.a.property('MaxDate'); expect(el.validationStates.error).to.have.property('MaxDate');
el.modelValue = new Date('2017/06/14'); el.modelValue = new Date('2017/06/14');
expect(el.hasFeedbackFor).not.to.include('error'); expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).not.to.have.a.property('MaxDate'); expect(el.validationStates.error).not.to.have.property('MaxDate');
}); });
it('uses formatOptions.locale', async () => { it('uses formatOptions.locale', async () => {

View file

@ -6,8 +6,8 @@ import { LocalizeMixin } from '@lion/localize';
* LionInputEmail: extension of lion-input * LionInputEmail: extension of lion-input
* *
* @customElement lion-input-email * @customElement lion-input-email
* @extends {LionInput}
*/ */
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/40110
export class LionInputEmail extends LocalizeMixin(LionInput) { export class LionInputEmail extends LocalizeMixin(LionInput) {
constructor() { constructor() {
super(); super();

View file

@ -1,7 +1,13 @@
import { expect, fixture } from '@open-wc/testing'; import { expect, fixture as _fixture } from '@open-wc/testing';
import '../lion-input-email.js'; import '../lion-input-email.js';
/**
* @typedef {import('../src/LionInputEmail').LionInputEmail} LionInputEmail
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult|string) => Promise<LionInputEmail>} */ (_fixture);
describe('<lion-input-email>', () => { describe('<lion-input-email>', () => {
it('has a type = text', async () => { it('has a type = text', async () => {
const el = await fixture(`<lion-input-email></lion-input-email>`); const el = await fixture(`<lion-input-email></lion-input-email>`);

View file

@ -6,9 +6,9 @@ import { IsIBAN } from './validators.js';
/** /**
* `LionInputIban` is a class for an IBAN custom form element (`<lion-input-iban>`). * `LionInputIban` is a class for an IBAN custom form element (`<lion-input-iban>`).
* * @customElement lion-input-iban
* @extends {LionInput}
*/ */
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/40110
export class LionInputIban extends LocalizeMixin(LionInput) { export class LionInputIban extends LocalizeMixin(LionInput) {
constructor() { constructor() {
super(); super();

View file

@ -4,7 +4,7 @@ import { isValidIBAN } from 'ibantools';
* Parses an IBAN trimming spaces and making uppercase. * Parses an IBAN trimming spaces and making uppercase.
* *
* @param {string} viewValue value to be parsed * @param {string} viewValue value to be parsed
* @return {string} parsed value * @return {string|undefined} parsed value
*/ */
export function parseIBAN(viewValue) { export function parseIBAN(viewValue) {
const trimmedValue = viewValue.replace(/\s/g, '').toUpperCase(); const trimmedValue = viewValue.replace(/\s/g, '').toUpperCase();

View file

@ -11,7 +11,7 @@ const loadTranslations = async () => {
} }
await localize.loadNamespace( await localize.loadNamespace(
{ {
'lion-validate+iban': locale => { 'lion-validate+iban': /** @param {string} locale */ locale => {
switch (locale) { switch (locale) {
case 'bg-BG': case 'bg-BG':
return import('../translations/bg-BG.js'); return import('../translations/bg-BG.js');
@ -86,7 +86,7 @@ const loadTranslations = async () => {
} }
}, },
}, },
{ locale: localize.localize }, { locale: localize.locale },
); );
loaded = true; loaded = true;
}; };
@ -96,11 +96,22 @@ export class IsIBAN extends Validator {
return 'IsIBAN'; return 'IsIBAN';
} }
/** @param {string} value */
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
execute(value) { execute(value) {
return !isValidIBAN(value); return !isValidIBAN(value);
} }
/**
* @param {object} [data]
* @param {*} [data.modelValue]
* @param {string} [data.fieldName]
* @param {*} [data.params]
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
*/
static async getMessage(data) { static async getMessage(data) {
await loadTranslations(); await loadTranslations();
return localize.msg('lion-validate+iban:error.IsIBAN', data); return localize.msg('lion-validate+iban:error.IsIBAN', data);
@ -112,6 +123,10 @@ export class IsCountryIBAN extends IsIBAN {
return 'IsCountryIBAN'; return 'IsCountryIBAN';
} }
/**
* @param {?} [value]
* @returns {Boolean}
*/
execute(value) { execute(value) {
const notIBAN = super.execute(value); const notIBAN = super.execute(value);
if (value.slice(0, 2) !== this.param) { if (value.slice(0, 2) !== this.param) {
@ -123,6 +138,16 @@ export class IsCountryIBAN extends IsIBAN {
return false; return false;
} }
/**
* @param {object} [data]
* @param {*} [data.modelValue]
* @param {string} [data.fieldName]
* @param {*} [data.params]
* @param {string} [data.type]
* @param {Object.<string,?>} [data.config]
* @param {string} [data.name]
* @returns {Promise<string|Node>}
*/
static async getMessage(data) { static async getMessage(data) {
await loadTranslations(); await loadTranslations();
return localize.msg('lion-validate+iban:error.IsCountryIBAN', data); return localize.msg('lion-validate+iban:error.IsCountryIBAN', data);

View file

@ -1,4 +1,4 @@
import { expect, fixture } from '@open-wc/testing'; import { expect, fixture as _fixture } from '@open-wc/testing';
import { html } from '@lion/core'; import { html } from '@lion/core';
import { IsCountryIBAN } from '../src/validators.js'; import { IsCountryIBAN } from '../src/validators.js';
@ -7,6 +7,12 @@ import { parseIBAN } from '../src/parsers.js';
import '../lion-input-iban.js'; import '../lion-input-iban.js';
/**
* @typedef {import('../src/LionInputIban').LionInputIban} LionInputIban
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult|string) => Promise<LionInputIban>} */ (_fixture);
describe('<lion-input-iban>', () => { describe('<lion-input-iban>', () => {
it('uses formatIBAN for formatting', async () => { it('uses formatIBAN for formatting', async () => {
const el = await fixture(`<lion-input-iban></lion-input-iban>`); const el = await fixture(`<lion-input-iban></lion-input-iban>`);
@ -27,12 +33,12 @@ describe('<lion-input-iban>', () => {
const el = await fixture(`<lion-input-iban></lion-input-iban>`); const el = await fixture(`<lion-input-iban></lion-input-iban>`);
el.modelValue = 'FOO'; el.modelValue = 'FOO';
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.a.property('IsIBAN'); expect(el.validationStates.error).to.have.property('IsIBAN');
el.modelValue = 'DE89370400440532013000'; el.modelValue = 'DE89370400440532013000';
expect(el.hasFeedbackFor).not.to.include('error'); expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).not.to.have.a.property('IsIBAN'); expect(el.validationStates.error).not.to.have.property('IsIBAN');
}); });
it('can apply validator "IsCountryIBAN" to restrict countries', async () => { it('can apply validator "IsCountryIBAN" to restrict countries', async () => {
@ -41,17 +47,17 @@ describe('<lion-input-iban>', () => {
`); `);
el.modelValue = 'DE89370400440532013000'; el.modelValue = 'DE89370400440532013000';
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.a.property('IsCountryIBAN'); expect(el.validationStates.error).to.have.property('IsCountryIBAN');
el.modelValue = 'NL17INGB0002822608'; el.modelValue = 'NL17INGB0002822608';
expect(el.hasFeedbackFor).not.to.include('error'); expect(el.hasFeedbackFor).not.to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).not.to.have.a.property('IsCountryIBAN'); expect(el.validationStates.error).not.to.have.property('IsCountryIBAN');
el.modelValue = 'FOO'; el.modelValue = 'FOO';
expect(el.hasFeedbackFor).to.include('error'); expect(el.hasFeedbackFor).to.include('error');
expect(el.validationStates).to.have.a.property('error'); expect(el.validationStates).to.have.property('error');
expect(el.validationStates.error).to.have.a.property('IsIBAN'); expect(el.validationStates.error).to.have.property('IsIBAN');
expect(el.validationStates.error).to.have.a.property('IsCountryIBAN'); expect(el.validationStates.error).to.have.property('IsCountryIBAN');
}); });
it('is accessible', async () => { it('is accessible', async () => {

View file

@ -3,18 +3,31 @@ import { css, html, unsafeCSS } from '@lion/core';
import { LionInput } from '@lion/input'; import { LionInput } from '@lion/input';
import { formatNumber, LocalizeMixin } from '@lion/localize'; import { formatNumber, LocalizeMixin } from '@lion/localize';
/**
* @typedef {import('lit-element').CSSResult} CSSResult
*/
/** /**
* LionInputRange: extension of lion-input. * LionInputRange: extension of lion-input.
* *
* @customElement `lion-input-range` * @customElement `lion-input-range`
* @extends LionInput
*/ */
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/40110 + false positive for incompatible static get properties. Lit-element merges super properties already for you.
export class LionInputRange extends LocalizeMixin(LionInput) { export class LionInputRange extends LocalizeMixin(LionInput) {
static get properties() { static get properties() {
return { return {
min: Number, min: {
max: Number, type: Number,
unit: String, reflect: true,
},
max: {
type: Number,
reflect: true,
},
unit: {
type: String,
reflect: true,
},
step: { step: {
type: Number, type: Number,
reflect: true, reflect: true,
@ -26,6 +39,9 @@ export class LionInputRange extends LocalizeMixin(LionInput) {
}; };
} }
/**
* @param {CSSResult} scope
*/
static rangeStyles(scope) { static rangeStyles(scope) {
return css` return css`
/* Custom input range styling comes here, be aware that this won't work for polyfilled browsers */ /* Custom input range styling comes here, be aware that this won't work for polyfilled browsers */
@ -37,9 +53,24 @@ export class LionInputRange extends LocalizeMixin(LionInput) {
`; `;
} }
connectedCallback() { constructor() {
if (super.connectedCallback) super.connectedCallback(); super();
this.min = Infinity;
this.max = Infinity;
this.step = 1;
this.unit = '';
this.type = 'range'; this.type = 'range';
this.noMinMaxLabels = false;
/**
* @param {string} modelValue
*/
this.parser = modelValue => parseFloat(modelValue);
this.scopedClass = `${this.localName}-${Math.floor(Math.random() * 10000)}`;
this.__styleTag = document.createElement('style');
}
connectedCallback() {
super.connectedCallback();
/* eslint-disable-next-line wc/no-self-class */ /* eslint-disable-next-line wc/no-self-class */
this.classList.add(this.scopedClass); this.classList.add(this.scopedClass);
@ -47,38 +78,34 @@ export class LionInputRange extends LocalizeMixin(LionInput) {
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) super.disconnectedCallback(); super.disconnectedCallback();
this.__teardownStyleTag(); this.__teardownStyleTag();
} }
constructor() { /** @param {import('lit-element').PropertyValues } changedProperties */
super();
this.parser = modelValue => parseFloat(modelValue);
this.scopedClass = `${this.localName}-${Math.floor(Math.random() * 10000)}`;
}
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('min')) { if (changedProperties.has('min')) {
this._inputNode.min = this.min; this._inputNode.min = `${this.min}`;
} }
if (changedProperties.has('max')) { if (changedProperties.has('max')) {
this._inputNode.max = this.max; this._inputNode.max = `${this.max}`;
} }
if (changedProperties.has('step')) { if (changedProperties.has('step')) {
this._inputNode.step = this.step; this._inputNode.step = `${this.step}`;
} }
} }
/** @param {import('lit-element').PropertyValues } changedProperties */
firstUpdated(changedProperties) { firstUpdated(changedProperties) {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
if (changedProperties.has('modelValue')) { if (changedProperties.has('modelValue')) {
// TODO: find out why this hack is needed to display the initial modelValue // TODO: find out why this hack is needed to display the initial modelValue
this.updateComplete.then(() => { this.updateComplete.then(() => {
this._inputNode.value = this.modelValue; this._inputNode.value = `${this.modelValue}`;
}); });
} }
} }
@ -86,7 +113,7 @@ export class LionInputRange extends LocalizeMixin(LionInput) {
_inputGroupTemplate() { _inputGroupTemplate() {
return html` return html`
<div> <div>
<span class="input-range__value">${formatNumber(this.formattedValue)}</span> <span class="input-range__value">${formatNumber(parseFloat(this.formattedValue))}</span>
<span class="input-range__unit">${this.unit}</span> <span class="input-range__unit">${this.unit}</span>
</div> </div>
<div class="input-group"> <div class="input-group">
@ -117,8 +144,9 @@ export class LionInputRange extends LocalizeMixin(LionInput) {
} }
__setupStyleTag() { __setupStyleTag() {
this.__styleTag = document.createElement('style'); this.__styleTag.textContent = /** @type {typeof LionInputRange} */ (this.constructor)
this.__styleTag.textContent = this.constructor.rangeStyles(unsafeCSS(this.scopedClass)); .rangeStyles(unsafeCSS(this.scopedClass))
.toString();
this.insertBefore(this.__styleTag, this.childNodes[0]); this.insertBefore(this.__styleTag, this.childNodes[0]);
} }

View file

@ -1,7 +1,13 @@
import { expect, fixture, nextFrame, html } from '@open-wc/testing'; import { expect, fixture as _fixture, nextFrame, html } from '@open-wc/testing';
import '../lion-input-range.js'; import '../lion-input-range.js';
/**
* @typedef {import('../src/LionInputRange').LionInputRange} LionInputRange
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult|string) => Promise<LionInputRange>} */ (_fixture);
describe('<lion-input-range>', () => { describe('<lion-input-range>', () => {
it('has a type = range', async () => { it('has a type = range', async () => {
const el = await fixture(`<lion-input-range></lion-input-range>`); const el = await fixture(`<lion-input-range></lion-input-range>`);
@ -41,35 +47,41 @@ describe('<lion-input-range>', () => {
const el = await fixture(html` const el = await fixture(html`
<lion-input-range .modelValue=${75} unit="${`%`}"></lion-input-range> <lion-input-range .modelValue=${75} unit="${`%`}"></lion-input-range>
`); `);
expect(el.shadowRoot.querySelector('.input-range__value').innerText).to.equal('75'); expect(
expect(el.shadowRoot.querySelector('.input-range__unit').innerText).to.equal('%'); /** @type {HTMLElement} */ (el.shadowRoot?.querySelector('.input-range__value')).innerText,
).to.equal('75');
expect(
/** @type {HTMLElement} */ (el.shadowRoot?.querySelector('.input-range__unit')).innerText,
).to.equal('%');
}); });
it('displays 2 tick labels (min and max values) by default', async () => { it('displays 2 tick labels (min and max values) by default', async () => {
const el = await fixture(`<lion-input-range min="100" max="200"></lion-input-range>`); const el = await fixture(`<lion-input-range min="100" max="200"></lion-input-range>`);
expect(el.shadowRoot.querySelectorAll('.input-range__limits span').length).to.equal(2); expect(el.shadowRoot?.querySelectorAll('.input-range__limits span').length).to.equal(2);
expect(el.shadowRoot.querySelectorAll('.input-range__limits span')[0].innerText).to.equal( expect(
el.min, /** @type {HTMLElement} */ (el.shadowRoot?.querySelectorAll('.input-range__limits span')[0])
); .innerText,
expect(el.shadowRoot.querySelectorAll('.input-range__limits span')[1].innerText).to.equal( ).to.equal(el.min.toString());
el.max, expect(
); /** @type {HTMLElement} */ (el.shadowRoot?.querySelectorAll('.input-range__limits span')[1])
.innerText,
).to.equal(el.max.toString());
}); });
it('update min and max attributes when min and max property change', async () => { it('update min and max attributes when min and max property change', async () => {
const el = await fixture(`<lion-input-range min="100" max="200"></lion-input-range>`); const el = await fixture(`<lion-input-range min="100" max="200"></lion-input-range>`);
el.min = '120'; el.min = 120;
el.max = '220'; el.max = 220;
await nextFrame(); // sync to native element takes some time await nextFrame(); // sync to native element takes some time
expect(el._inputNode.min).to.equal(el.min); expect(el._inputNode.min).to.equal(el.min.toString());
expect(el._inputNode.max).to.equal(el.max); expect(el._inputNode.max).to.equal(el.max.toString());
}); });
it('can hide the tick labels', async () => { it('can hide the tick labels', async () => {
const el = await fixture( const el = await fixture(
`<lion-input-range min="100" max="200" no-min-max-labels></lion-input-range>`, `<lion-input-range min="100" max="200" no-min-max-labels></lion-input-range>`,
); );
expect(el.shadowRoot.querySelectorAll('.input-group__input')[0]).dom.to.equal(` expect(el.shadowRoot?.querySelectorAll('.input-group__input')[0]).dom.to.equal(`
<div class="input-group__input"> <div class="input-group__input">
<slot name="input"></slot> <slot name="input"></slot>
</div> </div>

View file

@ -6,8 +6,8 @@ import { IsNumber, MinNumber, MaxNumber } from '@lion/form-core';
* `LionInputStepper` is a class for custom input-stepper element (`<lion-input-stepper>` web component). * `LionInputStepper` is a class for custom input-stepper element (`<lion-input-stepper>` web component).
* *
* @customElement lion-input-stepper * @customElement lion-input-stepper
* @extends LitElement
*/ */
// @ts-expect-error false positive for incompatible static get properties. Lit-element merges super properties already for you.
export class LionInputStepper extends LionInput { export class LionInputStepper extends LionInput {
static get styles() { static get styles() {
return [ return [
@ -22,30 +22,53 @@ export class LionInputStepper extends LionInput {
static get properties() { static get properties() {
return { return {
min: Number, min: {
max: Number, type: Number,
step: Number, reflect: true,
modelValue: Number, },
__disableIncrementor: Boolean, max: {
__disableDecrementor: Boolean, type: Number,
reflect: true,
},
step: {
type: Number,
reflect: true,
},
__disableIncrementor: { attribute: false },
__disableDecrementor: { attribute: false },
}; };
} }
/**
* @returns {number}
*/
get currentValue() { get currentValue() {
return parseFloat(this.value || 0); return parseFloat(this.value) || 0;
} }
constructor() { constructor() {
super(); super();
/** @param {string} modelValue */
this.parser = modelValue => parseFloat(modelValue); this.parser = modelValue => parseFloat(modelValue);
this.__disableIncrementor = false; this.__disableIncrementor = false;
this.__disableDecrementor = false; this.__disableDecrementor = false;
this.min = Infinity;
this.max = Infinity;
this.step = 1;
this.values = {
max: this.max,
min: this.min,
step: this.step,
};
} }
connectedCallback() { connectedCallback() {
if (super.connectedCallback) {
super.connectedCallback(); super.connectedCallback();
} this.values = {
max: this.max,
min: this.min,
step: this.step,
};
this.role = 'spinbutton'; this.role = 'spinbutton';
this.addEventListener('keydown', this.__keyDownHandler); this.addEventListener('keydown', this.__keyDownHandler);
this._inputNode.setAttribute('inputmode', 'decimal'); this._inputNode.setAttribute('inputmode', 'decimal');
@ -56,29 +79,27 @@ export class LionInputStepper extends LionInput {
} }
disconnectedCallback() { disconnectedCallback() {
if (super.disconnectedCallback) {
super.disconnectedCallback(); super.disconnectedCallback();
}
this.removeEventListener('keydown', this.__keyDownHandler); this.removeEventListener('keydown', this.__keyDownHandler);
} }
/** /** @param {import('lit-element').PropertyValues } changedProperties */
* Update native input values updated(changedProperties) {
* @param {Object} changedProps - changed props super.updated(changedProperties);
*/
updated(changedProps) {
super.updated(changedProps);
if (changedProps.has('min')) { if (changedProperties.has('min')) {
this._inputNode.min = this.min; this._inputNode.min = `${this.min}`;
this.values.min = this.min;
} }
if (changedProps.has('max')) { if (changedProperties.has('max')) {
this._inputNode.max = this.max; this._inputNode.max = `${this.max}`;
this.values.max = this.max;
} }
if (changedProps.has('step')) { if (changedProperties.has('step')) {
this._inputNode.step = this.step; this._inputNode.step = `${this.step}`;
this.values.step = this.step;
} }
} }
@ -87,27 +108,23 @@ export class LionInputStepper extends LionInput {
* @private * @private
*/ */
__setAriaLabelsAndValidator() { __setAriaLabelsAndValidator() {
this.values = {
max: parseFloat(this.max || Infinity),
min: parseFloat(this.min || Infinity),
step: parseFloat(this.step),
};
const ariaAttributes = { const ariaAttributes = {
'aria-valuemax': this.values.max, 'aria-valuemax': this.values.max,
'aria-valuemin': this.values.min, 'aria-valuemin': this.values.min,
}; };
let validators = Object.entries(ariaAttributes) const minMaxValidators = /** @type {(MaxNumber | MinNumber)[]} */ (Object.entries(
ariaAttributes,
)
.map(([key, val]) => { .map(([key, val]) => {
if (val !== Infinity) { if (val !== Infinity) {
this.setAttribute(key, val); this.setAttribute(key, `${val}`);
return key === 'aria-valuemax' ? new MaxNumber(val) : new MinNumber(val); return key === 'aria-valuemax' ? new MaxNumber(val) : new MinNumber(val);
} }
return null; return null;
}) })
.filter(validator => validator); .filter(validator => validator !== null));
validators = [new IsNumber(), ...validators]; const validators = [new IsNumber(), ...minMaxValidators];
this.defaultValidators.push(...validators); this.defaultValidators.push(...validators);
} }
@ -134,7 +151,7 @@ export class LionInputStepper extends LionInput {
const { min, max } = this.values; const { min, max } = this.values;
this.__disableIncrementor = this.currentValue >= max && max !== Infinity; this.__disableIncrementor = this.currentValue >= max && max !== Infinity;
this.__disableDecrementor = this.currentValue <= min && min !== Infinity; this.__disableDecrementor = this.currentValue <= min && min !== Infinity;
this.setAttribute('aria-valuenow', this.currentValue); this.setAttribute('aria-valuenow', `${this.currentValue}`);
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('user-input-changed', { new CustomEvent('user-input-changed', {
bubbles: true, bubbles: true,
@ -150,7 +167,7 @@ export class LionInputStepper extends LionInput {
const { step, max } = this.values; const { step, max } = this.values;
const newValue = this.currentValue + step; const newValue = this.currentValue + step;
if (newValue <= max || max === Infinity) { if (newValue <= max || max === Infinity) {
this.value = newValue; this.value = `${newValue}`;
this.__toggleSpinnerButtonsState(); this.__toggleSpinnerButtonsState();
} }
} }
@ -163,7 +180,7 @@ export class LionInputStepper extends LionInput {
const { step, min } = this.values; const { step, min } = this.values;
const newValue = this.currentValue - step; const newValue = this.currentValue - step;
if (newValue >= min || min === Infinity) { if (newValue >= min || min === Infinity) {
this.value = newValue; this.value = `${newValue}`;
this.__toggleSpinnerButtonsState(); this.__toggleSpinnerButtonsState();
} }
} }

View file

@ -1,6 +1,12 @@
import { expect, fixture, nextFrame, html } from '@open-wc/testing'; import { expect, fixture as _fixture, nextFrame, html } from '@open-wc/testing';
import '../lion-input-stepper.js'; import '../lion-input-stepper.js';
/**
* @typedef {import('../src/LionInputStepper').LionInputStepper} LionInputStepper
* @typedef {import('lit-html').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult|string) => Promise<LionInputStepper>} */ (_fixture);
const defaultInputStepper = html` const defaultInputStepper = html`
<lion-input-stepper name="year" label="Years"></lion-input-stepper> <lion-input-stepper name="year" label="Years"></lion-input-stepper>
`; `;
@ -18,26 +24,26 @@ describe('<lion-input-stepper>', () => {
it('should increment the value to 1 on + button click', async () => { it('should increment the value to 1 on + button click', async () => {
const el = await fixture(defaultInputStepper); const el = await fixture(defaultInputStepper);
expect(el.value).to.equal(''); expect(el.value).to.equal('');
const incrementButton = el.shadowRoot.querySelector('[name=increment]'); const incrementButton = el.shadowRoot?.querySelector('[name=increment]');
incrementButton.dispatchEvent(new Event('click')); incrementButton?.dispatchEvent(new Event('click'));
expect(el.value).to.equal('1'); expect(el.value).to.equal('1');
}); });
it('should decrement the value to -1 on - button click', async () => { it('should decrement the value to -1 on - button click', async () => {
const el = await fixture(defaultInputStepper); const el = await fixture(defaultInputStepper);
expect(el.value).to.equal(''); expect(el.value).to.equal('');
const incrementButton = el.shadowRoot.querySelector('[name=decrement]'); const incrementButton = el.shadowRoot?.querySelector('[name=decrement]');
incrementButton.dispatchEvent(new Event('click')); incrementButton?.dispatchEvent(new Event('click'));
expect(el.value).to.equal('-1'); expect(el.value).to.equal('-1');
}); });
it('should update min and max attributes when min and max property change', async () => { it('should update min and max attributes when min and max property change', async () => {
const el = await fixture(inputStepperWithAttrs); const el = await fixture(inputStepperWithAttrs);
el.min = '100'; el.min = 100;
el.max = '200'; el.max = 200;
await nextFrame(); await nextFrame();
expect(el._inputNode.min).to.equal(el.min); expect(el._inputNode.min).to.equal(el.min.toString());
expect(el._inputNode.max).to.equal(el.max); expect(el._inputNode.max).to.equal(el.max.toString());
}); });
}); });
@ -60,8 +66,8 @@ describe('<lion-input-stepper>', () => {
it('updates aria-valuenow when stepper is changed', async () => { it('updates aria-valuenow when stepper is changed', async () => {
const el = await fixture(inputStepperWithAttrs); const el = await fixture(inputStepperWithAttrs);
const incrementButton = el.shadowRoot.querySelector('[name=increment]'); const incrementButton = el.shadowRoot?.querySelector('[name=increment]');
incrementButton.dispatchEvent(new Event('click')); incrementButton?.dispatchEvent(new Event('click'));
expect(el).to.have.attribute('aria-valuenow', '1'); expect(el).to.have.attribute('aria-valuenow', '1');
}); });
}); });

View file

@ -26,6 +26,11 @@
"packages/form-core/**/*.js", "packages/form-core/**/*.js",
"packages/input/**/*.js", "packages/input/**/*.js",
"packages/input-amount/**/*.js", "packages/input-amount/**/*.js",
"packages/input-date/**/*.js",
"packages/input-email/**/*.js",
"packages/input-iban/**/*.js",
"packages/input-range/**/*.js",
"packages/input-stepper/**/*.js",
"packages/listbox/src/*.js", "packages/listbox/src/*.js",
"packages/localize/**/*.js", "packages/localize/**/*.js",
"packages/overlays/**/*.js", "packages/overlays/**/*.js",