@m3ui-vue/m3ui-vue 0.3.0 → 0.4.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 (84) hide show
  1. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  2. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  3. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  4. package/dist/components/MBox.vue.d.ts +2 -2
  5. package/dist/components/MButton.vue.d.ts +4 -0
  6. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  7. package/dist/components/MCarousel.vue.d.ts +21 -12
  8. package/dist/components/MChatBubble.vue.d.ts +32 -0
  9. package/dist/components/MColorPickerModal.vue.d.ts +18 -0
  10. package/dist/components/MDataTable.vue.d.ts +1 -1
  11. package/dist/components/MDatePickerModal.vue.d.ts +21 -0
  12. package/dist/components/MDateRangePickerModal.vue.d.ts +24 -0
  13. package/dist/components/MDialog.vue.d.ts +9 -1
  14. package/dist/components/MExpansionPanel.vue.d.ts +3 -2
  15. package/dist/components/MFab.vue.d.ts +14 -2
  16. package/dist/components/MFlex.vue.d.ts +2 -2
  17. package/dist/components/MIconButton.vue.d.ts +4 -2
  18. package/dist/components/MInfiniteScroll.vue.d.ts +1 -1
  19. package/dist/components/MList.vue.d.ts +2 -0
  20. package/dist/components/MLoadingOverlay.vue.d.ts +1 -1
  21. package/dist/components/MMenu.vue.d.ts +3 -1
  22. package/dist/components/MMenuItem.vue.d.ts +14 -3
  23. package/dist/components/MMultiAutocomplete.vue.d.ts +2 -0
  24. package/dist/components/MMultiSelect.vue.d.ts +2 -0
  25. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  26. package/dist/components/MSelect.vue.d.ts +2 -0
  27. package/dist/components/MSlider.vue.d.ts +15 -4
  28. package/dist/components/MSplitButton.vue.d.ts +39 -0
  29. package/dist/components/MSplitter.vue.d.ts +1 -1
  30. package/dist/components/MStack.vue.d.ts +2 -2
  31. package/dist/components/MTable.vue.d.ts +1 -1
  32. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  33. package/dist/components/MToolbar.vue.d.ts +30 -0
  34. package/dist/components/MTooltip.vue.d.ts +8 -2
  35. package/dist/components/MWindow.vue.d.ts +4 -2
  36. package/dist/composables/useDevice.d.ts +3 -0
  37. package/dist/composables/useNotification.d.ts +64 -0
  38. package/dist/composables/useToast.d.ts +8 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/m3ui-vue.css +1 -1
  41. package/dist/m3ui.js +5231 -2916
  42. package/dist/m3ui.js.map +1 -1
  43. package/dist/rich-text-editor.js +10 -10
  44. package/dist/styles.css +1 -1
  45. package/package.json +1 -1
  46. package/src/components/MAutocomplete.vue +108 -13
  47. package/src/components/MButton.vue +38 -16
  48. package/src/components/MButtonGroup.vue +158 -0
  49. package/src/components/MCarousel.vue +223 -101
  50. package/src/components/MChatBubble.vue +98 -0
  51. package/src/components/MColorPicker.vue +1 -4
  52. package/src/components/MColorPickerModal.vue +276 -0
  53. package/src/components/MDatePicker.vue +1 -4
  54. package/src/components/MDatePickerModal.vue +265 -0
  55. package/src/components/MDateRangePicker.vue +1 -4
  56. package/src/components/MDateRangePickerModal.vue +264 -0
  57. package/src/components/MDialog.vue +38 -1
  58. package/src/components/MExpansionPanel.vue +10 -32
  59. package/src/components/MFab.vue +92 -11
  60. package/src/components/MIconButton.vue +36 -6
  61. package/src/components/MList.vue +6 -3
  62. package/src/components/MListItem.vue +9 -3
  63. package/src/components/MMenu.vue +10 -5
  64. package/src/components/MMenuItem.vue +93 -10
  65. package/src/components/MMultiAutocomplete.vue +104 -10
  66. package/src/components/MMultiSelect.vue +106 -13
  67. package/src/components/MNotificationHost.vue +163 -0
  68. package/src/components/MSelect.vue +99 -16
  69. package/src/components/MSlider.vue +367 -134
  70. package/src/components/MSnackbar.vue +3 -0
  71. package/src/components/MSplitButton.vue +240 -0
  72. package/src/components/MTabs.vue +82 -39
  73. package/src/components/MTagInput.vue +11 -1
  74. package/src/components/MTimePicker.vue +1 -4
  75. package/src/components/MTimePickerModal.vue +458 -0
  76. package/src/components/MToolbar.vue +75 -0
  77. package/src/components/MTooltip.vue +60 -10
  78. package/src/components/MWindow.vue +83 -48
  79. package/src/components/_MContextMenuPanel.vue +2 -2
  80. package/src/composables/useDevice.ts +29 -0
  81. package/src/composables/useNotification.ts +93 -0
  82. package/src/composables/useToast.ts +42 -5
  83. package/src/index.ts +13 -0
  84. package/dist/MMenuItem-C6RwHl-l.js.map +0 -1
