diff --git a/.changeset/sixty-donkeys-tell.md b/.changeset/sixty-donkeys-tell.md new file mode 100644 index 000000000..026c21bbd --- /dev/null +++ b/.changeset/sixty-donkeys-tell.md @@ -0,0 +1,5 @@ +--- +'@lion/progress-indicator': minor +--- + +Add option to make the progress-indicator determinate diff --git a/docs/components/progress-indicator/assets/my-determinate-progress-bar.js b/docs/components/progress-indicator/assets/my-determinate-progress-bar.js new file mode 100644 index 000000000..1eff1d4e9 --- /dev/null +++ b/docs/components/progress-indicator/assets/my-determinate-progress-bar.js @@ -0,0 +1,30 @@ +import { css, html } from '@lion/core'; +import { LionProgressIndicator } from '@lion/progress-indicator'; + +export class MyDeterminateProgressBar extends LionProgressIndicator { + static get styles() { + return [ + css` + :host { + display: block; + position: relative; + width: 100%; + height: 6px; + overflow: hidden; + background-color: #eee; + } + + .progress__filled { + height: inherit; + background-color: green; + } + `, + ]; + } + + _graphicTemplate() { + return html`
`; + } +} + +customElements.define('my-determinate-progress-bar', MyDeterminateProgressBar); diff --git a/docs/components/progress-indicator/assets/custom-progress-indicator.js b/docs/components/progress-indicator/assets/my-indeterminate-progress-spinner.js similarity index 68% rename from docs/components/progress-indicator/assets/custom-progress-indicator.js rename to docs/components/progress-indicator/assets/my-indeterminate-progress-spinner.js index da27c83ec..f28930bdb 100644 --- a/docs/components/progress-indicator/assets/custom-progress-indicator.js +++ b/docs/components/progress-indicator/assets/my-indeterminate-progress-spinner.js @@ -1,27 +1,22 @@ import { html, css } from '@lion/core'; import { LionProgressIndicator } from '@lion/progress-indicator'; -export class CustomProgressIndicator extends LionProgressIndicator { +export class MyIndeterminateProgressSpinner extends LionProgressIndicator { static get styles() { return [ css` - :host { - display: block; - } - - .progress--icon { + .progress__icon { display: inline-block; width: 48px; height: 48px; animation: spinner-rotate 2s linear infinite; } - .progress--icon--circle { + .progress__filled { animation: spinner-dash 1.35s ease-in-out infinite; fill: none; stroke-width: 6px; stroke: var(--primary-color); - stroke-dasharray: 100, 28; /* This is a fallback for IE11 */ } @keyframes spinner-rotate { @@ -50,11 +45,11 @@ export class CustomProgressIndicator extends LionProgressIndicator { _graphicTemplate() { return html` - - + + `; } } -customElements.define('custom-progress-indicator', CustomProgressIndicator); +customElements.define('my-indeterminate-progress-spinner', MyIndeterminateProgressSpinner); diff --git a/docs/components/progress-indicator/examples.md b/docs/components/progress-indicator/examples.md index 63ea8e964..4d5bcdfc3 100644 --- a/docs/components/progress-indicator/examples.md +++ b/docs/components/progress-indicator/examples.md @@ -2,37 +2,87 @@ ```js script import { html } from '@mdjs/mdjs-preview'; -import './assets/custom-progress-indicator.js'; +import '@lion/progress-indicator/define'; +import './assets/my-indeterminate-progress-spinner.js'; +import './assets/my-determinate-progress-bar.js'; + +const changeProgress = () => { + const progressBar = document.getElementsByName('my-bar')[0]; + progressBar.value = Math.floor(Math.random() * 101); +}; ``` -## Extended indicator with a custom visual +## Styled progress bar example -`LionProgressIndicator` is designed to be extended to add visuals. Implement the `_graphicTemplate()` method to set the rendered content and apply styles normally. - -### Example extension +Add custom styles and more features by extending the `LionProgressIndicator`. ```js -class CustomProgressIndicator extends LionProgressIndicator { +export class MyDeterminateProgressBar extends LionProgressIndicator { static get styles() { return [ css` :host { display: block; + position: relative; + width: 100%; + height: 6px; + overflow: hidden; + background-color: #eee; } - .progress--icon { + .progress__filled { + height: inherit; + background-color: green; + border-radius: inherit; + } + `, + ]; + } + + _graphicTemplate() { + return html` +
+ `; + } +} +``` + +By given the progress-indicator a value it becomes determinate. +The min is automatically set to "0" and max to "100", but they can be set to your local needs. + +```js preview-story +export const progressBarDemo = () => + html` + + + `; +``` + +## Styled progress spinner example + +`LionProgressIndicator` is designed to be extended to add visuals. Implement the `_graphicTemplate()` method to set the rendered content and apply styles normally. + +```js +class MyIndeterminateProgressSpinner extends LionProgressIndicator { + static get styles() { + return [ + css` + .progress__icon { display: inline-block; width: 48px; height: 48px; animation: spinner-rotate 2s linear infinite; } - .progress--icon--circle { + .progress__filled { animation: spinner-dash 1.35s ease-in-out infinite; fill: none; stroke-width: 6px; stroke: var(--primary-color); - stroke-dasharray: 100, 28; /* This is a fallback for IE11 */ } @keyframes spinner-rotate { @@ -61,16 +111,15 @@ class CustomProgressIndicator extends LionProgressIndicator { _graphicTemplate() { return html` - - + + `; } } ``` -### Result - ```js preview-story -export const main = () => html` `; +export const main = () => + html` `; ``` diff --git a/docs/components/progress-indicator/overview.md b/docs/components/progress-indicator/overview.md index 3fb881783..93eefcd9b 100644 --- a/docs/components/progress-indicator/overview.md +++ b/docs/components/progress-indicator/overview.md @@ -2,19 +2,26 @@ A web component that implements accessibility requirements for progress indicators. -```html - +```js script +import { html } from '@mdjs/mdjs-preview'; +import '@lion/progress-indicator/define'; ``` -Note: You don't see a live demo here as it would be empty, since there is no styling. Check out the [examples](./examples.md) if you want to see a possible implementation. +```html + +``` ## Features This component is designed to be extended in order to add visuals. +- Can be indeterminate or determinate, depending on whether it has a value. - Accessibility compliant -- Localized "Loading" label +- Localized "Loading" label in case of an indeterminate progress-indicator - Implementation independent of visuals +- `value`: progress value, setting this makes the progress-indicator determinate. +- `min`: progress min value +- `max`: progress max value ## Installation diff --git a/packages/progress-indicator/package.json b/packages/progress-indicator/package.json index d0fa461ec..493a0ad78 100644 --- a/packages/progress-indicator/package.json +++ b/packages/progress-indicator/package.json @@ -43,6 +43,7 @@ "keywords": [ "lion", "loading-indicator", + "progress-bar", "progress-indicator", "spinner", "web-components" diff --git a/packages/progress-indicator/src/LionProgressIndicator.js b/packages/progress-indicator/src/LionProgressIndicator.js index ecb394776..1af6cc42c 100644 --- a/packages/progress-indicator/src/LionProgressIndicator.js +++ b/packages/progress-indicator/src/LionProgressIndicator.js @@ -1,9 +1,27 @@ -/* eslint-disable class-methods-use-this, import/no-extraneous-dependencies */ - -import { nothing, LitElement } from '@lion/core'; +/* eslint-disable import/no-extraneous-dependencies */ +import { LitElement, nothing } from '@lion/core'; import { localize, LocalizeMixin } from '@lion/localize'; +/** + * @typedef {import('@lion/core').TemplateResult} TemplateResult + */ export class LionProgressIndicator extends LocalizeMixin(LitElement) { + static get properties() { + return { + value: { + type: Number, + }, + min: { + type: Number, + }, + max: { + type: Number, + }, + _ariaLabel: { attribute: 'aria-label', type: String }, + _ariaLabelledby: { attribute: 'aria-labelledby', type: String }, + }; + } + static get localizeNamespaces() { return [ { @@ -67,7 +85,41 @@ export class LionProgressIndicator extends LocalizeMixin(LitElement) { ]; } + /** + * @readonly + * @type {boolean} + */ + get indeterminate() { + return !this.hasAttribute('value'); + } + + /** + * In case of a determinate progress-indicator it returns the progress percentage + * based on value, min & max. + * Could be used for styling inside the _graphicTemplate + * + * @example + * style="width: ${this._progressPercentage}%" + */ + get _progressPercentage() { + if (this.indeterminate) { + return undefined; + } + return ((this.value - this.min) / (this.max - this.min)) * 100; + } + + constructor() { + super(); + this.value = 0; + this.min = 0; + this.max = 100; + this._ariaLabel = ''; + this._ariaLabelledby = ''; + this.__hasDefaultLabelSet = false; + } + /** @protected */ + // eslint-disable-next-line class-methods-use-this _graphicTemplate() { return nothing; } @@ -78,12 +130,76 @@ export class LionProgressIndicator extends LocalizeMixin(LitElement) { connectedCallback() { super.connectedCallback(); - this.setAttribute('role', 'status'); - this.setAttribute('aria-live', 'polite'); + this.setAttribute('role', 'progressbar'); + } + + /** + * Update aria labels on state change. + * @param {import('@lion/core').PropertyValues } changedProperties + */ + updated(changedProperties) { + super.updated(changedProperties); + + if (this.indeterminate) { + if (changedProperties.has('_ariaLabel') || changedProperties.has('_ariaLabelledby')) { + this._setDefaultLabel(); + } + if (changedProperties.has('value')) { + this._resetAriaValueAttributes(); + this._setDefaultLabel(); + } + } else { + if (changedProperties.has('value')) { + if (!this.value || typeof this.value !== 'number') { + this.removeAttribute('value'); + } else if (this.value < this.min) { + this.value = this.min; + this.setAttribute('aria-valuenow', this.min.toString()); + } else if (this.value > this.max) { + this.value = this.max; + this.setAttribute('aria-valuenow', this.max.toString()); + } else { + this.setAttribute('aria-valuenow', this.value.toString()); + } + if (this.__hasDefaultLabelSet === true) { + this.removeAttribute('aria-label'); + } + } + if (changedProperties.has('min')) { + this.setAttribute('aria-valuemin', this.min.toString()); + if (this.value < this.min) { + this.value = this.min; + } + } + if (changedProperties.has('max')) { + this.setAttribute('aria-valuemax', this.max.toString()); + if (this.value > this.max) { + this.value = this.max; + } + } + } } onLocaleUpdated() { - const label = localize.msg('lion-progress-indicator:loading'); - this.setAttribute('aria-label', label); + super.onLocaleUpdated(); + // only set default label for indeterminate + if (this.indeterminate) { + this._setDefaultLabel(); + } + } + + _resetAriaValueAttributes() { + this.removeAttribute('aria-valuenow'); + this.removeAttribute('aria-valuemin'); + this.removeAttribute('aria-valuemax'); + } + + _setDefaultLabel() { + if (this._ariaLabelledby) { + this.removeAttribute('aria-label'); + } else if (!this._ariaLabel) { + this.setAttribute('aria-label', localize.msg('lion-progress-indicator:loading')); + this.__hasDefaultLabelSet = true; + } } } diff --git a/packages/progress-indicator/test/lion-progress-indicator.test.js b/packages/progress-indicator/test/lion-progress-indicator.test.js index b7a8d7507..361c37a0d 100644 --- a/packages/progress-indicator/test/lion-progress-indicator.test.js +++ b/packages/progress-indicator/test/lion-progress-indicator.test.js @@ -1,23 +1,211 @@ -import { expect, fixture } from '@open-wc/testing'; import { html } from '@lion/core'; +import { expect, fixture as _fixture } from '@open-wc/testing'; import '@lion/progress-indicator/define'; +/** + * @typedef {import('../src/LionProgressIndicator').LionProgressIndicator} LionProgressIndicator + * @typedef {import('@lion/core').TemplateResult} TemplateResult + */ + +const fixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + describe('lion-progress-indicator', () => { - describe('Accessibility', () => { - it('adds a label', async () => { + describe('indeterminate', async () => { + it('is indeterminate when has no value attribute', async () => { const el = await fixture(html` `); + expect(el.indeterminate).to.be.true; + }); + + it('adds a label by default', async () => { + const el = await fixture(html` `); + await el.localizeNamespacesLoaded; expect(el.getAttribute('aria-label')).to.equal('Loading'); }); - it('sets the right role', async () => { - const el = await fixture(html` `); - expect(el.getAttribute('role')).to.equal('status'); + it('can override a label with "aria-label"', async () => { + const el = await fixture( + html` `, + ); + await el.localizeNamespacesLoaded; + expect(el.getAttribute('aria-label')).to.equal('foo'); + el.setAttribute('aria-label', 'bar'); + expect(el.getAttribute('aria-label')).to.equal('bar'); + el.removeAttribute('aria-label'); + await el.updateComplete; + expect(el.getAttribute('aria-label')).to.equal('Loading'); }); - it('sets aria-live to "polite"', async () => { + it('can override a label with "aria-labelledby"', async () => { + const el = await fixture( + html` `, + ); + await el.localizeNamespacesLoaded; + expect(el.getAttribute('aria-labelledby')).to.equal('foo-id'); + expect(el.hasAttribute('aria-label')).to.be.false; + el.setAttribute('aria-labelledby', 'bar-id'); + expect(el.getAttribute('aria-labelledby')).to.equal('bar-id'); + expect(el.hasAttribute('aria-label')).to.be.false; + el.removeAttribute('aria-labelledby'); + await el.updateComplete; + expect(el.hasAttribute('aria-labelledby')).to.be.false; + expect(el.getAttribute('aria-label')).to.equal('Loading'); + }); + + it('loosses default aria-label when switch to determinate state', async () => { const el = await fixture(html` `); - expect(el.getAttribute('aria-live')).to.equal('polite'); + await el.localizeNamespacesLoaded; + expect(el.getAttribute('aria-label')).to.equal('Loading'); + el.setAttribute('value', '30'); + await el.updateComplete; + expect(el.hasAttribute('aria-label')).to.be.false; + }); + + it('keeps own aria-label when switch to determinate state', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-label')).to.equal('foo'); + el.setAttribute('value', '30'); + await el.updateComplete; + expect(el.getAttribute('aria-label')).to.equal('foo'); + }); + }); + + describe('determinate', async () => { + it('is determinate when it has a value', async () => { + const el = await fixture( + html` `, + ); + expect(el.indeterminate).to.be.false; + }); + + it('can update value', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-valuenow')).to.equal('25'); + el.value = 30; + await el.updateComplete; + expect(el.getAttribute('aria-valuenow')).to.equal('30'); + }); + + it('can update min', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-valuemin')).to.equal('0'); + el.min = 30; + await el.updateComplete; + expect(el.getAttribute('aria-valuemin')).to.equal('30'); + }); + + it('can update max', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-valuemax')).to.equal('100'); + el.max = 70; + await el.updateComplete; + expect(el.getAttribute('aria-valuemax')).to.equal('70'); + }); + + it('min & max limits value', async () => { + const el = await fixture( + html` `, + ); + // sets to default max: 100 + expect(el.getAttribute('aria-valuenow')).to.equal('100'); + el.value = -20; + await el.updateComplete; + // sets to default min: 0 + expect(el.getAttribute('aria-valuenow')).to.equal('0'); + }); + + // TODO make this feature available + it.skip('supports valuetext', async () => { + const el = await fixture( + html` + + `, + ); + expect(el.getAttribute('aria-valuetext')).to.equal('8% (34 minutes) remaining'); + }); + + it('becomes indeterminate if value gets removed', async () => { + const el = await fixture( + html` `, + ); + el.removeAttribute('value'); + await el.updateComplete; + expect(el.indeterminate).to.be.true; + expect(el.getAttribute('aria-label')).to.equal('Loading'); + }); + + it("becomes indeterminate if value ain't a number", async () => { + const el = await fixture( + html` `, + ); + el.setAttribute('value', ''); + await el.updateComplete; + expect(el.indeterminate).to.be.true; + await el.updateComplete; + expect(el.hasAttribute('aria-valuenow')).to.be.false; + expect(el.hasAttribute('aria-valuemin')).to.be.false; + expect(el.hasAttribute('aria-valuemax')).to.be.false; + expect(el.getAttribute('aria-label')).to.equal('Loading'); + }); + }); + + describe('Subclasers', () => { + it('can use _progressPercentage getter to get the progress percentage', async () => { + const el = await fixture( + html` + + `, + ); + expect(el._progressPercentage).to.equal(20); + }); + }); + + describe('Accessibility', () => { + it('by default', async () => { + const el = await fixture(html` `); + expect(el.getAttribute('role')).to.equal('progressbar'); + }); + + describe('indeterminate', () => { + it('passes a11y test', async () => { + const el = await fixture(html` `); + await expect(el).to.be.accessible(); + }); + }); + + describe('determinate', () => { + it('passes a11y test', async () => { + const el = await fixture( + html` `, + ); + await expect(el).to.be.accessible(); + }); + + it('once value is set', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-valuenow')).to.equal('25'); + }); + + it('allows to set min & max values', async () => { + const el = await fixture( + html` `, + ); + expect(el.getAttribute('aria-valuemin')).to.equal('0'); + expect(el.getAttribute('aria-valuemax')).to.equal('100'); + }); }); }); });