@m3ui-vue/m3ui-vue 0.3.1 → 0.4.1

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 (83) hide show
  1. package/README.md +219 -59
  2. package/dist/{MMenuItem-C6RwHl-l.js → MMenuItem-FyttjOj_.js} +399 -207
  3. package/dist/MMenuItem-FyttjOj_.js.map +1 -0
  4. package/dist/components/MAutocomplete.vue.d.ts +2 -0
  5. package/dist/components/MBox.vue.d.ts +2 -2
  6. package/dist/components/MButton.vue.d.ts +4 -0
  7. package/dist/components/MButtonGroup.vue.d.ts +28 -0
  8. package/dist/components/MCarousel.vue.d.ts +21 -12
  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/MNavigationDrawer.vue.d.ts +1 -1
  26. package/dist/components/MNotificationHost.vue.d.ts +3 -0
  27. package/dist/components/MSelect.vue.d.ts +2 -0
  28. package/dist/components/MSlider.vue.d.ts +15 -4
  29. package/dist/components/MSplitButton.vue.d.ts +39 -0
  30. package/dist/components/MSplitter.vue.d.ts +1 -1
  31. package/dist/components/MStack.vue.d.ts +2 -2
  32. package/dist/components/MTable.vue.d.ts +1 -1
  33. package/dist/components/MTimePickerModal.vue.d.ts +21 -0
  34. package/dist/components/MToolbar.vue.d.ts +30 -0
  35. package/dist/components/MTooltip.vue.d.ts +8 -2
  36. package/dist/components/MWindow.vue.d.ts +2 -2
  37. package/dist/composables/useDevice.d.ts +3 -0
  38. package/dist/composables/useNotification.d.ts +64 -0
  39. package/dist/composables/useToast.d.ts +8 -0
  40. package/dist/index.d.ts +12 -0
  41. package/dist/m3ui-vue.css +1 -1
  42. package/dist/m3ui.js +5222 -2994
  43. package/dist/m3ui.js.map +1 -1
  44. package/dist/rich-text-editor.js +10 -10
  45. package/dist/styles.css +1 -1
  46. package/package.json +1 -1
  47. package/src/components/MAutocomplete.vue +109 -13
  48. package/src/components/MButton.vue +38 -16
  49. package/src/components/MButtonGroup.vue +158 -0
  50. package/src/components/MCarousel.vue +223 -101
  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 +105 -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/_MContextMenuPanel.vue +2 -2
  79. package/src/composables/useDevice.ts +29 -0
  80. package/src/composables/useNotification.ts +93 -0
  81. package/src/composables/useToast.ts +42 -5
  82. package/src/index.ts +12 -0
  83. 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,15 +155,22 @@ 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
  }
145
171
 
146
172
  function onInputBlur(e: FocusEvent) {
173
+ if (modalOpen.value) return
147
174
  const related = e.relatedTarget as Node | null
148
175
  if (dropdownEl.value?.contains(related) || fieldEl.value?.contains(related)) return
149
176
  closeDropdown()
@@ -157,6 +184,7 @@ function onInput(e: Event) {
157
184
  }
158
185
 
159
186
  function onOutsideClick(e: MouseEvent) {
187
+ if (modalOpen.value) return
160
188
  const t = e.target as Node
161
189
  if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) closeDropdown()
162
190
  }
