feat(progress-indicator): add support for progress bar (#1713)

Co-authored-by: gerjanvangeest <gerjanvangeest@users.noreply.github.com>
This commit is contained in:
Hardik Pithva 2022-08-11 08:55:25 +02:00 committed by GitHub
parent 0b97918d1e
commit a616fd8dca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 435 additions and 44 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/progress-indicator': minor
---
Add option to make the progress-indicator determinate

View file

@ -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` <div class="progress__filled" style="width: ${this._progressPercentage}%"></div> `;
}
}
customElements.define('my-determinate-progress-bar', MyDeterminateProgressBar);

View file

@ -1,27 +1,22 @@
import { html, css } from '@lion/core'; import { html, css } from '@lion/core';
import { LionProgressIndicator } from '@lion/progress-indicator'; import { LionProgressIndicator } from '@lion/progress-indicator';
export class CustomProgressIndicator extends LionProgressIndicator { export class MyIndeterminateProgressSpinner extends LionProgressIndicator {
static get styles() { static get styles() {
return [ return [
css` css`
:host { .progress__icon {
display: block;
}
.progress--icon {
display: inline-block; display: inline-block;
width: 48px; width: 48px;
height: 48px; height: 48px;
animation: spinner-rotate 2s linear infinite; animation: spinner-rotate 2s linear infinite;
} }
.progress--icon--circle { .progress__filled {
animation: spinner-dash 1.35s ease-in-out infinite; animation: spinner-dash 1.35s ease-in-out infinite;
fill: none; fill: none;
stroke-width: 6px; stroke-width: 6px;
stroke: var(--primary-color); stroke: var(--primary-color);
stroke-dasharray: 100, 28; /* This is a fallback for IE11 */
} }
@keyframes spinner-rotate { @keyframes spinner-rotate {
@ -50,11 +45,11 @@ export class CustomProgressIndicator extends LionProgressIndicator {
_graphicTemplate() { _graphicTemplate() {
return html` return html`
<svg class="progress--icon" viewBox="20 20 47 47"> <svg class="progress__icon" viewBox="20 20 47 47">
<circle class="progress--icon--circle" cx="44" cy="44" r="20.2" /> <circle class="progress__filled" cx="44" cy="44" r="20.2" />
</svg> </svg>
`; `;
} }
} }
customElements.define('custom-progress-indicator', CustomProgressIndicator); customElements.define('my-indeterminate-progress-spinner', MyIndeterminateProgressSpinner);

View file

@ -2,37 +2,87 @@
```js script ```js script
import { html } from '@mdjs/mdjs-preview'; 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. Add custom styles and more features by extending the `LionProgressIndicator`.
### Example extension
```js ```js
class CustomProgressIndicator extends LionProgressIndicator { export class MyDeterminateProgressBar extends LionProgressIndicator {
static get styles() { static get styles() {
return [ return [
css` css`
:host { :host {
display: block; 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`
<div class="progress__filled" style="width: \${this._progressPercentage}%"></div>
`;
}
}
```
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`
<my-determinate-progress-bar
aria-label="Interest rate"
name="my-bar"
value="50"
></my-determinate-progress-bar>
<button @click="${changeProgress}">Randomize Value</button>
`;
```
## 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; display: inline-block;
width: 48px; width: 48px;
height: 48px; height: 48px;
animation: spinner-rotate 2s linear infinite; animation: spinner-rotate 2s linear infinite;
} }
.progress--icon--circle { .progress__filled {
animation: spinner-dash 1.35s ease-in-out infinite; animation: spinner-dash 1.35s ease-in-out infinite;
fill: none; fill: none;
stroke-width: 6px; stroke-width: 6px;
stroke: var(--primary-color); stroke: var(--primary-color);
stroke-dasharray: 100, 28; /* This is a fallback for IE11 */
} }
@keyframes spinner-rotate { @keyframes spinner-rotate {
@ -61,16 +111,15 @@ class CustomProgressIndicator extends LionProgressIndicator {
_graphicTemplate() { _graphicTemplate() {
return html` return html`
<svg class="progress--icon" viewBox="20 20 47 47"> <svg class="progress__icon" viewBox="20 20 47 47">
<circle class="progress--icon--circle" cx="44" cy="44" r="20.2" /> <circle class="progress__filled" cx="44" cy="44" r="20.2" />
</svg> </svg>
`; `;
} }
} }
``` ```
### Result
```js preview-story ```js preview-story
export const main = () => html` <custom-progress-indicator></custom-progress-indicator> `; export const main = () =>
html` <my-indeterminate-progress-spinner></my-indeterminate-progress-spinner> `;
``` ```

View file

@ -2,19 +2,26 @@
A web component that implements accessibility requirements for progress indicators. A web component that implements accessibility requirements for progress indicators.
```html ```js script
<lion-progress-indicator></lion-progress-indicator> 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
<lion-progress-indicator aria-label="Interest rate" value="50"></lion-progress-indicator>
```
## Features ## Features
This component is designed to be extended in order to add visuals. 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 - Accessibility compliant
- Localized "Loading" label - Localized "Loading" label in case of an indeterminate progress-indicator
- Implementation independent of visuals - Implementation independent of visuals
- `value`: progress value, setting this makes the progress-indicator determinate.
- `min`: progress min value
- `max`: progress max value
## Installation ## Installation

View file

@ -43,6 +43,7 @@
"keywords": [ "keywords": [
"lion", "lion",
"loading-indicator", "loading-indicator",
"progress-bar",
"progress-indicator", "progress-indicator",
"spinner", "spinner",
"web-components" "web-components"

View file

@ -1,9 +1,27 @@
/* eslint-disable class-methods-use-this, import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import { LitElement, nothing } from '@lion/core';
import { nothing, LitElement } from '@lion/core';
import { localize, LocalizeMixin } from '@lion/localize'; import { localize, LocalizeMixin } from '@lion/localize';
/**
* @typedef {import('@lion/core').TemplateResult} TemplateResult
*/
export class LionProgressIndicator extends LocalizeMixin(LitElement) { 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() { static get localizeNamespaces() {
return [ 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 */ /** @protected */
// eslint-disable-next-line class-methods-use-this
_graphicTemplate() { _graphicTemplate() {
return nothing; return nothing;
} }
@ -78,12 +130,76 @@ export class LionProgressIndicator extends LocalizeMixin(LitElement) {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this.setAttribute('role', 'status'); this.setAttribute('role', 'progressbar');
this.setAttribute('aria-live', 'polite'); }
/**
* 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() { onLocaleUpdated() {
const label = localize.msg('lion-progress-indicator:loading'); super.onLocaleUpdated();
this.setAttribute('aria-label', label); // 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;
}
} }
} }

View file

@ -1,23 +1,211 @@
import { expect, fixture } from '@open-wc/testing';
import { html } from '@lion/core'; import { html } from '@lion/core';
import { expect, fixture as _fixture } from '@open-wc/testing';
import '@lion/progress-indicator/define'; import '@lion/progress-indicator/define';
/**
* @typedef {import('../src/LionProgressIndicator').LionProgressIndicator} LionProgressIndicator
* @typedef {import('@lion/core').TemplateResult} TemplateResult
*/
const fixture = /** @type {(arg: TemplateResult) => Promise<LionProgressIndicator>} */ (_fixture);
describe('lion-progress-indicator', () => { describe('lion-progress-indicator', () => {
describe('Accessibility', () => { describe('indeterminate', async () => {
it('adds a label', async () => { it('is indeterminate when has no value attribute', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `); const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
expect(el.indeterminate).to.be.true;
});
it('adds a label by default', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
await el.localizeNamespacesLoaded;
expect(el.getAttribute('aria-label')).to.equal('Loading'); expect(el.getAttribute('aria-label')).to.equal('Loading');
}); });
it('sets the right role', async () => { it('can override a label with "aria-label"', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `); const el = await fixture(
expect(el.getAttribute('role')).to.equal('status'); html` <lion-progress-indicator aria-label="foo"></lion-progress-indicator> `,
);
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` <lion-progress-indicator aria-labelledby="foo-id"></lion-progress-indicator> `,
);
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` <lion-progress-indicator></lion-progress-indicator> `); const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
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` <lion-progress-indicator aria-label="foo"></lion-progress-indicator> `,
);
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` <lion-progress-indicator value="25" aria-label="foo"></lion-progress-indicator> `,
);
expect(el.indeterminate).to.be.false;
});
it('can update value', async () => {
const el = await fixture(
html` <lion-progress-indicator value="25" aria-label="foo"></lion-progress-indicator> `,
);
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` <lion-progress-indicator value="50" aria-label="foo"></lion-progress-indicator> `,
);
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` <lion-progress-indicator value="50" aria-label="foo"></lion-progress-indicator> `,
);
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` <lion-progress-indicator value="150" aria-label="foo"></lion-progress-indicator> `,
);
// 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`
<lion-progress-indicator
value="8"
value-text="{value}% (34 minutes) remaining"
></lion-progress-indicator>
`,
);
expect(el.getAttribute('aria-valuetext')).to.equal('8% (34 minutes) remaining');
});
it('becomes indeterminate if value gets removed', async () => {
const el = await fixture(
html`<lion-progress-indicator value="30"></lion-progress-indicator> `,
);
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`<lion-progress-indicator value="30"></lion-progress-indicator> `,
);
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`
<lion-progress-indicator max="50" value="10" aria-label="foo"></lion-progress-indicator>
`,
);
expect(el._progressPercentage).to.equal(20);
});
});
describe('Accessibility', () => {
it('by default', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
expect(el.getAttribute('role')).to.equal('progressbar');
});
describe('indeterminate', () => {
it('passes a11y test', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
await expect(el).to.be.accessible();
});
});
describe('determinate', () => {
it('passes a11y test', async () => {
const el = await fixture(
html` <lion-progress-indicator value="25" aria-label="foo"></lion-progress-indicator> `,
);
await expect(el).to.be.accessible();
});
it('once value is set', async () => {
const el = await fixture(
html` <lion-progress-indicator value="25" aria-label="foo"></lion-progress-indicator> `,
);
expect(el.getAttribute('aria-valuenow')).to.equal('25');
});
it('allows to set min & max values', async () => {
const el = await fixture(
html` <lion-progress-indicator value="25" aria-label="foo"></lion-progress-indicator> `,
);
expect(el.getAttribute('aria-valuemin')).to.equal('0');
expect(el.getAttribute('aria-valuemax')).to.equal('100');
});
}); });
}); });
}); });