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 { 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`
<svg class="progress--icon" viewBox="20 20 47 47">
<circle class="progress--icon--circle" cx="44" cy="44" r="20.2" />
<svg class="progress__icon" viewBox="20 20 47 47">
<circle class="progress__filled" cx="44" cy="44" r="20.2" />
</svg>
`;
}
}
customElements.define('custom-progress-indicator', CustomProgressIndicator);
customElements.define('my-indeterminate-progress-spinner', MyIndeterminateProgressSpinner);

View file

@ -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`
<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;
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`
<svg class="progress--icon" viewBox="20 20 47 47">
<circle class="progress--icon--circle" cx="44" cy="44" r="20.2" />
<svg class="progress__icon" viewBox="20 20 47 47">
<circle class="progress__filled" cx="44" cy="44" r="20.2" />
</svg>
`;
}
}
```
### Result
```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.
```html
<lion-progress-indicator></lion-progress-indicator>
```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
<lion-progress-indicator aria-label="Interest rate" value="50"></lion-progress-indicator>
```
## 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

View file

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

View file

@ -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;
}
}
}

View file

@ -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<LionProgressIndicator>} */ (_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` <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');
});
it('sets the right role', async () => {
const el = await fixture(html` <lion-progress-indicator></lion-progress-indicator> `);
expect(el.getAttribute('role')).to.equal('status');
it('can override a label with "aria-label"', async () => {
const el = await fixture(
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> `);
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');
});
});
});
});