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