@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.
Files changed (57) hide show
  1. package/dist/{MMenuItem-COdgD4m7.js → MMenuItem-Dw2ARZic.js} +30 -24
  2. package/dist/{MMenuItem-COdgD4m7.js.map → MMenuItem-Dw2ARZic.js.map} +1 -1
  3. package/dist/components/MAutocomplete.vue.d.ts +2 -1
  4. package/dist/components/MCircleProgressBar.vue.d.ts +27 -0
  5. package/dist/components/MColorPicker.vue.d.ts +2 -1
  6. package/dist/components/MContextMenu.vue.d.ts +7 -21
  7. package/dist/components/MDatePicker.vue.d.ts +2 -1
  8. package/dist/components/MDateRangePicker.vue.d.ts +2 -1
  9. package/dist/components/MMaskField.vue.d.ts +2 -1
  10. package/dist/components/MMenuDivider.vue.d.ts +3 -0
  11. package/dist/components/MMenuItem.vue.d.ts +1 -0
  12. package/dist/components/MMultiAutocomplete.vue.d.ts +2 -1
  13. package/dist/components/MMultiSelect.vue.d.ts +2 -1
  14. package/dist/components/MNumberField.vue.d.ts +2 -1
  15. package/dist/components/MProgressBar.vue.d.ts +1 -0
  16. package/dist/components/MSelect.vue.d.ts +2 -1
  17. package/dist/components/MTagInput.vue.d.ts +2 -1
  18. package/dist/components/MTextField.vue.d.ts +2 -1
  19. package/dist/components/MTimePicker.vue.d.ts +2 -1
  20. package/dist/composables/useNotification.d.ts +7 -4
  21. package/dist/composables/useToast.d.ts +7 -4
  22. package/dist/index.d.ts +2 -1
  23. package/dist/m3ui-vue.css +1 -1
  24. package/dist/m3ui.js +1291 -1026
  25. package/dist/m3ui.js.map +1 -1
  26. package/dist/rich-text-editor.js +1 -1
  27. package/dist/styles.css +1 -1
  28. package/package.json +1 -1
  29. package/src/components/MAbsolute.vue +3 -2
  30. package/src/components/MAutocomplete.vue +3 -2
  31. package/src/components/MCircleProgressBar.vue +354 -0
  32. package/src/components/MColorPicker.vue +4 -3
  33. package/src/components/MColorPickerModal.vue +1 -1
  34. package/src/components/MContextMenu.vue +51 -38
  35. package/src/components/MDatePicker.vue +3 -2
  36. package/src/components/MDateRangePicker.vue +3 -2
  37. package/src/components/MMaskField.vue +3 -2
  38. package/src/components/MMenuDivider.vue +3 -0
  39. package/src/components/MMenuItem.vue +2 -0
  40. package/src/components/MMultiAutocomplete.vue +3 -2
  41. package/src/components/MMultiSelect.vue +3 -2
  42. package/src/components/MNotificationHost.vue +22 -2
  43. package/src/components/MNumberField.vue +3 -2
  44. package/src/components/MProgressBar.vue +200 -75
  45. package/src/components/MSelect.vue +3 -2
  46. package/src/components/MSlider.vue +20 -16
  47. package/src/components/MSnackbar.vue +24 -2
  48. package/src/components/MTabs.vue +1 -1
  49. package/src/components/MTagInput.vue +3 -2
  50. package/src/components/MTextField.vue +3 -2
  51. package/src/components/MTimePicker.vue +3 -2
  52. package/src/components/MWindow.vue +5 -5
  53. package/src/composables/useNotification.ts +21 -4
  54. package/src/composables/useToast.ts +21 -4
  55. package/src/index.ts +2 -1
  56. package/dist/components/_MContextMenuPanel.vue.d.ts +0 -13
  57. 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 position = ref({ x: 0, y: 0 })
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
- <slot :show="show" />
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
- class="fixed inset-0 z-200"
55
- @mousedown.self="hide"
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
- <MContextMenuPanel
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?: string
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">{{ error }}</p>
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?: string
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">{{ error }}</p>
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?: string
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">{{ error }}</p>
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>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <hr class="my-1 border-outline-variant" />
3
+ </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?: string
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">{{ error }}</p>
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?: string
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">{{ error }}</p>
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?: string
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">{{ error }}</p>
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 = computed(() => Math.min(100, Math.max(0, props.value ?? 0)));
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: { bar: "bg-primary", track: "bg-primary-container", text: "text-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: { bar: "bg-tertiary", track: "bg-tertiary-container", text: "text-tertiary" },
28
- error: { bar: "bg-error", track: "bg-error-container", text: "text-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
- // Smooth sine wave sampled as a single polyline path.
33
- // Period = 20px (one full up-down cycle). We render a wide strip so that
34
- // translating by exactly one period gives a seamless infinite scroll.
35
- const PERIOD = 20; // px per full sine cycle
36
- const AMP = 2.5; // amplitude (bar is 8px tall, mid at 4)
37
- const MID = 4;
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
- const waveWidth = PERIOD * PERIODS;
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
- const wavePath = (() => {
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 = MID - AMP * Math.sin((x / PERIOD) * Math.PI * 2);
48
- d += (x === 0 ? "M" : "L") + x + "," + y.toFixed(2) + " ";
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 h-1 w-full overflow-hidden rounded-full"
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 h-2 w-full overflow-visible"
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 (wavy) portion: clipped to value%, but the wave keeps flowing -->
234
+ <!-- Active wave, clipped to displayedValue% -->
93
235
  <div
236
+ ref="waveClipEl"
94
237
  class="absolute inset-0 overflow-hidden"
95
- :style="{
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
- <div
101
- class="absolute top-0 left-0 h-full animate-[m3-wave-flow_0.8s_linear_infinite]"
240
+ <svg
241
+ class="absolute top-0 left-0 h-full"
102
242
  :class="colorMap[color].text"
103
- :style="{ width: `${waveWidth}px` }"
243
+ :width="waveWidth"
244
+ :height="waveViewH"
245
+ :viewBox="`0 0 ${waveWidth} ${waveViewH}`"
104
246
  >
105
- <svg
106
- :width="waveWidth"
107
- :height="VIEW_H"
108
- :viewBox="`0 0 ${waveWidth} ${VIEW_H}`"
109
- class="h-full"
110
- xmlns="http://www.w3.org/2000/svg"
111
- >
112
- <path
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 (straight track) portion -->
257
+ <!-- Inactive track rAF owns left and height; no Vue-managed left -->
124
258
  <div
125
- class="absolute inset-y-0 right-0 flex items-center"
259
+ ref="trackEl"
260
+ class="absolute right-0"
126
261
  :class="colorMap[color].track"
127
- :style="{ left: `calc(${clampedValue}% + 4px)`, transition: 'left 300ms ease' }"
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 indicator (dot at the end of the track) -->
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: '4px',
140
- height: '4px',
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="VIEW_H"
155
- :viewBox="`0 0 ${waveWidth} ${VIEW_H}`"
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="wavePath"
294
+ :d="staticWavePath"
161
295
  fill="none"
162
296
  stroke="currentColor"
163
- stroke-width="3"
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
- transform: translateX(0);
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
- left: -40%;
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) {