Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/late-rules-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@solid-design-system/tokens': minor
---

Add `progress-bar`new tokens:
- --sd-progress-bar__slide-bar--inverted-color-background
- --sd-progress-bar__slide-bar-color-background
- --sd-progress-bar--active--inverted-color-background
6 changes: 6 additions & 0 deletions .changeset/shy-feet-ring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@solid-design-system/components': minor
'@solid-design-system/docs': minor
---

Added `sd-progress-bar`.
130 changes: 130 additions & 0 deletions packages/components/src/components/progress-bar/progress-bar.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import '../../../dist/solid-components';
import { expect, fixture, html } from '@open-wc/testing';
import type SdProgressBar from './progress-bar';

describe('<sd-progress-bar>', () => {
let el: SdProgressBar;

const getProgress = (component: SdProgressBar) => component.shadowRoot!.querySelector('progress')!;

describe('when provided no parameters', () => {
it('should pass accessibility tests', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar></sd-progress-bar>`);
await expect(el).to.be.accessible();
});

it('should have default values set correctly', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar></sd-progress-bar>`);

const progress = getProgress(el);

expect(el.value).to.equal(0);
expect(el.max).to.equal(100);
expect(el.loading).to.equal(false);
expect(el.label).to.equal('');
expect(el.valueRight).to.equal(false);
expect(el.valueBottom).to.equal(false);
expect(el.inverted).to.equal(false);

expect(progress.getAttribute('max')).to.equal('100');
expect(progress.getAttribute('value')).to.equal('0');
expect(progress.getAttribute('aria-label')).to.equal('Progress');
expect(progress.getAttribute('aria-labelledby')).to.equal(null);
});
});

describe('label handling', () => {
it('should render the label attribute and connect aria-labelledby', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar label="Label"></sd-progress-bar>`);

const label = el.shadowRoot!.querySelector('[part="label"]');
const progress = getProgress(el);

expect(label).to.exist;
expect(label!.textContent!.trim()).to.equal('Label');
expect(progress.getAttribute('aria-labelledby')).to.equal('label');
expect(progress.getAttribute('aria-label')).to.equal(null);
});

it('should render the label slot content', async () => {
el = await fixture<SdProgressBar>(html`
<sd-progress-bar>
<span slot="label">Label slot</span>
</sd-progress-bar>
`);

const label = el.shadowRoot!.querySelector('[part="label"]');
const labelSlot = el.shadowRoot!.querySelector<HTMLSlotElement>('slot[name="label"]');
expect(label).to.exist;
expect(labelSlot).to.exist;
expect(labelSlot!.assignedElements()[0].textContent!.trim()).to.equal('Label slot');
});
});

describe('value display', () => {
it('should render percentage on the right', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar value="25" value-right></sd-progress-bar>`);

const valueRight = el.shadowRoot!.querySelector('[part="value-right"]');
expect(valueRight).to.exist;
expect(valueRight!.textContent!.trim()).to.equal('25%');
});

it('should render percentage at the bottom', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar value="40" value-bottom></sd-progress-bar>`);

const valueBottom = el.shadowRoot!.querySelector('[part="value-bottom"]');
expect(valueBottom).to.exist;
expect(valueBottom!.textContent!.trim()).to.equal('40%');
});

it('should clamp value to max and fallback safe max when max is invalid', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar value="150" max="0" value-right></sd-progress-bar>`);

const progress = getProgress(el);
const valueRight = el.shadowRoot!.querySelector('[part="value-right"]');

expect(progress.getAttribute('max')).to.equal('100');
expect(progress.getAttribute('value')).to.equal('100');
expect(valueRight!.textContent!.trim()).to.equal('100%');
});

