@m3ui-vue/m3ui-vue 0.4.6 → 0.4.7
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/components/MCircleProgressBar.vue.d.ts +27 -0
- package/dist/components/MProgressBar.vue.d.ts +1 -0
- package/dist/composables/useNotification.d.ts +7 -4
- package/dist/composables/useToast.d.ts +7 -4
- package/dist/index.d.ts +1 -0
- package/dist/m3ui-vue.css +1 -1
- package/dist/m3ui.js +1316 -983
- package/dist/m3ui.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/MCircleProgressBar.vue +352 -0
- package/src/components/MColorPicker.vue +1 -1
- package/src/components/MColorPickerModal.vue +1 -1
- package/src/components/MNotificationHost.vue +22 -2
- package/src/components/MProgressBar.vue +200 -75
- package/src/components/MSnackbar.vue +24 -2
- package/src/composables/useNotification.ts +21 -4
- package/src/composables/useToast.ts +21 -4
- package/src/index.ts +1 -0
|
@@ -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) {
|
|
@@ -90,7 +90,18 @@ const getVariantStyle = (variant: string): VariantStyle =>
|
|
|
90
90
|
<template>
|
|
91
91
|
<div :class="containerClass">
|
|
92
92
|
<TransitionGroup :name="isTop ? 'm3-toast-top' : 'm3-toast-bot'">
|
|
93
|
-
<div v-for="t in toasts" :key="t.id" class="toast-row w-full min-w-64 max-w-xs">
|
|
93
|
+
<div v-for="t in toasts" :key="t.id" class="toast-row relative w-full min-w-64 max-w-xs">
|
|
94
|
+
<Transition name="m3-badge">
|
|
95
|
+
<span
|
|
96
|
+
v-if="t.count >= 2"
|
|
97
|
+
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"
|
|
98
|
+
>
|
|
99
|
+
<Transition name="m3-count" mode="out-in">
|
|
100
|
+
<span :key="t.count">{{ t.count }}</span>
|
|
101
|
+
</Transition>
|
|
102
|
+
</span>
|
|
103
|
+
</Transition>
|
|
104
|
+
|
|
94
105
|
<div
|
|
95
106
|
class="toast-inner pointer-events-auto relative flex items-center gap-3 overflow-hidden rounded-2xl px-4 py-4 shadow-elevation-2"
|
|
96
107
|
:class="t.color ? 'text-white ring-1 ring-inset ring-white/10' : getVariantStyle(t.variant).container"
|
|
@@ -98,7 +109,7 @@ const getVariantStyle = (variant: string): VariantStyle =>
|
|
|
98
109
|
>
|
|
99
110
|
<MSpinner v-if="t.loading" :size="20" class="shrink-0" />
|
|
100
111
|
<MIcon
|
|
101
|
-
v-else
|
|
112
|
+
v-else-if="t.icon !== null"
|
|
102
113
|
:name="t.icon ?? getVariantStyle(t.variant).iconName"
|
|
103
114
|
:size="20"
|
|
104
115
|
class="shrink-0"
|
|
@@ -244,6 +255,17 @@ const getVariantStyle = (variant: string): VariantStyle =>
|
|
|
244
255
|
transform: scale(0.92);
|
|
245
256
|
}
|
|
246
257
|
|
|
258
|
+
/* badge appear/disappear */
|
|
259
|
+
.m3-badge-enter-active { transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
260
|
+
.m3-badge-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
|
261
|
+
.m3-badge-enter-from, .m3-badge-leave-to { opacity: 0; transform: scale(0.5); }
|
|
262
|
+
|
|
263
|
+
/* count number bump when incrementing */
|
|
264
|
+
.m3-count-enter-active { transition: opacity 0.12s ease, transform 0.12s ease; }
|
|
265
|
+
.m3-count-leave-active { transition: opacity 0.08s ease, transform 0.08s ease; }
|
|
266
|
+
.m3-count-enter-from { opacity: 0; transform: scale(1.5) translateY(-3px); }
|
|
267
|
+
.m3-count-leave-to { opacity: 0; transform: scale(0.6) translateY(2px); }
|
|
268
|
+
|
|
247
269
|
@keyframes m3-toast-progress {
|
|
248
270
|
from {
|
|
249
271
|
transform: scaleX(1);
|
|
@@ -14,9 +14,10 @@ export interface Notification {
|
|
|
14
14
|
variant: NotificationVariant
|
|
15
15
|
duration: number
|
|
16
16
|
loading?: boolean
|
|
17
|
-
icon?: string
|
|
17
|
+
icon?: string | null // null = suppress icon entirely
|
|
18
18
|
closable?: boolean
|
|
19
19
|
action?: NotificationAction
|
|
20
|
+
count: number // ≥ 2 → show badge
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
let nextId = 1
|
|
@@ -44,7 +45,7 @@ function dismiss(id: number) {
|
|
|
44
45
|
export interface NotificationOptions {
|
|
45
46
|
duration?: number
|
|
46
47
|
loading?: boolean
|
|
47
|
-
icon?: string
|
|
48
|
+
icon?: string | null
|
|
48
49
|
closable?: boolean
|
|
49
50
|
action?: NotificationAction
|
|
50
51
|
}
|
|
@@ -55,11 +56,27 @@ function show(
|
|
|
55
56
|
options: NotificationOptions = {},
|
|
56
57
|
) {
|
|
57
58
|
ensureMounted()
|
|
58
|
-
const id = nextId++
|
|
59
59
|
const isLoading = options.loading ?? false
|
|
60
|
+
|
|
61
|
+
// Dedup: identical non-loading, non-action notifications increment count
|
|
62
|
+
if (!isLoading && !options.action) {
|
|
63
|
+
const existing = notifications.value.find(
|
|
64
|
+
n => n.message === message && n.variant === variant && !n.loading && !n.action,
|
|
65
|
+
)
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.count++
|
|
68
|
+
const old = timers.get(existing.id)
|
|
69
|
+
if (old) clearTimeout(old)
|
|
70
|
+
if (existing.duration > 0)
|
|
71
|
+
timers.set(existing.id, setTimeout(() => dismiss(existing.id), existing.duration))
|
|
72
|
+
return existing.id
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const id = nextId++
|
|
60
77
|
const duration = isLoading ? 0 : (options.duration ?? 3000)
|
|
61
78
|
const closable = options.closable ?? true
|
|
62
|
-
notifications.value.push({ id, message, variant, duration, loading: isLoading, icon: options.icon, closable, action: options.action })
|
|
79
|
+
notifications.value.push({ id, message, variant, duration, loading: isLoading, icon: options.icon, closable, action: options.action, count: 1 })
|
|
63
80
|
if (duration > 0) timers.set(id, setTimeout(() => dismiss(id), duration))
|
|
64
81
|
return id
|
|
65
82
|
}
|
|
@@ -15,8 +15,9 @@ export interface Toast {
|
|
|
15
15
|
duration: number
|
|
16
16
|
loading?: boolean
|
|
17
17
|
action?: ToastAction
|
|
18
|
-
icon?: string
|
|
18
|
+
icon?: string | null // null = suppress icon entirely
|
|
19
19
|
color?: string
|
|
20
|
+
count: number // ≥ 2 → show badge
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
let nextId = 1
|
|
@@ -45,7 +46,7 @@ export interface ToastOptions {
|
|
|
45
46
|
duration?: number
|
|
46
47
|
loading?: boolean
|
|
47
48
|
action?: ToastAction
|
|
48
|
-
icon?: string
|
|
49
|
+
icon?: string | null
|
|
49
50
|
color?: string
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -55,11 +56,27 @@ function show(
|
|
|
55
56
|
options: number | ToastOptions = {},
|
|
56
57
|
) {
|
|
57
58
|
ensureMounted()
|
|
58
|
-
const id = nextId++
|
|
59
59
|
const opts = typeof options === 'number' ? { duration: options } : options
|
|
60
60
|
const isLoading = opts.loading ?? false
|
|
61
|
+
|
|
62
|
+
// Dedup: identical non-loading, non-action toasts increment count instead of stacking
|
|
63
|
+
if (!isLoading && !opts.action) {
|
|
64
|
+
const existing = toasts.value.find(
|
|
65
|
+
t => t.message === message && t.variant === variant && !t.loading && !t.action,
|
|
66
|
+
)
|
|
67
|
+
if (existing) {
|
|
68
|
+
existing.count++
|
|
69
|
+
const old = timers.get(existing.id)
|
|
70
|
+
if (old) clearTimeout(old)
|
|
71
|
+
if (existing.duration > 0)
|
|
72
|
+
timers.set(existing.id, setTimeout(() => dismiss(existing.id), existing.duration))
|
|
73
|
+
return existing.id
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const id = nextId++
|
|
61
78
|
const duration = isLoading ? 0 : (opts.duration ?? (variant === 'error' ? 6000 : 4000))
|
|
62
|
-
toasts.value.push({ id, message, variant, duration, loading: isLoading, action: opts.action, icon: opts.icon, color: opts.color })
|
|
79
|
+
toasts.value.push({ id, message, variant, duration, loading: isLoading, action: opts.action, icon: opts.icon, color: opts.color, count: 1 })
|
|
63
80
|
if (duration > 0) timers.set(id, setTimeout(() => dismiss(id), duration))
|
|
64
81
|
return id
|
|
65
82
|
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ export { default as MNavigationRail } from './components/MNavigationRail.vue'
|
|
|
94
94
|
export { default as MOverlay } from './components/MOverlay.vue'
|
|
95
95
|
export { default as MPagination } from './components/MPagination.vue'
|
|
96
96
|
export { default as MProgressBar } from './components/MProgressBar.vue'
|
|
97
|
+
export { default as MCircleProgressBar } from './components/MCircleProgressBar.vue'
|
|
97
98
|
export { default as MRadio } from './components/MRadio.vue'
|
|
98
99
|
export { default as MRadioGroup } from './components/MRadioGroup.vue'
|
|
99
100
|
export { default as MRating } from './components/MRating.vue'
|