@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.
- package/dist/{MMenuItem-DpoEsH91.js → MMenuItem-C6RwHl-l.js} +14 -14
- package/dist/{MMenuItem-DpoEsH91.js.map → MMenuItem-C6RwHl-l.js.map} +1 -1
- package/dist/components/MAutocomplete.vue.d.ts +29 -0
- package/dist/components/MMultiAutocomplete.vue.d.ts +33 -0
- package/dist/components/MMultiSelect.vue.d.ts +5 -3
- package/dist/components/MNavigationDrawer.vue.d.ts +7 -3
- package/dist/components/MSlider.vue.d.ts +1 -1
- package/dist/components/MTagInput.vue.d.ts +29 -0
- package/dist/components/MTextField.vue.d.ts +1 -1
- package/dist/components/MWindow.vue.d.ts +53 -0
- package/dist/index.d.ts +4 -0
- package/dist/m3ui-vue.css +1 -1
- package/dist/m3ui.js +1899 -986
- package/dist/m3ui.js.map +1 -1
- package/dist/rich-text-editor.js +1 -1
- package/dist/styles.css +1 -1
- package/package.json +1 -1
- package/src/components/MAppBar.vue +17 -13
- package/src/components/MAutocomplete.vue +373 -0
- package/src/components/MMaskField.vue +2 -3
- package/src/components/MMultiAutocomplete.vue +446 -0
- package/src/components/MMultiSelect.vue +38 -9
- package/src/components/MNavigationDrawer.vue +2 -0
- package/src/components/MNumberField.vue +2 -2
- package/src/components/MSelect.vue +4 -4
- package/src/components/MTagInput.vue +223 -0
- package/src/components/MTextField.vue +3 -4
- package/src/components/MWindow.vue +295 -0
- package/src/index.ts +4 -0
|
@@ -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"> *</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
|
-
|
|
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
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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"
|
|
@@ -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-
|
|
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"> *</span>
|
|
154
154
|
</label>
|
|
155
155
|
|
|
156
|
-
<div v-if="stepper" class="absolute right-1
|
|
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
|
|
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
|
|
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"
|