@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
@@ -20,7 +20,8 @@ const props = withDefaults(
20
20
  variant?: 'filled' | 'outlined'
21
21
  mode?: 'docked' | 'modal'
22
22
  disabled?: boolean
23
- error?: string
23
+ error?: boolean
24
+ errorLabel?: string
24
25
  hint?: string
25
26
  required?: boolean
26
27
  leadingIcon?: string
@@ -247,7 +248,7 @@ const labelClasses = computed(() => {
247
248
  </div>
248
249
  </div>
249
250
 
250
- <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
251
+ <p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
251
252
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
252
253
  </div>
253
254
 
@@ -37,7 +37,7 @@ const props = withDefaults(
37
37
  const emit = defineEmits<{ 'update:modelValue': [number | [number, number]] }>()
38
38
 
39
39
  const trackEl = ref<HTMLElement>()
40
- const dragging = ref<false | 'single' | 'lo' | 'hi'>(false)
40
+ const dragging = ref<false | 'single' | 0 | 1>(false)
41
41
  const isVertical = computed(() => props.orientation === 'vertical')
42
42
  const isRange = computed(() => props.variant === 'range')
43
43
  const isCentered = computed(() => props.variant === 'centered')
@@ -100,11 +100,14 @@ function onPointerDown(e: PointerEvent) {
100
100
  e.preventDefault()
101
101
  const v = valueFromEvent(e)
102
102
  if (isRange.value) {
103
- const distLo = Math.abs(v - val.value.lo)
104
- const distHi = Math.abs(v - val.value.hi)
105
- dragging.value = distLo <= distHi ? 'lo' : 'hi'
106
103
  const cur = props.modelValue as [number, number]
107
- emit('update:modelValue', dragging.value === 'lo' ? [v, cur[1]] : [cur[0], v])
104
+ const dist0 = Math.abs(v - cur[0])
105
+ const dist1 = Math.abs(v - cur[1])
106
+ const idx: 0 | 1 = dist0 <= dist1 ? 0 : 1
107
+ dragging.value = idx
108
+ const next: [number, number] = [cur[0], cur[1]]
109
+ next[idx] = v
110
+ emit('update:modelValue', next)
108
111
  } else {
109
112
  dragging.value = 'single'
110
113
  emit('update:modelValue', v)
@@ -115,11 +118,13 @@ function onPointerDown(e: PointerEvent) {
115
118
  }
116
119
 
117
120
  function onPointerMove(e: PointerEvent) {
118
- if (!dragging.value) return
121
+ if (dragging.value === false) return
119
122
  const v = valueFromEvent(e)
120
123
  if (isRange.value) {
121
124
  const cur = props.modelValue as [number, number]
122
- emit('update:modelValue', dragging.value === 'lo' ? [v, cur[1]] : [cur[0], v])
125
+ const next: [number, number] = [cur[0], cur[1]]
126
+ next[dragging.value as 0 | 1] = v
127
+ emit('update:modelValue', next)
123
128
  } else {
124
129
  emit('update:modelValue', v)
125
130
  }
@@ -169,7 +174,7 @@ const stopPositions = computed(() => {
169
174
  })
170
175
 
171
176
  const r = computed(() => s.value.radius)
172
- const nd = computed(() => !dragging.value)
177
+ const nd = computed(() => dragging.value === false)
173
178
  const tr = computed(() => nd.value ? '75ms ease' : '0s')
174
179
 
175
180
  function thumbPos(pct: number) {
@@ -188,11 +193,11 @@ function thumbPos(pct: number) {
188
193
  if (isVertical.value) {
189
194
  base.left = '50%'
190
195
  base.bottom = `${pct}%`
191
- base.transform = `translateX(-50%) translateY(50%) scaleX(${dragging.value ? 1.08 : 1})`
196
+ base.transform = `translateX(-50%) translateY(50%) scaleX(${dragging.value !== false ? 1.08 : 1})`
192
197
  } else {
193
198
  base.left = `${pct}%`
194
199
  base.top = '50%'
195
- base.transform = `translateX(-50%) translateY(-50%) scaleY(${dragging.value ? 1.08 : 1})`
200
+ base.transform = `translateX(-50%) translateY(-50%) scaleY(${dragging.value !== false ? 1.08 : 1})`
196
201
  }
197
202
  return base
198
203
  }
@@ -212,15 +217,14 @@ const displayValue = computed(() => {
212
217
  })
213
218
 
214
219
  const tooltipValue = computed(() => {
215
- if (isRange.value) {
216
- if (dragging.value === 'lo') return val.value.lo
217
- return val.value.hi
218
- }
220
+ if (isRange.value && typeof dragging.value === 'number')
221
+ return (props.modelValue as [number, number])[dragging.value]
219
222
  return props.modelValue
220
223
  })
221
224
 
222
225
  const tooltipPct = computed(() => {
223
- if (isRange.value) return dragging.value === 'lo' ? pctLo.value : pctHi.value
226
+ if (isRange.value && typeof dragging.value === 'number')
227
+ return toPct((props.modelValue as [number, number])[dragging.value])
224
228
  return pctLo.value
225
229
  })
226
230
  </script>
@@ -408,7 +412,7 @@ const tooltipPct = computed(() => {
408
412
  leave-to-class="opacity-0"
409
413
  >
410
414
  <div
411
- v-if="showTooltip && dragging"
415
+ v-if="showTooltip && dragging !== false"
412
416
  class="pointer-events-none absolute z-10 flex h-7 min-w-7 items-center justify-center rounded-full bg-inverse-surface px-2 text-label-small tabular-nums text-inverse-on-surface"
413
417
  :style="tooltipPos(tooltipPct)"
414
418
  >
@@ -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);
@@ -103,7 +103,7 @@ function select(tab: Tab) {
103
103
  width: `${indicatorWidth}px`,
104
104
  transition: stretching
105
105
  ? 'left 150ms cubic-bezier(0.4, 0, 0.2, 1), width 150ms cubic-bezier(0.4, 0, 0.2, 1)'
106
- : 'left 250ms cubic-bezier(0.34, 1.56, 0.64, 1), width 250ms cubic-bezier(0.34, 1.56, 0.64, 1)',
106
+ : 'left 180ms cubic-bezier(0.34, 1.4, 0.64, 1), width 180ms cubic-bezier(0.34, 1.4, 0.64, 1)',
107
107
  }"
108
108
  />
109
109
  </div>
@@ -11,7 +11,8 @@ const props = withDefaults(
11
11
  placeholder?: string
12
12
  variant?: 'filled' | 'outlined'
13
13
  disabled?: boolean
14
- error?: string
14
+ error?: boolean
15
+ errorLabel?: string
15
16
  hint?: string
16
17
  required?: boolean
17
18
  leadingIcon?: string
@@ -235,7 +236,7 @@ const labelClasses = computed(() => {
235
236
  </button>
236
237
  </div>
237
238
 
238
- <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
239
+ <p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
239
240
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
240
241
  </div>
241
242
  </template>
@@ -10,7 +10,8 @@ const props = withDefaults(
10
10
  label: string;
11
11
  type?: string;
12
12
  variant?: "filled" | "outlined";
13
- error?: string;
13
+ error?: boolean;
14
+ errorLabel?: string;
14
15
  hint?: string;
15
16
  disabled?: boolean;
16
17
  required?: boolean;
@@ -188,7 +189,7 @@ function onInput(event: Event) {
188
189
  </button>
189
190
  </div>
190
191
 
191
- <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
192
+ <p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
192
193
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
193
194
  </div>
194
195
  </template>
@@ -9,7 +9,8 @@ const props = withDefaults(defineProps<{
9
9
  label?: string
10
10
  placeholder?: string
11
11
  disabled?: boolean
12
- error?: string
12
+ error?: boolean
13
+ errorLabel?: string
13
14
  hint?: string
14
15
  minuteStep?: number
15
16
  use24h?: boolean
@@ -148,7 +149,7 @@ onUnmounted(() => {
148
149
  </label>
149
150
  </div>
150
151
 
151
- <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
152
+ <p v-if="error && errorLabel" class="px-4 text-body-small text-error">{{ errorLabel }}</p>
152
153
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
153
154
 
154
155
  <Teleport to="body">
@@ -257,11 +257,11 @@ onUnmounted(() => {
257
257
 
258
258
  // Resize handle definitions
259
259
  const resizeHandles: { dir: ResizeDirection; class: string; cursor: string }[] = [
260
- { dir: 's', class: 'bottom-0 left-[10px] right-[10px] h-[5px]', cursor: 'cursor-ns-resize' },
261
- { dir: 'e', class: 'top-[10px] right-0 bottom-[10px] w-[5px]', cursor: 'cursor-ew-resize' },
262
- { dir: 'w', class: 'top-[10px] left-0 bottom-[10px] w-[5px]', cursor: 'cursor-ew-resize' },
263
- { dir: 'se', class: 'bottom-0 right-0 w-[12px] h-[12px]', cursor: 'cursor-nwse-resize' },
264
- { dir: 'sw', class: 'bottom-0 left-0 w-[12px] h-[12px]', cursor: 'cursor-nesw-resize' },
260
+ { dir: 's', class: 'bottom-0 left-[20px] right-[20px] h-[8px]', cursor: 'cursor-ns-resize' },
261
+ { dir: 'e', class: 'top-[20px] right-0 bottom-[20px] w-[8px]', cursor: 'cursor-ew-resize' },
262
+ { dir: 'w', class: 'top-[20px] left-0 bottom-[20px] w-[8px]', cursor: 'cursor-ew-resize' },
263
+ { dir: 'se', class: 'bottom-0 right-0 w-[20px] h-[20px]', cursor: 'cursor-nwse-resize' },
264
+ { dir: 'sw', class: 'bottom-0 left-0 w-[20px] h-[20px]', cursor: 'cursor-nesw-resize' },
265
265
  ]
266
266
  </script>
267
267
 
@@ -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
@@ -81,6 +81,7 @@ export { default as MLoadingOverlay } from './components/MLoadingOverlay.vue'
81
81
  // MMarkdown — import from '@m3ui-vue/m3ui-vue/markdown'
82
82
  export { default as MMasonry } from './components/MMasonry.vue'
83
83
  export { default as MMenu } from './components/MMenu.vue'
84
+ export { default as MMenuDivider } from './components/MMenuDivider.vue'
84
85
  export { default as MMenuItem } from './components/MMenuItem.vue'
85
86
  export { default as MMaskField } from './components/MMaskField.vue'
86
87
  export type { MaskPreset } from './components/MMaskField.vue'
@@ -94,6 +95,7 @@ export { default as MNavigationRail } from './components/MNavigationRail.vue'
94
95
  export { default as MOverlay } from './components/MOverlay.vue'
95
96
  export { default as MPagination } from './components/MPagination.vue'
96
97
  export { default as MProgressBar } from './components/MProgressBar.vue'
98
+ export { default as MCircleProgressBar } from './components/MCircleProgressBar.vue'
97
99
  export { default as MRadio } from './components/MRadio.vue'
98
100
  export { default as MRadioGroup } from './components/MRadioGroup.vue'
99
101
  export { default as MRating } from './components/MRating.vue'
@@ -150,7 +152,6 @@ export type { Placement } from './components/MAbsolute.vue'
150
152
  export type { BadgePosition, BadgeOverlap } from './components/MBadge.vue'
151
153
  export type { CalendarEvent } from './components/MCalendar.vue'
152
154
  export type { CarouselItem } from './components/MCarousel.vue'
153
- export type { ContextMenuItem } from './components/MContextMenu.vue'
154
155
  export type { DataTableColumn, DataTableFetchParams } from './components/MDataTable.vue'
155
156
  export type { DateRange } from './components/MDateRangePicker.vue'
156
157
  export type { DragDropItem } from './components/MDragDropList.vue'
@@ -1,13 +0,0 @@
1
- import type { ContextMenuItem } from "./MContextMenu.vue";
2
- type __VLS_Props = {
3
- items: ContextMenuItem[];
4
- x: number;
5
- y: number;
6
- };
7
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
- close: () => any;
9
- }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
- onClose?: (() => any) | undefined;
11
- }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
- declare const _default: typeof __VLS_export;
13
- export default _default;
@@ -1,137 +0,0 @@
1
- <script setup lang="ts">
2
- import { ref, onMounted, nextTick } from "vue";
3
- import MIcon from "./MIcon.vue";
4
- import MContextMenuPanel from "./_MContextMenuPanel.vue";
5
- import type { ContextMenuItem } from "./MContextMenu.vue";
6
-
7
- const props = defineProps<{
8
- items: ContextMenuItem[];
9
- x: number;
10
- y: number;
11
- }>();
12
-
13
- const emit = defineEmits<{ close: [] }>();
14
-
15
- const panel = ref<HTMLElement | null>(null);
16
- const panelX = ref(props.x);
17
- const panelY = ref(props.y);
18
- const activeIndex = ref<number | null>(null);
19
- const subPos = ref({ x: 0, y: 0 });
20
-
21
- onMounted(async () => {
22
- await nextTick();
23
- if (!panel.value) return;
24
- const el = panel.value;
25
- panelX.value = Math.min(props.x, window.innerWidth - el.offsetWidth - 8);
26
- panelY.value = Math.min(props.y, window.innerHeight - el.offsetHeight - 8);
27
- });
28
-
29
- function onItemMouseEnter(index: number, item: ContextMenuItem, e: MouseEvent) {
30
- if (item.divider || item.disabled) {
31
- activeIndex.value = null;
32
- return;
33
- }
34
- if (!item.children?.length) {
35
- activeIndex.value = null;
36
- return;
37
- }
38
-
39
- activeIndex.value = index;
40
- const itemEl = e.currentTarget as HTMLElement;
41
- const itemRect = itemEl.getBoundingClientRect();
42
- const panelRect = panel.value!.getBoundingClientRect();
43
-
44
- let x = panelRect.right;
45
- let y = itemRect.top;
46
- if (x + 220 > window.innerWidth) x = panelRect.left - 220;
47
- if (y + 300 > window.innerHeight) y = Math.max(8, window.innerHeight - 300);
48
-
49
- subPos.value = { x, y };
50
- }
51
-
52
- function onItemClick(item: ContextMenuItem) {
53
- if (item.disabled || item.divider || item.children?.length) return;
54
- item.onClick?.();
55
- emit("close");
56
- }
57
-
58
- function onPanelMouseLeave(e: MouseEvent) {
59
- // Don't close if the mouse moved to another context menu panel (sibling sub-panel)
60
- const related = e.relatedTarget as Element | null;
61
- if (related?.closest(".m3-ctx-panel")) return;
62
- activeIndex.value = null;
63
- }
64
- </script>
65
-
66
- <template>
67
- <div
68
- ref="panel"
69
- class="m3-ctx-panel absolute z-201 min-w-[200px] overflow-hidden rounded-lg bg-surface-container shadow-elevation-2"
70
- :style="{ left: `${panelX}px`, top: `${panelY}px` }"
71
- @mouseleave="onPanelMouseLeave"
72
- >
73
- <div class="py-1">
74
- <template v-for="(item, i) in items" :key="i">
75
- <hr v-if="item.divider" class="my-1 border-outline-variant" />
76
-
77
- <component
78
- v-else
79
- :is="item.to && !item.disabled ? 'RouterLink' : 'div'"
80
- :to="item.to && !item.disabled ? item.to : undefined"
81
- class="relative flex cursor-default select-none items-center gap-3 px-4 py-2.5 text-body-large"
82
- :class="[
83
- item.disabled
84
- ? 'cursor-not-allowed opacity-38 text-on-surface'
85
- : item.danger
86
- ? 'cursor-pointer text-error hover:bg-error/8'
87
- : 'cursor-pointer text-on-surface hover:bg-on-surface/8',
88
- activeIndex === i && !item.disabled
89
- ? item.danger
90
- ? 'bg-error/8'
91
- : 'bg-on-surface/8'
92
- : '',
93
- ]"
94
- @mouseenter="onItemMouseEnter(i, item, $event)"
95
- @click="onItemClick(item)"
96
- >
97
- <MIcon
98
- v-if="item.icon"
99
- :name="item.icon"
100
- :size="18"
101
- class="shrink-0"
102
- :class="item.danger ? 'text-error' : 'text-on-surface-variant'"
103
- />
104
- <span v-else class="w-[18px] shrink-0" />
105
-
106
- <span class="flex-1">{{ item.label }}</span>
107
-
108
- <span
109
- v-if="item.shortcut"
110
- class="text-label-small text-on-surface-variant"
111
- >
112
- {{ item.shortcut }}
113
- </span>
114
-
115
- <MIcon
116
- v-if="item.children?.length"
117
- name="chevron_right"
118
- :size="18"
119
- class="shrink-0 text-on-surface-variant"
120
- />
121
- </component>
122
- </template>
123
- </div>
124
- </div>
125
-
126
- <!-- Sub-panel: sibling in the same Teleport layer.
127
- No <Transition> wrapper — MContextMenuPanel is a fragment and cannot be
128
- animated by Vue's Transition (produces a console warning). -->
129
- <MContextMenuPanel
130
- v-if="activeIndex !== null && items[activeIndex]?.children?.length"
131
- :key="activeIndex"
132
- :items="items[activeIndex]!.children!"
133
- :x="subPos.x"
134
- :y="subPos.y"
135
- @close="emit('close')"
136
- />
137
- </template>