@m3ui-vue/m3ui-vue 0.4.6 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{MMenuItem-COdgD4m7.js → MMenuItem-Dw2ARZic.js} +30 -24
- package/dist/{MMenuItem-COdgD4m7.js.map → MMenuItem-Dw2ARZic.js.map} +1 -1
- package/dist/components/MAutocomplete.vue.d.ts +2 -1
- package/dist/components/MCircleProgressBar.vue.d.ts +27 -0
- package/dist/components/MColorPicker.vue.d.ts +2 -1
- package/dist/components/MContextMenu.vue.d.ts +7 -21
- package/dist/components/MDatePicker.vue.d.ts +2 -1
- package/dist/components/MDateRangePicker.vue.d.ts +2 -1
- package/dist/components/MMaskField.vue.d.ts +2 -1
- package/dist/components/MMenuDivider.vue.d.ts +3 -0
- package/dist/components/MMenuItem.vue.d.ts +1 -0
- package/dist/components/MMultiAutocomplete.vue.d.ts +2 -1
- package/dist/components/MMultiSelect.vue.d.ts +2 -1
- package/dist/components/MNumberField.vue.d.ts +2 -1
- package/dist/components/MProgressBar.vue.d.ts +1 -0
- package/dist/components/MSelect.vue.d.ts +2 -1
- package/dist/components/MTagInput.vue.d.ts +2 -1
- package/dist/components/MTextField.vue.d.ts +2 -1
- package/dist/components/MTimePicker.vue.d.ts +2 -1
- package/dist/composables/useNotification.d.ts +7 -4
- package/dist/composables/useToast.d.ts +7 -4
- package/dist/index.d.ts +2 -1
- package/dist/m3ui-vue.css +1 -1
- package/dist/m3ui.js +1291 -1026
- package/dist/m3ui.js.map +1 -1
- package/dist/rich-text-editor.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/MAbsolute.vue +3 -2
- package/src/components/MAutocomplete.vue +3 -2
- package/src/components/MCircleProgressBar.vue +354 -0
- package/src/components/MColorPicker.vue +4 -3
- package/src/components/MColorPickerModal.vue +1 -1
- package/src/components/MContextMenu.vue +51 -38
- package/src/components/MDatePicker.vue +3 -2
- package/src/components/MDateRangePicker.vue +3 -2
- package/src/components/MMaskField.vue +3 -2
- package/src/components/MMenuDivider.vue +3 -0
- package/src/components/MMenuItem.vue +2 -0
- package/src/components/MMultiAutocomplete.vue +3 -2
- package/src/components/MMultiSelect.vue +3 -2
- package/src/components/MNotificationHost.vue +22 -2
- package/src/components/MNumberField.vue +3 -2
- package/src/components/MProgressBar.vue +200 -75
- package/src/components/MSelect.vue +3 -2
- package/src/components/MSlider.vue +20 -16
- package/src/components/MSnackbar.vue +24 -2
- package/src/components/MTabs.vue +1 -1
- package/src/components/MTagInput.vue +3 -2
- package/src/components/MTextField.vue +3 -2
- package/src/components/MTimePicker.vue +3 -2
- package/src/components/MWindow.vue +5 -5
- package/src/composables/useNotification.ts +21 -4
- package/src/composables/useToast.ts +21 -4
- package/src/index.ts +2 -1
- package/dist/components/_MContextMenuPanel.vue.d.ts +0 -13
- package/src/components/_MContextMenuPanel.vue +0 -137
|
@@ -1,66 +1,79 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref } from 'vue'
|
|
3
|
-
import MContextMenuPanel from './_MContextMenuPanel.vue'
|
|
4
|
-
|
|
5
|
-
export interface ContextMenuItem {
|
|
6
|
-
label?: string
|
|
7
|
-
icon?: string
|
|
8
|
-
shortcut?: string
|
|
9
|
-
disabled?: boolean
|
|
10
|
-
danger?: boolean
|
|
11
|
-
divider?: boolean
|
|
12
|
-
to?: string | Record<string, any>
|
|
13
|
-
children?: ContextMenuItem[]
|
|
14
|
-
onClick?: () => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
defineProps<{ items: ContextMenuItem[] }>()
|
|
2
|
+
import { nextTick, onMounted, onUnmounted, provide, ref } from 'vue'
|
|
18
3
|
|
|
19
4
|
const visible = ref(false)
|
|
20
|
-
const
|
|
5
|
+
const adjustedPos = ref({ x: 0, y: 0 })
|
|
6
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
7
|
+
|
|
8
|
+
async function showAt(x: number, y: number) {
|
|
9
|
+
adjustedPos.value = { x, y }
|
|
10
|
+
visible.value = true
|
|
11
|
+
await nextTick()
|
|
12
|
+
if (!panelEl.value) return
|
|
13
|
+
const el = panelEl.value
|
|
14
|
+
adjustedPos.value = {
|
|
15
|
+
x: Math.min(x, window.innerWidth - el.offsetWidth - 8),
|
|
16
|
+
y: Math.min(y, window.innerHeight - el.offsetHeight - 8),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
21
19
|
|
|
22
20
|
function show(e: MouseEvent) {
|
|
23
21
|
e.preventDefault()
|
|
24
|
-
e.stopPropagation()
|
|
25
22
|
showAt(e.clientX, e.clientY)
|
|
26
23
|
}
|
|
27
24
|
|
|
28
|
-
function showAt(x: number, y: number) {
|
|
29
|
-
position.value = { x, y }
|
|
30
|
-
visible.value = true
|
|
31
|
-
}
|
|
32
|
-
|
|
33
25
|
function hide() {
|
|
34
26
|
visible.value = false
|
|
35
27
|
}
|
|
36
28
|
|
|
29
|
+
provide('m-menu-close', hide)
|
|
37
30
|
defineExpose({ show, showAt, hide })
|
|
31
|
+
|
|
32
|
+
function onDocMouseDown(e: MouseEvent) {
|
|
33
|
+
if (!visible.value) return
|
|
34
|
+
const t = e.target as Node
|
|
35
|
+
if (panelEl.value?.contains(t)) return
|
|
36
|
+
if ((t as Element).closest?.('.m3-submenu')) return
|
|
37
|
+
hide()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onKeydown(e: KeyboardEvent) {
|
|
41
|
+
if (e.key === 'Escape') hide()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
onMounted(() => {
|
|
45
|
+
document.addEventListener('mousedown', onDocMouseDown)
|
|
46
|
+
document.addEventListener('keydown', onKeydown)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
onUnmounted(() => {
|
|
50
|
+
document.removeEventListener('mousedown', onDocMouseDown)
|
|
51
|
+
document.removeEventListener('keydown', onKeydown)
|
|
52
|
+
})
|
|
38
53
|
</script>
|
|
39
54
|
|
|
40
55
|
<template>
|
|
41
|
-
<
|
|
56
|
+
<div @contextmenu="show">
|
|
57
|
+
<slot name="trigger" />
|
|
58
|
+
</div>
|
|
42
59
|
|
|
43
60
|
<Teleport to="body">
|
|
44
61
|
<Transition
|
|
45
|
-
enter-active-class="transition-opacity duration-100"
|
|
46
|
-
enter-from-class="opacity-0"
|
|
47
|
-
enter-to-class="opacity-100"
|
|
48
|
-
leave-active-class="transition-opacity duration-75"
|
|
49
|
-
leave-from-class="opacity-100"
|
|
50
|
-
leave-to-class="opacity-0"
|
|
62
|
+
enter-active-class="transition-[opacity,transform] duration-100 ease-out"
|
|
63
|
+
enter-from-class="opacity-0 scale-95"
|
|
64
|
+
enter-to-class="opacity-100 scale-100"
|
|
65
|
+
leave-active-class="transition-[opacity,transform] duration-75 ease-in"
|
|
66
|
+
leave-from-class="opacity-100 scale-100"
|
|
67
|
+
leave-to-class="opacity-0 scale-95"
|
|
51
68
|
>
|
|
52
69
|
<div
|
|
53
70
|
v-if="visible"
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
ref="panelEl"
|
|
72
|
+
class="fixed z-[500] min-w-48 overflow-hidden rounded-lg bg-surface-container py-1 shadow-elevation-2"
|
|
73
|
+
:style="{ left: `${adjustedPos.x}px`, top: `${adjustedPos.y}px`, transformOrigin: 'top left' }"
|
|
56
74
|
@contextmenu.prevent
|
|
57
75
|
>
|
|
58
|
-
<
|
|
59
|
-
:items="items"
|
|
60
|
-
:x="position.x"
|
|
61
|
-
:y="position.y"
|
|
62
|
-
@close="hide"
|
|
63
|
-
/>
|
|
76
|
+
<slot />
|
|
64
77
|
</div>
|
|
65
78
|
</Transition>
|
|
66
79
|
</Teleport>
|
|
@@ -12,7 +12,8 @@ const props = withDefaults(defineProps<{
|
|
|
12
12
|
min?: string
|
|
13
13
|
max?: string
|
|
14
14
|
disabled?: boolean
|
|
15
|
-
error?:
|
|
15
|
+
error?: boolean
|
|
16
|
+
errorLabel?: string
|
|
16
17
|
hint?: string
|
|
17
18
|
locale?: string
|
|
18
19
|
fieldBg?: string
|
|
@@ -195,7 +196,7 @@ onUnmounted(() => {
|
|
|
195
196
|
</label>
|
|
196
197
|
</div>
|
|
197
198
|
|
|
198
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
199
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
199
200
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
200
201
|
|
|
201
202
|
<!-- Calendar dropdown -->
|
|
@@ -17,7 +17,8 @@ const props = withDefaults(defineProps<{
|
|
|
17
17
|
min?: string
|
|
18
18
|
max?: string
|
|
19
19
|
disabled?: boolean
|
|
20
|
-
error?:
|
|
20
|
+
error?: boolean
|
|
21
|
+
errorLabel?: string
|
|
21
22
|
hint?: string
|
|
22
23
|
locale?: string
|
|
23
24
|
fieldBg?: string
|
|
@@ -209,7 +210,7 @@ onUnmounted(() => {
|
|
|
209
210
|
</label>
|
|
210
211
|
</div>
|
|
211
212
|
|
|
212
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
213
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
213
214
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
214
215
|
|
|
215
216
|
<Teleport to="body">
|
|
@@ -22,7 +22,8 @@ const props = withDefaults(
|
|
|
22
22
|
label: string
|
|
23
23
|
mask: string | MaskPreset
|
|
24
24
|
variant?: 'filled' | 'outlined'
|
|
25
|
-
error?:
|
|
25
|
+
error?: boolean
|
|
26
|
+
errorLabel?: string
|
|
26
27
|
hint?: string
|
|
27
28
|
disabled?: boolean
|
|
28
29
|
required?: boolean
|
|
@@ -198,7 +199,7 @@ const labelClasses = computed(() => {
|
|
|
198
199
|
</button>
|
|
199
200
|
</div>
|
|
200
201
|
|
|
201
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
202
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
202
203
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
203
204
|
</div>
|
|
204
205
|
</template>
|
|
@@ -4,6 +4,7 @@ import MIcon from './MIcon.vue'
|
|
|
4
4
|
|
|
5
5
|
const props = withDefaults(defineProps<{
|
|
6
6
|
icon?: string
|
|
7
|
+
shortcut?: string
|
|
7
8
|
to?: string | Record<string, any>
|
|
8
9
|
disabled?: boolean
|
|
9
10
|
danger?: boolean
|
|
@@ -78,6 +79,7 @@ function onSubLeave(e: MouseEvent) {
|
|
|
78
79
|
>
|
|
79
80
|
<MIcon v-if="icon" :name="icon" :size="20" class="shrink-0" :class="danger ? 'text-error' : 'text-on-surface-variant'" />
|
|
80
81
|
<span class="flex-1"><slot /></span>
|
|
82
|
+
<span v-if="shortcut && !hasChildren" class="ml-4 shrink-0 text-label-small text-on-surface-variant opacity-60">{{ shortcut }}</span>
|
|
81
83
|
<MIcon v-if="hasChildren" name="chevron_right" :size="18" class="shrink-0 text-on-surface-variant" />
|
|
82
84
|
</component>
|
|
83
85
|
|
|
@@ -17,7 +17,8 @@ const props = withDefaults(
|
|
|
17
17
|
variant?: 'filled' | 'outlined'
|
|
18
18
|
mode?: 'docked' | 'modal'
|
|
19
19
|
disabled?: boolean
|
|
20
|
-
error?:
|
|
20
|
+
error?: boolean
|
|
21
|
+
errorLabel?: string
|
|
21
22
|
hint?: string
|
|
22
23
|
required?: boolean
|
|
23
24
|
leadingIcon?: string
|
|
@@ -441,7 +442,7 @@ const labelClasses = computed(() => {
|
|
|
441
442
|
</div>
|
|
442
443
|
</div>
|
|
443
444
|
|
|
444
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
445
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
445
446
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
446
447
|
</div>
|
|
447
448
|
|
|
@@ -23,7 +23,8 @@ const props = withDefaults(
|
|
|
23
23
|
variant?: 'filled' | 'outlined'
|
|
24
24
|
mode?: 'docked' | 'modal'
|
|
25
25
|
disabled?: boolean
|
|
26
|
-
error?:
|
|
26
|
+
error?: boolean
|
|
27
|
+
errorLabel?: string
|
|
27
28
|
hint?: string
|
|
28
29
|
required?: boolean
|
|
29
30
|
leadingIcon?: string
|
|
@@ -328,7 +329,7 @@ const labelClasses = computed(() => {
|
|
|
328
329
|
</div>
|
|
329
330
|
</div>
|
|
330
331
|
|
|
331
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
332
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
332
333
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
333
334
|
</div>
|
|
334
335
|
|
|
@@ -49,14 +49,25 @@ const getStyle = (variant: string) => variantStyles[variant] ?? variantStyles.in
|
|
|
49
49
|
<template>
|
|
50
50
|
<div :class="containerClass">
|
|
51
51
|
<TransitionGroup :name="isTop ? 'm3-notif-top' : 'm3-notif-bot'">
|
|
52
|
-
<div v-for="n in notifications" :key="n.id" class="notif-row w-full min-w-56 max-w-sm">
|
|
52
|
+
<div v-for="n in notifications" :key="n.id" class="notif-row relative w-full min-w-56 max-w-sm">
|
|
53
|
+
<Transition name="m3-badge">
|
|
54
|
+
<span
|
|
55
|
+
v-if="n.count >= 2"
|
|
56
|
+
class="absolute -top-1.5 -right-1.5 z-10 flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-on-primary shadow-elevation-1"
|
|
57
|
+
>
|
|
58
|
+
<Transition name="m3-count" mode="out-in">
|
|
59
|
+
<span :key="n.count">{{ n.count }}</span>
|
|
60
|
+
</Transition>
|
|
61
|
+
</span>
|
|
62
|
+
</Transition>
|
|
63
|
+
|
|
53
64
|
<div
|
|
54
65
|
class="notif-inner pointer-events-auto flex items-center gap-2.5 rounded px-4 py-3 shadow-elevation-2"
|
|
55
66
|
:class="getStyle(n.variant).bg"
|
|
56
67
|
>
|
|
57
68
|
<MSpinner v-if="n.loading" :size="18" class="shrink-0" />
|
|
58
69
|
<MIcon
|
|
59
|
-
v-else
|
|
70
|
+
v-else-if="n.icon !== null"
|
|
60
71
|
:name="n.icon ?? getStyle(n.variant).iconName"
|
|
61
72
|
:size="18"
|
|
62
73
|
class="shrink-0"
|
|
@@ -160,4 +171,13 @@ const getStyle = (variant: string) => variantStyles[variant] ?? variantStyles.in
|
|
|
160
171
|
opacity: 0;
|
|
161
172
|
transform: scale(0.93);
|
|
162
173
|
}
|
|
174
|
+
|
|
175
|
+
.m3-badge-enter-active { transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
176
|
+
.m3-badge-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
|
177
|
+
.m3-badge-enter-from, .m3-badge-leave-to { opacity: 0; transform: scale(0.5); }
|
|
178
|
+
|
|
179
|
+
.m3-count-enter-active { transition: opacity 0.12s ease, transform 0.12s ease; }
|
|
180
|
+
.m3-count-leave-active { transition: opacity 0.08s ease, transform 0.08s ease; }
|
|
181
|
+
.m3-count-enter-from { opacity: 0; transform: scale(1.5) translateY(-3px); }
|
|
182
|
+
.m3-count-leave-to { opacity: 0; transform: scale(0.6) translateY(2px); }
|
|
163
183
|
</style>
|
|
@@ -13,7 +13,8 @@ const props = withDefaults(
|
|
|
13
13
|
min?: number
|
|
14
14
|
max?: number
|
|
15
15
|
step?: number
|
|
16
|
-
error?:
|
|
16
|
+
error?: boolean
|
|
17
|
+
errorLabel?: string
|
|
17
18
|
hint?: string
|
|
18
19
|
disabled?: boolean
|
|
19
20
|
required?: boolean
|
|
@@ -174,7 +175,7 @@ const labelClasses = computed(() => {
|
|
|
174
175
|
</div>
|
|
175
176
|
</div>
|
|
176
177
|
|
|
177
|
-
<p v-if="error" class="px-4 text-body-small text-error">{{
|
|
178
|
+
<p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
|
|
178
179
|
<p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
|
|
179
180
|
</div>
|
|
180
181
|
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from "vue";
|
|
2
|
+
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
|
3
3
|
|
|
4
4
|
const props = withDefaults(
|
|
5
5
|
defineProps<{
|
|
@@ -8,47 +8,187 @@ const props = withDefaults(
|
|
|
8
8
|
color?: "primary" | "secondary" | "tertiary" | "error";
|
|
9
9
|
variant?: "linear" | "wavy";
|
|
10
10
|
label?: string;
|
|
11
|
+
thickness?: number;
|
|
11
12
|
}>(),
|
|
12
|
-
{
|
|
13
|
-
color: "primary",
|
|
14
|
-
variant: "linear",
|
|
15
|
-
},
|
|
13
|
+
{ color: "primary", variant: "linear" },
|
|
16
14
|
);
|
|
17
15
|
|
|
18
16
|
const isIndeterminate = computed(() => props.indeterminate || props.value === undefined);
|
|
19
|
-
const clampedValue
|
|
17
|
+
const clampedValue = computed(() => Math.min(100, Math.max(0, props.value ?? 0)));
|
|
20
18
|
|
|
21
19
|
const colorMap: Record<
|
|
22
20
|
"primary" | "secondary" | "tertiary" | "error",
|
|
23
21
|
{ bar: string; track: string; text: string }
|
|
24
22
|
> = {
|
|
25
|
-
primary:
|
|
23
|
+
primary: { bar: "bg-primary", track: "bg-primary-container", text: "text-primary" },
|
|
26
24
|
secondary: { bar: "bg-secondary", track: "bg-secondary-container", text: "text-secondary" },
|
|
27
|
-
tertiary:
|
|
28
|
-
error:
|
|
25
|
+
tertiary: { bar: "bg-tertiary", track: "bg-tertiary-container", text: "text-tertiary" },
|
|
26
|
+
error: { bar: "bg-error", track: "bg-error-container", text: "text-error" },
|
|
29
27
|
};
|
|
30
28
|
|
|
31
|
-
// ── Wave geometry
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const VIEW_H = 8;
|
|
39
|
-
const PERIODS = 80; // total cycles → 1600px strip
|
|
40
|
-
const STEP = 1; // px sampling resolution
|
|
29
|
+
// ── Wave geometry ─────────────────────────────────────────────────────────
|
|
30
|
+
const PERIOD = 20;
|
|
31
|
+
const AMP = 2.5;
|
|
32
|
+
const PERIODS = 80;
|
|
33
|
+
const STEP = 2;
|
|
34
|
+
const MARGIN_PX = 4; // gap between wave right-end and track start
|
|
35
|
+
const WAVE_START = 10; // threshold % below which wave collapses to line
|
|
41
36
|
|
|
42
|
-
|
|
37
|
+
// thickness-derived (computed so they react to prop changes)
|
|
38
|
+
const effectiveThickness = computed(() =>
|
|
39
|
+
props.thickness ?? (props.variant === "wavy" ? 3 : 4),
|
|
40
|
+
);
|
|
41
|
+
const waveMid = computed(() => AMP + effectiveThickness.value / 2);
|
|
42
|
+
const waveViewH = computed(() => 2 * AMP + effectiveThickness.value);
|
|
43
|
+
|
|
44
|
+
const waveWidth = PERIOD * PERIODS; // 1600px — covers any container width
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
// Static path for indeterminate — built once with the wavy default (thickness=3)
|
|
47
|
+
const STATIC_MID = AMP + 3 / 2; // = 4
|
|
48
|
+
const staticWavePath = (() => {
|
|
45
49
|
let d = "";
|
|
46
50
|
for (let x = 0; x <= waveWidth; x += STEP) {
|
|
47
|
-
const y =
|
|
48
|
-
d +=
|
|
51
|
+
const y = STATIC_MID - AMP * Math.sin((x / PERIOD) * Math.PI * 2);
|
|
52
|
+
d += `${x === 0 ? "M" : "L"}${x},${y.toFixed(2)} `;
|
|
49
53
|
}
|
|
50
54
|
return d.trim();
|
|
51
55
|
})();
|
|
56
|
+
|
|
57
|
+
// ── rAF state (plain JS — no Vue refs to avoid 60fps reactive overhead) ───
|
|
58
|
+
//
|
|
59
|
+
// Architecture mirrors MCircleProgressBar:
|
|
60
|
+
// • displayedValue follows clampedValue with exponential smoothing (~300ms)
|
|
61
|
+
// • appearanceF animates 0↔1 at the WAVE_START threshold and at 100%
|
|
62
|
+
// • animPhase advances every frame to scroll the wave leftward
|
|
63
|
+
// clip-path and track.left are written via direct DOM setAttribute/style
|
|
64
|
+
// to avoid the CSS-transition vs rAF conflict.
|
|
65
|
+
let displayedValue = 0;
|
|
66
|
+
let appearanceF = 0;
|
|
67
|
+
let animPhase = 0;
|
|
68
|
+
|
|
69
|
+
type Anim = { from: number; to: number; t0: number; dur: number };
|
|
70
|
+
const appearAnim: Anim = { from: 0, to: 0, t0: -1, dur: 500 };
|
|
71
|
+
|
|
72
|
+
function easeInOut(t: number) {
|
|
73
|
+
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
74
|
+
}
|
|
75
|
+
function startAnim(a: Anim, from: number, to: number, now: number) {
|
|
76
|
+
if (Math.abs(from - to) < 0.001) { a.t0 = -1; a.to = to; return; }
|
|
77
|
+
a.from = from; a.to = to; a.t0 = now;
|
|
78
|
+
}
|
|
79
|
+
function tickAnim(a: Anim, now: number): number {
|
|
80
|
+
if (a.t0 < 0) return a.to;
|
|
81
|
+
const t = Math.min(1, (now - a.t0) / a.dur);
|
|
82
|
+
const v = a.from + (a.to - a.from) * easeInOut(t);
|
|
83
|
+
if (t >= 1) a.t0 = -1;
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Template refs for direct DOM updates ─────────────────────────────────
|
|
88
|
+
const waveClipEl = ref<HTMLDivElement | null>(null);
|
|
89
|
+
const trackEl = ref<HTMLDivElement | null>(null);
|
|
90
|
+
const wavePath = ref(""); // updated in tick → drives path's :d binding
|
|
91
|
+
|
|
92
|
+
// ── rAF tick ─────────────────────────────────────────────────────────────
|
|
93
|
+
let lastTime: number | null = null;
|
|
94
|
+
let rafId = 0;
|
|
95
|
+
const PHASE_PER_MS = (2 * Math.PI) / 800; // one full PERIOD scroll per 800ms
|
|
96
|
+
const VALUE_TAU = 130; // exponential τ → ~300ms 90%-settle
|
|
97
|
+
|
|
98
|
+
function buildWavePath(factor: number, ph: number): string {
|
|
99
|
+
const mid = waveMid.value;
|
|
100
|
+
const pts: string[] = [];
|
|
101
|
+
for (let x = 0; x <= waveWidth; x += STEP) {
|
|
102
|
+
const y = mid - AMP * factor * Math.sin((x / PERIOD) * Math.PI * 2 + ph);
|
|
103
|
+
pts.push(`${x === 0 ? "M" : "L"}${x},${y.toFixed(2)}`);
|
|
104
|
+
}
|
|
105
|
+
return pts.join(" ");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tick(now: number) {
|
|
109
|
+
const dt = lastTime !== null ? now - lastTime : 0;
|
|
110
|
+
lastTime = now;
|
|
111
|
+
|
|
112
|
+
// 1. Wave phase scrolls leftward
|
|
113
|
+
animPhase -= dt * PHASE_PER_MS;
|
|
114
|
+
|
|
115
|
+
// 2. Smooth displayed value (exponential interpolation)
|
|
116
|
+
if (dt > 0) {
|
|
117
|
+
const k = 1 - Math.exp(-dt / VALUE_TAU);
|
|
118
|
+
displayedValue += (clampedValue.value - displayedValue) * k;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 3. Advance appearance animation
|
|
122
|
+
appearanceF = tickAnim(appearAnim, now);
|
|
123
|
+
|
|
124
|
+
// 4. Update DOM directly
|
|
125
|
+
const margin = MARGIN_PX * appearanceF;
|
|
126
|
+
const dv = displayedValue;
|
|
127
|
+
|
|
128
|
+
if (waveClipEl.value)
|
|
129
|
+
waveClipEl.value.style.clipPath = `inset(0 ${(100 - dv).toFixed(3)}% 0 0)`;
|
|
130
|
+
|
|
131
|
+
if (trackEl.value) {
|
|
132
|
+
trackEl.value.style.left = `calc(${dv.toFixed(3)}% + ${margin.toFixed(2)}px)`;
|
|
133
|
+
trackEl.value.style.height = `${effectiveThickness.value}px`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 5. Rebuild wave path (drives :d via Vue ref)
|
|
137
|
+
wavePath.value = buildWavePath(appearanceF, animPhase);
|
|
138
|
+
|
|
139
|
+
rafId = requestAnimationFrame(tick);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Watchers ─────────────────────────────────────────────────────────────
|
|
143
|
+
watch(clampedValue, (v, prev) => {
|
|
144
|
+
if (props.variant !== "wavy" || isIndeterminate.value) return;
|
|
145
|
+
const now = performance.now();
|
|
146
|
+
|
|
147
|
+
if (prev <= WAVE_START && v > WAVE_START && v < 100) startAnim(appearAnim, appearanceF, 1, now);
|
|
148
|
+
else if (prev > WAVE_START && v <= WAVE_START) startAnim(appearAnim, appearanceF, 0, now);
|
|
149
|
+
else if (v === 100) startAnim(appearAnim, appearanceF, 0, now);
|
|
150
|
+
else if (prev === 100 && v > WAVE_START) startAnim(appearAnim, appearanceF, 1, now);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
function initAndStart() {
|
|
154
|
+
displayedValue = clampedValue.value;
|
|
155
|
+
const v = clampedValue.value;
|
|
156
|
+
appearanceF = (v > WAVE_START && v < 100) ? 1 : 0;
|
|
157
|
+
appearAnim.to = appearanceF;
|
|
158
|
+
appearAnim.t0 = -1;
|
|
159
|
+
|
|
160
|
+
// Set initial DOM values immediately to avoid a one-frame flash
|
|
161
|
+
const margin = MARGIN_PX * appearanceF;
|
|
162
|
+
if (waveClipEl.value)
|
|
163
|
+
waveClipEl.value.style.clipPath = `inset(0 ${(100 - v).toFixed(3)}% 0 0)`;
|
|
164
|
+
if (trackEl.value) {
|
|
165
|
+
trackEl.value.style.left = `calc(${v.toFixed(3)}% + ${margin.toFixed(2)}px)`;
|
|
166
|
+
trackEl.value.style.height = `${effectiveThickness.value}px`;
|
|
167
|
+
}
|
|
168
|
+
wavePath.value = buildWavePath(appearanceF, animPhase);
|
|
169
|
+
|
|
170
|
+
if (!rafId) rafId = requestAnimationFrame(tick);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stopRaf() {
|
|
174
|
+
cancelAnimationFrame(rafId);
|
|
175
|
+
rafId = 0; lastTime = null;
|
|
176
|
+
appearAnim.t0 = -1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
onMounted(() => {
|
|
180
|
+
if (props.variant === "wavy" && !isIndeterminate.value) initAndStart();
|
|
181
|
+
});
|
|
182
|
+
onUnmounted(stopRaf);
|
|
183
|
+
|
|
184
|
+
watch(() => props.variant, async (v) => {
|
|
185
|
+
if (v === "wavy" && !isIndeterminate.value) { await nextTick(); initAndStart(); }
|
|
186
|
+
else stopRaf();
|
|
187
|
+
});
|
|
188
|
+
watch(isIndeterminate, async (v) => {
|
|
189
|
+
if (!v && props.variant === "wavy") { await nextTick(); initAndStart(); }
|
|
190
|
+
else stopRaf();
|
|
191
|
+
});
|
|
52
192
|
</script>
|
|
53
193
|
|
|
54
194
|
<template>
|
|
@@ -58,8 +198,9 @@ const wavePath = (() => {
|
|
|
58
198
|
<!-- ── Linear variant ────────────────────────────────────────────────── -->
|
|
59
199
|
<div
|
|
60
200
|
v-if="variant === 'linear'"
|
|
61
|
-
class="relative
|
|
201
|
+
class="relative w-full overflow-hidden rounded-full"
|
|
62
202
|
:class="colorMap[color].track"
|
|
203
|
+
:style="{ height: `${effectiveThickness}px` }"
|
|
63
204
|
role="progressbar"
|
|
64
205
|
:aria-valuenow="isIndeterminate ? undefined : clampedValue"
|
|
65
206
|
aria-valuemin="0"
|
|
@@ -81,54 +222,47 @@ const wavePath = (() => {
|
|
|
81
222
|
<!-- ── Wavy variant ───────────────────────────────────────────────────── -->
|
|
82
223
|
<div
|
|
83
224
|
v-else
|
|
84
|
-
class="relative
|
|
225
|
+
class="relative w-full overflow-visible"
|
|
226
|
+
:style="{ height: `${waveViewH}px` }"
|
|
85
227
|
role="progressbar"
|
|
86
228
|
:aria-valuenow="isIndeterminate ? undefined : clampedValue"
|
|
87
229
|
aria-valuemin="0"
|
|
88
230
|
aria-valuemax="100"
|
|
89
231
|
>
|
|
90
|
-
<!-- DETERMINATE -->
|
|
232
|
+
<!-- DETERMINATE — rAF drives clip-path, track.left, and wave path -->
|
|
91
233
|
<template v-if="!isIndeterminate">
|
|
92
|
-
<!-- Active
|
|
234
|
+
<!-- Active wave, clipped to displayedValue% -->
|
|
93
235
|
<div
|
|
236
|
+
ref="waveClipEl"
|
|
94
237
|
class="absolute inset-0 overflow-hidden"
|
|
95
|
-
|
|
96
|
-
clipPath: `inset(0 ${100 - clampedValue}% 0 0)`,
|
|
97
|
-
transition: 'clip-path 300ms ease',
|
|
98
|
-
}"
|
|
238
|
+
style="clip-path: inset(0 100% 0 0)"
|
|
99
239
|
>
|
|
100
|
-
<
|
|
101
|
-
class="absolute top-0 left-0 h-full
|
|
240
|
+
<svg
|
|
241
|
+
class="absolute top-0 left-0 h-full"
|
|
102
242
|
:class="colorMap[color].text"
|
|
103
|
-
:
|
|
243
|
+
:width="waveWidth"
|
|
244
|
+
:height="waveViewH"
|
|
245
|
+
:viewBox="`0 0 ${waveWidth} ${waveViewH}`"
|
|
104
246
|
>
|
|
105
|
-
<
|
|
106
|
-
:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:d="wavePath"
|
|
114
|
-
fill="none"
|
|
115
|
-
stroke="currentColor"
|
|
116
|
-
stroke-width="3"
|
|
117
|
-
stroke-linecap="round"
|
|
118
|
-
/>
|
|
119
|
-
</svg>
|
|
120
|
-
</div>
|
|
247
|
+
<path
|
|
248
|
+
:d="wavePath"
|
|
249
|
+
fill="none"
|
|
250
|
+
stroke="currentColor"
|
|
251
|
+
:stroke-width="effectiveThickness"
|
|
252
|
+
stroke-linecap="round"
|
|
253
|
+
/>
|
|
254
|
+
</svg>
|
|
121
255
|
</div>
|
|
122
256
|
|
|
123
|
-
<!-- Inactive
|
|
257
|
+
<!-- Inactive track — rAF owns left and height; no Vue-managed left -->
|
|
124
258
|
<div
|
|
125
|
-
|
|
259
|
+
ref="trackEl"
|
|
260
|
+
class="absolute right-0"
|
|
126
261
|
:class="colorMap[color].track"
|
|
127
|
-
|
|
128
|
-
style="border-radius: 9999px; height: 4px; top: 50%; transform: translateY(-50%)"
|
|
262
|
+
style="border-radius: 9999px; top: 50%; transform: translateY(-50%);"
|
|
129
263
|
/>
|
|
130
264
|
|
|
131
|
-
<!-- Stop
|
|
265
|
+
<!-- Stop dot at far right -->
|
|
132
266
|
<div
|
|
133
267
|
class="absolute rounded-full"
|
|
134
268
|
:class="colorMap[color].bar"
|
|
@@ -136,13 +270,13 @@ const wavePath = (() => {
|
|
|
136
270
|
right: '0',
|
|
137
271
|
top: '50%',
|
|
138
272
|
transform: 'translateY(-50%)',
|
|
139
|
-
width:
|
|
140
|
-
height:
|
|
273
|
+
width: `${effectiveThickness}px`,
|
|
274
|
+
height: `${effectiveThickness}px`,
|
|
141
275
|
}"
|
|
142
276
|
/>
|
|
143
277
|
</template>
|
|
144
278
|
|
|
145
|
-
<!-- INDETERMINATE -->
|
|
279
|
+
<!-- INDETERMINATE — keeps original CSS-animated wide strip -->
|
|
146
280
|
<div v-else class="absolute inset-0 overflow-hidden rounded-full">
|
|
147
281
|
<div
|
|
148
282
|
class="absolute top-0 left-0 h-full animate-[m3-wave-flow_0.9s_linear_infinite]"
|
|
@@ -151,16 +285,16 @@ const wavePath = (() => {
|
|
|
151
285
|
>
|
|
152
286
|
<svg
|
|
153
287
|
:width="waveWidth"
|
|
154
|
-
:height="
|
|
155
|
-
:viewBox="`0 0 ${waveWidth} ${
|
|
288
|
+
:height="waveViewH"
|
|
289
|
+
:viewBox="`0 0 ${waveWidth} ${waveViewH}`"
|
|
156
290
|
class="h-full"
|
|
157
291
|
xmlns="http://www.w3.org/2000/svg"
|
|
158
292
|
>
|
|
159
293
|
<path
|
|
160
|
-
:d="
|
|
294
|
+
:d="staticWavePath"
|
|
161
295
|
fill="none"
|
|
162
296
|
stroke="currentColor"
|
|
163
|
-
stroke-width="
|
|
297
|
+
:stroke-width="effectiveThickness"
|
|
164
298
|
stroke-linecap="round"
|
|
165
299
|
/>
|
|
166
300
|
</svg>
|
|
@@ -171,23 +305,14 @@ const wavePath = (() => {
|
|
|
171
305
|
</template>
|
|
172
306
|
|
|
173
307
|
<style>
|
|
174
|
-
/* Scroll exactly one period (20px) so the loop is perfectly seamless. */
|
|
175
308
|
@keyframes m3-wave-flow {
|
|
176
|
-
from {
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
to {
|
|
180
|
-
transform: translateX(-20px);
|
|
181
|
-
}
|
|
309
|
+
from { transform: translateX(0); }
|
|
310
|
+
to { transform: translateX(-20px); }
|
|
182
311
|
}
|
|
183
312
|
|
|
184
313
|
@keyframes m3-progress-indeterminate {
|
|
185
|
-
0%
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
100% {
|
|
189
|
-
left: 100%;
|
|
190
|
-
}
|
|
314
|
+
0% { left: -40%; }
|
|
315
|
+
100% { left: 100%; }
|
|
191
316
|
}
|
|
192
317
|
|
|
193
318
|
@media (prefers-reduced-motion: reduce) {
|