Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dirty-scissors-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lion/ui': patch
---

[input-stepper] move role="spinbutton" and relevant aria attributes to the inputNode
112 changes: 70 additions & 42 deletions packages/ui/components/input-stepper/src/LionInputStepper.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
.input-group__container > .input-group__input ::slotted(.form-control) {
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;
}
`,
];
}
Expand Down Expand Up @@ -84,7 +97,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {

this.__increment = this.__increment.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() {
Expand All @@ -94,11 +108,12 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
min: this.min,
step: this.step,
};

this.role = 'spinbutton';
if (this._inputNode) {
this._inputNode.role = 'spinbutton';
this._inputNode.setAttribute('inputmode', 'decimal');
this._inputNode.setAttribute('autocomplete', 'off');
}
this.addEventListener('keydown', this.__keyDownHandler);
this._inputNode.setAttribute('inputmode', 'decimal');
this._inputNode.setAttribute('autocomplete', 'off');
this.__setDefaultValidators();
this.__toggleSpinnerButtonsState();
}
Expand All @@ -120,9 +135,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.min = `${this.min}`;
this.values.min = this.min;
if (this.min !== Infinity) {
this.setAttribute('aria-valuemin', `${this.min}`);
this._inputNode.setAttribute('aria-valuemin', `${this.min}`);
} else {
this.removeAttribute('aria-valuemin');
this._inputNode.removeAttribute('aria-valuemin');
}
this.__toggleSpinnerButtonsState();
}
Expand All @@ -131,9 +146,9 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.max = `${this.max}`;
this.values.max = this.max;
if (this.max !== Infinity) {
this.setAttribute('aria-valuemax', `${this.max}`);
this._inputNode.setAttribute('aria-valuemax', `${this.max}`);
} else {
this.removeAttribute('aria-valuemax');
this._inputNode.removeAttribute('aria-valuemax');
}
this.__toggleSpinnerButtonsState();
}
Expand All @@ -146,14 +161,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
this._inputNode.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() {
Expand All @@ -164,22 +171,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
* @private
Expand Down Expand Up @@ -228,7 +219,6 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
}
decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true');
incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true');

this._updateAriaAttributes();
}

Expand All @@ -238,21 +228,22 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
_updateAriaAttributes() {
const displayValue = this._inputNode.value;
if (displayValue) {
this.setAttribute('aria-valuenow', `${displayValue}`);
this._inputNode.setAttribute('aria-valuenow', `${displayValue}`);
if (
Object.keys(this.valueTextMapping).length !== 0 &&
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 {
// VoiceOver announces percentages once the valuemin or valuemax are used.
// 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
this.setAttribute('aria-valuetext', `${displayValue}`);
this.__valueText = displayValue;
}
this._inputNode.setAttribute('aria-valuetext', `${this.__valueText}`);
} else {
this.removeAttribute('aria-valuenow');
this.removeAttribute('aria-valuetext');
this._inputNode.removeAttribute('aria-valuenow');
this._inputNode.removeAttribute('aria-valuetext');
}
}

Expand Down Expand Up @@ -374,7 +365,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
<button
?disabled=${this.disabled || this.readOnly}
@click=${this.__decrement}
@blur=${this.__boundOnLeaveButton}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:decrease')}"
>
Expand All @@ -393,7 +385,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
<button
?disabled=${this.disabled || this.readOnly}
@click=${this.__increment}
@blur=${this.__boundOnLeaveButton}
@focus=${this._onEnterButton}
@blur=${this._onLeaveButton}
type="button"
aria-label="${this.msgLit('lion-input-stepper:increase')}"
>
Expand All @@ -402,6 +395,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
* on the incrementor and decrementor button.
Expand All @@ -411,8 +431,16 @@ export class LionInputStepper extends LocalizeMixin(LionInput) {
*
* Interacting with the buttons is "user interactions"
* 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));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -278,17 +278,23 @@ describe('<lion-input-stepper>', () => {
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 () => {
const el = await fixture(defaultInputStepper);
el.modelValue = 1;

await el.updateComplete;
expect(el.hasAttribute('aria-valuenow')).to.be.true;
expect(el.getAttribute('aria-valuenow')).to.equal('1');
expect(el._inputNode.hasAttribute('aria-valuenow')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuenow')).to.equal('1');

el.modelValue = '';
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 () => {
Expand All @@ -299,12 +305,12 @@ describe('<lion-input-stepper>', () => {
el.modelValue = 1;
await el.updateComplete;

expect(el.hasAttribute('aria-valuetext')).to.be.true;
expect(el.getAttribute('aria-valuetext')).to.equal('1');
expect(el._inputNode.hasAttribute('aria-valuetext')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuetext')).to.equal('1');

el.modelValue = '';
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 () => {
Expand All @@ -318,30 +324,60 @@ describe('<lion-input-stepper>', () => {
`);
el.modelValue = 1;
await el.updateComplete;
expect(el.hasAttribute('aria-valuetext')).to.be.true;
expect(el.getAttribute('aria-valuetext')).to.equal('first');
expect(el._inputNode.hasAttribute('aria-valuetext')).to.be.true;
expect(el._inputNode.getAttribute('aria-valuetext')).to.equal('first');
});

it('updates aria-valuemin when stepper is changed', async () => {
const el = await fixture(inputStepperWithAttrs);
const incrementButton = el.querySelector('[slot=suffix]');
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;
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 () => {
const el = await fixture(inputStepperWithAttrs);
const incrementButton = el.querySelector('[slot=suffix]');
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;
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;
});
});
});
Loading