diff --git a/.changeset/sweet-cows-happen.md b/.changeset/sweet-cows-happen.md new file mode 100644 index 000000000..ce2d62634 --- /dev/null +++ b/.changeset/sweet-cows-happen.md @@ -0,0 +1,5 @@ +--- +'@lion/ui': minor +--- + +[input-stepper] add aria-valuemin, aria-valuemax and an option to set aria-valuetext diff --git a/docs/components/input-stepper/use-cases.md b/docs/components/input-stepper/use-cases.md index 76d8e88dd..ff5a9ed32 100644 --- a/docs/components/input-stepper/use-cases.md +++ b/docs/components/input-stepper/use-cases.md @@ -49,6 +49,36 @@ Use `min` and `max` attribute to specify a range. > ``` +### Value text + +Use the `.valueTextMapping` property to override the value with a text. + +```js preview-story +export const valueTextMapping = () => { + const values = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + 5: 'fifth', + 6: 'sixth', + 7: 'seventh', + 8: 'eighth', + 9: 'ninth', + 10: 'tenth', + }; + return html` + + `; +}; +``` + ### Formatting Just like with the `input-amount` you can add the `formatOptions` to format the numbers to your preferences, to a different locale or adjust the amount of fractions. diff --git a/packages/ui/components/input-stepper/src/LionInputStepper.js b/packages/ui/components/input-stepper/src/LionInputStepper.js index 8dc50d864..13b25361b 100644 --- a/packages/ui/components/input-stepper/src/LionInputStepper.js +++ b/packages/ui/components/input-stepper/src/LionInputStepper.js @@ -36,6 +36,10 @@ export class LionInputStepper extends LocalizeMixin(LionInput) { type: Number, reflect: true, }, + valueTextMapping: { + type: Object, + attribute: 'value-text', + }, step: { type: Number, reflect: true, @@ -66,6 +70,11 @@ export class LionInputStepper extends LocalizeMixin(LionInput) { this.formatter = formatNumber; this.min = Infinity; this.max = Infinity; + /** + * The aria-valuetext attribute defines the human-readable text alternative of aria-valuenow. + * @type {{[key: number]: string}} + */ + this.valueTextMapping = {}; this.step = 1; this.values = { max: this.max, @@ -110,15 +119,29 @@ export class LionInputStepper extends LocalizeMixin(LionInput) { if (changedProperties.has('min')) { this._inputNode.min = `${this.min}`; this.values.min = this.min; + if (this.min !== Infinity) { + this.setAttribute('aria-valuemin', `${this.min}`); + } else { + this.removeAttribute('aria-valuemin'); + } this.__toggleSpinnerButtonsState(); } if (changedProperties.has('max')) { this._inputNode.max = `${this.max}`; this.values.max = this.max; + if (this.max !== Infinity) { + this.setAttribute('aria-valuemax', `${this.max}`); + } else { + this.removeAttribute('aria-valuemax'); + } this.__toggleSpinnerButtonsState(); } + if (changedProperties.has('valueTextMapping')) { + this._updateAriaAttributes(); + } + if (changedProperties.has('step')) { this._inputNode.step = `${this.step}`; this.values.step = this.step; @@ -193,8 +216,8 @@ export class LionInputStepper extends LocalizeMixin(LionInput) { */ __toggleSpinnerButtonsState() { const { min, max } = this.values; - const decrementButton = this.__getSlot('prefix'); - const incrementButton = this.__getSlot('suffix'); + const decrementButton = /** @type {HTMLButtonElement} */ (this.__getSlot('prefix')); + const incrementButton = /** @type {HTMLButtonElement} */ (this.__getSlot('suffix')); const disableIncrementor = this.currentValue >= max && max !== Infinity; const disableDecrementor = this.currentValue <= min && min !== Infinity; if ( @@ -205,7 +228,32 @@ export class LionInputStepper extends LocalizeMixin(LionInput) { } decrementButton[disableDecrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); incrementButton[disableIncrementor ? 'setAttribute' : 'removeAttribute']('disabled', 'true'); - this.setAttribute('aria-valuenow', `${this.currentValue}`); + + this._updateAriaAttributes(); + } + + /** + * @protected + */ + _updateAriaAttributes() { + const displayValue = this._inputNode.value; + if (displayValue) { + this.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]}`); + } 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}`); + } + } else { + this.removeAttribute('aria-valuenow'); + this.removeAttribute('aria-valuetext'); + } } /** diff --git a/packages/ui/components/input-stepper/test/lion-input-stepper.test.js b/packages/ui/components/input-stepper/test/lion-input-stepper.test.js index e33294f0b..4af54879d 100644 --- a/packages/ui/components/input-stepper/test/lion-input-stepper.test.js +++ b/packages/ui/components/input-stepper/test/lion-input-stepper.test.js @@ -280,9 +280,68 @@ describe('', () => { 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'); + + el.modelValue = ''; + await el.updateComplete; + expect(el.hasAttribute('aria-valuenow')).to.be.false; + }); + + it('updates aria-valuetext when stepper is changed', async () => { + // 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 + const el = await fixture(defaultInputStepper); + el.modelValue = 1; + await el.updateComplete; + + expect(el.hasAttribute('aria-valuetext')).to.be.true; + expect(el.getAttribute('aria-valuetext')).to.equal('1'); + + el.modelValue = ''; + await el.updateComplete; + expect(el.hasAttribute('aria-valuetext')).to.be.false; + }); + + it('can give aria-valuetext to override default value as a human-readable text alternative', async () => { + const values = { + 1: 'first', + 2: 'second', + 3: 'third', + }; + const el = await fixture(html` + + `); + el.modelValue = 1; + await el.updateComplete; + expect(el.hasAttribute('aria-valuetext')).to.be.true; + expect(el.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'); + + el.min = 0; + await el.updateComplete; + expect(el).to.have.attribute('aria-valuemin', '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-valuenow', '1'); + expect(el).to.have.attribute('aria-valuemax', '200'); + + el.max = 1000; + await el.updateComplete; + expect(el).to.have.attribute('aria-valuemax', '1000'); }); }); });