@dnax/ui 0.0.1

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.
Files changed (150) hide show
  1. package/README.md +15 -0
  2. package/components/QAccordion.vue +68 -0
  3. package/components/QAccordionContent.vue +62 -0
  4. package/components/QAccordionItem.vue +24 -0
  5. package/components/QAccordionTrigger.vue +51 -0
  6. package/components/QActionSheet.vue +159 -0
  7. package/components/QApp.vue +8 -0
  8. package/components/QAutocomplete.vue +316 -0
  9. package/components/QAvatar.vue +92 -0
  10. package/components/QBackHeader.vue +69 -0
  11. package/components/QBackTop.vue +125 -0
  12. package/components/QBadge.vue +55 -0
  13. package/components/QBottomSheet.vue +177 -0
  14. package/components/QBottomSheetFooter.vue +9 -0
  15. package/components/QBottomSheetHeader.vue +35 -0
  16. package/components/QBottomSheetProvider.vue +62 -0
  17. package/components/QBottomSheetTrigger.vue +18 -0
  18. package/components/QBtn.vue +144 -0
  19. package/components/QBtnGroup.vue +31 -0
  20. package/components/QBubble.vue +36 -0
  21. package/components/QBubbleContent.vue +28 -0
  22. package/components/QBubbleGroup.vue +25 -0
  23. package/components/QBubbleReactions.vue +40 -0
  24. package/components/QCard.vue +63 -0
  25. package/components/QCardActions.vue +27 -0
  26. package/components/QCardContent.vue +29 -0
  27. package/components/QCarousel.vue +115 -0
  28. package/components/QCarouselContent.vue +23 -0
  29. package/components/QCarouselItem.vue +20 -0
  30. package/components/QCarouselNav.vue +51 -0
  31. package/components/QCheckbox.vue +146 -0
  32. package/components/QChip.vue +91 -0
  33. package/components/QCircularProgress.vue +122 -0
  34. package/components/QCol.vue +52 -0
  35. package/components/QCollapse.vue +141 -0
  36. package/components/QConfigProvider.vue +164 -0
  37. package/components/QContainer.vue +201 -0
  38. package/components/QCountDown.vue +164 -0
  39. package/components/QCountryPicker.vue +593 -0
  40. package/components/QDataGrid.vue +491 -0
  41. package/components/QDatePicker.vue +250 -0
  42. package/components/QDialog.vue +163 -0
  43. package/components/QDialogFooter.vue +9 -0
  44. package/components/QDialogHeader.vue +41 -0
  45. package/components/QDialogProvider.vue +65 -0
  46. package/components/QDialogTrigger.vue +18 -0
  47. package/components/QFab.vue +80 -0
  48. package/components/QFabAction.vue +48 -0
  49. package/components/QFilePicker.vue +294 -0
  50. package/components/QFooter.vue +72 -0
  51. package/components/QGallery.vue +280 -0
  52. package/components/QGrid.vue +54 -0
  53. package/components/QHeader.vue +38 -0
  54. package/components/QIcon.vue +53 -0
  55. package/components/QImagePicker.vue +248 -0
  56. package/components/QImagePreview.vue +225 -0
  57. package/components/QImagePreviewProvider.vue +35 -0
  58. package/components/QImg.vue +142 -0
  59. package/components/QInfiniteScroll.vue +109 -0
  60. package/components/QInnerLoading.vue +67 -0
  61. package/components/QInput.vue +289 -0
  62. package/components/QInputOtp.vue +238 -0
  63. package/components/QInputPassword.vue +67 -0
  64. package/components/QInputTag.vue +281 -0
  65. package/components/QIntersection.vue +119 -0
  66. package/components/QItem.vue +60 -0
  67. package/components/QItemSection.vue +47 -0
  68. package/components/QLinearProgress.vue +94 -0
  69. package/components/QList.vue +39 -0
  70. package/components/QLoading.vue +77 -0
  71. package/components/QLoadingProvider.vue +41 -0
  72. package/components/QMarquee.vue +56 -0
  73. package/components/QMessageScroller.vue +10 -0
  74. package/components/QMessageScrollerButton.vue +48 -0
  75. package/components/QMessageScrollerContent.vue +31 -0
  76. package/components/QMessageScrollerItem.vue +49 -0
  77. package/components/QMessageScrollerProvider.vue +198 -0
  78. package/components/QMessageScrollerViewport.vue +109 -0
  79. package/components/QNavMenu.vue +75 -0
  80. package/components/QNavMenuContent.vue +10 -0
  81. package/components/QNavMenuItem.vue +60 -0
  82. package/components/QNavMenuTrigger.vue +96 -0
  83. package/components/QNotifyProvider.vue +33 -0
  84. package/components/QNotifyToast.vue +60 -0
  85. package/components/QPage.vue +56 -0
  86. package/components/QPagination.vue +195 -0
  87. package/components/QParallax.vue +124 -0
  88. package/components/QPullToRefresh.vue +138 -0
  89. package/components/QRadio.vue +99 -0
  90. package/components/QRating.vue +105 -0
  91. package/components/QReorder.vue +191 -0
  92. package/components/QRollingText.vue +225 -0
  93. package/components/QRow.vue +23 -0
  94. package/components/QSafeArea.vue +39 -0
  95. package/components/QScrollArea.vue +118 -0
  96. package/components/QSelect.vue +698 -0
  97. package/components/QSeparator.vue +39 -0
  98. package/components/QSidebar.vue +153 -0
  99. package/components/QSidebarContent.vue +14 -0
  100. package/components/QSidebarFooter.vue +14 -0
  101. package/components/QSidebarHeader.vue +14 -0
  102. package/components/QSidebarMenu.vue +9 -0
  103. package/components/QSidebarMenuButton.vue +59 -0
  104. package/components/QSidebarMenuItem.vue +9 -0
  105. package/components/QSidebarTrigger.vue +32 -0
  106. package/components/QSkeleton.vue +95 -0
  107. package/components/QSlider.vue +205 -0
  108. package/components/QSpace.vue +10 -0
  109. package/components/QSpinner.vue +172 -0
  110. package/components/QSplitter.vue +145 -0
  111. package/components/QSticky.vue +181 -0
  112. package/components/QSwipeCell.vue +217 -0
  113. package/components/QSwiper.vue +102 -0
  114. package/components/QSwiperSlide.vue +19 -0
  115. package/components/QSyntax.vue +171 -0
  116. package/components/QTab.vue +175 -0
  117. package/components/QTabPanel.vue +50 -0
  118. package/components/QTabPanels.vue +106 -0
  119. package/components/QTable.vue +575 -0
  120. package/components/QTabs.vue +188 -0
  121. package/components/QText.vue +316 -0
  122. package/components/QTextCaption.vue +122 -0
  123. package/components/QToolbar.vue +28 -0
  124. package/components/QTooltip.vue +313 -0
  125. package/components/QUploader.vue +473 -0
  126. package/components/QVideo.vue +289 -0
  127. package/components/QVirtualScroll.vue +190 -0
  128. package/components/internal/QDateCalendar.vue +153 -0
  129. package/index.ts +151 -0
  130. package/lib/breakpoints.ts +44 -0
  131. package/lib/colors.ts +24 -0
  132. package/lib/config.ts +34 -0
  133. package/lib/countries.ts +261 -0
  134. package/lib/formula.ts +630 -0
  135. package/lib/icons.ts +38 -0
  136. package/lib/loading.ts +75 -0
  137. package/lib/overlayBack.ts +124 -0
  138. package/lib/platform.ts +146 -0
  139. package/lib/primitive.ts +25 -0
  140. package/lib/q.ts +287 -0
  141. package/lib/screen.ts +105 -0
  142. package/lib/search.ts +35 -0
  143. package/lib/themeVars.ts +22 -0
  144. package/lib/useComponentProps.ts +54 -0
  145. package/lib/utils.ts +7 -0
  146. package/module.ts +89 -0
  147. package/package.json +41 -0
  148. package/scripts/generate-exports.ts +56 -0
  149. package/styles/main.css +7627 -0
  150. package/tsconfig.json +33 -0