it('should clamp negative values to 0', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar value="-20" value-bottom></sd-progress-bar>`);

const progress = getProgress(el);
const valueBottom = el.shadowRoot!.querySelector('[part="value-bottom"]');

expect(progress.getAttribute('value')).to.equal('0');
expect(valueBottom!.textContent!.trim()).to.equal('0%');
});
});

describe('special modes', () => {
it('should render loading mode without value and with loading text', async () => {
el = await fixture<SdProgressBar>(html`<sd-progress-bar loading></sd-progress-bar>`);

const progress = getProgress(el);

expect(progress.classList.contains('loading')).to.equal(true);
expect(progress.getAttribute('value')).to.equal(null);
expect(progress.getAttribute('aria-valuetext')).to.equal('Loading');
});

it('should apply inverted classes to progress and value output', async () => {
el = await fixture<SdProgressBar>(html`
<sd-progress-bar inverted label="Inverted" value="60" value-right value-bottom></sd-progress-bar>
`);

const progress = getProgress(el);
const label = el.shadowRoot!.querySelector('[part="label"]');
const valueRight = el.shadowRoot!.querySelector('[part="value-right"]');
const valueBottom = el.shadowRoot!.querySelector('[part="value-bottom"]');

expect(progress.classList.contains('sd-progress-bar__slide-bar--inverted-color-background')).to.equal(true);
expect(label!.classList.contains('text-white')).to.equal(true);
expect(valueRight!.classList.contains('text-white')).to.equal(true);
expect(valueBottom!.classList.contains('text-white')).to.equal(true);
});
});
});
202 changes: 202 additions & 0 deletions packages/components/src/components/progress-bar/progress-bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { css, html, nothing } from 'lit';
import { customElement } from '../../internal/register-custom-element';
import { ifDefined } from 'lit/directives/if-defined.js';
import { LocalizeController } from '../../utilities/localize';
import { HasSlotController } from '../../internal/slot.js';
import { property } from 'lit/decorators.js';
import cx from 'classix';
import SolidElement from '../../internal/solid-element';

/**
* @summary Progress bars are used to visualize the completion state of a process.
* @status stable
* @since 6.20
*
* @slot label - The input's label. Alternatively, you can use the `label` attribute.
*
* @csspart base - The component's base wrapper.
* @csspart value-right - Value's on the right side of the indicator.
* @csspart value-bottom - Value's on the bottom of the indicator.
* @csspart label - The label element.
*
* @cssproperty --height - Use this property to set the height of the progress-bar.
* @cssproperty --gap-color - Use this property to set the color of the gap between the progress indicator and the progress bar (needs to be rgba).
* @cssproperty --sd-progress-bar__slide-bar--inverted-color-background - The background color of the progress bar when the `inverted` attribute is set.
* @cssproperty --sd-progress-bar__slide-bar-color-background - The background color of the progress bar.
* @cssproperty --sd-progress-bar--active--inverted-color-background - The color of the progress indicator when the `inverted` attribute is set.
*/
@customElement('sd-progress-bar')
export default class SdProgressBar extends SolidElement {
private readonly hasSlotController = new HasSlotController(this, 'label');

/** The current progress value. */
@property({ type: Number, reflect: true }) value = 0;

/** The maximum progress value. */
@property({ type: Number, reflect: true }) max = 100;

/** Draws the progress bar in loading mode. */
@property({ type: Boolean, reflect: true }) loading = false;

/** The progress bar's label. If you need to display HTML, use the `label` slot instead. */
@property({ type: String, reflect: true }) label = '';

/** Displays the progress value on the right side of the indicator. */
@property({ type: Boolean, reflect: true, attribute: 'value-right' }) valueRight = false;

/** Displays the progress value on the bottom of the indicator. */
@property({ type: Boolean, reflect: true, attribute: 'value-bottom' }) valueBottom = false;

/** Inverts the progress bar's colors. */
@property({ type: Boolean, reflect: true }) inverted = false;

public localize = new LocalizeController(this);

private get safeMax() {
return this.max > 0 ? this.max : 100;
}

private get clampedValue() {
return Math.min(Math.max(this.value, 0), this.safeMax);
}

private get percentage() {
return (this.clampedValue / this.safeMax) * 100;
}

render() {
const slots = { label: this.hasSlotController.test('label') };
const hasLabel = this.label ? true : !!slots['label'];

return html`
<div class="flex flex-col gap-1">
${hasLabel
? html`<div class="flex items-center">
<label
part="label"
id="label"
class=${cx(hasLabel ? 'inline-block text-sm' : 'hidden', this.inverted ? 'text-white' : 'text-black')}
for="base"
aria-hidden=${hasLabel ? 'false' : 'true'}
>
<slot name="label">${this.label}</slot>
</label>
</div>`
: undefined}

<div class="flex items-center gap-2">
<progress
part="base"
id="base"
class=${cx(
'progress-bar w-full block',
this.loading && 'loading',
this.inverted
? 'sd-progress-bar__slide-bar--inverted-color-background'
: 'sd-progress-bar__slide-bar-color-background'
)}
max=${this.safeMax}
value=${this.loading ? nothing : this.clampedValue}
aria-label=${ifDefined(!hasLabel ? this.label || this.localize.term('progress') : undefined)}
aria-labelledby=${ifDefined(hasLabel ? 'label' : undefined)}
aria-valuetext=${ifDefined(this.loading ? this.localize.term('loading') : undefined)}
></progress>

${this.valueRight
? html`<span part="value-right" class=${cx('text-xs', this.inverted ? 'text-white' : 'text-neutral-700')}
>${this.percentage}%</span
>`
: undefined}
</div>

${this.valueBottom
? html`<span
part="value-bottom"
class=${cx('text-xs text-left', this.inverted ? 'text-white' : 'text-neutral-700')}
>${this.percentage}%</span
>`
: undefined}
</div>
`;
}

static styles = [
...SolidElement.styles,
css`
:host {
@apply block w-full;
--gap-color: rgba(var(--sd-color-background-white));
}

.progress-bar {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: none;
height: var(--height, 0.125rem); /* 2px */
}

.progress-bar::-webkit-progress-bar {
background-color: rgba(var(--sd-progress-bar__slide-bar-color-background));
}

:host([inverted]) {
--gap-color: rgba(var(--sd-color-background-primary));
.progress-bar::-webkit-progress-bar {
background-color: rgba(var(--sd-progress-bar__slide-bar--inverted-color-background));
}

progress::-moz-progress-bar {
background-color: rgba(var(--sd-progress-bar--active--inverted-color-background));
border-right: 2px solid var(--gap-color);
}

.progress-bar::-webkit-progress-value {
background-color: rgba(var(--sd-progress-bar--active--inverted-color-background));
border-right: 2px solid var(--gap-color);
}

.progress-bar.loading::-webkit-progress-value {
border-left: 2px solid var(--gap-color);
}
}

progress::-moz-progress-bar {
@apply bg-accent;
border-right: 2px solid var(--gap-color);
}

.progress-bar::-webkit-progress-value {
@apply bg-accent;
border-right: 2px solid var(--gap-color);
}

.progress-bar.loading::-webkit-progress-value {
width: 25% !important;
animation: progress-loading 1.25s ease-in-out infinite alternate;
border-left: 2px solid var(--gap-color);
}

.progress-bar.loading::-moz-progress-bar {
width: 25% !important;
animation: progress-loading 1.25s ease-in-out infinite alternate;
border-left: 2px solid var(--gap-color);
}

@keyframes progress-loading {
0% {
transform: translateX(0%);
}
100% {
transform: translateX(300%);
}
}
`
];
}

declare global {
interface HTMLElementTagNameMap {
'sd-progress-bar': SdProgressBar;
}
}
1 change: 1 addition & 0 deletions packages/components/src/solid-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export { default as SdNotification } from './components/notification/notificatio
export { default as SdOptgroup } from './components/optgroup/optgroup.js';
export { default as SdOption } from './components/option/option.js';
export { default as SdPopup } from './components/popup/popup.js';
export { default as SdProgressBar } from './components/progress-bar/progress-bar.js';
export { default as SdQuickfact } from './components/quickfact/quickfact.js';
export { default as SdRadio } from './components/radio/radio.js';
export { default as SdRadioButton } from './components/radio-button/radio-button.js';
Expand Down
Loading
Loading