Skip to content

Commit d8ec512

Browse files
authored
feat(Progress): add new component/composable (#180)
1 parent 193a2a5 commit d8ec512

31 files changed

Lines changed: 1780 additions & 12 deletions

apps/docs/build/generate-nav.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const SECTIONS: Record<string, { order: number, hasSubcategories: boolean, rootP
5858
const SUBCATEGORY_ORDER: Record<string, string[]> = {
5959
guide: ['essentials', 'fundamentals', 'features', 'integration', 'tooling'],
6060
components: ['primitives', 'providers', 'actions', 'disclosure', 'forms', 'semantic'],
61-
composables: ['foundation', 'registration', 'selection', 'forms', 'data', 'reactivity', 'system', 'plugins', 'utilities', 'transformers'],
61+
composables: ['foundation', 'registration', 'selection', 'forms', 'data', 'semantic', 'reactivity', 'system', 'plugins', 'transformers'],
6262
}
6363

6464
// Standalone pages that appear between sections
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<script setup lang="ts">
2+
import { Progress } from '@vuetify/v0'
3+
4+
const { buffer = 0 } = defineProps<{
5+
buffer?: number
6+
}>()
7+
8+
const model = defineModel<number>({ default: 0 })
9+
</script>
10+
11+
<template>
12+
<Progress.Root v-model="model">
13+
<Progress.Track class="relative h-1 w-full overflow-hidden rounded-full bg-surface-variant">
14+
<Progress.Buffer
15+
class="absolute inset-y-0 left-0 rounded-full bg-on-surface/20"
16+
:value="buffer"
17+
/>
18+
19+
<Progress.Fill class="absolute inset-y-0 left-0 rounded-full bg-red-500" />
20+
</Progress.Track>
21+
</Progress.Root>
22+
</template>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { Progress } from '@vuetify/v0'
3+
4+
const { categories } = defineProps<{
5+
categories: { name: string, value: number, color: string }[]
6+
}>()
7+
8+
const model = defineModel<number[]>({ default: () => [] })
9+
</script>
10+
11+
<template>
12+
<Progress.Root v-model="model" :max="128">
13+
<div class="flex items-center justify-between mb-1">
14+
<Progress.Label class="text-sm font-medium">Storage</Progress.Label>
15+
16+
<Progress.Value v-slot="{ total }">
17+
<span class="text-sm text-neutral-500">{{ total }} of 128 GB</span>
18+
</Progress.Value>
19+
</div>
20+
21+
<Progress.Track class="relative flex h-3 w-full overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
22+
<Progress.Fill
23+
v-for="cat in categories"
24+
:key="cat.name"
25+
class="h-full"
26+
:class="cat.color"
27+
:value="cat.value"
28+
/>
29+
</Progress.Track>
30+
</Progress.Root>
31+
</template>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script setup lang="ts">
2+
import { Progress, Slider } from '@vuetify/v0'
3+
import { shallowRef } from 'vue'
4+
5+
const value = shallowRef(65)
6+
</script>
7+
8+
<template>
9+
<div class="flex flex-col gap-4 w-full">
10+
<Progress.Root v-model="value">
11+
<Progress.Label class="text-sm font-medium">Loading...</Progress.Label>
12+
13+
<Progress.Track class="relative h-2 w-full overflow-hidden rounded-full bg-surface-variant">
14+
<Progress.Fill class="h-full rounded-full bg-primary" />
15+
</Progress.Track>
16+
17+
<Progress.Value class="text-sm text-neutral-500" />
18+
</Progress.Root>
19+
20+
<Slider.Root v-model="value" class="relative flex items-center w-full h-5">
21+
<Slider.Track class="relative h-1 w-full rounded-full bg-surface-variant">
22+
<Slider.Range class="absolute h-full rounded-full bg-primary" />
23+
</Slider.Track>
24+
25+
<Slider.Thumb class="absolute size-5 rounded-full bg-primary -translate-x-1/2 focus:outline-2 focus:outline-primary" />
26+
</Slider.Root>
27+
</div>
28+
</template>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<script setup lang="ts">
2+
import MediaProgress from './MediaProgress.vue'
3+
import { useTimer } from '@vuetify/v0'
4+
import { shallowRef, toRef } from 'vue'
5+
6+
const elapsed = shallowRef(0)
7+
const buffered = shallowRef(0)
8+
const playing = shallowRef(false)
9+
const duration = 180
10+
const step = 100 / duration
11+
12+
function format (seconds: number) {
13+
const m = Math.floor(seconds / 60)
14+
const s = Math.floor(seconds % 60)
15+
return `${m}:${s.toString().padStart(2, '0')}`
16+
}
17+
18+
const time = toRef(() => format((elapsed.value / 100) * duration))
19+
const total = toRef(() => format(duration))
20+
21+
const playback = useTimer(() => {
22+
elapsed.value = Math.min(elapsed.value + step, 100)
23+
24+
if (elapsed.value >= 100) {
25+
playing.value = false
26+
playback.stop()
27+
buffer.stop()
28+
}
29+
}, { duration: 1000, repeat: true })
30+
31+
const buffer = useTimer(() => {
32+
buffered.value = Math.min(buffered.value + step * 2, 100)
33+
34+
if (buffered.value >= 100) {
35+
buffer.stop()
36+
}
37+
}, { duration: 350, repeat: true })
38+
39+
function toggle () {
40+
playing.value = !playing.value
41+
42+
if (playing.value) {
43+
playback.start()
44+
if (!buffer.isActive.value) buffer.start()
45+
} else {
46+
playback.pause()
47+
}
48+
}
49+
50+
function reset () {
51+
elapsed.value = 0
52+
buffered.value = 0
53+
playing.value = false
54+
playback.stop()
55+
buffer.stop()
56+
}
57+
</script>
58+
59+
<template>
60+
<div class="flex flex-col gap-2 w-full rounded-lg bg-surface p-4">
61+
<div class="flex items-center gap-3 mb-1">
62+
<div class="size-10 rounded bg-surface-variant flex items-center justify-center">
63+
<svg class="size-5 text-on-surface-variant" viewBox="0 0 24 24"><path d="M21 3v12.5a3.5 3.5 0 0 1-3.5 3.5a3.5 3.5 0 0 1-3.5-3.5a3.5 3.5 0 0 1 3.5-3.5c.54 0 1.05.12 1.5.34V6.47L9 8.6v8.9A3.5 3.5 0 0 1 5.5 21A3.5 3.5 0 0 1 2 17.5A3.5 3.5 0 0 1 5.5 14c.54 0 1.05.12 1.5.34V4l14-3z" fill="currentColor" /></svg>
64+
</div>
65+
66+
<div class="flex flex-col">
67+
<span class="text-sm font-medium text-on-surface">Midnight Drive</span>
68+
<span class="text-xs text-on-surface-variant">Synthwave Collection</span>
69+
</div>
70+
</div>
71+
72+
<MediaProgress v-model="elapsed" :buffer="buffered" />
73+
74+
<div class="flex items-center justify-between">
75+
<span class="text-xs text-on-surface-variant tabular-nums">{{ time }}</span>
76+
<span class="text-xs text-on-surface-variant tabular-nums">{{ total }}</span>
77+
</div>
78+
79+
<div class="flex items-center justify-center gap-4">
80+
<button
81+
class="text-on-surface-variant hover:text-on-surface"
82+
@click="reset"
83+
>
84+
<svg class="size-5" viewBox="0 0 24 24"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" fill="currentColor" /></svg>
85+
</button>
86+
87+
<button
88+
class="size-9 rounded-full bg-on-surface text-surface flex items-center justify-center"
89+
@click="toggle"
90+
>
91+
<svg v-if="playing" class="size-5" viewBox="0 0 24 24"><path d="M14 19h4V5h-4M6 19h4V5H6v14z" fill="currentColor" /></svg>
92+
<svg v-else class="size-5 ml-0.5" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor" /></svg>
93+
</button>
94+
95+
<button
96+
class="text-on-surface-variant hover:text-on-surface"
97+
@click="elapsed = 100; buffered = 100"
98+
>
99+
<svg class="size-5" viewBox="0 0 24 24"><path d="M16 18h2V6h-2M6 18l8.5-6L6 6v12z" fill="currentColor" /></svg>
100+
</button>
101+
</div>
102+
</div>
103+
</template>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<script setup lang="ts">
2+
import StorageBar from './StorageBar.vue'
3+
import { Slider } from '@vuetify/v0'
4+
import { ref } from 'vue'
5+
6+
const categories = ref([
7+
{ name: 'Photos', value: 32, color: 'bg-blue-500' },
8+
{ name: 'Apps', value: 24, color: 'bg-purple-500' },
9+
{ name: 'Documents', value: 12, color: 'bg-amber-500' },
10+
{ name: 'System', value: 8, color: 'bg-neutral-400' },
11+
])
12+
13+
const values = ref(categories.value.map(c => c.value))
14+
const capacity = 128
15+
16+
function remaining (index: number) {
17+
let used = 0
18+
for (let i = 0; i < categories.value.length; i++) {
19+
if (i !== index) used += categories.value[i].value
20+
}
21+
return capacity - used
22+
}
23+
</script>
24+
25+
<template>
26+
<div class="flex flex-col gap-6 w-full">
27+
<StorageBar v-model="values" :categories />
28+
29+
<div class="grid grid-cols-2 gap-3">
30+
<div v-for="(cat, index) in categories" :key="cat.name" class="flex items-center gap-2">
31+
<span class="size-3 rounded-full" :class="cat.color" />
32+
<span class="text-sm min-w-18">{{ cat.name }}</span>
33+
34+
<Slider.Root v-model="categories[index].value" class="relative flex flex-1 items-center h-5" :max="remaining(index)">
35+
<Slider.Track class="relative h-1 w-full rounded-full bg-surface-variant">
36+
<Slider.Range class="absolute h-full rounded-full bg-primary" />
37+
</Slider.Track>
38+
39+
<Slider.Thumb class="absolute size-4 rounded-full bg-primary -translate-x-1/2 focus:outline-2 focus:outline-primary" />
40+
</Slider.Root>
41+
42+
<span class="text-sm text-neutral-500 w-12 text-right">{{ cat.value }} GB</span>
43+
</div>
44+
</div>
45+
</div>
46+
</template>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<script setup lang="ts">
2+
import { useUpload } from './useUpload'
3+
import { toValue } from 'vue'
4+
5+
const { files, percent, isIndeterminate, upload, clear, fromValue } = useUpload()
6+
7+
const names = ['photo.jpg', 'report.pdf', 'video.mp4', 'backup.zip', 'notes.md']
8+
let index = 0
9+
10+
function add () {
11+
upload(names[index % names.length]!)
12+
index++
13+
}
14+
</script>
15+
16+
<template>
17+
<div class="flex flex-col gap-4 w-full">
18+
<div class="flex items-center gap-2">
19+
<button
20+
class="px-3 py-1 rounded text-sm bg-blue-500 text-white"
21+
@click="add"
22+
>
23+
Add file
24+
</button>
25+
26+
<button
27+
class="px-3 py-1 rounded text-sm bg-neutral-200 dark:bg-neutral-800"
28+
@click="clear"
29+
>
30+
Clear
31+
</button>
32+
33+
<span class="text-sm text-neutral-500 ml-auto">
34+
{{ isIndeterminate ? 'Idle' : `${Math.round(percent)}% total` }}
35+
</span>
36+
</div>
37+
38+
<div v-if="files.length > 0" class="flex flex-col gap-2">
39+
<div
40+
v-for="file in files"
41+
:key="file.ticket.id"
42+
class="flex items-center gap-3"
43+
>
44+
<span class="text-sm w-24 truncate">{{ file.name }}</span>
45+
46+
<div class="relative flex-1 h-2 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
47+
<div
48+
class="h-full rounded-full transition-all"
49+
:class="file.status === 'complete' ? 'bg-green-500' : 'bg-blue-500'"
50+
:style="{ width: `${fromValue(toValue(file.ticket.value))}%` }"
51+
/>
52+
</div>
53+
54+
<span class="text-sm text-neutral-500 w-10 text-right">
55+
{{ file.status === 'complete' ? '\u2713' : `${Math.round(toValue(file.ticket.value))}%` }}
56+
</span>
57+
</div>
58+
</div>
59+
</div>
60+
</template>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { createProgress } from '@vuetify/v0'
2+
import { onUnmounted, shallowRef, toRef } from 'vue'
3+
4+
import type { ModelTicket, ModelTicketInput } from '@vuetify/v0'
5+
import type { ShallowRef } from 'vue'
6+
7+
interface ProgressTicketInput extends ModelTicketInput<ShallowRef<number>> {
8+
value: ShallowRef<number>
9+
}
10+
11+
export interface UploadFile {
12+
name: string
13+
ticket: ModelTicket<ProgressTicketInput>
14+
status: 'uploading' | 'complete'
15+
}
16+
17+
export function useUpload () {
18+
const progress = createProgress({ max: 100 })
19+
const files = shallowRef<UploadFile[]>([])
20+
const timers: ReturnType<typeof setInterval>[] = []
21+
22+
function upload (name: string) {
23+
const ticket = progress.register()
24+
25+
const file: UploadFile = {
26+
name,
27+
ticket,
28+
status: 'uploading',
29+
}
30+
31+
const updated = [...files.value, file]
32+
files.value = updated
33+
34+
const timer = setInterval(() => {
35+
const current = ticket.value.value
36+
if (current >= 100) {
37+
clearInterval(timer)
38+
file.status = 'complete'
39+
files.value = [...files.value]
40+
return
41+
}
42+
ticket.value.value = Math.min(current + Math.random() * 15, 100)
43+
}, 200)
44+
45+
timers.push(timer)
46+
}
47+
48+
function clear () {
49+
for (const timer of timers) clearInterval(timer)
50+
timers.length = 0
51+
for (const file of files.value) file.ticket.unregister()
52+
files.value = []
53+
}
54+
55+
onUnmounted(() => {
56+
for (const timer of timers) clearInterval(timer)
57+
})
58+
59+
const total = toRef(() => progress.total.value)
60+
const percent = toRef(() => progress.percent.value)
61+
const isIndeterminate = toRef(() => progress.isIndeterminate.value)
62+
63+
return {
64+
files,
65+
total,
66+
percent,
67+
isIndeterminate,
68+
upload,
69+
clear,
70+
fromValue: progress.fromValue,
71+
}
72+
}

apps/docs/src/pages/components/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Components with meaningful HTML defaults. Render semantic elements by default bu
7777
| [Avatar](/components/semantic/avatar) | Image/fallback avatar with priority loading |
7878
| [Breadcrumbs](/components/semantic/breadcrumbs) | Navigation breadcrumbs with overflow detection and truncation |
7979
| [Pagination](/components/semantic/pagination) | Page navigation with semantic `<nav>` wrapper |
80+
| [Progress](/components/semantic/progress/) | Headless progress bar with multi-segment and buffer support |
8081
| [Snackbar](/components/semantic/snackbar) | Toast notification with queue, positioning, and auto-dismiss |
8182
| [Splitter](/components/semantic/splitter) | Resizable panel layout with drag handles |
8283

apps/docs/src/pages/components/semantic/breadcrumbs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ features:
1212
renderless: false
1313
level: 2
1414
related:
15-
- /composables/utilities/create-breadcrumbs
15+
- /composables/semantic/create-breadcrumbs
1616
- /composables/selection/create-group
17-
- /composables/utilities/create-overflow
17+
- /composables/semantic/create-overflow
1818
- /composables/plugins/use-locale
1919
- /components/semantic/pagination
2020
---

0 commit comments

Comments
 (0)