@@ -0,0 +1,698 @@
1
+ <script lang="ts">
2
+ // QSelect — options spécifiques par mode d'ouverture.
3
+ // sheetOptions / modalOptions / inlineOptions : style, classes, hauteur, arrondi, placeholder de recherche.
4
+ export interface QSelectModeOptions {
5
+ /** Largeur du panneau/popup (remplace width) */
6
+ width?: string
7
+ /** Style appliqué au panneau (sheet/modal) ou au popup (inline) */
8
+ style?: Record<string, string>
9
+ /** Classes ajoutées au panneau/popup */
10
+ class?: string
11
+ /** Hauteur de la liste scrollable (remplace height) */
12
+ height?: string
13
+ /** Arrondi du panneau (remplace rounded) */
14
+ rounded?: boolean | string | { tl?: string | number; tr?: string | number; br?: string | number; bl?: string | number }
15
+ /** Placeholder du champ de recherche pour ce mode */
16
+ searchPlaceholder?: string
17
+ }
18
+
19
+ export interface UseSearchOptions {
20
+ /** Clés à chercher sur les options (ex. ["name", "email"]) — défaut : optionLabel/optionValue */
21
+ keys?: string[]
22
+ /** Seuil fuse.js : 0 = exact, 1 = tout (défaut 0.4) */
23
+ threshold?: number
24
+ }
25
+ </script>
26
+
27
+ <script setup lang="ts">
28
+ // QSelect — API Quasar : <q-select v-model="val" :options="opts" option-value="id" option-label="name" multiple use-input use-search @filter />
29
+ // Sélection unique ou multiple, recherche client (simple ou floue fuse.js, serveur via @filter), chips, emit-value.
30
+ // Modes : inline (dropdown) | modal (centré) | sheet (bottom sheet) | dialog (plein écran) — options par mode via sheetOptions/modalOptions/inlineOptions.
31
+ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
32
+ import { Icon } from "@iconify/vue"
33
+ import { icons } from "../lib/icons"
34
+ import { cn } from "../lib/utils"
35
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
36
+ import type { RadiusProp } from "../lib/useComponentProps"
37
+ import { useOverlayBack } from "../lib/overlayBack"
38
+ import { createSearcher } from "../lib/search"
39
+
40
+ interface Props {
41
+ /** Valeur sélectionnée : option, valeur (emit-value), ou tableau (multiple) */
42
+ modelValue?: any
43
+ /** Options candidates ({ label, value, … } par défaut) */
44
+ options?: any[]
45
+ /** Propriété ou fonction donnant la valeur d'une option (défaut "value") */
46
+ optionValue?: string | ((opt: any) => any)
47
+ /** Propriété ou fonction donnant le label (défaut "label") */
48
+ optionLabel?: string | ((opt: any) => any)
49
+ /** Sélection multiple */
50
+ multiple?: boolean
51
+ /** Input de recherche dans le champ (filtre client, ou serveur avec @filter) */
52
+ useInput?: boolean
53
+ /** Texte saisi (v-model:input-value) */
54
+ inputValue?: string
55
+ /** Affiche les sélections comme chips (multiple) */
56
+ useChips?: boolean
57
+ /** v-model = option-value au lieu de l'objet option */
58
+ emitValue?: boolean
59
+ /** Masque les options déjà sélectionnées dans le dropdown */
60
+ hideSelected?: boolean
61
+ /** Nombre max de sélections (multiple) */
62
+ maxValues?: number
63
+ /** Affichage custom de la sélection (string ou fonction) */
64
+ displayValue?: string | ((opt: any) => any)
65
+ label?: string
66
+ stackLabel?: boolean
67
+ hint?: string
68
+ error?: boolean
69
+ errorMessage?: string
70
+ placeholder?: string
71
+ outlined?: boolean
72
+ filled?: boolean
73
+ borderless?: boolean
74
+ /** Rayon des coins du champ : true = pilule, ou échelle xs|sm|md|lg (none = carré) */
75
+ fieldRadius?: RadiusProp
76
+ dense?: boolean
77
+ clearable?: boolean
78
+ disable?: boolean
79
+ readonly?: boolean
80
+ loading?: boolean
81
+ noOptionsLabel?: string
82
+ /** Icône Iconify de la flèche (défaut : lucide:chevron-down) */
83
+ dropdownIcon?: string
84
+ /** Recherche floue fuse.js : true (défauts) ou { keys, threshold } — champ de recherche séparé affiché avant la liste */
85
+ useSearch?: boolean | UseSearchOptions
86
+ /** Placeholder du champ de recherche (useSearch) */
87
+ searchPlaceholder?: string
88
+ /** Mode d'ouverture : inline (dropdown) | modal (centré) | sheet (bottom sheet) | dialog (plein écran) */
89
+ mode?: "inline" | "modal" | "sheet" | "dialog"
90
+ /** Largeur du panneau (modal/sheet) : "300px", "80%"… (par défaut : modal 480px / 90% si écran < 768px, sheet 640px) */
91
+ width?: string
92
+ /** Hauteur de la liste scrollable dans les modes panneau (sheet/modal/dialog) : "400px", "50vh"… */
93
+ height?: string
94
+ /** Arrondi du panneau : true (défaut du mode) | false (carré) | valeur CSS | objet { tl, tr, br, bl } */
95
+ rounded?: boolean | string | { tl?: string | number; tr?: string | number; br?: string | number; bl?: string | number }
96
+ /** Options spécifiques au mode sheet (style, class, height, rounded, width, searchPlaceholder) */
97
+ sheetOptions?: QSelectModeOptions
98
+ /** Options spécifiques au mode modal */
99
+ modalOptions?: QSelectModeOptions
100
+ /** Options spécifiques au mode inline (popup) */
101
+ inlineOptions?: QSelectModeOptions
102
+ }
103
+
104
+ const props = withDefaults(defineProps<Props>(), {
105
+ options: () => [],
106
+ optionValue: "value",
107
+ optionLabel: "label",
108
+ multiple: false,
109
+ useInput: false,
110
+ useChips: false,
111
+ emitValue: false,
112
+ hideSelected: false,
113
+ maxValues: 0,
114
+ stackLabel: false,
115
+ error: false,
116
+ outlined: false,
117
+ filled: false,
118
+ borderless: false,
119
+ dense: false,
120
+ clearable: false,
121
+ disable: false,
122
+ readonly: false,
123
+ loading: false,
124
+ noOptionsLabel: "No options to display",
125
+ mode: "inline",
126
+ })
127
+
128
+ const emit = defineEmits<{
129
+ "update:modelValue": [value: any]
130
+ "update:inputValue": [value: string]
131
+ filter: [value: string, update: (fn: () => void) => void]
132
+ focus: [event: FocusEvent]
133
+ blur: [event: FocusEvent]
134
+ clear: []
135
+ }>()
136
+
137
+ defineSlots<{
138
+ default?: (props: Record<string, any>) => any // option : { opt, index, selected }
139
+ option?: (props: Record<string, any>) => any // { opt, index, selected }
140
+ noOption?: () => any
141
+ prepend?: () => any
142
+ append?: () => any
143
+ error?: () => any
144
+ hint?: () => any
145
+ }>()
146
+
147
+ const instance = getCurrentInstance()
148
+ const hasFilter = computed(() => !!instance?.vnode.props?.onFilter)
149
+
150
+ const rootEl = ref<HTMLElement | null>(null)
151
+ const popupRef = ref<HTMLElement | null>(null)
152
+ const sheetRef = ref<HTMLElement | null>(null)
153
+ const inputEl = ref<HTMLInputElement | null>(null)
154
+ const focused = ref(false)
155
+ const open = ref(false)
156
+ const query = ref(props.inputValue ?? "")
157
+ const activeIndex = ref(0)
158
+
159
+ // « Retour » navigateur → ferme le panneau (modal/sheet/dialog) au lieu de naviguer
160
+ const panelOpen = computed(() => props.mode !== "inline" && open.value)
161
+ useOverlayBack(panelOpen, () => closePopup(), "QSelect")
162
+
163
+ // — Options du mode courant (sheet/modal/inline) —
164
+ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
165
+ if (props.mode === "sheet") return props.sheetOptions
166
+ if (props.mode === "modal") return props.modalOptions
167
+ if (props.mode === "inline") return props.inlineOptions
168
+ return undefined
169
+ })
170
+
171
+ // — Valeurs des options —
172
+ const getOptionValue = (opt: any): any =>
173
+ typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
174
+
175
+ const getOptionLabel = (opt: any): string => {
176
+ const v = typeof props.optionLabel === "function" ? props.optionLabel(opt) : opt?.[props.optionLabel]
177
+ return v === undefined || v === null ? "" : String(v)
178
+ }
179
+
180
+ // — Sélection (dérivée du modèle) —
181
+ const selectedOptions = computed<any[]>(() => {
182
+ if (props.modelValue === undefined || props.modelValue === null) return []
183
+ const list = props.multiple ? (props.modelValue as any[]) : [props.modelValue]
184
+ return list
185
+ .map((v) => {
186
+ if (props.emitValue) return props.options.find((o) => getOptionValue(o) === v)
187
+ return v
188
+ })
189
+ .filter((o) => o !== undefined)
190
+ })
191
+
192
+ const isSelected = (opt: any) =>
193
+ selectedOptions.value.some((o) => getOptionValue(o) === getOptionValue(opt))
194
+
195
+ const select = (opt: any) => {
196
+ if (props.disable || props.readonly) return
197
+ const value = props.emitValue ? getOptionValue(opt) : opt
198
+
199
+ if (props.multiple) {
200
+ if (isSelected(opt)) {
201
+ const next = selectedOptions.value.filter((o) => getOptionValue(o) !== getOptionValue(opt))
202
+ emit("update:modelValue", props.emitValue ? next.map(getOptionValue) : next)
203
+ }
204
+ else if (!props.maxValues || selectedOptions.value.length < props.maxValues) {
205
+ const next = [...selectedOptions.value, opt]
206
+ emit("update:modelValue", props.emitValue ? next.map(getOptionValue) : next)
207
+ }
208
+ if (showInput.value) {
209
+ query.value = ""
210
+ emit("update:inputValue", "")
211
+ inputEl.value?.focus()
212
+ }
213
+ }
214
+ else {
215
+ if (!isSelected(opt)) emit("update:modelValue", value)
216
+ closePopup()
217
+ }
218
+ }
219
+
220
+ const remove = (opt: any) => select(opt)
221
+
222
+ const clear = () => {
223
+ emit("update:modelValue", props.multiple ? [] : undefined)
224
+ emit("update:inputValue", "")
225
+ query.value = ""
226
+ emit("clear")
227
+ inputEl.value?.focus()
228
+ }
229
+
230
+ const hasValue = computed(() =>
231
+ props.multiple ? (props.modelValue as any[])?.length > 0 : props.modelValue !== undefined && props.modelValue !== null,
232
+ )
233
+
234
+ // — Recherche floue (fuse.js) —
235
+ const searchEnabled = computed(() => props.useSearch !== undefined && props.useSearch !== false)
236
+
237
+ /** Input dans le champ (use-input uniquement) ; useSearch affiche un champ séparé dans la liste */
238
+ const showInput = computed(() => props.useInput)
239
+
240
+ // En mode sheet, le champ de recherche est affiché EN BAS de la liste (au-dessus de la barre d'accueil)
241
+ const sheetSearchAtBottom = computed(() => props.mode === "sheet" && searchEnabled.value && !props.useInput)
242
+ const showSheetSearchTop = computed(() => searchEnabled.value && !props.useInput && props.mode !== "sheet")
243
+ const showSheetSearchBottom = sheetSearchAtBottom
244
+
245
+ const searchKeys = computed<string[]>(() => {
246
+ if (searchEnabled.value && typeof props.useSearch === "object" && props.useSearch.keys?.length) {
247
+ return props.useSearch.keys
248
+ }
249
+ const keys: string[] = []
250
+ if (typeof props.optionLabel === "string") keys.push(props.optionLabel)
251
+ if (typeof props.optionValue === "string" && !keys.includes(props.optionValue)) keys.push(props.optionValue)
252
+ return keys
253
+ })
254
+
255
+ const searcher = computed(() =>
256
+ createSearcher(props.options, {
257
+ keys: searchKeys.value,
258
+ threshold: typeof props.useSearch === "object" ? (props.useSearch.threshold ?? 0.4) : 0.4,
259
+ }),
260
+ )
261
+
262
+ // — Options affichées (recherche floue, filtre simple, ou serveur) —
263
+ const displayOptions = computed(() => {
264
+ let list = props.options
265
+ if (!hasFilter.value) {
266
+ if (searchEnabled.value && searchKeys.value.length > 0) {
267
+ list = searcher.value.search(query.value)
268
+ }
269
+ else if (showInput.value) {
270
+ const q = query.value.trim().toLowerCase()
271
+ if (q) list = list.filter((o) => getOptionLabel(o).toLowerCase().includes(q))
272
+ }
273
+ }
274
+ if (props.hideSelected) {
275
+ list = list.filter((o) => !isSelected(o))
276
+ }
277
+ return list
278
+ })
279
+
280
+ // — Affichage —
281
+ const selectedText = computed(() =>
282
+ props.multiple
283
+ ? selectedOptions.value.map(getOptionLabel).join(", ")
284
+ : selectedOptions.value[0] ? getOptionLabel(selectedOptions.value[0]) : "",
285
+ )
286
+
287
+ const displayText = computed(() => {
288
+ if (!selectedOptions.value[0]) return ""
289
+ if (props.displayValue !== undefined) {
290
+ return typeof props.displayValue === "function"
291
+ ? props.displayValue(selectedOptions.value[0])
292
+ : props.displayValue
293
+ }
294
+ return selectedText.value
295
+ })
296
+
297
+ const inputDisplay = computed(() => {
298
+ if (query.value !== "") return query.value
299
+ if (props.multiple) return ""
300
+ return selectedOptions.value[0] ? getOptionLabel(selectedOptions.value[0]) : ""
301
+ })
302
+
303
+ const floatActive = computed(
304
+ () => focused.value || query.value !== "" || selectedOptions.value.length > 0 || props.stackLabel,
305
+ )
306
+
307
+ const fieldClasses = computed(() =>
308
+ cn(
309
+ "q-select q-input",
310
+ props.outlined && "q-field--outlined",
311
+ props.filled && "q-field--filled",
312
+ props.borderless && "q-field--borderless",
313
+ effectiveFieldRadius.value === true && "q-field--rounded",
314
+ props.dense && "q-field--dense",
315
+ props.error && "q-field--error",
316
+ props.disable && "q-field--disabled",
317
+ floatActive.value && "q-field--float",
318
+ ),
319
+ )
320
+
321
+ // radius du champ : prop explicite > composantProps.QSelect > composantProps.default
322
+ const effectiveFieldRadius = useRadius("QSelect", () => props.fieldRadius)
323
+ const fieldRadiusStyle = computed(() => radiusStyle(effectiveFieldRadius.value))
324
+
325
+ // — Ouverture / fermeture —
326
+ const openPopup = () => {
327
+ if (props.disable || props.readonly) return
328
+ activeIndex.value = 0
329
+ open.value = true
330
+ }
331
+
332
+ const closePopup = () => {
333
+ open.value = false
334
+ }
335
+
336
+ const togglePopup = () => (open.value ? closePopup() : openPopup())
337
+
338
+ const onControlClick = () => {
339
+ if (props.disable || props.readonly) return
340
+ if (showInput.value) {
341
+ inputEl.value?.focus()
342
+ openPopup()
343
+ }
344
+ else {
345
+ togglePopup()
346
+ }
347
+ }
348
+
349
+ const onInput = (e: Event) => {
350
+ const v = (e.target as HTMLInputElement).value
351
+ query.value = v
352
+ emit("update:inputValue", v)
353
+ activeIndex.value = 0
354
+ openPopup()
355
+ if (hasFilter.value) emit("filter", v, (fn) => fn())
356
+ }
357
+
358
+ const onFocus = (e: FocusEvent) => {
359
+ focused.value = true
360
+ emit("focus", e)
361
+ }
362
+
363
+ const onBlur = (e: FocusEvent) => {
364
+ focused.value = false
365
+ closePopup()
366
+ emit("blur", e)
367
+ }
368
+
369
+ // — Clavier —
370
+ const move = (dir: number) => {
371
+ if (displayOptions.value.length) {
372
+ activeIndex.value = (activeIndex.value + dir + displayOptions.value.length) % displayOptions.value.length
373
+ }
374
+ }
375
+
376
+ const onKeydown = (e: KeyboardEvent) => {
377
+ if (props.disable) return
378
+ if (!open.value) {
379
+ if (e.key === "ArrowDown" || e.key === "Enter") {
380
+ e.preventDefault()
381
+ openPopup()
382
+ }
383
+ return
384
+ }
385
+ if (e.key === "ArrowDown") {
386
+ e.preventDefault()
387
+ move(1)
388
+ }
389
+ else if (e.key === "ArrowUp") {
390
+ e.preventDefault()
391
+ move(-1)
392
+ }
393
+ else if (e.key === "Enter") {
394
+ e.preventDefault()
395
+ const opt = displayOptions.value[activeIndex.value]
396
+ if (opt) select(opt)
397
+ }
398
+ else if (e.key === "Escape") {
399
+ e.preventDefault()
400
+ closePopup()
401
+ }
402
+ }
403
+
404
+ watch(activeIndex, async () => {
405
+ await nextTick()
406
+ const items = popupRef.value?.querySelectorAll(".q-select__option")
407
+ const el = items?.[activeIndex.value] as HTMLElement | undefined
408
+ el?.scrollIntoView({ block: "nearest" })
409
+ })
410
+
411
+ // Fermeture au clic extérieur + Échap (mode modal/sheet/dialog)
412
+ const onDocMousedown = (e: MouseEvent) => {
413
+ const target = e.target as Node
414
+ if (rootEl.value?.contains(target)) return
415
+ if (sheetRef.value?.contains(target)) return
416
+ closePopup()
417
+ }
418
+
419
+ const onDocKeydown = (e: KeyboardEvent) => {
420
+ if (e.key === "Escape" && open.value && props.mode !== "inline") closePopup()
421
+ }
422
+
423
+ // Verrouille le scroll du body quand un panneau modal est ouvert
424
+ watch(open, (v) => {
425
+ if (typeof document !== "undefined" && props.mode !== "inline") {
426
+ document.body.style.overflow = v ? "hidden" : ""
427
+ }
428
+ })
429
+
430
+ onMounted(() => {
431
+ if (typeof document !== "undefined") {
432
+ document.addEventListener("mousedown", onDocMousedown)
433
+ document.addEventListener("keydown", onDocKeydown)
434
+ }
435
+ })
436
+ onBeforeUnmount(() => {
437
+ if (typeof document !== "undefined") {
438
+ document.removeEventListener("mousedown", onDocMousedown)
439
+ document.removeEventListener("keydown", onDocKeydown)
440
+ document.body.style.overflow = ""
441
+ }
442
+ })
443
+
444
+ // — Styles du panneau (props globales fusionnées avec les options du mode) —
445
+ const sheetTitle = computed(() => props.label || props.placeholder || "Sélection")
446
+
447
+ const effectiveSearchPlaceholder = computed(() => modeOptions.value?.searchPlaceholder ?? props.searchPlaceholder)
448
+
449
+ const sheetWidthStyle = computed<Record<string, string> | undefined>(() => {
450
+ if (props.mode === "dialog") return undefined
451
+ const w = modeOptions.value?.width ?? props.width
452
+ if (!w) return undefined
453
+ return { width: w, maxWidth: w }
454
+ })
455
+
456
+ const sheetListStyle = computed<Record<string, string> | undefined>(() => {
457
+ const h = modeOptions.value?.height ?? props.height
458
+ return h ? { height: h } : undefined
459
+ })
460
+
461
+ const sheetRadiusStyle = computed<Record<string, string> | undefined>(() => {
462
+ if (props.mode === "inline" || props.mode === "dialog") return undefined
463
+ const rounded = modeOptions.value?.rounded ?? props.rounded
464
+ if (rounded === false) return { borderRadius: "0" }
465
+
466
+ const defaultRadius = props.mode === "modal" ? "20px" : "12px"
467
+ const value = typeof rounded === "string" ? rounded : defaultRadius
468
+ const asPx = (v: string | number) => (typeof v === "number" ? `${v}px` : v)
469
+
470
+ if (props.mode === "sheet") {
471
+ if (rounded && typeof rounded === "object") {
472
+ return { borderRadius: `${asPx(rounded.tl ?? value)} ${asPx(rounded.tr ?? value)} 0 0` }
473
+ }
474
+ return { borderRadius: `${value} ${value} 0 0` }
475
+ }
476
+
477
+ if (rounded && typeof rounded === "object") {
478
+ return {
479
+ borderRadius: `${asPx(rounded.tl ?? value)} ${asPx(rounded.tr ?? value)} ${asPx(rounded.br ?? value)} ${asPx(rounded.bl ?? value)}`,
480
+ }
481
+ }
482
+ return { borderRadius: value }
483
+ })
484
+
485
+ const sheetStyle = computed<(Record<string, string> | undefined)[]>(() => [
486
+ sheetWidthStyle.value,
487
+ sheetRadiusStyle.value,
488
+ modeOptions.value?.style,
489
+ ])
490
+
491
+ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions.value?.class])
492
+ </script>
493
+
494
+ <template>
495
+ <div ref="rootEl" class="q-select" :class="fieldClasses" :style="fieldRadiusStyle">
496
+ <label v-if="label" class="q-field__label-stack">{{ label }}</label>
497
+ <div class="q-field__control" @click="onControlClick">
498
+ <slot name="prepend" />
499
+
500
+ <span v-for="(opt, i) in selectedOptions" :key="`${String(getOptionValue(opt))}-${i}`" class="q-select__chip">
501
+ <span class="q-select__chip-label">{{ getOptionLabel(opt) }}</span>
502
+ <button
503
+ v-if="!disable && !readonly"
504
+ type="button"
505
+ class="q-select__chip-remove"
506
+ :aria-label="`Retirer ${getOptionLabel(opt)}`"
507
+ @click.stop="remove(opt)"
508
+ >
509
+ <Icon :icon="icons.x" aria-hidden="true" />
510
+ </button>
511
+ </span>
512
+
513
+ <input
514
+ v-if="showInput"
515
+ ref="inputEl"
516
+ class="q-field__native"
517
+ :value="inputDisplay"
518
+ :placeholder="placeholder"
519
+ :disabled="disable || undefined"
520
+ :readonly="readonly || undefined"
521
+ role="combobox"
522
+ :aria-expanded="open ? 'true' : 'false'"
523
+ aria-autocomplete="list"
524
+ @input="onInput"
525
+ @focus="onFocus"
526
+ @blur="onBlur"
527
+ @keydown="onKeydown"
528
+ />
529
+ <span
530
+ v-else
531
+ class="q-select__display"
532
+ :class="{ 'q-select__display--placeholder': !displayText }"
533
+ >
534
+ {{ displayText || placeholder }}
535
+ </span>
536
+
537
+ <span v-if="loading" class="q-spinner-ring q-autocomplete__spinner" aria-hidden="true" />
538
+ <button
539
+ v-if="clearable && hasValue && !disable && !readonly"
540
+ class="q-field__clear"
541
+ type="button"
542
+ aria-label="Effacer"
543
+ @click.stop="clear"
544
+ >
545
+ <Icon :icon="icons.x" aria-hidden="true" />
546
+ </button>
547
+ <Icon
548
+ :icon="dropdownIcon || icons.chevronDown"
549
+ class="q-select__arrow"
550
+ :class="{ 'q-select__arrow--rotated': open }"
551
+ aria-hidden="true"
552
+ />
553
+ <slot name="append" />
554
+ </div>
555
+
556
+ <div v-if="error" class="q-field__bottom">
557
+ <div class="q-field__error">
558
+ <slot name="error">{{ errorMessage }}</slot>
559
+ </div>
560
+ </div>
561
+ <div v-else-if="hint || $slots.hint" class="q-field__bottom">
562
+ <div class="q-field__hint">
563
+ <slot name="hint">{{ hint }}</slot>
564
+ </div>
565
+ </div>
566
+
567
+ <!-- Popup inline (mode inline) -->
568
+ <div
569
+ v-if="open && mode === 'inline'"
570
+ ref="popupRef"
571
+ class="q-select__popup"
572
+ :class="inlineOptions?.class"
573
+ :style="inlineOptions?.style"
574
+ role="listbox"
575
+ @mousedown.prevent
576
+ >
577
+ <div v-if="searchEnabled && !useInput" class="q-select__search" @mousedown.stop>
578
+ <Icon :icon="icons.search" class="q-select__search-icon" aria-hidden="true" />
579
+ <input
580
+ class="q-select__search-input"
581
+ :value="query"
582
+ :placeholder="effectiveSearchPlaceholder"
583
+ @input="onInput"
584
+ @keydown="onKeydown"
585
+ />
586
+ </div>
587
+ <template v-if="displayOptions.length">
588
+ <div
589
+ v-for="(opt, i) in displayOptions"
590
+ :key="String(getOptionValue(opt))"
591
+ role="option"
592
+ :aria-selected="isSelected(opt) ? 'true' : 'false'"
593
+ class="q-select__option"
594
+ :class="{
595
+ 'q-select__option--active': i === activeIndex,
596
+ 'q-select__option--selected': isSelected(opt),
597
+ }"
598
+ @mouseenter="activeIndex = i"
599
+ @mousedown.prevent="select(opt)"
600
+ >
601
+ <slot :opt="opt" :index="i" :selected="isSelected(opt)">
602
+ <span class="q-select__option-label">{{ getOptionLabel(opt) }}</span>
603
+ </slot>
604
+ <Icon :icon="icons.check" v-if="multiple && isSelected(opt)" class="q-select__check" aria-hidden="true" />
605
+ </div>
606
+ </template>
607
+ <div v-else class="q-select__empty">
608
+ <slot name="noOption">{{ noOptionsLabel }}</slot>
609
+ </div>
610
+ </div>
611
+
612
+ <!-- Mode modal / sheet / dialog : panneau téléporté -->
613
+ <Teleport to="body">
614
+ <Transition name="q-select-modal">
615
+ <div
616
+ v-if="open && mode !== 'inline'"
617
+ class="q-select__overlay"
618
+ :class="`q-select__overlay--${mode}`"
619
+ role="presentation"
620
+ @mousedown.self="closePopup"
621
+ >
622
+ <div
623
+ ref="sheetRef"
624
+ class="q-select__sheet"
625
+ :class="sheetClass"
626
+ :style="sheetStyle"
627
+ role="dialog"
628
+ aria-modal="true"
629
+ :aria-label="sheetTitle"
630
+ >
631
+ <div class="q-select__sheet-header">
632
+ <span class="q-select__sheet-title">{{ sheetTitle }}</span>
633
+ <button
634
+ type="button"
635
+ class="q-select__sheet-close"
636
+ :aria-label="`Fermer ${sheetTitle}`"
637
+ @click="closePopup"
638
+ >
639
+ <Icon :icon="icons.x" aria-hidden="true" />
640
+ </button>
641
+ </div>
642
+ <div v-if="showSheetSearchTop" class="q-select__search">
643
+ <Icon :icon="icons.search" class="q-select__search-icon" aria-hidden="true" />
644
+ <input
645
+ class="q-select__search-input"
646
+ :value="query"
647
+ :placeholder="effectiveSearchPlaceholder"
648
+ @input="onInput"
649
+ @keydown="onKeydown"
650
+ />
651
+ </div>
652
+ <div
653
+ ref="popupRef"
654
+ class="q-select__sheet-list"
655
+ :class="{ 'q-select__sheet-list--safe-bottom': !sheetSearchAtBottom }"
656
+ :style="sheetListStyle"
657
+ role="listbox"
658
+ >
659
+ <template v-if="displayOptions.length">
660
+ <div
661
+ v-for="(opt, i) in displayOptions"
662
+ :key="String(getOptionValue(opt))"
663
+ role="option"
664
+ :aria-selected="isSelected(opt) ? 'true' : 'false'"
665
+ class="q-select__option"
666
+ :class="{
667
+ 'q-select__option--active': i === activeIndex,
668
+ 'q-select__option--selected': isSelected(opt),
669
+ }"
670
+ @mouseenter="activeIndex = i"
671
+ @mousedown.prevent="select(opt)"
672
+ >
673
+ <slot :opt="opt" :index="i" :selected="isSelected(opt)">
674
+ <span class="q-select__option-label">{{ getOptionLabel(opt) }}</span>
675
+ </slot>
676
+ <Icon :icon="icons.check" v-if="multiple && isSelected(opt)" class="q-select__check" aria-hidden="true" />
677
+ </div>
678
+ </template>
679
+ <div v-else class="q-select__empty">
680
+ <slot name="noOption">{{ noOptionsLabel }}</slot>
681
+ </div>
682
+ </div>
683
+ <div v-if="showSheetSearchBottom" class="q-select__search q-select__search--sheet-bottom">
684
+ <Icon :icon="icons.search" class="q-select__search-icon" aria-hidden="true" />
685
+ <input
686
+ class="q-select__search-input"
687
+ :value="query"
688
+ :placeholder="effectiveSearchPlaceholder"
689
+ @input="onInput"
690
+ @keydown="onKeydown"
691
+ />
692
+ </div>
693
+ </div>
694
+ </div>
695
+ </Transition>
696
+ </Teleport>
697
+ </div>
698
+ </template>