@m3ui-vue/m3ui-vue 0.2.6 → 0.3.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.
@@ -0,0 +1,446 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId, onMounted, onUnmounted, watch, nextTick } from 'vue'
3
+ import MIcon from './MIcon.vue'
4
+ import MCheckbox from './MCheckbox.vue'
5
+ import { useFieldBg } from '../composables/useFieldBg'
6
+ import { useLocale } from '../composables/useLocale'
7
+ import type { MultiSelectOption } from './MMultiSelect.vue'
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ modelValue: unknown[]
12
+ options: MultiSelectOption[]
13
+ label?: string
14
+ placeholder?: string
15
+ variant?: 'filled' | 'outlined'
16
+ disabled?: boolean
17
+ error?: string
18
+ hint?: string
19
+ required?: boolean
20
+ leadingIcon?: string
21
+ fieldBg?: string
22
+ clearable?: boolean
23
+ maxChips?: number
24
+ noResultsText?: string
25
+ hideSelected?: boolean
26
+ }>(),
27
+ {
28
+ modelValue: () => [],
29
+ variant: 'filled',
30
+ disabled: false,
31
+ required: false,
32
+ clearable: false,
33
+ maxChips: 3,
34
+ hideSelected: false,
35
+ },
36
+ )
37
+
38
+ const emit = defineEmits<{ 'update:modelValue': [unknown[]] }>()
39
+
40
+ const locale = useLocale()
41
+ const id = useId()
42
+ const open = ref(false)
43
+ const search = ref('')
44
+ const highlightIndex = ref(-1)
45
+ const fieldEl = ref<HTMLElement | null>(null)
46
+ const inputEl = ref<HTMLInputElement | null>(null)
47
+ const { resolvedFieldBg } = useFieldBg(fieldEl, () => props.fieldBg)
48
+ const dropdownEl = ref<HTMLElement | null>(null)
49
+ const dropPos = ref({ top: '0px', left: '0px', width: '0px' })
50
+
51
+ function eq(a: unknown, b: unknown): boolean {
52
+ if (a === b) return true
53
+ if (typeof a !== 'object' || typeof b !== 'object' || a == null || b == null) return false
54
+ return JSON.stringify(a) === JSON.stringify(b)
55
+ }
56
+
57
+ function includes(arr: unknown[], val: unknown): boolean {
58
+ return arr.some((v) => eq(v, val))
59
+ }
60
+
61
+ const hasValue = computed(() => props.modelValue.length > 0)
62
+
63
+ const filteredOptions = computed(() => {
64
+ let opts = props.options
65
+ if (props.hideSelected) {
66
+ opts = opts.filter((o) => !includes(props.modelValue, o.value))
67
+ }
68
+ if (!search.value) return opts
69
+ const q = search.value.toLowerCase()
70
+ return opts.filter((o) => o.label.toLowerCase().includes(q))
71
+ })
72
+
73
+ const resolvedNoResultsText = computed(() => props.noResultsText ?? locale.noResults)
74
+
75
+ const visibleChips = computed(() =>
76
+ props.modelValue.slice(0, props.maxChips).map((v) => ({
77
+ value: v,
78
+ label: props.options.find((o) => eq(o.value, v))?.label ?? String(v),
79
+ })),
80
+ )
81
+
82
+ const overflowCount = computed(() => Math.max(0, props.modelValue.length - props.maxChips))
83
+ const chipsExpanded = ref(false)
84
+
85
+ const displayChips = computed(() => {
86
+ const all = props.modelValue.map((v) => ({
87
+ value: v,
88
+ label: props.options.find((o) => eq(o.value, v))?.label ?? String(v),
89
+ }))
90
+ return chipsExpanded.value ? all : all.slice(0, props.maxChips)
91
+ })
92
+
93
+ const overflowLabels = computed(() =>
94
+ props.modelValue.slice(props.maxChips).map((v) =>
95
+ props.options.find((o) => eq(o.value, v))?.label ?? String(v),
96
+ ).join(', '),
97
+ )
98
+
99
+ function toggle(value: unknown) {
100
+ const current = props.modelValue
101
+ if (includes(current, value)) {
102
+ emit('update:modelValue', current.filter((v) => !eq(v, value)))
103
+ } else {
104
+ emit('update:modelValue', [...current, value])
105
+ }
106
+ search.value = ''
107
+ nextTick(() => inputEl.value?.focus())
108
+ }
109
+
110
+ function removeChip(value: unknown, e: Event) {
111
+ e.stopPropagation()
112
+ emit('update:modelValue', props.modelValue.filter((v) => !eq(v, value)))
113
+ nextTick(() => inputEl.value?.focus())
114
+ }
115
+
116
+ function computeDropPos() {
117
+ if (!fieldEl.value) return
118
+ const rect = fieldEl.value.getBoundingClientRect()
119
+ const spaceBelow = window.innerHeight - rect.bottom - 8
120
+ const dropH = Math.min(240, filteredOptions.value.length * 52 + 8)
121
+ const openAbove = spaceBelow < dropH && rect.top > dropH
122
+ dropPos.value = {
123
+ top: openAbove ? `${rect.top - 4 - dropH}px` : `${rect.bottom + 4}px`,
124
+ left: `${rect.left}px`,
125
+ width: `${rect.width}px`,
126
+ }
127
+ }
128
+
129
+ function openDropdown() {
130
+ if (props.disabled || open.value) return
131
+ computeDropPos()
132
+ open.value = true
133
+ highlightIndex.value = -1
134
+ }
135
+
136
+ function closeDropdown() {
137
+ open.value = false
138
+ search.value = ''
139
+ highlightIndex.value = -1
140
+ }
141
+
142
+ function onInputFocus() {
143
+ openDropdown()
144
+ }
145
+
146
+ function onInputBlur(e: FocusEvent) {
147
+ const related = e.relatedTarget as Node | null
148
+ if (dropdownEl.value?.contains(related) || fieldEl.value?.contains(related)) return
149
+ closeDropdown()
150
+ }
151
+
152
+ function onInput(e: Event) {
153
+ search.value = (e.target as HTMLInputElement).value
154
+ if (!open.value) openDropdown()
155
+ highlightIndex.value = -1
156
+ nextTick(computeDropPos)
157
+ }
158
+
159
+ function onOutsideClick(e: MouseEvent) {
160
+ const t = e.target as Node
161
+ if (!fieldEl.value?.contains(t) && !dropdownEl.value?.contains(t)) closeDropdown()
162
+ }
163
+
164
+ function onScroll(e: Event) {
165
+ if (!open.value) return
166
+ 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()
174
+ }
175
+
176
+ function onKeydown(e: KeyboardEvent) {
177
+ if (e.key === 'Escape') {
178
+ closeDropdown()
179
+ inputEl.value?.blur()
180
+ return
181
+ }
182
+
183
+ if (e.key === 'Backspace' && !search.value && hasValue.value) {
184
+ const last = props.modelValue[props.modelValue.length - 1]
185
+ emit('update:modelValue', props.modelValue.filter((v) => !eq(v, last)))
186
+ return
187
+ }
188
+
189
+ if (!open.value) {
190
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
191
+ e.preventDefault()
192
+ openDropdown()
193
+ return
194
+ }
195
+ return
196
+ }
197
+
198
+ const opts = filteredOptions.value.filter((o) => !o.disabled)
199
+ if (e.key === 'ArrowDown') {
200
+ e.preventDefault()
201
+ highlightIndex.value = highlightIndex.value < opts.length - 1 ? highlightIndex.value + 1 : 0
202
+ scrollToHighlighted()
203
+ } else if (e.key === 'ArrowUp') {
204
+ e.preventDefault()
205
+ highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : opts.length - 1
206
+ scrollToHighlighted()
207
+ } else if (e.key === 'Enter') {
208
+ e.preventDefault()
209
+ const idx = highlightIndex.value >= 0 ? highlightIndex.value : 0
210
+ const target = opts[idx]
211
+ if (target) {
212
+ toggle(target.value)
213
+ highlightIndex.value = -1
214
+ }
215
+ }
216
+ }
217
+
218
+ function scrollToHighlighted() {
219
+ nextTick(() => {
220
+ if (!dropdownEl.value) return
221
+ const items = dropdownEl.value.querySelectorAll('[data-option]')
222
+ const item = items[highlightIndex.value] as HTMLElement | undefined
223
+ item?.scrollIntoView({ block: 'nearest' })
224
+ })
225
+ }
226
+
227
+ function getEnabledIndex(opt: MultiSelectOption): number {
228
+ return filteredOptions.value.filter((o) => !o.disabled).indexOf(opt)
229
+ }
230
+
231
+ onMounted(() => {
232
+ document.addEventListener('mousedown', onOutsideClick)
233
+ window.addEventListener('scroll', onScroll, true)
234
+ })
235
+ onUnmounted(() => {
236
+ document.removeEventListener('mousedown', onOutsideClick)
237
+ window.removeEventListener('scroll', onScroll, true)
238
+ })
239
+
240
+ watch(filteredOptions, () => {
241
+ if (open.value) nextTick(computeDropPos)
242
+ })
243
+
244
+ const isFloated = computed(() => hasValue.value || open.value)
245
+
246
+ const triggerClasses = computed(() => {
247
+ const base = [
248
+ 'flex min-h-[56px] w-full items-center gap-1.5 flex-wrap',
249
+ 'transition-[border-color,border-width] duration-150',
250
+ props.leadingIcon ? 'pl-12 pr-10' : 'pl-4 pr-10',
251
+ ]
252
+
253
+ if (props.variant === 'outlined') {
254
+ return [
255
+ ...base,
256
+ 'rounded-sm border bg-transparent py-2',
257
+ open.value
258
+ ? (props.error ? 'border-2 border-error' : 'border-2 border-primary')
259
+ : (props.error ? 'border-error' : 'border-outline hover:border-on-surface'),
260
+ ].join(' ')
261
+ }
262
+
263
+ return [
264
+ ...base,
265
+ 'rounded-t-sm bg-surface-container-highest border-b pb-2',
266
+ isFloated.value ? 'pt-7' : 'pt-4',
267
+ open.value
268
+ ? (props.error ? 'border-b-2 border-error' : 'border-b-2 border-primary')
269
+ : (props.error ? 'border-error' : 'border-on-surface-variant hover:border-on-surface'),
270
+ ].join(' ')
271
+ })
272
+
273
+ const labelClasses = computed(() => {
274
+ const left = props.leadingIcon
275
+ ? (props.variant === 'outlined' ? 'left-11' : 'left-12')
276
+ : (props.variant === 'outlined' ? 'left-3' : 'left-4')
277
+
278
+ const floated = props.variant === 'outlined'
279
+ ? '-top-2.5 translate-y-0 text-label-small bg-[var(--field-bg)] px-1 right-auto max-w-[calc(100%-1.5rem)]'
280
+ : 'top-2 translate-y-0 text-label-small'
281
+
282
+ const unFloated = props.variant === 'filled'
283
+ ? 'top-[53%] -translate-y-1/2 text-body-large'
284
+ : 'top-1/2 -translate-y-1/2 text-body-large'
285
+
286
+ return [
287
+ 'pointer-events-none absolute right-10 truncate transition-all duration-200',
288
+ left,
289
+ isFloated.value ? floated : unFloated,
290
+ open.value
291
+ ? (props.error ? 'text-error' : 'text-primary')
292
+ : (props.error ? 'text-error' : 'text-on-surface-variant'),
293
+ ].join(' ')
294
+ })
295
+ </script>
296
+
297
+ <template>
298
+ <div class="flex flex-col gap-1">
299
+ <div
300
+ ref="fieldEl"
301
+ class="relative"
302
+ :class="variant === 'outlined' ? 'mt-2' : ''"
303
+ :style="variant === 'outlined' ? { '--field-bg': resolvedFieldBg } : undefined"
304
+ >
305
+ <!-- Leading icon -->
306
+ <div
307
+ v-if="leadingIcon"
308
+ class="pointer-events-none absolute left-3.5 text-on-surface-variant"
309
+ :class="variant === 'filled' ? 'top-5' : 'top-4.5'"
310
+ >
311
+ <MIcon :name="leadingIcon" :size="20" />
312
+ </div>
313
+
314
+ <!-- Trigger field with chips and inline input -->
315
+ <div
316
+ :class="[
317
+ triggerClasses,
318
+ disabled ? 'pointer-events-none opacity-[0.38]' : 'cursor-text',
319
+ ]"
320
+ @click="inputEl?.focus()"
321
+ >
322
+ <template v-if="hasValue">
323
+ <span
324
+ v-for="(chip, i) in displayChips"
325
+ :key="i"
326
+ class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
327
+ >
328
+ {{ chip.label }}
329
+ <button
330
+ type="button"
331
+ class="flex h-4 w-4 items-center justify-center rounded-full hover:bg-on-secondary-container/20"
332
+ @click="removeChip(chip.value, $event)"
333
+ >
334
+ <MIcon name="close" :size="12" />
335
+ </button>
336
+ </span>
337
+ <span
338
+ v-if="overflowCount > 0 && !chipsExpanded"
339
+ :title="overflowLabels"
340
+ class="cursor-pointer rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant transition-colors hover:bg-on-surface/12"
341
+ @click.stop="chipsExpanded = true"
342
+ >
343
+ +{{ overflowCount }}
344
+ </span>
345
+ <span
346
+ v-if="chipsExpanded && modelValue.length > maxChips"
347
+ class="cursor-pointer rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant transition-colors hover:bg-on-surface/12"
348
+ @click.stop="chipsExpanded = false"
349
+ >
350
+ <MIcon name="unfold_less" :size="14" />
351
+ </span>
352
+ </template>
353
+
354
+ <input
355
+ :id="id"
356
+ ref="inputEl"
357
+ type="text"
358
+ :value="search"
359
+ :placeholder="!label && !hasValue ? placeholder : undefined"
360
+ :disabled="disabled"
361
+ autocomplete="off"
362
+ role="combobox"
363
+ :aria-expanded="open"
364
+ :aria-disabled="disabled"
365
+ class="flex-1 min-w-[60px] bg-transparent text-on-surface text-body-large outline-none placeholder:text-on-surface-variant"
366
+ @focus="onInputFocus"
367
+ @blur="onInputBlur"
368
+ @input="onInput"
369
+ @keydown="onKeydown"
370
+ />
371
+ </div>
372
+
373
+ <!-- Floating label -->
374
+ <label :for="id" :class="labelClasses">
375
+ {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
376
+ </label>
377
+
378
+ <!-- Clear button -->
379
+ <button
380
+ v-if="clearable && hasValue && !disabled"
381
+ type="button"
382
+ class="absolute right-9 top-4 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
383
+ @click.stop="emit('update:modelValue', []); closeDropdown()"
384
+ >
385
+ <MIcon name="close" :size="18" />
386
+ </button>
387
+
388
+ <!-- Arrow icon -->
389
+ <div class="pointer-events-none absolute right-2 top-4">
390
+ <MIcon
391
+ :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
392
+ :size="24"
393
+ class="text-on-surface-variant transition-transform duration-200"
394
+ />
395
+ </div>
396
+ </div>
397
+
398
+ <p v-if="error" class="px-4 text-body-small text-error">{{ error }}</p>
399
+ <p v-else-if="hint" class="px-4 text-body-small text-on-surface-variant">{{ hint }}</p>
400
+ </div>
401
+
402
+ <!-- Dropdown teleported to body to escape overflow clipping -->
403
+ <Teleport to="body">
404
+ <Transition
405
+ enter-active-class="transition-[opacity,transform] duration-150"
406
+ enter-from-class="opacity-0 -translate-y-1 scale-[0.98]"
407
+ enter-to-class="opacity-100 translate-y-0 scale-100"
408
+ leave-active-class="transition-[opacity,transform] duration-100"
409
+ leave-from-class="opacity-100 translate-y-0 scale-100"
410
+ leave-to-class="opacity-0 -translate-y-1 scale-[0.98]"
411
+ >
412
+ <div
413
+ v-if="open"
414
+ ref="dropdownEl"
415
+ class="fixed z-500 max-h-60 overflow-auto rounded-sm bg-surface-container shadow-elevation-2"
416
+ :style="dropPos"
417
+ >
418
+ <div class="flex flex-col py-1">
419
+ <label
420
+ v-for="(opt, i) in filteredOptions"
421
+ :key="i"
422
+ data-option
423
+ class="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-on-surface/8"
424
+ :class="[
425
+ opt.disabled ? 'cursor-not-allowed opacity-38' : '',
426
+ getEnabledIndex(opt) === highlightIndex && !opt.disabled ? 'bg-on-surface/12' : '',
427
+ ]"
428
+ @mousedown.prevent="!opt.disabled && toggle(opt.value)"
429
+ >
430
+ <MCheckbox
431
+ :model-value="includes(modelValue, opt.value)"
432
+ :disabled="opt.disabled"
433
+ />
434
+ <span class="text-body-large text-on-surface">{{ opt.label }}</span>
435
+ </label>
436
+ <p
437
+ v-if="!filteredOptions.length"
438
+ class="px-4 py-3 text-center text-body-small text-on-surface-variant"
439
+ >
440
+ {{ resolvedNoResultsText }}
441
+ </p>
442
+ </div>
443
+ </div>
444
+ </Transition>
445
+ </Teleport>
446
+ </template>
@@ -31,6 +31,7 @@ const props = withDefaults(
31
31
  clearable?: boolean
32
32
  searchPlaceholder?: string
33
33
  noResultsText?: string
34
+ hideSelected?: boolean
34
35
  }>(),
