fix(input-stepper): move role=spinbutton to input node (#2426)

* fix(input-stepper): move role=spinbutton to input node

* chore: rename __boundOnEnterButton and __boundOnLeaveButton
This commit is contained in:
gerjanvangeest 2024-12-11 15:41:32 +01:00 committed by GitHub
parent fc1e66775f
commit 67f5735538
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 54 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ui': patch
---
[input-stepper] move role="spinbutton" and relevant aria attributes to the inputNode

View file

@ -21,6 +21,19 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
.input-group__container > .input-group__input ::slotted(.form-control) { .input-group__container > .input-group__input ::slotted(.form-control) {
text-align: center; text-align: center;
} }
.input-stepper__value {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(100%);
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
border: 0;
margin: 0;
padding: 0;
}
`, `,
]; ];
} }
@ -83,7 +96,8 @@ 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.__boundOnLeaveButton = this._onLeaveButton.bind(this); this._onEnterButton = this._onEnterButton.bind(this);
this._onLeaveButton = this._onLeaveButton.bind(this);
} }
connectedCallback() { connectedCallback() {
@ -93,11 +107,12 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
min: this.min, min: this.min,
step: this.step, step: this.step,
}; };
if (this._inputNode) {
this.role = 'spinbutton'; this._inputNode.role = 'spinbutton';
this.addEventListener('keydown', this.__keyDownHandler);
this._inputNode.setAttribute('inputmode', 'decimal'); this._inputNode.setAttribute('inputmode', 'decimal');
this._inputNode.setAttribute('autocomplete', 'off'); this._inputNode.setAttribute('autocomplete', 'off');
}
this.addEventListener('keydown', this.__keyDownHandler);
this.__setDefaultValidators(); this.__setDefaultValidators();
this.__toggleSpinnerButtonsState(); this.__toggleSpinnerButtonsState();
} }
@ -119,9 +134,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.min = `${this.min}`; this._inputNode.min = `${this.min}`;
this.values.min = this.min; this.values.min = this.min;
if (this.min !== Infinity) { if (this.min !== Infinity) {
this.setAttribute('aria-valuemin', `${this.min}`); this._inputNode.setAttribute('aria-valuemin', `${this.min}`);
} else { } else {
this.removeAttribute('aria-valuemin'); this._inputNode.removeAttribute('aria-valuemin');
} }
this.__toggleSpinnerButtonsState(); this.__toggleSpinnerButtonsState();
} }
@ -130,9 +145,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.max = `${this.max}`; this._inputNode.max = `${this.max}`;
this.values.max = this.max; this.values.max = this.max;
if (this.max !== Infinity) { if (this.max !== Infinity) {
this.setAttribute('aria-valuemax', `${this.max}`); this._inputNode.setAttribute('aria-valuemax', `${this.max}`);
} else { } else {
this.removeAttribute('aria-valuemax'); this._inputNode.removeAttribute('aria-valuemax');
} }
this.__toggleSpinnerButtonsState(); this.__toggleSpinnerButtonsState();
} }
@ -145,14 +160,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.step = `${this.step}`; this._inputNode.step = `${this.step}`;
this.values.step = this.step; this.values.step = this.step;
} }
if (changedProperties.has('_ariaLabelledNodes')) {
this.__reflectAriaAttrToSpinButton('aria-labelledby', this._ariaLabelledNodes);
}
if (changedProperties.has('_ariaDescribedNodes')) {
this.__reflectAriaAttrToSpinButton('aria-describedby', this._ariaDescribedNodes);
}
} }
get slots() { get slots() {
@ -163,22 +170,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
}; };
} }
/**
* Based on FormControlMixin __reflectAriaAttr()
*
* Will handle help text, validation feedback and character counter,
* prefix/suffix/before/after (if they contain data-description flag attr).
* Also, contents of id references that will be put in the <lion-field>._ariaDescribedby property
* from an external context, will be read by a screen reader.
* @param {string} attrName
* @param {Element[]} nodes
* @private
*/
__reflectAriaAttrToSpinButton(attrName, nodes) {
const string = nodes.map(n => n.id).join(' ');
this.setAttribute(attrName, string);
}
/** /**
* Set aria labels and apply validators * Set aria labels and apply validators
* @private * @private
@ -227,7 +218,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
} }
decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true');
incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true');
this._updateAriaAttributes(); this._updateAriaAttributes();
} }
@ -237,21 +227,22 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
_updateAriaAttributes() { _updateAriaAttributes() {
const displayValue = this._inputNode.value; const displayValue = this._inputNode.value;
if (displayValue) { if (displayValue) {
this.setAttribute('aria-valuenow', `${displayValue}`); this._inputNode.setAttribute('aria-valuenow', `${displayValue}`);
if ( if (
Object.keys(this.valueTextMapping).length !== 0 && Object.keys(this.valueTextMapping).length !== 0 &&
Object.keys(this.valueTextMapping).find(key => Number(key) === this.currentValue) Object.keys(this.valueTextMapping).find(key => Number(key) === this.currentValue)
) { ) {
this.setAttribute('aria-valuetext', `${this.valueTextMapping[this.currentValue]}`); this.__valueText = this.valueTextMapping[this.currentValue];
} else { } else {
// VoiceOver announces percentages once the valuemin or valuemax are used. // VoiceOver announces percentages once the valuemin or valuemax are used.
// This can be fixed by setting valuetext to the same value as valuenow // This can be fixed by setting valuetext to the same value as valuenow
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-valuenow // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-valuenow
this.setAttribute('aria-valuetext', `${displayValue}`); this.__valueText = displayValue;
} }
this._inputNode.setAttribute('aria-valuetext', `${this.__valueText}`);
} else { } else {
this.removeAttribute('aria-valuenow'); this._inputNode.removeAttribute('aria-valuenow');
this.removeAttribute('aria-valuetext'); this._inputNode.removeAttribute('aria-valuetext');
} }
} }
@ -373,7 +364,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
<button <button
?disabled=${this.disabled || this.readOnly} ?disabled=${this.disabled || this.readOnly}
@click=${this.__decrement} @click=${this.__decrement}
@blur=${this.__boundOnLeaveButton} @focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button" type="button"
aria-label="${this.msgLit('lion-input-stepper:decrease')}" aria-label="${this.msgLit('lion-input-stepper:decrease')}"
> >
@ -392,7 +384,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
<button <button
?disabled=${this.disabled || this.readOnly} ?disabled=${this.disabled || this.readOnly}
@click=${this.__increment} @click=${this.__increment}
@blur=${this.__boundOnLeaveButton} @focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button" type="button"
aria-label="${this.msgLit('lion-input-stepper:increase')}" aria-label="${this.msgLit('lion-input-stepper:increase')}"
> >
@ -401,6 +394,33 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
`; `;
} }
/** @protected */
_inputGroupTemplate() {
return html`
<div class="input-stepper__value">${this.__valueText}</div>
<div class="input-group">
${this._inputGroupBeforeTemplate()}
<div class="input-group__container">
${this._inputGroupPrefixTemplate()} ${this._inputGroupInputTemplate()}
${this._inputGroupSuffixTemplate()}
</div>
${this._inputGroupAfterTemplate()}
</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 * Redispatch leave event on host when catching leave event
* on the incrementor and decrementor button. * on the incrementor and decrementor button.
@ -410,8 +430,16 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
* *
* Interacting with the buttons is "user interactions" * Interacting with the buttons is "user interactions"
* the same way as focusing + blurring the field (native input) * the same way as focusing + blurring the field (native input)
*
* @protected
* @param {Event} ev
*/ */
_onLeaveButton() { // 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)); this.dispatchEvent(new Event(this._leaveEvent));
} }
} }

View file

@ -278,17 +278,23 @@ describe('<lion-input-stepper>', () => {
await expect(el).to.be.accessible(); await expect(el).to.be.accessible();
}); });
it('has role="spinbutton"', async () => {
const el = await fixture(defaultInputStepper);
expect(el._inputNode.hasAttribute('role')).to.be.true;
expect(el._inputNode.getAttribute('role')).to.equal('spinbutton');
});
it('updates aria-valuenow when stepper is changed', async () => { it('updates aria-valuenow when stepper is changed', async () => {
const el = await fixture(defaultInputStepper); const el = await fixture(defaultInputStepper);
el.modelValue = 1; el.modelValue = 1;
await el.updateComplete; await el.updateComplete;
expect(el.hasAttribute('aria-valuenow')).to.be.true; expect(el._inputNode.hasAttribute('aria-valuenow')).to.be.true;
expect(el.getAttribute('aria-valuenow')).to.equal('1'); expect(el._inputNode.getAttribute('aria-valuenow')).to.equal('1');
el.modelValue = ''; el.modelValue = '';
await el.updateComplete; await el.updateComplete;
expect(el.hasAttribute('aria-valuenow')).to.be.false; expect(el._inputNode.hasAttribute('aria-valuenow')).to.be.false;
}); });
it('updates aria-valuetext when stepper is changed', async () => { it('updates aria-valuetext when stepper is changed', async () => {
@ -299,12 +305,12 @@ describe('<lion-input-stepper>', () => {
el.modelValue = 1; el.modelValue = 1;
await el.updateComplete; await el.updateComplete;
expect(el.hasAttribute('aria-valuetext')).to.be.true; expect(el._inputNode.hasAttribute('aria-valuetext')).to.be.true;
expect(el.getAttribute('aria-valuetext')).to.equal('1'); expect(el._inputNode.getAttribute('aria-valuetext')).to.equal('1');
el.modelValue = ''; el.modelValue = '';
await el.updateComplete; await el.updateComplete;
expect(el.hasAttribute('aria-valuetext')).to.be.false; expect(el._inputNode.hasAttribute('aria-valuetext')).to.be.false;
}); });
it('can give aria-valuetext to override default value as a human-readable text alternative', async () => { it('can give aria-valuetext to override default value as a human-readable text alternative', async () => {
@ -318,30 +324,60 @@ describe('<lion-input-stepper>', () => {
`); `);
el.modelValue = 1; el.modelValue = 1;
await el.updateComplete; await el.updateComplete;
expect(el.hasAttribute('aria-valuetext')).to.be.true; expect(el._inputNode.hasAttribute('aria-valuetext')).to.be.true;
expect(el.getAttribute('aria-valuetext')).to.equal('first'); expect(el._inputNode.getAttribute('aria-valuetext')).to.equal('first');
}); });
it('updates aria-valuemin when stepper is changed', async () => { it('updates aria-valuemin when stepper is changed', async () => {
const el = await fixture(inputStepperWithAttrs); const el = await fixture(inputStepperWithAttrs);
const incrementButton = el.querySelector('[slot=suffix]'); const incrementButton = el.querySelector('[slot=suffix]');
incrementButton?.dispatchEvent(new Event('click')); incrementButton?.dispatchEvent(new Event('click'));
expect(el).to.have.attribute('aria-valuemin', '100'); expect(el._inputNode.hasAttribute('aria-valuemin')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuemin')).to.equal('100');
el.min = 0; el.min = 0;
await el.updateComplete; await el.updateComplete;
expect(el).to.have.attribute('aria-valuemin', '0'); expect(el._inputNode.hasAttribute('aria-valuemin')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuemin')).to.equal('0');
}); });
it('updates aria-valuemax when stepper is changed', async () => { it('updates aria-valuemax when stepper is changed', async () => {
const el = await fixture(inputStepperWithAttrs); const el = await fixture(inputStepperWithAttrs);
const incrementButton = el.querySelector('[slot=suffix]'); const incrementButton = el.querySelector('[slot=suffix]');
incrementButton?.dispatchEvent(new Event('click')); incrementButton?.dispatchEvent(new Event('click'));
expect(el).to.have.attribute('aria-valuemax', '200'); expect(el._inputNode.hasAttribute('aria-valuemax')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuemax')).to.equal('200');
el.max = 1000; el.max = 1000;
await el.updateComplete; await el.updateComplete;
expect(el).to.have.attribute('aria-valuemax', '1000'); expect(el._inputNode.hasAttribute('aria-valuemax')).to.be.true;
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;
}); });
}); });
}); });