feat(LionInputStepper): implement self-destructing output content for… (#2629)
* feat(LionInputStepper): implement self-destructing output content for value display #2602 * chore(LionInputStepper): output for should use _inputId instead of fieldName Co-authored-by: gerjanvangeest <gerjanvangeest@users.noreply.github.com> * chore(LionInputStepper): add docs for output tag implementation --------- Co-authored-by: gerjanvangeest <gerjanvangeest@users.noreply.github.com>
This commit is contained in:
parent
901bff2803
commit
45af9dc02c
3 changed files with 237 additions and 93 deletions
8
.changeset/giant-ties-attack.md
Normal file
8
.changeset/giant-ties-attack.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
'@lion/ui': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat(LionInputStepper): implement self-destructing output content for value display
|
||||||
|
|
||||||
|
1. from <div class="input-stepper__value">${this.__valueText}</div> to <output class="input-stepper__value" for="..">${this.\_\_valueText}</output>
|
||||||
|
2. remove the \_onEnterButton() and \_onLeaveButton() logic.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { html, css, render } from 'lit';
|
import { html, css, render, nothing } from 'lit';
|
||||||
import { formatNumber, LocalizeMixin, parseNumber } from '@lion/ui/localize-no-side-effects.js';
|
import { formatNumber, LocalizeMixin, parseNumber } from '@lion/ui/localize-no-side-effects.js';
|
||||||
import { LionInput } from '@lion/ui/input.js';
|
import { LionInput } from '@lion/ui/input.js';
|
||||||
import { IsNumber, MinNumber, MaxNumber } from '@lion/ui/form-core.js';
|
import { IsNumber, MinNumber, MaxNumber } from '@lion/ui/form-core.js';
|
||||||
|
|
@ -96,8 +96,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
|
|
||||||
this._increment = this._increment.bind(this);
|
this._increment = this._increment.bind(this);
|
||||||
this._decrement = this._decrement.bind(this);
|
this._decrement = this._decrement.bind(this);
|
||||||
this._onEnterButton = this._onEnterButton.bind(this);
|
|
||||||
this._onLeaveButton = this._onLeaveButton.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -244,6 +242,26 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
this._inputNode.removeAttribute('aria-valuenow');
|
this._inputNode.removeAttribute('aria-valuenow');
|
||||||
this._inputNode.removeAttribute('aria-valuetext');
|
this._inputNode.removeAttribute('aria-valuetext');
|
||||||
}
|
}
|
||||||
|
this._destroyOutputContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_destroyOutputContent() {
|
||||||
|
const outputElement = /** @type {HTMLElement} */ (
|
||||||
|
this.shadowRoot?.querySelector('.input-stepper__value')
|
||||||
|
);
|
||||||
|
|
||||||
|
const timeoutValue = outputElement?.dataset?.selfDestruct
|
||||||
|
? Number(outputElement.dataset.selfDestruct)
|
||||||
|
: 2000;
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
if (outputElement) {
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
if (outputElement.parentNode) {
|
||||||
|
this.__valueText = nothing;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}, timeoutValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -394,8 +412,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
<button
|
<button
|
||||||
?disabled=${this.disabled || this.readOnly}
|
?disabled=${this.disabled || this.readOnly}
|
||||||
@click=${this._decrement}
|
@click=${this._decrement}
|
||||||
@focus=${this._onEnterButton}
|
|
||||||
@blur=${this._onLeaveButton}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="${this.msgLit('lion-input-stepper:decrease')} ${this.fieldName}"
|
aria-label="${this.msgLit('lion-input-stepper:decrease')} ${this.fieldName}"
|
||||||
>
|
>
|
||||||
|
|
@ -414,8 +430,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
<button
|
<button
|
||||||
?disabled=${this.disabled || this.readOnly}
|
?disabled=${this.disabled || this.readOnly}
|
||||||
@click=${this._increment}
|
@click=${this._increment}
|
||||||
@focus=${this._onEnterButton}
|
|
||||||
@blur=${this._onLeaveButton}
|
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="${this.msgLit('lion-input-stepper:increase')} ${this.fieldName}"
|
aria-label="${this.msgLit('lion-input-stepper:increase')} ${this.fieldName}"
|
||||||
>
|
>
|
||||||
|
|
@ -427,7 +441,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
/** @protected */
|
/** @protected */
|
||||||
_inputGroupTemplate() {
|
_inputGroupTemplate() {
|
||||||
return html`
|
return html`
|
||||||
<div class="input-stepper__value">${this.__valueText}</div>
|
<output for="${this._inputId}" data-self-destruct="2000" class="input-stepper__value"
|
||||||
|
>${this.__valueText}</output
|
||||||
|
>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
${this._inputGroupBeforeTemplate()}
|
${this._inputGroupBeforeTemplate()}
|
||||||
<div class="input-group__container">
|
<div class="input-group__container">
|
||||||
|
|
@ -438,38 +454,4 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @protected
|
|
||||||
* @param {Event} ev
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
_onEnterButton(ev) {
|
|
||||||
const valueNode = /** @type {HTMLElement} */ (
|
|
||||||
this.shadowRoot?.querySelector('.input-stepper__value')
|
|
||||||
);
|
|
||||||
valueNode.setAttribute('aria-live', 'assertive');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redispatch leave event on host when catching leave event
|
|
||||||
* on the incrementor and decrementor button.
|
|
||||||
*
|
|
||||||
* This redispatched leave event will be caught by
|
|
||||||
* InteractionStateMixin to set "touched" state to true.
|
|
||||||
*
|
|
||||||
* Interacting with the buttons is "user interactions"
|
|
||||||
* the same way as focusing + blurring the field (native input)
|
|
||||||
*
|
|
||||||
* @protected
|
|
||||||
* @param {Event} ev
|
|
||||||
*/
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
_onLeaveButton(ev) {
|
|
||||||
const valueNode = /** @type {HTMLElement} */ (
|
|
||||||
this.shadowRoot?.querySelector('.input-stepper__value')
|
|
||||||
);
|
|
||||||
valueNode.removeAttribute('aria-live');
|
|
||||||
this.dispatchEvent(new Event(this._leaveEvent));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { expect, fixture as _fixture, nextFrame } from '@open-wc/testing';
|
import { expect, fixture as _fixture, nextFrame } from '@open-wc/testing';
|
||||||
|
import { nothing } from 'lit';
|
||||||
import { html } from 'lit/static-html.js';
|
import { html } from 'lit/static-html.js';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import { formatNumber } from '@lion/ui/localize-no-side-effects.js';
|
import { formatNumber } from '@lion/ui/localize-no-side-effects.js';
|
||||||
|
|
@ -178,31 +179,6 @@ describe('<lion-input-stepper>', () => {
|
||||||
expect(counter).to.equal(1);
|
expect(counter).to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fires a leave event ("blur") on button clicks', async () => {
|
|
||||||
const blurSpy = sinon.spy();
|
|
||||||
const el = await fixture(html`
|
|
||||||
<lion-input-stepper @blur=${blurSpy} name="year" label="Years"></lion-input-stepper>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(el.value).to.equal('');
|
|
||||||
const decrementButton = el.querySelector('[slot=prefix]');
|
|
||||||
decrementButton?.dispatchEvent(new Event('focus'));
|
|
||||||
decrementButton?.dispatchEvent(new Event('click'));
|
|
||||||
decrementButton?.dispatchEvent(new Event('blur'));
|
|
||||||
expect(el.value).to.equal('−1');
|
|
||||||
expect(blurSpy.calledOnce).to.be.true;
|
|
||||||
expect(el.touched).to.be.true;
|
|
||||||
|
|
||||||
el.touched = false;
|
|
||||||
const incrementButton = el.querySelector('[slot=suffix]');
|
|
||||||
incrementButton?.dispatchEvent(new Event('focus'));
|
|
||||||
incrementButton?.dispatchEvent(new Event('click'));
|
|
||||||
incrementButton?.dispatchEvent(new Event('blur'));
|
|
||||||
expect(el.value).to.equal('0');
|
|
||||||
expect(blurSpy.calledTwice).to.be.true;
|
|
||||||
expect(el.touched).to.be.true;
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -403,6 +379,210 @@ describe('<lion-input-stepper>', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('_destroyOutputContent method', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Ensure clean state for each test
|
||||||
|
sinon.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear existing timer before setting a new one', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const clearTimeoutSpy = sinon.spy(window, 'clearTimeout');
|
||||||
|
|
||||||
|
// Set an initial timer
|
||||||
|
el.timer = setTimeout(() => {}, 1000);
|
||||||
|
const initialTimer = el.timer;
|
||||||
|
|
||||||
|
// Call _destroyOutputContent
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
expect(clearTimeoutSpy.calledWith(initialTimer)).to.be.true;
|
||||||
|
clearTimeoutSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set __valueText to nothing and request update after timeout', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const requestUpdateSpy = sinon.spy(el, 'requestUpdate');
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
|
||||||
|
// Set initial value text
|
||||||
|
el.__valueText = 'test value';
|
||||||
|
|
||||||
|
// Call _destroyOutputContent
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
// Fast forward time by 2000ms (default timeout)
|
||||||
|
clock.tick(2000);
|
||||||
|
|
||||||
|
await expect(el.__valueText).to.equal(nothing);
|
||||||
|
expect(requestUpdateSpy.calledOnce).to.be.true;
|
||||||
|
|
||||||
|
clock.restore();
|
||||||
|
requestUpdateSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom timeout from data-self-destruct attribute', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<lion-input-stepper name="test" label="Test"> </lion-input-stepper>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Get the output element and set custom self-destruct value
|
||||||
|
const outputElement = /** @type {HTMLElement} */ (
|
||||||
|
el.shadowRoot?.querySelector('.input-stepper__value')
|
||||||
|
);
|
||||||
|
if (outputElement) {
|
||||||
|
outputElement.dataset.selfDestruct = '5000';
|
||||||
|
}
|
||||||
|
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
const requestUpdateSpy = sinon.spy(el, 'requestUpdate');
|
||||||
|
|
||||||
|
el.__valueText = 'test value';
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
// Should not trigger after default 2000ms
|
||||||
|
clock.tick(2000);
|
||||||
|
await expect(el.__valueText).to.equal('test value');
|
||||||
|
expect(requestUpdateSpy.called).to.be.false;
|
||||||
|
|
||||||
|
// Should trigger after custom 5000ms
|
||||||
|
clock.tick(3000); // Total 5000ms
|
||||||
|
await expect(el.__valueText).to.equal(nothing);
|
||||||
|
expect(requestUpdateSpy.calledOnce).to.be.true;
|
||||||
|
|
||||||
|
clock.restore();
|
||||||
|
requestUpdateSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid data-self-destruct value by using default timeout', async () => {
|
||||||
|
const el = await fixture(html`
|
||||||
|
<lion-input-stepper name="test" label="Test"> </lion-input-stepper>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Get the output element and set invalid self-destruct value
|
||||||
|
const outputElement = /** @type {HTMLElement} */ (
|
||||||
|
el.shadowRoot?.querySelector('.input-stepper__value')
|
||||||
|
);
|
||||||
|
if (outputElement) {
|
||||||
|
outputElement.dataset.selfDestruct = 'invalid';
|
||||||
|
}
|
||||||
|
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
const requestUpdateSpy = sinon.spy(el, 'requestUpdate');
|
||||||
|
|
||||||
|
el.__valueText = 'test value';
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
// Should use default 2000ms when invalid value is provided
|
||||||
|
clock.tick(2000);
|
||||||
|
await expect(el.__valueText).to.equal(nothing);
|
||||||
|
expect(requestUpdateSpy.calledOnce).to.be.true;
|
||||||
|
|
||||||
|
clock.restore();
|
||||||
|
requestUpdateSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not set timeout if output element does not exist', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const setTimeoutSpy = sinon.spy(window, 'setTimeout');
|
||||||
|
|
||||||
|
// Mock shadowRoot to return null for querySelector
|
||||||
|
const originalQuerySelector = el.shadowRoot?.querySelector;
|
||||||
|
if (el.shadowRoot) {
|
||||||
|
el.shadowRoot.querySelector = () => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
expect(setTimeoutSpy.called).to.be.false;
|
||||||
|
|
||||||
|
// Restore original querySelector
|
||||||
|
if (el.shadowRoot && originalQuerySelector) {
|
||||||
|
el.shadowRoot.querySelector = originalQuerySelector;
|
||||||
|
}
|
||||||
|
setTimeoutSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only execute timeout callback if output element still has parent', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
const requestUpdateSpy = sinon.spy(el, 'requestUpdate');
|
||||||
|
|
||||||
|
el.__valueText = 'test value';
|
||||||
|
el._destroyOutputContent();
|
||||||
|
|
||||||
|
// Remove the output element from DOM before timeout
|
||||||
|
const outputElement = el.shadowRoot?.querySelector('.input-stepper__value');
|
||||||
|
if (outputElement && outputElement.parentNode) {
|
||||||
|
outputElement.parentNode.removeChild(outputElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast forward time
|
||||||
|
clock.tick(2000);
|
||||||
|
|
||||||
|
// Should not have updated since element was removed
|
||||||
|
expect(requestUpdateSpy.called).to.be.false;
|
||||||
|
expect(el.__valueText).to.equal('test value');
|
||||||
|
|
||||||
|
clock.restore();
|
||||||
|
requestUpdateSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be called when increment button is clicked', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const destroyOutputSpy = sinon.spy(el, '_destroyOutputContent');
|
||||||
|
|
||||||
|
const incrementButton = el.querySelector('[slot=suffix]');
|
||||||
|
incrementButton?.dispatchEvent(new Event('click'));
|
||||||
|
|
||||||
|
expect(destroyOutputSpy.calledOnce).to.be.true;
|
||||||
|
destroyOutputSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be called when decrement button is clicked', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const destroyOutputSpy = sinon.spy(el, '_destroyOutputContent');
|
||||||
|
|
||||||
|
const decrementButton = el.querySelector('[slot=prefix]');
|
||||||
|
decrementButton?.dispatchEvent(new Event('click'));
|
||||||
|
|
||||||
|
expect(destroyOutputSpy.calledOnce).to.be.true;
|
||||||
|
destroyOutputSpy.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple rapid calls by clearing previous timers', async () => {
|
||||||
|
const el = await fixture(defaultInputStepper);
|
||||||
|
const clock = sinon.useFakeTimers();
|
||||||
|
const requestUpdateSpy = sinon.spy(el, 'requestUpdate');
|
||||||
|
|
||||||
|
el.__valueText = 'test value';
|
||||||
|
|
||||||
|
// Call _destroyOutputContent multiple times rapidly
|
||||||
|
el._destroyOutputContent();
|
||||||
|
clock.tick(1000); // 1 second
|
||||||
|
|
||||||
|
el._destroyOutputContent(); // This should clear the previous timer
|
||||||
|
clock.tick(1000); // Total 2 seconds from first call, 1 second from second call
|
||||||
|
|
||||||
|
// Should not have triggered yet since second call reset the timer
|
||||||
|
expect(el.__valueText).to.equal('test value');
|
||||||
|
expect(requestUpdateSpy.called).to.be.false;
|
||||||
|
|
||||||
|
clock.tick(1000); // Total 2 seconds from second call
|
||||||
|
|
||||||
|
// Should trigger now
|
||||||
|
await expect(el.__valueText).to.equal(nothing);
|
||||||
|
expect(requestUpdateSpy.calledOnce).to.be.true;
|
||||||
|
|
||||||
|
clock.restore();
|
||||||
|
requestUpdateSpy.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Accessibility', () => {
|
describe('Accessibility', () => {
|
||||||
it('is a11y AXE accessible', async () => {
|
it('is a11y AXE accessible', async () => {
|
||||||
const el = await fixture(defaultInputStepper);
|
const el = await fixture(defaultInputStepper);
|
||||||
|
|
@ -490,32 +670,6 @@ describe('<lion-input-stepper>', () => {
|
||||||
expect(el._inputNode.getAttribute('aria-valuemax')).to.equal('1000');
|
expect(el._inputNode.getAttribute('aria-valuemax')).to.equal('1000');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('when decrease button gets focus, it sets aria-live to input-stepper__value', async () => {
|
|
||||||
const el = await fixture(inputStepperWithAttrs);
|
|
||||||
const stepperValue = el.shadowRoot?.querySelector('.input-stepper__value');
|
|
||||||
const decrementButton = el.querySelector('[slot=prefix]');
|
|
||||||
|
|
||||||
decrementButton?.dispatchEvent(new Event('focus'));
|
|
||||||
expect(stepperValue?.hasAttribute('aria-live')).to.be.true;
|
|
||||||
expect(stepperValue?.getAttribute('aria-live')).to.equal('assertive');
|
|
||||||
|
|
||||||
decrementButton?.dispatchEvent(new Event('blur'));
|
|
||||||
expect(stepperValue?.hasAttribute('aria-live')).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('when increase button gets focus, it sets aria-live to input-stepper__value', async () => {
|
|
||||||
const el = await fixture(inputStepperWithAttrs);
|
|
||||||
const stepperValue = el.shadowRoot?.querySelector('.input-stepper__value');
|
|
||||||
const incrementButton = el.querySelector('[slot=suffix]');
|
|
||||||
|
|
||||||
incrementButton?.dispatchEvent(new Event('focus'));
|
|
||||||
expect(stepperValue?.hasAttribute('aria-live')).to.be.true;
|
|
||||||
expect(stepperValue?.getAttribute('aria-live')).to.equal('assertive');
|
|
||||||
|
|
||||||
incrementButton?.dispatchEvent(new Event('blur'));
|
|
||||||
expect(stepperValue?.hasAttribute('aria-live')).to.be.false;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('decrease button should have aria-label with the component label', async () => {
|
it('decrease button should have aria-label with the component label', async () => {
|
||||||
const el = await fixture(inputStepperWithAttrs);
|
const el = await fixture(inputStepperWithAttrs);
|
||||||
const decrementButton = el.querySelector('[slot=prefix]');
|
const decrementButton = el.querySelector('[slot=prefix]');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue