aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/atoms/loaders/progress-bar
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-09-29 15:49:14 +0200
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:14:40 +0100
commit9128c224c65f8f2a172b22a443ccb4573c7acd90 (patch)
treebc435554174a5ed4c3f8808190cb94016f8d28f0 /src/components/atoms/loaders/progress-bar
parent81b1e0e05919eb368a66aef47adcf7738af76f29 (diff)
refactor(components): rewrite ProgressBar component
* Avoid browser vendors by adding an extra div * Add a loading state * Add an option to center the progress bar (no longer the default) * Remove min since it is always 0
Diffstat (limited to 'src/components/atoms/loaders/progress-bar')
-rw-r--r--src/components/atoms/loaders/progress-bar/index.ts1
-rw-r--r--src/components/atoms/loaders/progress-bar/progress-bar.module.scss85
-rw-r--r--src/components/atoms/loaders/progress-bar/progress-bar.stories.tsx60
-rw-r--r--src/components/atoms/loaders/progress-bar/progress-bar.test.tsx45
-rw-r--r--src/components/atoms/loaders/progress-bar/progress-bar.tsx104
5 files changed, 295 insertions, 0 deletions
diff --git a/src/components/atoms/loaders/progress-bar/index.ts b/src/components/atoms/loaders/progress-bar/index.ts
new file mode 100644
index 0000000..d71d9b1
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar/index.ts
@@ -0,0 +1 @@
+export * from './progress-bar';
diff --git a/src/components/atoms/loaders/progress-bar/progress-bar.module.scss b/src/components/atoms/loaders/progress-bar/progress-bar.module.scss
new file mode 100644
index 0000000..3605ae7
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar/progress-bar.module.scss
@@ -0,0 +1,85 @@
+@use "../../../../styles/abstracts/functions" as fun;
+
+.wrapper {
+ width: fit-content;
+ text-align: center;
+
+ &--centered {
+ margin-inline: auto;
+ }
+}
+
+.label {
+ margin-bottom: var(--spacing-2xs);
+ font-size: var(--font-size-sm);
+ cursor: default;
+}
+
+.progress {
+ width: clamp(25ch, 20vw, 30ch);
+ height: fun.convert-px(13);
+ position: relative;
+ overflow: hidden;
+ background: var(--color-bg-tertiary);
+ border: fun.convert-px(1) solid var(--color-primary-darker);
+ border-radius: 1em;
+ box-shadow: inset 0 0 fun.convert-px(4) fun.convert-px(1)
+ var(--color-shadow-light);
+ container-type: inline-size;
+
+ &::before {
+ content: "";
+ position: absolute;
+ width: 15%;
+ left: 0;
+ }
+
+ &::before,
+ &__bar {
+ background-color: var(--color-primary-dark);
+ border-radius: 1em;
+ }
+
+ &::before,
+ progress {
+ height: 100%;
+ opacity: 0;
+ }
+
+ &__bar,
+ progress {
+ width: 100%;
+ position: absolute;
+ inset: 0;
+ }
+
+ &__bar {
+ transform: translateX(var(--currentProgress));
+ transition: all 0.25s linear 0s;
+ }
+
+ progress {
+ appearance: none;
+ }
+
+ &--loading {
+ &::before {
+ opacity: 1;
+ animation: move 1s linear 0s infinite alternate both;
+ }
+ }
+
+ &--loading &__bar {
+ opacity: 0;
+ }
+}
+
+@keyframes move {
+ 0% {
+ transform: translateX(0);
+ }
+
+ 100% {
+ transform: translateX(calc(100cqw - 100%));
+ }
+}
diff --git a/src/components/atoms/loaders/progress-bar/progress-bar.stories.tsx b/src/components/atoms/loaders/progress-bar/progress-bar.stories.tsx
new file mode 100644
index 0000000..fb600fb
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar/progress-bar.stories.tsx
@@ -0,0 +1,60 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { ProgressBar as ProgressBarComponent } from './progress-bar';
+
+/**
+ * ProgressBar - Storybook Meta
+ */
+export default {
+ title: 'Atoms/Loaders',
+ component: ProgressBarComponent,
+ argTypes: {
+ current: {
+ control: {
+ type: 'number',
+ },
+ description: 'The current value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ label: {
+ control: {
+ type: 'text',
+ },
+ description: 'The progress bar label.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ max: {
+ control: {
+ type: 'number',
+ },
+ description: 'The maximal value.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof ProgressBarComponent>;
+
+const Template: ComponentStory<typeof ProgressBarComponent> = (args) => (
+ <ProgressBarComponent {...args} />
+);
+
+const max = 50;
+const current = 10;
+const label = `${current} loaded out of a total of ${max}`;
+
+/**
+ * Loaders Stories - Progress bar
+ */
+export const ProgressBar = Template.bind({});
+ProgressBar.args = {
+ current,
+ label,
+ max,
+};
diff --git a/src/components/atoms/loaders/progress-bar/progress-bar.test.tsx b/src/components/atoms/loaders/progress-bar/progress-bar.test.tsx
new file mode 100644
index 0000000..1f5d90e
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar/progress-bar.test.tsx
@@ -0,0 +1,45 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { ProgressBar } from './progress-bar';
+
+describe('ProgressBar', () => {
+ it('renders a progress bar', () => {
+ const max = 50;
+ const current = 10;
+ const label = `${current} loaded out of a total of ${max}`;
+
+ render(<ProgressBar current={current} label={label} max={max} />);
+
+ expect(
+ rtlScreen.getByRole('progressbar', { name: label })
+ ).toBeInTheDocument();
+ });
+
+ it('can render a progress bar with loading state', () => {
+ const max = 50;
+ const current = 10;
+ const label = `${current} loaded out of a total of ${max}`;
+
+ render(<ProgressBar current={current} isLoading label={label} max={max} />);
+
+ const progressBar = rtlScreen.getByRole('progressbar', { name: label });
+
+ expect(progressBar).not.toHaveValue();
+ expect(progressBar.parentElement).toHaveClass('progress--loading');
+ });
+
+ it('can render a centered progress bar', () => {
+ const max = 50;
+ const current = 10;
+ const label = `${current} loaded out of a total of ${max}`;
+
+ render(
+ <ProgressBar current={current} isCentered label={label} max={max} />
+ );
+
+ expect(
+ rtlScreen.getByRole('progressbar', { name: label }).parentElement
+ ?.parentElement
+ ).toHaveClass('wrapper--centered');
+ });
+});
diff --git a/src/components/atoms/loaders/progress-bar/progress-bar.tsx b/src/components/atoms/loaders/progress-bar/progress-bar.tsx
new file mode 100644
index 0000000..d885260
--- /dev/null
+++ b/src/components/atoms/loaders/progress-bar/progress-bar.tsx
@@ -0,0 +1,104 @@
+import {
+ useId,
+ type CSSProperties,
+ type HTMLAttributes,
+ type ForwardRefRenderFunction,
+ forwardRef,
+} from 'react';
+import { Label } from '../../forms';
+import styles from './progress-bar.module.scss';
+
+export type ProgressBarProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children'
+> & {
+ /**
+ * Current value.
+ */
+ current: number;
+ /**
+ * Should the progress bar be centered inside its parent?
+ *
+ * @default false
+ */
+ isCentered?: boolean;
+ /**
+ * Should the progress bar indicate a loading state?
+ *
+ * @default false
+ */
+ isLoading?: boolean;
+ /**
+ * The progress bar label.
+ */
+ label: string;
+ /**
+ * Maximal value.
+ */
+ max: number;
+};
+
+const ProgressBarWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ ProgressBarProps
+> = (
+ {
+ className = '',
+ current,
+ isCentered = false,
+ isLoading = false,
+ label,
+ max,
+ ...props
+ },
+ ref
+) => {
+ const wrapperClass = [
+ styles.wrapper,
+ styles[isCentered ? 'wrapper--centered' : ''],
+ className,
+ ].join(' ');
+ const progressClass = `${styles.progress} ${
+ styles[isLoading ? 'progress--loading' : '']
+ }`;
+ const progressBarId = useId();
+ const progressValueFallback = `${current}/${max}`;
+ // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- Percent
+ const progressPercent = `${((max - current) / max) * 100}%`;
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ <Label
+ className={styles.label}
+ htmlFor={progressBarId}
+ // eslint-disable-next-line react/jsx-no-literals -- Size allowed
+ size="md"
+ >
+ {label}
+ </Label>
+ <div
+ className={progressClass}
+ style={{ '--currentProgress': `-${progressPercent}` } as CSSProperties}
+ >
+ <progress
+ aria-valuemin={0}
+ aria-valuemax={max}
+ aria-valuenow={current}
+ id={progressBarId}
+ max={max}
+ value={isLoading ? undefined : current}
+ >
+ {progressValueFallback}
+ </progress>
+ <div aria-hidden className={styles.progress__bar} />
+ </div>
+ </div>
+ );
+};
+
+/**
+ * ProgressBar component
+ *
+ * Render a progress bar.
+ */
+export const ProgressBar = forwardRef(ProgressBarWithRef);