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 { 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue