Skip to content

Commit 706964e

Browse files
authored
feat: shared progress and meter component (#644)
1 parent c8b04a6 commit 706964e

File tree

12 files changed

+301
-76
lines changed

12 files changed

+301
-76
lines changed

packages/ui/src/docs/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
mdiFormTextarea,
4242
mdiFormTextbox,
4343
mdiFormTextboxPassword,
44+
mdiGauge,
4445
mdiHelpBox,
4546
mdiHelpBoxOutline,
4647
mdiHomeCircle,
@@ -172,6 +173,7 @@ export const componentGroups: ComponentGroup[] = [
172173
{ name: 'Field', icon: mdiListBoxOutline, activeIcon: mdiListBox },
173174
{ name: 'HelperText', icon: mdiHelpBoxOutline, activeIcon: mdiHelpBox },
174175
{ name: 'Input', icon: mdiFormTextbox },
176+
{ name: 'Meter', icon: mdiGauge },
175177
{ name: 'NumberInput', icon: mdiNumeric },
176178
{ name: 'PasswordInput', icon: mdiFormTextboxPassword },
177179
{ name: 'PinInput', icon: mdiLockSmart },
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script lang="ts">
2+
import ProgressBar, { type Props } from '$lib/internal/ProgressBar.svelte';
3+
4+
let props: Omit<Props, 'type'> = $props();
5+
</script>
6+
7+
<ProgressBar type="meter" {...props} />
Lines changed: 5 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,81 +1,11 @@
11
<script lang="ts">
2-
import { styleVariants } from '$lib/styles.js';
3-
import type { Color, Shape, Size } from '$lib/types.js';
4-
import { cleanClass } from '$lib/utilities/internal.js';
5-
import type { Snippet } from 'svelte';
6-
import { tv } from 'tailwind-variants';
2+
import ProgressBar, { type Props as InternalProps } from '$lib/internal/ProgressBar.svelte';
73
8-
type Props = {
9-
progress: number;
10-
size?: Size;
11-
shape?: Shape;
12-
color?: Color;
13-
border?: boolean;
14-
class?: string;
15-
children?: Snippet;
4+
type Props = Omit<InternalProps, 'type'> & {
5+
progress?: number;
166
};
177
18-
let {
19-
progress,
20-
shape = 'round',
21-
size = 'medium',
22-
color = 'primary',
23-
border = false,
24-
class: className,
25-
children,
26-
}: Props = $props();
27-
28-
const containerStyles = tv({
29-
base: 'bg-light-100 dark:bg-light-200 relative w-full overflow-hidden',
30-
variants: {
31-
shape: styleVariants.shape,
32-
size: {
33-
tiny: 'h-2',
34-
small: 'h-3',
35-
medium: 'h-4',
36-
large: 'h-5',
37-
giant: 'h-7',
38-
},
39-
roundedSize: {
40-
tiny: 'rounded-sm',
41-
small: 'rounded-md',
42-
medium: 'rounded-md',
43-
large: 'rounded-lg',
44-
giant: 'rounded-xl',
45-
},
46-
border: {
47-
true: 'dark:border-light-300 border',
48-
},
49-
},
50-
});
51-
52-
const barStyles = tv({
53-
base: 'h-full transition-all duration-700 ease-in-out',
54-
variants: {
55-
color: styleVariants.filledColor,
56-
shape: styleVariants.shape,
57-
size: {
58-
tiny: 'min-w-2',
59-
small: 'min-w-3',
60-
medium: 'min-w-4',
61-
large: 'min-w-5',
62-
giant: 'min-w-7',
63-
},
64-
},
65-
});
8+
let { progress, value, ...props }: Props = $props();
669
</script>
6710

68-
<div
69-
class={cleanClass(
70-
containerStyles({ size, shape, roundedSize: shape === 'semi-round' ? size : undefined, border }),
71-
className,
72-
)}
73-
>
74-
<div class="absolute flex h-full w-full items-center justify-center">
75-
{@render children?.()}
76-
</div>
77-
<div
78-
class={cleanClass(barStyles({ size: progress > 0 ? size : undefined, color, shape }))}
79-
style="width: {progress * 100}%"
80-
></div>
81-
</div>
11+
<ProgressBar type="progress" value={progress ?? value} {...props} />

packages/ui/src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export { default as ListButton } from '$lib/components/ListButton/ListButton.sve
6464
export { default as LoadingSpinner } from '$lib/components/LoadingSpinner/LoadingSpinner.svelte';
6565
export { default as Logo } from '$lib/components/Logo/Logo.svelte';
6666
export { Markdown } from '$lib/components/Markdown/index.js';
67+
export { default as Meter } from '$lib/components/Meter/Meter.svelte';
6768
export { default as Modal } from '$lib/components/Modal/Modal.svelte';
6869
export { default as ModalBody } from '$lib/components/Modal/ModalBody.svelte';
6970
export { default as ModalFooter } from '$lib/components/Modal/ModalFooter.svelte';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<script lang="ts">
2+
import { styleVariants } from '$lib/styles.js';
3+
import type { Color, Shape, Size } from '$lib/types.js';
4+
import { cleanClass } from '$lib/utilities/internal.js';
5+
import { Meter, Progress } from 'bits-ui';
6+
import type { ComponentProps, Snippet } from 'svelte';
7+
import { tv } from 'tailwind-variants';
8+
9+
export type Props = ComponentProps<typeof Meter.Root> & {
10+
value?: number;
11+
max?: number;
12+
min?: number;
13+
border?: boolean;
14+
class?: string;
15+
color?: Color;
16+
containerClass?: string;
17+
label?: string;
18+
size?: Size;
19+
shape?: Shape;
20+
stop?: boolean;
21+
type?: 'meter' | 'progress';
22+
valueLabel?: string;
23+
thresholds?: { from: number; className: string }[];
24+
ref?: HTMLElement | null;
25+
children?: Snippet;
26+
};
27+
28+
let {
29+
value = 0,
30+
max = 1,
31+
min = 0,
32+
border = false,
33+
class: className,
34+
color: baseColor = 'primary',
35+
containerClass,
36+
thresholds = [],
37+
shape = 'round',
38+
size = 'medium',
39+
stop = true,
40+
type = 'progress',
41+
label,
42+
valueLabel,
43+
children,
44+
...props
45+
}: Props = $props();
46+
47+
const containerStyles = tv({
48+
base: 'bg-light-100 dark:bg-light-200 relative box-content w-full overflow-hidden',
49+
variants: {
50+
shape: styleVariants.shape,
51+
size: {
52+
tiny: 'h-2',
53+
small: 'h-3',
54+
medium: 'h-4',
55+
large: 'h-5',
56+
giant: 'h-7',
57+
},
58+
roundedSize: {
59+
tiny: 'rounded-sm',
60+
small: 'rounded-md',
61+
medium: 'rounded-md',
62+
large: 'rounded-lg',
63+
giant: 'rounded-xl',
64+
},
65+
border: {
66+
true: 'dark:border-light-300 border',
67+
},
68+
},
69+
});
70+
71+
const barStyles = tv({
72+
base: 'h-full transition-all duration-700 ease-in-out',
73+
variants: {
74+
color: styleVariants.filledColor,
75+
shape: styleVariants.shape,
76+
size: {
77+
tiny: 'min-w-2',
78+
small: 'min-w-3',
79+
medium: 'min-w-4',
80+
large: 'min-w-5',
81+
giant: 'min-w-7',
82+
},
83+
roundedSize: {
84+
tiny: 'rounded-sm',
85+
small: 'rounded-md',
86+
medium: 'rounded-md',
87+
large: 'rounded-lg',
88+
giant: 'rounded-xl',
89+
},
90+
},
91+
});
92+
93+
const barClass = $derived.by(() => {
94+
for (let threshold of thresholds.sort((a, b) => b.from - a.from)) {
95+
if (value >= threshold.from) {
96+
return threshold.className;
97+
}
98+
}
99+
});
100+
101+
const stopStyles = tv({
102+
base: 'absolute inset-y-[calc(50%-var(--spacing)*0.75)] size-1.5 rounded-full opacity-70',
103+
variants: {
104+
color: styleVariants.filledColor,
105+
size: {
106+
tiny: 'inset-y-0.5 end-0.5 size-1',
107+
small: 'end-0.75',
108+
medium: 'end-1.25',
109+
large: 'end-1.75',
110+
giant: 'end-2.75',
111+
},
112+
},
113+
});
114+
115+
const ComponentType = $derived(type === 'progress' ? Progress.Root : Meter.Root);
116+
const fillPercentage = $derived((100 * value) / (max - min));
117+
const labelId = $props.id();
118+
</script>
119+
120+
<div class={cleanClass('flex w-full flex-col gap-1', containerClass)}>
121+
{#if label}
122+
<div class="flex flex-wrap justify-between">
123+
<span id={labelId} class="text-immich-dark-gray font-medium dark:text-white">{label}</span>
124+
<span class="text-gray-500 dark:text-gray-300">{valueLabel}</span>
125+
</div>
126+
{/if}
127+
128+
<ComponentType
129+
aria-labelledby={label ? labelId : undefined}
130+
aria-valuetext={valueLabel ?? `${value} / ${max}`}
131+
{value}
132+
{min}
133+
{max}
134+
class={cleanClass(
135+
containerStyles({ shape, size, roundedSize: shape === 'semi-round' ? size : undefined, border }),
136+
className,
137+
)}
138+
{...props}
139+
>
140+
<div class="absolute flex h-full w-full items-center justify-center">
141+
{@render children?.()}
142+
</div>
143+
<div
144+
class={cleanClass(
145+
barStyles({
146+
color: baseColor,
147+
shape,
148+
size: value > 0 ? size : undefined,
149+
roundedSize: shape === 'semi-round' ? size : undefined,
150+
}),
151+
barClass,
152+
)}
153+
style="width: {fillPercentage}%"
154+
></div>
155+
{#if stop}
156+
<div class={cleanClass(stopStyles({ color: baseColor, size }), barClass)}></div>
157+
{/if}
158+
</ComponentType>
159+
</div>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<script lang="ts">
2+
import ComponentExamples from '$docs/components/ComponentExamples.svelte';
3+
import ComponentLink from '$docs/components/ComponentLink.svelte';
4+
import ComponentPage from '$docs/components/ComponentPage.svelte';
5+
import ComponentTipCard from '$docs/components/ComponentTipCard.svelte';
6+
import { Text } from '@immich/ui';
7+
import ColorExample from './ColorExample.svelte';
8+
import colorExample from './ColorExample.svelte?raw';
9+
import OtherExample from './OtherExample.svelte';
10+
import otherExample from './OtherExample.svelte?raw';
11+
import ShapeExample from './ShapeExample.svelte';
12+
import shapeExample from './ShapeExample.svelte?raw';
13+
import SizeExample from './SizeExample.svelte';
14+
import sizeExample from './SizeExample.svelte?raw';
15+
</script>
16+
17+
<ComponentPage name="Meter" description="A component for displaying a static measurement within a known range">
18+
<ComponentTipCard>
19+
<Text>
20+
Use a meter to show static measurements like storage usage. See <ComponentLink name="ProgressBar" /> for showing the
21+
completion status of a task.
22+
</Text>
23+
</ComponentTipCard>
24+
<ComponentExamples
25+
examples={[
26+
{ title: 'Size', code: sizeExample, component: SizeExample },
27+
{ title: 'Shape', code: shapeExample, component: ShapeExample },
28+
{ title: 'Color', code: colorExample, component: ColorExample },
29+
{ title: 'Other', code: otherExample, component: OtherExample },
30+
]}
31+
/>
32+
</ComponentPage>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<script>
2+
import ComponentColors from '$docs/components/ComponentColors.svelte';
3+
import { Meter, Stack } from '@immich/ui';
4+
</script>
5+
6+
<Stack>
7+
<ComponentColors>
8+
{#snippet child({ color })}
9+
<Meter {color} value={0.7} size="large" />
10+
{/snippet}
11+
</ComponentColors>
12+
</Stack>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script>
2+
import { Meter, Stack, Text } from '@immich/ui';
3+
4+
const colors = [
5+
{ from: 0.8, className: 'bg-warning' },
6+
{ from: 0.95, className: 'bg-danger' },
7+
];
8+
</script>
9+
10+
<Stack gap={4}>
11+
<div>
12+
<Meter label="Completely empty" valueLabel="Current usage: 0%" />
13+
</div>
14+
<div>
15+
<Meter value={0.001} label="Almost empty" valueLabel="Current usage: 0.1%" />
16+
</div>
17+
<div>
18+
<Text fontWeight="medium" class="mb-1">Storage card</Text>
19+
<Meter
20+
size="tiny"
21+
value={0.11}
22+
label="Storage space"
23+
valueLabel="87,6 GiB of 831,8 GiB used"
24+
class="bg-light-200"
25+
containerClass="bg-light-100 w-60 p-4 rounded-lg"
26+
/>
27+
</div>
28+
<Stack>
29+
<Text fontWeight="medium">Color change</Text>
30+
<Meter value={0.75} thresholds={colors} />
31+
<Meter value={0.85} thresholds={colors} />
32+
<Meter value={0.95} thresholds={colors} />
33+
</Stack>
34+
<div>
35+
<Meter value={0.2} border label="With border" />
36+
</div>
37+
<div>
38+
<Meter value={0.2} stop={false} label="No stop" />
39+
</div>
40+
<div dir="rtl">
41+
<Meter value={0.2} label="RTL" />
42+
</div>
43+
</Stack>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script>
2+
import { Meter, Stack } from '@immich/ui';
3+
</script>
4+
5+
<Stack gap={4}>
6+
<div>
7+
<Meter value={0.8} shape="round" label="Round" valueLabel="80% storage used" />
8+
</div>
9+
<div>
10+
<Meter value={0.8} shape="semi-round" label="Semi-round" valueLabel="80% storage used" />
11+
</div>
12+
<div>
13+
<Meter value={0.8} shape="rectangle" label="Rectangle" valueLabel="80% storage used" />
14+
</div>
15+
</Stack>

0 commit comments

Comments
 (0)