@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.
@@ -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
- open.value = false
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
- open.value = false
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
- if (rootEl.value && !rootEl.value.contains(e.target as Node)) open.value = false
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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;