@dnax/ui 0.0.14 → 0.0.15
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/components/QAutocomplete.vue +138 -7
- package/package.json +1 -1
- package/styles/main.css +124 -0
|
@@ -8,6 +8,7 @@ import { icons } from "../lib/icons"
|
|
|
8
8
|
import { cn } from "../lib/utils"
|
|
9
9
|
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
10
10
|
import type { RadiusProp } from "../lib/useComponentProps"
|
|
11
|
+
import { useOverlayBack } from "../lib/overlayBack"
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
/** Valeur sélectionnée */
|
|
@@ -38,6 +39,14 @@ interface Props {
|
|
|
38
39
|
readonly?: boolean
|
|
39
40
|
loading?: boolean
|
|
40
41
|
noOptionsLabel?: string
|
|
42
|
+
/** Mode d'ouverture : inline (dropdown) | modal (centré) | sheet (bottom sheet) */
|
|
43
|
+
mode?: "inline" | "modal" | "sheet"
|
|
44
|
+
/** Largeur du panneau (modal/sheet) */
|
|
45
|
+
width?: string
|
|
46
|
+
/** Hauteur de la liste dans les modes panneau (modal/sheet) */
|
|
47
|
+
height?: string
|
|
48
|
+
/** Titre du panneau (défaut : label → placeholder) */
|
|
49
|
+
title?: string
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -55,6 +64,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
55
64
|
readonly: false,
|
|
56
65
|
loading: false,
|
|
57
66
|
noOptionsLabel: "No options to display",
|
|
67
|
+
mode: "inline",
|
|
58
68
|
})
|
|
59
69
|
|
|
60
70
|
const emit = defineEmits<{
|
|
@@ -78,11 +88,34 @@ const hasFilter = computed(() => !!instance?.vnode.props?.onFilter)
|
|
|
78
88
|
|
|
79
89
|
const rootEl = ref<HTMLElement | null>(null)
|
|
80
90
|
const popupRef = ref<HTMLElement | null>(null)
|
|
91
|
+
const sheetRef = ref<HTMLElement | null>(null)
|
|
92
|
+
const panelInput = ref<HTMLInputElement | null>(null)
|
|
81
93
|
const focused = ref(false)
|
|
82
94
|
const open = ref(false)
|
|
83
95
|
const query = ref(props.inputValue ?? "")
|
|
84
96
|
const activeIndex = ref(0)
|
|
85
97
|
|
|
98
|
+
// — Mode panneau (modal / sheet) —
|
|
99
|
+
const panelMode = computed(() => props.mode !== "inline")
|
|
100
|
+
|
|
101
|
+
// « Retour » navigateur → ferme le panneau au lieu de naviguer
|
|
102
|
+
const panelOpen = computed(() => panelMode.value && open.value)
|
|
103
|
+
useOverlayBack(panelOpen, () => closePopup(), "QAutocomplete")
|
|
104
|
+
|
|
105
|
+
const sheetStyle = computed<Record<string, string>>(() => {
|
|
106
|
+
const style: Record<string, string> = {}
|
|
107
|
+
if (props.mode !== "inline" && props.width) style.width = props.width
|
|
108
|
+
return style
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const sheetListStyle = computed<Record<string, string>>(() => {
|
|
112
|
+
const style: Record<string, string> = {}
|
|
113
|
+
if (props.height) style.height = props.height
|
|
114
|
+
return style
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const sheetTitle = computed(() => props.title ?? props.label ?? props.placeholder ?? "Select")
|
|
118
|
+
|
|
86
119
|
// — Valeurs des options —
|
|
87
120
|
const getValue = (opt: any): any =>
|
|
88
121
|
typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
|
|
@@ -136,6 +169,11 @@ const openPopup = () => {
|
|
|
136
169
|
if (props.disable || props.readonly) return
|
|
137
170
|
activeIndex.value = 0
|
|
138
171
|
open.value = true
|
|
172
|
+
if (panelMode.value) nextTick(() => panelInput.value?.focus())
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const closePopup = () => {
|
|
176
|
+
open.value = false
|
|
139
177
|
}
|
|
140
178
|
|
|
141
179
|
const onInput = (e: Event) => {
|
|
@@ -153,7 +191,7 @@ const onSelect = (opt: any) => {
|
|
|
153
191
|
emit("update:modelValue", getValue(opt))
|
|
154
192
|
emit("update:inputValue", label)
|
|
155
193
|
query.value = label
|
|
156
|
-
|
|
194
|
+
closePopup()
|
|
157
195
|
}
|
|
158
196
|
|
|
159
197
|
const onClear = () => {
|
|
@@ -186,8 +224,12 @@ const onFocus = (e: FocusEvent) => {
|
|
|
186
224
|
|
|
187
225
|
const onBlur = (e: FocusEvent) => {
|
|
188
226
|
focused.value = false
|
|
189
|
-
|
|
190
|
-
commit
|
|
227
|
+
// En mode panneau, la perte de focus du champ ne ferme pas (l'input du
|
|
228
|
+
// panneau prend le relais) ; en inline, perte de focus → fermeture + commit
|
|
229
|
+
if (!panelMode.value) {
|
|
230
|
+
open.value = false
|
|
231
|
+
commit()
|
|
232
|
+
}
|
|
191
233
|
emit("blur", e)
|
|
192
234
|
}
|
|
193
235
|
|
|
@@ -228,9 +270,12 @@ watch(activeIndex, async () => {
|
|
|
228
270
|
el?.scrollIntoView({ block: "nearest" })
|
|
229
271
|
})
|
|
230
272
|
|
|
231
|
-
// Fermeture au clic extérieur
|
|
273
|
+
// Fermeture au clic extérieur (inline) / clic hors panneau (modal/sheet)
|
|
232
274
|
const onDocMousedown = (e: MouseEvent) => {
|
|
233
|
-
|
|
275
|
+
const target = e.target as Node
|
|
276
|
+
if (rootEl.value?.contains(target)) return
|
|
277
|
+
if (sheetRef.value?.contains(target)) return
|
|
278
|
+
open.value = false
|
|
234
279
|
}
|
|
235
280
|
|
|
236
281
|
onMounted(() => {
|
|
@@ -239,10 +284,17 @@ onMounted(() => {
|
|
|
239
284
|
onBeforeUnmount(() => {
|
|
240
285
|
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
241
286
|
})
|
|
287
|
+
|
|
288
|
+
// Verrou du scroll de la page derrière le panneau (modal/sheet)
|
|
289
|
+
watch(open, (v) => {
|
|
290
|
+
if (typeof document !== "undefined" && panelMode.value) {
|
|
291
|
+
document.body.style.overflow = v ? "hidden" : ""
|
|
292
|
+
}
|
|
293
|
+
})
|
|
242
294
|
</script>
|
|
243
295
|
|
|
244
296
|
<template>
|
|
245
|
-
<div ref="rootEl" class="q-autocomplete" :class="fieldClasses" :style="roundedStyle">
|
|
297
|
+
<div ref="rootEl" class="q-autocomplete" :class="fieldClasses" :style="roundedStyle" v-bind="$attrs">
|
|
246
298
|
<label v-if="label" class="q-field__label-stack">{{ label }}</label>
|
|
247
299
|
<div class="q-field__control">
|
|
248
300
|
<input
|
|
@@ -283,7 +335,7 @@ onBeforeUnmount(() => {
|
|
|
283
335
|
</div>
|
|
284
336
|
|
|
285
337
|
<div
|
|
286
|
-
v-if="open"
|
|
338
|
+
v-if="open && mode === 'inline'"
|
|
287
339
|
ref="popupRef"
|
|
288
340
|
class="q-autocomplete__popup"
|
|
289
341
|
role="listbox"
|
|
@@ -312,5 +364,84 @@ onBeforeUnmount(() => {
|
|
|
312
364
|
<slot name="noOptions">{{ noOptionsLabel }}</slot>
|
|
313
365
|
</div>
|
|
314
366
|
</div>
|
|
367
|
+
|
|
368
|
+
<!-- Mode modal / sheet : panneau téléporté (header + recherche + liste) -->
|
|
369
|
+
<Teleport to="body">
|
|
370
|
+
<Transition name="q-autocomplete-modal">
|
|
371
|
+
<div
|
|
372
|
+
v-if="open && mode !== 'inline'"
|
|
373
|
+
class="q-autocomplete__overlay"
|
|
374
|
+
:class="`q-autocomplete__overlay--${mode}`"
|
|
375
|
+
role="presentation"
|
|
376
|
+
@mousedown.self="closePopup"
|
|
377
|
+
>
|
|
378
|
+
<div
|
|
379
|
+
ref="sheetRef"
|
|
380
|
+
class="q-autocomplete__sheet"
|
|
381
|
+
:class="`q-autocomplete__sheet--${mode}`"
|
|
382
|
+
:style="sheetStyle"
|
|
383
|
+
role="dialog"
|
|
384
|
+
aria-modal="true"
|
|
385
|
+
:aria-label="sheetTitle"
|
|
386
|
+
>
|
|
387
|
+
<div class="q-autocomplete__sheet-header">
|
|
388
|
+
<span class="q-autocomplete__sheet-title">{{ sheetTitle }}</span>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
class="q-autocomplete__sheet-close"
|
|
392
|
+
:aria-label="`Fermer ${sheetTitle}`"
|
|
393
|
+
@click="closePopup"
|
|
394
|
+
>
|
|
395
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
<div class="q-autocomplete__search">
|
|
399
|
+
<Icon :icon="icons.search" class="q-autocomplete__search-icon" aria-hidden="true" />
|
|
400
|
+
<input
|
|
401
|
+
ref="panelInput"
|
|
402
|
+
class="q-autocomplete__search-input"
|
|
403
|
+
:value="query"
|
|
404
|
+
:placeholder="placeholder ?? 'Search…'"
|
|
405
|
+
role="combobox"
|
|
406
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
407
|
+
aria-autocomplete="list"
|
|
408
|
+
@input="onInput"
|
|
409
|
+
@keydown="onKeydown"
|
|
410
|
+
/>
|
|
411
|
+
</div>
|
|
412
|
+
<div
|
|
413
|
+
ref="popupRef"
|
|
414
|
+
class="q-autocomplete__sheet-list"
|
|
415
|
+
:style="sheetListStyle"
|
|
416
|
+
role="listbox"
|
|
417
|
+
@mousedown.prevent
|
|
418
|
+
>
|
|
419
|
+
<template v-if="filtered.length">
|
|
420
|
+
<div
|
|
421
|
+
v-for="(opt, i) in filtered"
|
|
422
|
+
:key="String(getValue(opt))"
|
|
423
|
+
role="option"
|
|
424
|
+
:aria-selected="i === activeIndex ? 'true' : 'false'"
|
|
425
|
+
class="q-autocomplete__item"
|
|
426
|
+
:class="{
|
|
427
|
+
'q-autocomplete__item--active': i === activeIndex,
|
|
428
|
+
'q-autocomplete__item--selected': isSelected(opt),
|
|
429
|
+
}"
|
|
430
|
+
@mouseenter="activeIndex = i"
|
|
431
|
+
@mousedown.prevent="onSelect(opt)"
|
|
432
|
+
>
|
|
433
|
+
<slot :option="opt" :index="i" :selected="isSelected(opt)" :active="i === activeIndex">
|
|
434
|
+
<span class="q-autocomplete__label">{{ getLabel(opt) }}</span>
|
|
435
|
+
</slot>
|
|
436
|
+
</div>
|
|
437
|
+
</template>
|
|
438
|
+
<div v-else class="q-autocomplete__empty">
|
|
439
|
+
<slot name="noOptions">{{ noOptionsLabel }}</slot>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
</Transition>
|
|
445
|
+
</Teleport>
|
|
315
446
|
</div>
|
|
316
447
|
</template>
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -3420,6 +3420,130 @@ html.dark body {
|
|
|
3420
3420
|
}
|
|
3421
3421
|
}
|
|
3422
3422
|
|
|
3423
|
+
/* — modes modal / sheet : panneau téléporté — */
|
|
3424
|
+
.q-autocomplete__overlay {
|
|
3425
|
+
position: fixed;
|
|
3426
|
+
inset: 0;
|
|
3427
|
+
z-index: 3000;
|
|
3428
|
+
display: flex;
|
|
3429
|
+
align-items: center;
|
|
3430
|
+
justify-content: center;
|
|
3431
|
+
background-color: rgb(0 0 0 / 0.5);
|
|
3432
|
+
}
|
|
3433
|
+
.q-autocomplete__overlay--sheet {
|
|
3434
|
+
align-items: flex-end;
|
|
3435
|
+
}
|
|
3436
|
+
.q-autocomplete__sheet {
|
|
3437
|
+
display: flex;
|
|
3438
|
+
flex-direction: column;
|
|
3439
|
+
width: 100%;
|
|
3440
|
+
max-width: 640px;
|
|
3441
|
+
max-height: 70vh;
|
|
3442
|
+
overflow: hidden;
|
|
3443
|
+
background-color: #fff;
|
|
3444
|
+
color: inherit;
|
|
3445
|
+
border-radius: var(--q-radius);
|
|
3446
|
+
box-shadow: 0 12px 40px rgb(0 0 0 / 0.3);
|
|
3447
|
+
}
|
|
3448
|
+
.q-autocomplete__overlay--modal .q-autocomplete__sheet {
|
|
3449
|
+
max-width: 480px;
|
|
3450
|
+
max-height: 60vh;
|
|
3451
|
+
}
|
|
3452
|
+
.q-autocomplete__sheet--sheet {
|
|
3453
|
+
border-radius: 16px 16px 0 0;
|
|
3454
|
+
box-shadow: 0 -4px 24px rgb(0 0 0 / 0.2);
|
|
3455
|
+
padding-bottom: 0;
|
|
3456
|
+
padding-bottom: constant(safe-area-inset-bottom);
|
|
3457
|
+
padding-bottom: env(safe-area-inset-bottom);
|
|
3458
|
+
}
|
|
3459
|
+
.q-autocomplete__sheet-header {
|
|
3460
|
+
display: flex;
|
|
3461
|
+
align-items: center;
|
|
3462
|
+
justify-content: space-between;
|
|
3463
|
+
gap: 8px;
|
|
3464
|
+
padding: 14px 16px;
|
|
3465
|
+
border-bottom: 1px solid rgb(0 0 0 / 0.08);
|
|
3466
|
+
font-weight: 600;
|
|
3467
|
+
font-size: 15px;
|
|
3468
|
+
}
|
|
3469
|
+
.q-autocomplete__sheet-title {
|
|
3470
|
+
overflow: hidden;
|
|
3471
|
+
text-overflow: ellipsis;
|
|
3472
|
+
white-space: nowrap;
|
|
3473
|
+
}
|
|
3474
|
+
.q-autocomplete__sheet-close {
|
|
3475
|
+
display: inline-flex;
|
|
3476
|
+
align-items: center;
|
|
3477
|
+
justify-content: center;
|
|
3478
|
+
width: 28px;
|
|
3479
|
+
height: 28px;
|
|
3480
|
+
padding: 0;
|
|
3481
|
+
border: none;
|
|
3482
|
+
border-radius: 50%;
|
|
3483
|
+
background: transparent;
|
|
3484
|
+
color: inherit;
|
|
3485
|
+
cursor: pointer;
|
|
3486
|
+
flex-shrink: 0;
|
|
3487
|
+
}
|
|
3488
|
+
.q-autocomplete__sheet-close:hover {
|
|
3489
|
+
background-color: rgb(0 0 0 / 0.06);
|
|
3490
|
+
}
|
|
3491
|
+
.q-autocomplete__sheet-close svg {
|
|
3492
|
+
width: 16px;
|
|
3493
|
+
height: 16px;
|
|
3494
|
+
}
|
|
3495
|
+
.q-autocomplete__search {
|
|
3496
|
+
display: flex;
|
|
3497
|
+
align-items: center;
|
|
3498
|
+
gap: 8px;
|
|
3499
|
+
margin: 10px 12px 6px;
|
|
3500
|
+
padding: 0 10px;
|
|
3501
|
+
border: 1px solid rgb(0 0 0 / 0.15);
|
|
3502
|
+
border-radius: 6px;
|
|
3503
|
+
background-color: rgb(0 0 0 / 0.03);
|
|
3504
|
+
}
|
|
3505
|
+
.q-autocomplete__search-icon {
|
|
3506
|
+
width: 15px;
|
|
3507
|
+
height: 15px;
|
|
3508
|
+
flex-shrink: 0;
|
|
3509
|
+
color: rgb(0 0 0 / 0.5);
|
|
3510
|
+
}
|
|
3511
|
+
.q-autocomplete__search-input {
|
|
3512
|
+
flex: 1;
|
|
3513
|
+
min-width: 0;
|
|
3514
|
+
height: 32px;
|
|
3515
|
+
padding: 0;
|
|
3516
|
+
border: none;
|
|
3517
|
+
outline: none;
|
|
3518
|
+
background: transparent;
|
|
3519
|
+
color: inherit;
|
|
3520
|
+
font: inherit;
|
|
3521
|
+
font-size: 14px;
|
|
3522
|
+
}
|
|
3523
|
+
.q-autocomplete__search-input::placeholder {
|
|
3524
|
+
color: rgb(0 0 0 / 0.45);
|
|
3525
|
+
}
|
|
3526
|
+
.q-autocomplete__sheet-list {
|
|
3527
|
+
overflow-y: auto;
|
|
3528
|
+
padding: 6px;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
/* — transition du panneau (réutilise les keyframes q-sheet-pop-*) — */
|
|
3532
|
+
.q-autocomplete-modal-enter-active,
|
|
3533
|
+
.q-autocomplete-modal-leave-active {
|
|
3534
|
+
transition: opacity 0.2s ease;
|
|
3535
|
+
}
|
|
3536
|
+
.q-autocomplete-modal-enter-from,
|
|
3537
|
+
.q-autocomplete-modal-leave-to {
|
|
3538
|
+
opacity: 0;
|
|
3539
|
+
}
|
|
3540
|
+
.q-autocomplete-modal-enter-active .q-autocomplete__sheet {
|
|
3541
|
+
animation: q-sheet-pop-in 0.25s ease;
|
|
3542
|
+
}
|
|
3543
|
+
.q-autocomplete-modal-leave-active .q-autocomplete__sheet {
|
|
3544
|
+
animation: q-sheet-pop-out 0.2s ease forwards;
|
|
3545
|
+
}
|
|
3546
|
+
|
|
3423
3547
|
/* ===== QNavMenu ===== */
|
|
3424
3548
|
.q-nav-menu {
|
|
3425
3549
|
position: relative;
|