@dnax/ui 0.0.14 → 0.0.16
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 +176 -7
- package/package.json +1 -1
- package/styles/main.css +134 -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,33 @@ 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
|
|
50
|
+
/** Options spécifiques au mode sheet (width, height, title, style, class, searchPlaceholder) */
|
|
51
|
+
sheetOptions?: QAutocompleteModeOptions
|
|
52
|
+
/** Options spécifiques au mode modal */
|
|
53
|
+
modalOptions?: QAutocompleteModeOptions
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface QAutocompleteModeOptions {
|
|
57
|
+
/** Largeur du panneau (remplace width) */
|
|
58
|
+
width?: string
|
|
59
|
+
/** Hauteur de la liste scrollable (remplace height) */
|
|
60
|
+
height?: string
|
|
61
|
+
/** Titre du panneau (remplace title) */
|
|
62
|
+
title?: string
|
|
63
|
+
/** Style appliqué au panneau */
|
|
64
|
+
style?: Record<string, string>
|
|
65
|
+
/** Classes ajoutées au panneau */
|
|
66
|
+
class?: string
|
|
67
|
+
/** Placeholder du champ de recherche du panneau */
|
|
68
|
+
searchPlaceholder?: string
|
|
41
69
|
}
|
|
42
70
|
|
|
43
71
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -55,6 +83,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
55
83
|
readonly: false,
|
|
56
84
|
loading: false,
|
|
57
85
|
noOptionsLabel: "No options to display",
|
|
86
|
+
mode: "inline",
|
|
58
87
|
})
|
|
59
88
|
|
|
60
89
|
const emit = defineEmits<{
|
|
@@ -78,11 +107,53 @@ const hasFilter = computed(() => !!instance?.vnode.props?.onFilter)
|
|
|
78
107
|
|
|
79
108
|
const rootEl = ref<HTMLElement | null>(null)
|
|
80
109
|
const popupRef = ref<HTMLElement | null>(null)
|
|
110
|
+
const sheetRef = ref<HTMLElement | null>(null)
|
|
111
|
+
const panelInput = ref<HTMLInputElement | null>(null)
|
|
81
112
|
const focused = ref(false)
|
|
82
113
|
const open = ref(false)
|
|
83
114
|
const query = ref(props.inputValue ?? "")
|
|
84
115
|
const activeIndex = ref(0)
|
|
85
116
|
|
|
117
|
+
// — Mode panneau (modal / sheet) —
|
|
118
|
+
const panelMode = computed(() => props.mode !== "inline")
|
|
119
|
+
|
|
120
|
+
// Options du mode courant (sheet/modal) — surchargent les props directes
|
|
121
|
+
const modeOptions = computed<QAutocompleteModeOptions | undefined>(() => {
|
|
122
|
+
if (props.mode === "sheet") return props.sheetOptions
|
|
123
|
+
if (props.mode === "modal") return props.modalOptions
|
|
124
|
+
return undefined
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const panelWidth = computed(() => modeOptions.value?.width ?? props.width)
|
|
128
|
+
const panelHeight = computed(() => modeOptions.value?.height ?? props.height)
|
|
129
|
+
const panelSearchPlaceholder = computed(
|
|
130
|
+
() => modeOptions.value?.searchPlaceholder ?? props.placeholder ?? "Search…",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// « Retour » navigateur → ferme le panneau au lieu de naviguer
|
|
134
|
+
const panelOpen = computed(() => panelMode.value && open.value)
|
|
135
|
+
useOverlayBack(panelOpen, () => closePopup(), "QAutocomplete")
|
|
136
|
+
|
|
137
|
+
const sheetStyle = computed<Record<string, string>>(() => {
|
|
138
|
+
const style: Record<string, string> = {}
|
|
139
|
+
if (props.mode !== "inline" && panelWidth.value) {
|
|
140
|
+
// width ET maxWidth : sinon le max-width CSS (640px) plafonne le panneau
|
|
141
|
+
style.width = panelWidth.value
|
|
142
|
+
style.maxWidth = panelWidth.value
|
|
143
|
+
}
|
|
144
|
+
return style
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const sheetListStyle = computed<Record<string, string>>(() => {
|
|
148
|
+
const style: Record<string, string> = {}
|
|
149
|
+
if (panelHeight.value) style.height = panelHeight.value
|
|
150
|
+
return style
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const sheetTitle = computed(
|
|
154
|
+
() => modeOptions.value?.title ?? props.title ?? props.label ?? props.placeholder ?? "Select",
|
|
155
|
+
)
|
|
156
|
+
|
|
86
157
|
// — Valeurs des options —
|
|
87
158
|
const getValue = (opt: any): any =>
|
|
88
159
|
typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
|
|
@@ -136,6 +207,11 @@ const openPopup = () => {
|
|
|
136
207
|
if (props.disable || props.readonly) return
|
|
137
208
|
activeIndex.value = 0
|
|
138
209
|
open.value = true
|
|
210
|
+
if (panelMode.value) nextTick(() => panelInput.value?.focus())
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const closePopup = () => {
|
|
214
|
+
open.value = false
|
|
139
215
|
}
|
|
140
216
|
|
|
141
217
|
const onInput = (e: Event) => {
|
|
@@ -153,7 +229,7 @@ const onSelect = (opt: any) => {
|
|
|
153
229
|
emit("update:modelValue", getValue(opt))
|
|
154
230
|
emit("update:inputValue", label)
|
|
155
231
|
query.value = label
|
|
156
|
-
|
|
232
|
+
closePopup()
|
|
157
233
|
}
|
|
158
234
|
|
|
159
235
|
const onClear = () => {
|
|
@@ -186,8 +262,12 @@ const onFocus = (e: FocusEvent) => {
|
|
|
186
262
|
|
|
187
263
|
const onBlur = (e: FocusEvent) => {
|
|
188
264
|
focused.value = false
|
|
189
|
-
|
|
190
|
-
commit
|
|
265
|
+
// En mode panneau, la perte de focus du champ ne ferme pas (l'input du
|
|
266
|
+
// panneau prend le relais) ; en inline, perte de focus → fermeture + commit
|
|
267
|
+
if (!panelMode.value) {
|
|
268
|
+
open.value = false
|
|
269
|
+
commit()
|
|
270
|
+
}
|
|
191
271
|
emit("blur", e)
|
|
192
272
|
}
|
|
193
273
|
|
|
@@ -228,9 +308,12 @@ watch(activeIndex, async () => {
|
|
|
228
308
|
el?.scrollIntoView({ block: "nearest" })
|
|
229
309
|
})
|
|
230
310
|
|
|
231
|
-
// Fermeture au clic extérieur
|
|
311
|
+
// Fermeture au clic extérieur (inline) / clic hors panneau (modal/sheet)
|
|
232
312
|
const onDocMousedown = (e: MouseEvent) => {
|
|
233
|
-
|
|
313
|
+
const target = e.target as Node
|
|
314
|
+
if (rootEl.value?.contains(target)) return
|
|
315
|
+
if (sheetRef.value?.contains(target)) return
|
|
316
|
+
open.value = false
|
|
234
317
|
}
|
|
235
318
|
|
|
236
319
|
onMounted(() => {
|
|
@@ -239,10 +322,17 @@ onMounted(() => {
|
|
|
239
322
|
onBeforeUnmount(() => {
|
|
240
323
|
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
241
324
|
})
|
|
325
|
+
|
|
326
|
+
// Verrou du scroll de la page derrière le panneau (modal/sheet)
|
|
327
|
+
watch(open, (v) => {
|
|
328
|
+
if (typeof document !== "undefined" && panelMode.value) {
|
|
329
|
+
document.body.style.overflow = v ? "hidden" : ""
|
|
330
|
+
}
|
|
331
|
+
})
|
|
242
332
|
</script>
|
|
243
333
|
|
|
244
334
|
<template>
|
|
245
|
-
<div ref="rootEl" class="q-autocomplete" :class="fieldClasses" :style="roundedStyle">
|
|
335
|
+
<div ref="rootEl" class="q-autocomplete" :class="fieldClasses" :style="roundedStyle" v-bind="$attrs">
|
|
246
336
|
<label v-if="label" class="q-field__label-stack">{{ label }}</label>
|
|
247
337
|
<div class="q-field__control">
|
|
248
338
|
<input
|
|
@@ -283,7 +373,7 @@ onBeforeUnmount(() => {
|
|
|
283
373
|
</div>
|
|
284
374
|
|
|
285
375
|
<div
|
|
286
|
-
v-if="open"
|
|
376
|
+
v-if="open && mode === 'inline'"
|
|
287
377
|
ref="popupRef"
|
|
288
378
|
class="q-autocomplete__popup"
|
|
289
379
|
role="listbox"
|
|
@@ -312,5 +402,84 @@ onBeforeUnmount(() => {
|
|
|
312
402
|
<slot name="noOptions">{{ noOptionsLabel }}</slot>
|
|
313
403
|
</div>
|
|
314
404
|
</div>
|
|
405
|
+
|
|
406
|
+
<!-- Mode modal / sheet : panneau téléporté (header + recherche + liste) -->
|
|
407
|
+
<Teleport to="body">
|
|
408
|
+
<Transition name="q-autocomplete-modal">
|
|
409
|
+
<div
|
|
410
|
+
v-if="open && mode !== 'inline'"
|
|
411
|
+
class="q-autocomplete__overlay"
|
|
412
|
+
:class="`q-autocomplete__overlay--${mode}`"
|
|
413
|
+
role="presentation"
|
|
414
|
+
@mousedown.self="closePopup"
|
|
415
|
+
>
|
|
416
|
+
<div
|
|
417
|
+
ref="sheetRef"
|
|
418
|
+
class="q-autocomplete__sheet"
|
|
419
|
+
:class="[`q-autocomplete__sheet--${mode}`, modeOptions?.class]"
|
|
420
|
+
:style="[sheetStyle, modeOptions?.style]"
|
|
421
|
+
role="dialog"
|
|
422
|
+
aria-modal="true"
|
|
423
|
+
:aria-label="sheetTitle"
|
|
424
|
+
>
|
|
425
|
+
<div class="q-autocomplete__sheet-header">
|
|
426
|
+
<span class="q-autocomplete__sheet-title">{{ sheetTitle }}</span>
|
|
427
|
+
<button
|
|
428
|
+
type="button"
|
|
429
|
+
class="q-autocomplete__sheet-close"
|
|
430
|
+
:aria-label="`Fermer ${sheetTitle}`"
|
|
431
|
+
@click="closePopup"
|
|
432
|
+
>
|
|
433
|
+
<Icon :icon="icons.x" aria-hidden="true" />
|
|
434
|
+
</button>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="q-autocomplete__search">
|
|
437
|
+
<Icon :icon="icons.search" class="q-autocomplete__search-icon" aria-hidden="true" />
|
|
438
|
+
<input
|
|
439
|
+
ref="panelInput"
|
|
440
|
+
class="q-autocomplete__search-input"
|
|
441
|
+
:value="query"
|
|
442
|
+
:placeholder="panelSearchPlaceholder"
|
|
443
|
+
role="combobox"
|
|
444
|
+
:aria-expanded="open ? 'true' : 'false'"
|
|
445
|
+
aria-autocomplete="list"
|
|
446
|
+
@input="onInput"
|
|
447
|
+
@keydown="onKeydown"
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
<div
|
|
451
|
+
ref="popupRef"
|
|
452
|
+
class="q-autocomplete__sheet-list"
|
|
453
|
+
:style="sheetListStyle"
|
|
454
|
+
role="listbox"
|
|
455
|
+
@mousedown.prevent
|
|
456
|
+
>
|
|
457
|
+
<template v-if="filtered.length">
|
|
458
|
+
<div
|
|
459
|
+
v-for="(opt, i) in filtered"
|
|
460
|
+
:key="String(getValue(opt))"
|
|
461
|
+
role="option"
|
|
462
|
+
:aria-selected="i === activeIndex ? 'true' : 'false'"
|
|
463
|
+
class="q-autocomplete__item"
|
|
464
|
+
:class="{
|
|
465
|
+
'q-autocomplete__item--active': i === activeIndex,
|
|
466
|
+
'q-autocomplete__item--selected': isSelected(opt),
|
|
467
|
+
}"
|
|
468
|
+
@mouseenter="activeIndex = i"
|
|
469
|
+
@mousedown.prevent="onSelect(opt)"
|
|
470
|
+
>
|
|
471
|
+
<slot :option="opt" :index="i" :selected="isSelected(opt)" :active="i === activeIndex">
|
|
472
|
+
<span class="q-autocomplete__label">{{ getLabel(opt) }}</span>
|
|
473
|
+
</slot>
|
|
474
|
+
</div>
|
|
475
|
+
</template>
|
|
476
|
+
<div v-else class="q-autocomplete__empty">
|
|
477
|
+
<slot name="noOptions">{{ noOptionsLabel }}</slot>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
</Transition>
|
|
483
|
+
</Teleport>
|
|
315
484
|
</div>
|
|
316
485
|
</template>
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -3420,6 +3420,140 @@ 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
|
+
|
|
3460
|
+
/* écran < md (768px, convention Tailwind/shadcn) : le sheet prend 100% de la
|
|
3461
|
+
largeur par défaut — tant qu'aucun width n'est fourni (un width inline
|
|
3462
|
+
via sheetOptions/modalOptions ou la prop width prime sur cette règle) */
|
|
3463
|
+
@media (max-width: 767px) {
|
|
3464
|
+
.q-autocomplete__sheet--sheet {
|
|
3465
|
+
width: 100%;
|
|
3466
|
+
max-width: 100%;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
.q-autocomplete__sheet-header {
|
|
3470
|
+
display: flex;
|
|
3471
|
+
align-items: center;
|
|
3472
|
+
justify-content: space-between;
|
|
3473
|
+
gap: 8px;
|
|
3474
|
+
padding: 14px 16px;
|
|
3475
|
+
border-bottom: 1px solid rgb(0 0 0 / 0.08);
|
|
3476
|
+
font-weight: 600;
|
|
3477
|
+
font-size: 15px;
|
|
3478
|
+
}
|
|
3479
|
+
.q-autocomplete__sheet-title {
|
|
3480
|
+
overflow: hidden;
|
|
3481
|
+
text-overflow: ellipsis;
|
|
3482
|
+
white-space: nowrap;
|
|
3483
|
+
}
|
|
3484
|
+
.q-autocomplete__sheet-close {
|
|
3485
|
+
display: inline-flex;
|
|
3486
|
+
align-items: center;
|
|
3487
|
+
justify-content: center;
|
|
3488
|
+
width: 28px;
|
|
3489
|
+
height: 28px;
|
|
3490
|
+
padding: 0;
|
|
3491
|
+
border: none;
|
|
3492
|
+
border-radius: 50%;
|
|
3493
|
+
background: transparent;
|
|
3494
|
+
color: inherit;
|
|
3495
|
+
cursor: pointer;
|
|
3496
|
+
flex-shrink: 0;
|
|
3497
|
+
}
|
|
3498
|
+
.q-autocomplete__sheet-close:hover {
|
|
3499
|
+
background-color: rgb(0 0 0 / 0.06);
|
|
3500
|
+
}
|
|
3501
|
+
.q-autocomplete__sheet-close svg {
|
|
3502
|
+
width: 16px;
|
|
3503
|
+
height: 16px;
|
|
3504
|
+
}
|
|
3505
|
+
.q-autocomplete__search {
|
|
3506
|
+
display: flex;
|
|
3507
|
+
align-items: center;
|
|
3508
|
+
gap: 8px;
|
|
3509
|
+
margin: 10px 12px 6px;
|
|
3510
|
+
padding: 0 10px;
|
|
3511
|
+
border: 1px solid rgb(0 0 0 / 0.15);
|
|
3512
|
+
border-radius: 6px;
|
|
3513
|
+
background-color: rgb(0 0 0 / 0.03);
|
|
3514
|
+
}
|
|
3515
|
+
.q-autocomplete__search-icon {
|
|
3516
|
+
width: 15px;
|
|
3517
|
+
height: 15px;
|
|
3518
|
+
flex-shrink: 0;
|
|
3519
|
+
color: rgb(0 0 0 / 0.5);
|
|
3520
|
+
}
|
|
3521
|
+
.q-autocomplete__search-input {
|
|
3522
|
+
flex: 1;
|
|
3523
|
+
min-width: 0;
|
|
3524
|
+
height: 32px;
|
|
3525
|
+
padding: 0;
|
|
3526
|
+
border: none;
|
|
3527
|
+
outline: none;
|
|
3528
|
+
background: transparent;
|
|
3529
|
+
color: inherit;
|
|
3530
|
+
font: inherit;
|
|
3531
|
+
font-size: 14px;
|
|
3532
|
+
}
|
|
3533
|
+
.q-autocomplete__search-input::placeholder {
|
|
3534
|
+
color: rgb(0 0 0 / 0.45);
|
|
3535
|
+
}
|
|
3536
|
+
.q-autocomplete__sheet-list {
|
|
3537
|
+
overflow-y: auto;
|
|
3538
|
+
padding: 6px;
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
/* — transition du panneau (réutilise les keyframes q-sheet-pop-*) — */
|
|
3542
|
+
.q-autocomplete-modal-enter-active,
|
|
3543
|
+
.q-autocomplete-modal-leave-active {
|
|
3544
|
+
transition: opacity 0.2s ease;
|
|
3545
|
+
}
|
|
3546
|
+
.q-autocomplete-modal-enter-from,
|
|
3547
|
+
.q-autocomplete-modal-leave-to {
|
|
3548
|
+
opacity: 0;
|
|
3549
|
+
}
|
|
3550
|
+
.q-autocomplete-modal-enter-active .q-autocomplete__sheet {
|
|
3551
|
+
animation: q-sheet-pop-in 0.25s ease;
|
|
3552
|
+
}
|
|
3553
|
+
.q-autocomplete-modal-leave-active .q-autocomplete__sheet {
|
|
3554
|
+
animation: q-sheet-pop-out 0.2s ease forwards;
|
|
3555
|
+
}
|
|
3556
|
+
|
|
3423
3557
|
/* ===== QNavMenu ===== */
|
|
3424
3558
|
.q-nav-menu {
|
|
3425
3559
|
position: relative;
|