@@ -51,6 +51,7 @@ const isExpanded = computed(() => internalExpanded.value)
51
51
  const list = inject<{
52
52
  dense: ComputedRef<boolean>
53
53
  nav: ComputedRef<boolean>
54
+ segmented: ComputedRef<boolean>
54
55
  selectable: ComputedRef<boolean>
55
56
  selected: ComputedRef<string | number | null | undefined>
56
57
  dividers: ComputedRef<boolean | 'inset'>
@@ -102,18 +103,23 @@ const iconContainerClasses: Record<string, string> = {
102
103
 
103
104
  const isDense = computed(() => list?.dense.value ?? false)
104
105
  const isNav = computed(() => list?.nav.value ?? false)
106
+ const isSegmented = computed(() => list?.segmented.value ?? false)
105
107
  const hasDivider = computed(() => list?.dividers.value ?? false)
106
108
 
107
109
  const containerClasses = computed(() => [
108
110
  'mli-row relative flex w-full items-center gap-4 text-left',
109
- isNav.value ? 'rounded-full px-4' : 'px-4',
111
+ isSegmented.value
112
+ ? 'rounded-xl bg-surface-container px-4'
113
+ : isNav.value ? 'rounded-full px-4' : 'px-4',
110
114
  isDense.value
111
115
  ? 'py-1'
112
116
  : resolvedLines.value === 3 ? 'py-3' : 'py-2',
113
117
  isClickable.value && !props.disabled && 'cursor-pointer transition-colors duration-150 hover:bg-on-surface/8 active:bg-on-surface/12',
114
- isActive.value && (isNav.value
118
+ isActive.value && (isSegmented.value
115
119
  ? 'bg-secondary-container text-on-secondary-container'
116
- : 'bg-on-surface/8'),
120
+ : isNav.value
121
+ ? 'bg-secondary-container text-on-secondary-container'
122
+ : 'bg-on-surface/8'),
117
123
  props.disabled && 'opacity-[0.38] pointer-events-none',
118
124
  ])
119
125
 
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, ref } from 'vue'
2
+ import { computed, onMounted, onUnmounted, provide, ref } from 'vue'
3
3
 
4
4
  const props = withDefaults(
5
5
  defineProps<{
@@ -48,11 +48,15 @@ function close() {
48
48
  open.value = false
49
49
  }
50
50
 
51
+ provide('m-menu-close', close)
51
52
  defineExpose({ close, open })
52
53
 
53
54
  function onOutsideClick(e: MouseEvent) {
54
55
  const t = e.target as Node
55
- if (!triggerEl.value?.contains(t) && !dropdownEl.value?.contains(t)) close()
56
+ if (triggerEl.value?.contains(t)) return
57
+ if (dropdownEl.value?.contains(t)) return
58
+ if ((t as Element).closest?.('.m3-submenu')) return
59
+ close()
56
60
  }
57
61
 
58
62
  function onScroll(e: Event) {
@@ -102,11 +106,12 @@ const origin = computed(() =>
102
106
  <div
103
107
  v-if="open"
104
108
  ref="dropdownEl"
105
- class="fixed z-500 min-w-48 overflow-y-auto overflow-x-hidden rounded-xs bg-surface-container py-1 shadow-elevation-2"
109
+ class="fixed z-500 min-w-48 overflow-hidden rounded-lg bg-surface-container shadow-elevation-2"
106
110
  :style="{ ...dropStyle, transformOrigin: origin }"
107
- @click="close"
108
111
  >
109
- <slot />
112
+ <div class="overflow-y-auto py-1" :style="{ maxHeight: dropStyle.maxHeight }">
113
+ <slot :close="close" />
114
+ </div>
110
115
  </div>
111
116
  </Transition>
112
117
  </Teleport>
@@ -1,20 +1,103 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, inject, ref, useSlots } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
4
 
5
- const props = withDefaults(defineProps<{ icon?: string; to?: string | Record<string, any> }>(), {})
5
+ const props = withDefaults(defineProps<{
6
+ icon?: string
7
+ to?: string | Record<string, any>
8
+ disabled?: boolean
9
+ danger?: boolean
10
+ }>(), {
11
+ disabled: false,
12
+ danger: false,
13
+ })
6
14
 
15
+ const emit = defineEmits<{ click: [] }>()
16
+
17
+ const slots = useSlots()
7
18
  const tag = computed(() => props.to ? 'RouterLink' : 'button')
19
+ const hasChildren = computed(() => !!slots.children)
20
+ const menuClose = inject<(() => void) | null>('m-menu-close', null)
21
+
22
+ function handleClick() {
23
+ if (props.disabled || hasChildren.value) return
24
+ emit('click')
25
+ menuClose?.()
26
+ }
27
+ const showSub = ref(false)
28
+ const itemEl = ref<HTMLElement>()
29
+ const subStyle = ref<Record<string, string>>({})
30
+
31
+ function onMouseEnter() {
32
+ if (!hasChildren.value || props.disabled) return
33
+ showSub.value = true
34
+ if (!itemEl.value) return
35
+ const rect = itemEl.value.getBoundingClientRect()
36
+ const right = rect.right + 200 > window.innerWidth
37
+ subStyle.value = {
38
+ position: 'fixed',
39
+ top: `${rect.top}px`,
40
+ ...(right
41
+ ? { right: `${window.innerWidth - rect.left}px` }
42
+ : { left: `${rect.right}px` }),
43
+ zIndex: '501',
44
+ }
45
+ }
46
+
47
+ function onMouseLeave(e: MouseEvent) {
48
+ const related = e.relatedTarget as Element | null
49
+ if (related?.closest('.m3-submenu')) return
50
+ showSub.value = false
51
+ }
52
+
53
+ function onSubLeave(e: MouseEvent) {
54
+ const related = e.relatedTarget as Element | null
55
+ if (related?.closest('.m3-submenu') || itemEl.value?.contains(related as Node)) return
56
+ showSub.value = false
57
+ }
8
58
  </script>
9
59
 
10
60
  <template>
11
- <component
12
- :is="tag"
13
- :to="to || undefined"
14
- :type="to ? undefined : 'button'"
15
- class="flex w-full cursor-pointer items-center gap-3 px-4 py-2.5 text-left text-body-large text-on-surface hover:bg-on-surface/8"
61
+ <div
62
+ ref="itemEl"
63
+ @mouseenter="onMouseEnter"
64
+ @mouseleave="onMouseLeave"
16
65
  >
17
- <MIcon v-if="icon" :name="icon" :size="20" class="text-on-surface-variant" />
18
- <slot />
19
- </component>
66
+ <component
67
+ :is="tag"
68
+ :to="to || undefined"
69
+ :type="to ? undefined : 'button'"
70
+ class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-body-large transition-colors"
71
+ :class="[
72
+ disabled ? 'cursor-not-allowed opacity-[0.38] text-on-surface' : 'cursor-pointer',
73
+ !disabled && danger ? 'text-error hover:bg-error/8' : '',
74
+ !disabled && !danger ? 'text-on-surface hover:bg-on-surface/8' : '',
75
+ ]"
76
+ :disabled="disabled || undefined"
77
+ @click="handleClick"
78
+ >
79
+ <MIcon v-if="icon" :name="icon" :size="20" class="shrink-0" :class="danger ? 'text-error' : 'text-on-surface-variant'" />
80
+ <span class="flex-1"><slot /></span>
81
+ <MIcon v-if="hasChildren" name="chevron_right" :size="18" class="shrink-0 text-on-surface-variant" />
82
+ </component>
83
+
84
+ <!-- Submenu -->
85
+ <Teleport v-if="hasChildren" to="body">
86
+ <Transition
87
+ enter-active-class="transition-opacity duration-100"
88
+ enter-from-class="opacity-0"
89
+ leave-active-class="transition-opacity duration-75"
90
+ leave-to-class="opacity-0"
91
+ >
92
+ <div
93
+ v-if="showSub"
94
+ class="m3-submenu min-w-44 overflow-hidden rounded-lg bg-surface-container py-1 shadow-elevation-2"
95
+ :style="subStyle"
96
+ @mouseleave="onSubLeave"
97
+ >
98
+ <slot name="children" />
99
+ </div>
100
+ </Transition>
101
+ </Teleport>
102
+ </div>
20
103
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, useId, onMounted, onUnmounted, watch, nextTick } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
+ import MIconButton from './MIconButton.vue'
4
5
  import MCheckbox from './MCheckbox.vue'
5
6
  import { useFieldBg } from '../composables/useFieldBg'
6
7
  import { useLocale } from '../composables/useLocale'
@@ -13,6 +14,7 @@ const props = withDefaults(
13
14
  label?: string
14
15
  placeholder?: string
15
16
  variant?: 'filled' | 'outlined'
17
+ mode?: 'docked' | 'modal'
16
18
  disabled?: boolean
17
19
  error?: string
18
20
  hint?: string
@@ -27,6 +29,7 @@ const props = withDefaults(
27
29
  {
28
30
  modelValue: () => [],
29
31
  variant: 'filled',
32
+ mode: 'docked',
30
33
  disabled: false,
31
34
  required: false,
32
35
  clearable: false,
@@ -40,6 +43,8 @@ const emit = defineEmits<{ 'update:modelValue': [unknown[]] }>()
40
43
  const locale = useLocale()
41
44
  const id = useId()
42
45
  const open = ref(false)
46
+ const modalOpen = ref(false)
47
+ const modalSearch = ref('')
43
48
  const search = ref('')
44
49
  const highlightIndex = ref(-1)
45
50
  const fieldEl = ref<HTMLElement | null>(null)
@@ -126,8 +131,23 @@ function computeDropPos() {
126
131
  }
127
132
  }
128
133
 
134
+ const modalFilteredOptions = computed(() => {
135
+ let opts = props.options
136
+ if (props.hideSelected) {
137
+ opts = opts.filter((o) => !includes(props.modelValue, o.value))
138
+ }
139
+ if (!modalSearch.value) return opts
140
+ const q = modalSearch.value.toLowerCase()
141
+ return opts.filter((o) => o.label.toLowerCase().includes(q))
142
+ })
143
+
129
144
  function openDropdown() {
130
145
  if (props.disabled || open.value) return
146
+ if (props.mode === 'modal') {
147
+ modalOpen.value = true
148
+ modalSearch.value = ''
149
+ return
150
+ }
131
151
  computeDropPos()
132
152
  open.value = true
133
153
  highlightIndex.value = -1
@@ -135,10 +155,16 @@ function openDropdown() {
135
155
 
136
156
  function closeDropdown() {
137
157
  open.value = false
158
+ modalOpen.value = false
138
159
  search.value = ''
160
+ modalSearch.value = ''
139
161
  highlightIndex.value = -1
140
162
  }
141
163
 
164
+ watch(modalOpen, (v) => {
165
+ document.body.style.overflow = v ? 'hidden' : ''
166
+ })
167
+
142
168
  function onInputFocus() {
143
169
  openDropdown()
144
170
  }
@@ -157,6 +183,7 @@ function onInput(e: Event) {
157
183
  }
158
184
 
159
185
  function onOutsideClick(e: MouseEvent) {
186
+ if (modalOpen.value) return
160
187
  const t = e.target as Node
161
188
  if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) closeDropdown()
162
189
  }
@@ -164,13 +191,7 @@ function onOutsideClick(e: MouseEvent) {
164
191
  function onScroll(e: Event) {
165
192
  if (!open.value) return
166
193
  if (dropdownEl.value?.contains(e.target as Node)) return
167
- if (!fieldEl.value) return
168
- const rect = fieldEl.value.getBoundingClientRect()
169
- if (rect.bottom < 0 || rect.top > window.innerHeight) {
170
- closeDropdown()
171
- return
172
- }
173
- computeDropPos()
194
+ closeDropdown()
174
195
  }
175
196
 
176
197
  function onKeydown(e: KeyboardEvent) {
@@ -235,6 +256,7 @@ onMounted(() => {
235
256
  onUnmounted(() => {
236
257
  document.removeEventListener('mousedown', onOutsideClick)
237
258
  window.removeEventListener('scroll', onScroll, true)
259
+ document.body.style.overflow = ''
238
260
  })
239
261
 
240
262
  watch(filteredOptions, () => {
@@ -388,9 +410,10 @@ const labelClasses = computed(() => {
388
410
  <!-- Arrow icon -->
389
411
  <div class="pointer-events-none absolute right-2 top-4">
390
412
  <MIcon
391
- :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
413
+ name="arrow_drop_down"
392
414
  :size="24"
393
415
  class="text-on-surface-variant transition-transform duration-200"
416
+ :class="open || modalOpen ? 'rotate-180' : ''"
394
417
  />
395
418
  </div>
396
419
  </div>
@@ -399,7 +422,7 @@ const labelClasses = computed(() => {
399
422
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
400
423
  </div>
401
424
 
402
- <!-- Dropdown teleported to body to escape overflow clipping -->
425
+ <!-- Docked dropdown -->
403
426
  <Teleport to="body">
404
427
  <Transition
405
428
  enter-active-class="transition-[opacity,transform] duration-150"
@@ -410,7 +433,7 @@ const labelClasses = computed(() => {
410
433
  leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
411
434
  >
412
435
  <div
413
- v-if="open"
436
+ v-if="open && mode === 'docked'"
414
437
  ref="dropdownEl"
415
438
  class="fixed z-500 max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
416
439
  :style="dropPos"
@@ -442,5 +465,76 @@ const labelClasses = computed(() => {
442
465
  </div>
443
466
  </div>
444
467
  </Transition>
468
+
469
+ <!-- Modal -->
470
+ <Transition name="m3-mac-modal">
471
+ <div
472
+ v-if="modalOpen && mode === 'modal'"
473
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
474
+ @click.self="closeDropdown"
475
+ >
476
+ <div class="mac-modal-box flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-[28px] bg-surface-container-high shadow-elevation-3">
477
+ <div class="flex items-center justify-between px-6 pt-6 pb-4">
478
+ <h2 class="text-headline-small text-on-surface">{{ label || placeholder }}</h2>
479
+ <MIconButton icon="close" :label="locale.close" @click="closeDropdown" />
480
+ </div>
481
+
482
+ <div class="px-6 pb-3">
483
+ <div class="flex items-center gap-2 rounded-full bg-surface-container-highest px-3 py-2">
484
+ <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
485
+ <input
486
+ v-model="modalSearch"
487
+ type="text"
488
+ :placeholder="locale.search"
489
+ class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
490
+ />
491
+ </div>
492
+ </div>
493
+
494
+ <div class="h-px bg-outline-variant" />
495
+
496
+ <div class="flex-1 overflow-y-auto py-2">
497
+ <label
498
+ v-for="(opt, i) in modalFilteredOptions"
499
+ :key="i"
500
+ class="flex cursor-pointer items-center gap-4 px-6 py-3 transition-colors hover:bg-on-surface/8"
501
+ :class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
502
+ >
503
+ <MCheckbox
504
+ :model-value="includes(modelValue, opt.value)"
505
+ :disabled="opt.disabled"
506
+ @update:model-value="!opt.disabled && toggle(opt.value)"
507
+ />
508
+ <span class="text-body-large text-on-surface">{{ opt.label }}</span>
509
+ </label>
510
+ <p
511
+ v-if="!modalFilteredOptions.length"
512
+ class="px-6 py-4 text-center text-body-medium text-on-surface-variant"
513
+ >
514
+ {{ resolvedNoResultsText }}
515
+ </p>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ </Transition>
445
520
  </Teleport>
446
521
  </template>
522
+
523
+ <style scoped>
524
+ .m3-mac-modal-enter-active,
525
+ .m3-mac-modal-leave-active {
526
+ transition: opacity 0.15s ease;
527
+ }
528
+ .m3-mac-modal-enter-from,
529
+ .m3-mac-modal-leave-to {
530
+ opacity: 0;
531
+ }
532
+ .m3-mac-modal-enter-active .mac-modal-box,
533
+ .m3-mac-modal-leave-active .mac-modal-box {
534
+ transition: transform 0.15s ease;
535
+ }
536
+ .m3-mac-modal-enter-from .mac-modal-box,
537
+ .m3-mac-modal-leave-to .mac-modal-box {
538
+ transform: scale(0.95);
539
+ }
540
+ </style>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { computed, ref, useId, onMounted, onUnmounted, nextTick } from 'vue'
2
+ import { computed, ref, useId, onMounted, onUnmounted, nextTick, watch } from 'vue'
3
3
  import MIcon from './MIcon.vue'
4
+ import MIconButton from './MIconButton.vue'
4
5
  import MCheckbox from './MCheckbox.vue'
5
6
  import { useFieldBg } from '../composables/useFieldBg'
6
7
  import { useLocale } from '../composables/useLocale'
@@ -20,6 +21,7 @@ const props = withDefaults(
20
21
  label?: string
21
22
  placeholder?: string
22
23
  variant?: 'filled' | 'outlined'
24
+ mode?: 'docked' | 'modal'
23
25
  disabled?: boolean
24
26
  error?: string
25
27
  hint?: string
@@ -36,6 +38,7 @@ const props = withDefaults(
36
38
  {
37
39
  modelValue: () => [],
38
40
  variant: 'filled',
41
+ mode: 'docked',
39
42
  disabled: false,
40
43
  required: false,
41
44
  searchable: true,
@@ -49,6 +52,8 @@ const emit = defineEmits<{ 'update:modelValue': [unknown[]] }>()
49
52
 
50
53
  const id = useId()
51
54
  const open = ref(false)
55
+ const modalOpen = ref(false)
56
+ const modalSearch = ref('')
52
57
  const search = ref('')
53
58
  const fieldEl = ref<HTMLElement | null>(null)
54
59
  const { resolvedFieldBg } = useFieldBg(fieldEl, () => props.fieldBg)
@@ -128,6 +133,11 @@ function computeDropPos() {
128
133
 
129
134
  async function openDropdown() {
130
135
  if (props.disabled) return
136
+ if (props.mode === 'modal') {
137
+ modalOpen.value = true
138
+ modalSearch.value = ''
139
+ return
140
+ }
131
141
  computeDropPos()
132
142
  open.value = true
133
143
  search.value = ''
@@ -137,10 +147,27 @@ async function openDropdown() {
137
147
 
138
148
  function close() {
139
149
  open.value = false
150
+ modalOpen.value = false
140
151
  search.value = ''
152
+ modalSearch.value = ''
141
153
  }
142
154
 
155
+ const modalFilteredOptions = computed(() => {
156
+ let opts = props.options
157
+ if (props.hideSelected) {
158
+ opts = opts.filter((o) => !includes(props.modelValue, o.value))
159
+ }
160
+ if (!modalSearch.value) return opts
161
+ const q = modalSearch.value.toLowerCase()
162
+ return opts.filter((o) => o.label.toLowerCase().includes(q))
163
+ })
164
+
165
+ watch(modalOpen, (v) => {
166
+ document.body.style.overflow = v ? 'hidden' : ''
167
+ })
168
+
143
169
  function onOutsideClick(e: MouseEvent) {
170
+ if (modalOpen.value) return
144
171
  const t = e.target as Node
145
172
  if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) close()
146
173
  }
@@ -148,13 +175,7 @@ function onOutsideClick(e: MouseEvent) {
148
175
  function onScroll(e: Event) {
149
176
  if (!open.value) return
150
177
  if (dropdownEl.value?.contains(e.target as Node)) return
151
- if (!fieldEl.value) return
152
- const rect = fieldEl.value.getBoundingClientRect()
153
- if (rect.bottom < 0 || rect.top > window.innerHeight) {
154
- close()
155
- return
156
- }
157
- computeDropPos()
178
+ close()
158
179
  }
159
180
 
160
181
  onMounted(() => {
@@ -164,6 +185,7 @@ onMounted(() => {
164
185
  onUnmounted(() => {
165
186
  document.removeEventListener('mousedown', onOutsideClick)
166
187
  window.removeEventListener('scroll', onScroll, true)
188
+ document.body.style.overflow = ''
167
189
  })
168
190
 
169
191
  const triggerClasses = computed(() => {
@@ -298,9 +320,10 @@ const labelClasses = computed(() => {
298
320
 
299
321
  <div class="pointer-events-none absolute right-2 top-4">
300
322
  <MIcon
301
- :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
323
+ name="arrow_drop_down"
302
324
  :size="24"
303
- class="text-on-surface-variant"
325
+ class="text-on-surface-variant transition-transform duration-200"
326
+ :class="open || modalOpen ? 'rotate-180' : ''"
304
327
  />
305
328
  </div>
306
329
  </div>
@@ -309,7 +332,7 @@ const labelClasses = computed(() => {
309
332
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
310
333
  </div>
311
334
 
312
- <!-- Dropdown teleported to body to escape overflow clipping -->
335
+ <!-- Docked dropdown -->
313
336
  <Teleport to="body">
314
337
  <Transition
315
338
  enter-active-class="transition-[opacity,transform] duration-150"
@@ -320,12 +343,11 @@ const labelClasses = computed(() => {
320
343
  leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
321
344
  >
322
345
  <div
323
- v-if="open"
346
+ v-if="open && mode === 'docked'"
324
347
  ref="dropdownEl"
325
348
  class="fixed z-500 max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
326
349
  :style="dropPos"
327
350
  >
328
- <!-- Search -->
329
351
  <div v-if="searchable" class="sticky top-0 bg-surface-container px-3 py-2">
330
352
  <div class="flex items-center gap-2 rounded-full bg-surface-container-high px-3 py-1.5">
331
353
  <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
@@ -361,5 +383,76 @@ const labelClasses = computed(() => {
361
383
  </div>
362
384
  </div>
363
385
  </Transition>
386
+
387
+ <!-- Modal -->
388
+ <Transition name="m3-ms-modal">
389
+ <div
390
+ v-if="modalOpen && mode === 'modal'"
391
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
392
+ @click.self="close"
393
+ >
394
+ <div class="ms-modal-box flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-[28px] bg-surface-container-high shadow-elevation-3">
395
+ <div class="flex items-center justify-between px-6 pt-6 pb-4">
396
+ <h2 class="text-headline-small text-on-surface">{{ label || placeholder }}</h2>
397
+ <MIconButton icon="close" :label="locale.close" @click="close" />
398
+ </div>
399
+
400
+ <div v-if="searchable" class="px-6 pb-3">
401
+ <div class="flex items-center gap-2 rounded-full bg-surface-container-highest px-3 py-2">
402
+ <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
403
+ <input
404
+ v-model="modalSearch"
405
+ type="text"
406
+ :placeholder="searchPlaceholder ?? locale.search"
407
+ class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
408
+ />
409
+ </div>
410
+ </div>
411
+
412
+ <div class="h-px bg-outline-variant" />
413
+
414
+ <div class="flex-1 overflow-y-auto py-2">
415
+ <label
416
+ v-for="(opt, i) in modalFilteredOptions"
417
+ :key="i"
418
+ class="flex cursor-pointer items-center gap-4 px-6 py-3 transition-colors hover:bg-on-surface/8"
419
+ :class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
420
+ >
421
+ <MCheckbox
422
+ :model-value="includes(modelValue, opt.value)"
423
+ :disabled="opt.disabled"
424
+ @update:model-value="!opt.disabled && toggle(opt.value)"
425
+ />
426
+ <span class="text-body-large text-on-surface">{{ opt.label }}</span>
427
+ </label>
428
+ <p
429
+ v-if="!modalFilteredOptions.length"
430
+ class="px-6 py-4 text-center text-body-medium text-on-surface-variant"
431
+ >
432
+ {{ noResultsText ?? locale.noResults }}
433
+ </p>
434
+ </div>
435
+ </div>
436
+ </div>
437
+ </Transition>
364
438
  </Teleport>
365
439
  </template>
440
+
441
+ <style scoped>
442
+ .m3-ms-modal-enter-active,
443
+ .m3-ms-modal-leave-active {
444
+ transition: opacity 0.15s ease;
445
+ }
446
+ .m3-ms-modal-enter-from,
447
+ .m3-ms-modal-leave-to {
448
+ opacity: 0;
449
+ }
450
+ .m3-ms-modal-enter-active .ms-modal-box,
451
+ .m3-ms-modal-leave-active .ms-modal-box {
452
+ transition: transform 0.15s ease;
453
+ }
454
+ .m3-ms-modal-enter-from .ms-modal-box,
455
+ .m3-ms-modal-leave-to .ms-modal-box {
456
+ transform: scale(0.95);
457
+ }
458
+ </style>