@@ -164,13 +192,7 @@ function onOutsideClick(e: MouseEvent) {
164
192
  function onScroll(e: Event) {
165
193
  if (!open.value) return
166
194
  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()
195
+ closeDropdown()
174
196
  }
175
197
 
176
198
  function onKeydown(e: KeyboardEvent) {
@@ -235,6 +257,7 @@ onMounted(() => {
235
257
  onUnmounted(() => {
236
258
  document.removeEventListener('mousedown', onOutsideClick)
237
259
  window.removeEventListener('scroll', onScroll, true)
260
+ document.body.style.overflow = ''
238
261
  })
239
262
 
240
263
  watch(filteredOptions, () => {
@@ -388,9 +411,10 @@ const labelClasses = computed(() => {
388
411
  <!-- Arrow icon -->
389
412
  <div class="pointer-events-none absolute right-2 top-4">
390
413
  <MIcon
391
- :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
414
+ name="arrow_drop_down"
392
415
  :size="24"
393
416
  class="text-on-surface-variant transition-transform duration-200"
417
+ :class="open || modalOpen ? 'rotate-180' : ''"
394
418
  />
395
419
  </div>
396
420
  </div>
@@ -399,7 +423,7 @@ const labelClasses = computed(() => {
399
423
  <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
400
424
  </div>
401
425
 
402
- <!-- Dropdown teleported to body to escape overflow clipping -->
426
+ <!-- Docked dropdown -->
403
427
  <Teleport to="body">
404
428
  <Transition
405
429
  enter-active-class="transition-[opacity,transform] duration-150"
@@ -410,7 +434,7 @@ const labelClasses = computed(() => {
410
434
  leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
411
435
  >
412
436
  <div
413
- v-if="open"
437
+ v-if="open && mode === 'docked'"
414
438
  ref="dropdownEl"
415
439
  class="fixed z-500 max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
416
440
  :style="dropPos"
@@ -442,5 +466,76 @@ const labelClasses = computed(() => {
442
466
  </div>
443
467
  </div>
444
468
  </Transition>
469
+
470
+ <!-- Modal -->
471
+ <Transition name="m3-mac-modal">
472
+ <div
473
+ v-if="modalOpen && mode === 'modal'"
474
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
475
+ @click.self="closeDropdown"
476
+ >
477
+ <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">
478
+ <div class="flex items-center justify-between px-6 pt-6 pb-4">
479
+ <h2 class="text-headline-small text-on-surface">{{ label || placeholder }}</h2>
480
+ <MIconButton icon="close" :label="locale.close" @click="closeDropdown" />
481
+ </div>
482
+
483
+ <div class="px-6 pb-3">
484
+ <div class="flex items-center gap-2 rounded-full bg-surface-container-highest px-3 py-2">
485
+ <MIcon name="search" :size="16" class="shrink-0 text-on-surface-variant" />
486
+ <input
487
+ v-model="modalSearch"
488
+ type="text"
489
+ :placeholder="locale.search"
490
+ class="w-full bg-transparent text-body-medium text-on-surface outline-none placeholder:text-on-surface-variant"
491
+ />
492
+ </div>
493
+ </div>
494
+
495
+ <div class="h-px bg-outline-variant" />
496
+
497
+ <div class="flex-1 overflow-y-auto py-2">
498
+ <label
499
+ v-for="(opt, i) in modalFilteredOptions"
500
+ :key="i"
501
+ class="flex cursor-pointer items-center gap-4 px-6 py-3 transition-colors hover:bg-on-surface/8"
502
+ :class="opt.disabled ? 'cursor-not-allowed opacity-38' : ''"
503
+ >
504
+ <MCheckbox
505
+ :model-value="includes(modelValue, opt.value)"
506
+ :disabled="opt.disabled"
507
+ @update:model-value="!opt.disabled && toggle(opt.value)"
508
+ />
509
+ <span class="text-body-large text-on-surface">{{ opt.label }}</span>
510
+ </label>
511
+ <p
512
+ v-if="!modalFilteredOptions.length"
513
+ class="px-6 py-4 text-center text-body-medium text-on-surface-variant"
514
+ >
515
+ {{ resolvedNoResultsText }}
516
+ </p>
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </Transition>
445
521
  </Teleport>
446
522
  </template>
523
+
524
+ <style scoped>
525
+ .m3-mac-modal-enter-active,
526
+ .m3-mac-modal-leave-active {
527
+ transition: opacity 0.15s ease;
528
+ }
529
+ .m3-mac-modal-enter-from,
530
+ .m3-mac-modal-leave-to {
531
+ opacity: 0;
532
+ }
533
+ .m3-mac-modal-enter-active .mac-modal-box,
534
+ .m3-mac-modal-leave-active .mac-modal-box {
535
+ transition: transform 0.15s ease;
536
+ }
537
+ .m3-mac-modal-enter-from .mac-modal-box,
538
+ .m3-mac-modal-leave-to .mac-modal-box {
539
+ transform: scale(0.95);
540
+ }
541
+ </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>