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:
MiB-1 2025-11-24 10:06:56 +01:00 committed by GitHub
parent 901bff2803
commit 45af9dc02c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 237 additions and 93 deletions

View 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.

View file

@ -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 { LionInput } from '@lion/ui/input.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._decrement = this._decrement.bind(this);
this._onEnterButton = this._onEnterButton.bind(this);
this._onLeaveButton = this._onLeaveButton.bind(this);
}
connectedCallback() {
@ -244,6 +242,26 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.removeAttribute('aria-valuenow');
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
?disabled=${this.disabled || this.readOnly}
@click=${this._decrement}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:decrease')} ${this.fieldName}"
>
@ -414,8 +430,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
<button
?disabled=${this.disabled || this.readOnly}
@click=${this._increment}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:increase')} ${this.fieldName}"
>
@ -427,7 +441,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
/** @protected */
_inputGroupTemplate() {
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">
${this._inputGroupBeforeTemplate()}
<div class="input-group__container">
@ -438,38 +454,4 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
</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));
}
}

View file

@ -1,4 +1,5 @@
import { expect, fixture as _fixture, nextFrame } from '@open-wc/testing';
import { nothing } from 'lit';
import { html } from 'lit/static-html.js';
import sinon from 'sinon';
import { formatNumber } from '@lion/ui/localize-no-side-effects.js';
@ -178,31 +179,6 @@ describe('<lion-input-stepper>', () => {
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 () => {
const el = await fixture(inputStepperWithAttrs);
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', () => {
it('is a11y AXE accessible', async () => {
const el = await fixture(defaultInputStepper);
@ -490,32 +670,6 @@ describe('<lion-input-stepper>', () => {
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 () => {
const el = await fixture(inputStepperWithAttrs);
const decrementButton = el.querySelector('[slot=prefix]');