35
36
  {
36
37
  modelValue: () => [],
@@ -40,6 +41,7 @@ const props = withDefaults(
40
41
  searchable: true,
41
42
  maxChips: 3,
42
43
  clearable: false,
44
+ hideSelected: false,
43
45
  },
44
46
  )
45
47
 
@@ -67,9 +69,13 @@ function includes(arr: unknown[], val: unknown): boolean {
67
69
  const hasValue = computed(() => props.modelValue.length > 0)
68
70
 
69
71
  const filteredOptions = computed(() => {
70
- if (!search.value) return props.options
72
+ let opts = props.options
73
+ if (props.hideSelected) {
74
+ opts = opts.filter((o) => !includes(props.modelValue, o.value))
75
+ }
76
+ if (!search.value) return opts
71
77
  const q = search.value.toLowerCase()
72
- return props.options.filter((o) => o.label.toLowerCase().includes(q))
78
+ return opts.filter((o) => o.label.toLowerCase().includes(q))
73
79
  })
74
80
 
75
81
  const visibleChips = computed(() =>
@@ -80,6 +86,21 @@ const visibleChips = computed(() =>
80
86
  )
81
87
 
82
88
  const overflowCount = computed(() => Math.max(0, props.modelValue.length - props.maxChips))
89
+ const chipsExpanded = ref(false)
90
+
91
+ const displayChips = computed(() => {
92
+ const all = props.modelValue.map((v) => ({
93
+ value: v,
94
+ label: props.options.find((o) => eq(o.value, v))?.label ?? String(v),
95
+ }))
96
+ return chipsExpanded.value ? all : all.slice(0, props.maxChips)
97
+ })
98
+
99
+ const overflowLabels = computed(() =>
100
+ props.modelValue.slice(props.maxChips).map((v) =>
101
+ props.options.find((o) => eq(o.value, v))?.label ?? String(v),
102
+ ).join(', '),
103
+ )
83
104
 
84
105
  function toggle(value: unknown) {
85
106
  const current = props.modelValue
@@ -208,7 +229,7 @@ const labelClasses = computed(() => {
208
229
  <div
209
230
  v-if="leadingIcon"
210
231
  class="pointer-events-none absolute left-3.5 text-on-surface-variant"
211
- :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
232
+ :class="variant === 'filled' ? 'top-5' : 'top-4.5'"
212
233
  >
213
234
  <MIcon :name="leadingIcon" :size="20" />
214
235
  </div>
@@ -228,7 +249,7 @@ const labelClasses = computed(() => {
228
249
  >
229
250
  <template v-if="hasValue">
230
251
  <span
231
- v-for="(chip, i) in visibleChips"
252
+ v-for="(chip, i) in displayChips"
232
253
  :key="i"
233
254
  class="inline-flex items-center gap-1 rounded-full bg-secondary-container px-2 py-0.5 text-label-small text-on-secondary-container"
234
255
  >
@@ -242,11 +263,20 @@ const labelClasses = computed(() => {
242
263
  </button>
243
264
  </span>
244
265
  <span
245
- v-if="overflowCount > 0"
246
- class="rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant"
266
+ v-if="overflowCount > 0 && !chipsExpanded"
267
+ :title="overflowLabels"
268
+ class="cursor-pointer rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant transition-colors hover:bg-on-surface/12"
269
+ @click.stop="chipsExpanded = true"
247
270
  >
248
271
  +{{ overflowCount }}
249
272
  </span>
273
+ <span
274
+ v-if="chipsExpanded && modelValue.length > maxChips"
275
+ class="cursor-pointer rounded-full bg-surface-container-high px-2 py-0.5 text-label-small text-on-surface-variant transition-colors hover:bg-on-surface/12"
276
+ @click.stop="chipsExpanded = false"
277
+ >
278
+ <MIcon name="unfold_less" :size="14" />
279
+ </span>
250
280
  </template>
251
281
  <span v-else-if="!open" class="text-body-large text-on-surface-variant opacity-0">
252
282
  {{ placeholder }}
@@ -260,14 +290,13 @@ const labelClasses = computed(() => {
260
290
  <button
261
291
  v-if="clearable && hasValue && !disabled"
262
292
  type="button"
263
- class="absolute right-9 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
264
- :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
293
+ class="absolute right-9 top-4 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
265
294
  @click.stop="emit('update:modelValue', []); close()"
266
295
  >
267
296
  <MIcon name="close" :size="18" />
268
297
  </button>
269
298
 
270
- <div class="pointer-events-none absolute right-2" :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'">
299
+ <div class="pointer-events-none absolute right-2 top-4">
271
300
  <MIcon
272
301
  :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
273
302
  :size="24"
@@ -256,6 +256,7 @@ watch(
256
256
  </div>
257
257
  </Transition>
258
258
  </template>
259
+ <slot />
259
260
  </div>
260
261
  </nav>
261
262
  </div>
@@ -403,6 +404,7 @@ watch(
403
404
  </div>
404
405
  </Transition>
405
406
  </template>
407
+ <slot />
406
408
  </div>
407
409
  </nav>
408
410
  </template>
@@ -129,7 +129,7 @@ const labelClasses = computed(() => {
129
129
  <div
130
130
  v-if="leadingIcon"
131
131
  class="pointer-events-none absolute left-3.5 text-on-surface-variant"
132
- :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'"
132
+ :class="variant === 'filled' ? 'top-5' : 'top-4.5'"
133
133
  >
134
134
  <MIcon :name="leadingIcon" :size="20" />
135
135
  </div>
@@ -153,7 +153,7 @@ const labelClasses = computed(() => {
153
153
  {{ label }}<span v-if="required" class="text-error">&nbsp;*</span>
154
154
  </label>
155
155
 
156
- <div v-if="stepper" class="absolute right-1 flex items-center gap-0.5" :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'">
156
+ <div v-if="stepper" class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
157
157
  <MIconButton icon="remove" label="Decrease" :size="32" :disabled="disabled || (min !== undefined && (modelValue ?? 0) <= min)" @click="decrement" />
158
158
  <MIconButton icon="add" label="Increase" :size="32" :disabled="disabled || (max !== undefined && (modelValue ?? 0) >= max)" @click="increment" />
159
159
  </div>
@@ -188,7 +188,8 @@ const labelClasses = computed(() => {
188
188
  <!-- Leading icon -->
189
189
  <div
190
190
  v-if="leadingIcon"
191
- class="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-on-surface-variant"
191
+ class="pointer-events-none absolute left-3.5 text-on-surface-variant"
192
+ :class="variant === 'filled' ? 'top-5' : 'top-4.5'"
192
193
  >
193
194
  <MIcon :name="leadingIcon" :size="20" />
194
195
  </div>
@@ -216,15 +217,14 @@ const labelClasses = computed(() => {
216
217
  <button
217
218
  v-if="clearable && hasValue && !disabled"
218
219
  type="button"
219
- class="absolute flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
220
- :class="variant === 'filled' ? 'right-9 top-[57%] -translate-y-1/2' : 'right-9 top-[55%] -translate-y-1/2'"
220
+ class="absolute right-9 top-1/2 -translate-y-1/2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-on-surface-variant transition-colors hover:bg-on-surface/8 hover:text-on-surface"
221
221
  @click.stop="emit('update:modelValue', undefined as any); open = false"
222
222
  >
223
223
  <MIcon name="close" :size="18" />
224
224
  </button>
225
225
 
226
226
  <!-- Arrow icon -->
227
- <div class="pointer-events-none absolute right-2" :class="variant === 'filled' ? 'top-[57%] -translate-y-1/2' : 'top-[55%] -translate-y-1/2'">
227
+ <div class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 flex h-6 items-center">
228
228
  <MIcon
229
229
  :name="open ? 'arrow_drop_up' : 'arrow_drop_down'"
230
230
  :size="24"