@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.
- package/README.md +15 -0
- package/components/QAccordion.vue +68 -0
- package/components/QAccordionContent.vue +62 -0
- package/components/QAccordionItem.vue +24 -0
- package/components/QAccordionTrigger.vue +51 -0
- package/components/QActionSheet.vue +159 -0
- package/components/QApp.vue +8 -0
- package/components/QAutocomplete.vue +316 -0
- package/components/QAvatar.vue +92 -0
- package/components/QBackHeader.vue +69 -0
- package/components/QBackTop.vue +125 -0
- package/components/QBadge.vue +55 -0
- package/components/QBottomSheet.vue +177 -0
- package/components/QBottomSheetFooter.vue +9 -0
- package/components/QBottomSheetHeader.vue +35 -0
- package/components/QBottomSheetProvider.vue +62 -0
- package/components/QBottomSheetTrigger.vue +18 -0
- package/components/QBtn.vue +144 -0
- package/components/QBtnGroup.vue +31 -0
- package/components/QBubble.vue +36 -0
- package/components/QBubbleContent.vue +28 -0
- package/components/QBubbleGroup.vue +25 -0
- package/components/QBubbleReactions.vue +40 -0
- package/components/QCard.vue +63 -0
- package/components/QCardActions.vue +27 -0
- package/components/QCardContent.vue +29 -0
- package/components/QCarousel.vue +115 -0
- package/components/QCarouselContent.vue +23 -0
- package/components/QCarouselItem.vue +20 -0
- package/components/QCarouselNav.vue +51 -0
- package/components/QCheckbox.vue +146 -0
- package/components/QChip.vue +91 -0
- package/components/QCircularProgress.vue +122 -0
- package/components/QCol.vue +52 -0
- package/components/QCollapse.vue +141 -0
- package/components/QConfigProvider.vue +164 -0
- package/components/QContainer.vue +201 -0
- package/components/QCountDown.vue +164 -0
- package/components/QCountryPicker.vue +593 -0
- package/components/QDataGrid.vue +491 -0
- package/components/QDatePicker.vue +250 -0
- package/components/QDialog.vue +163 -0
- package/components/QDialogFooter.vue +9 -0
- package/components/QDialogHeader.vue +41 -0
- package/components/QDialogProvider.vue +65 -0
- package/components/QDialogTrigger.vue +18 -0
- package/components/QFab.vue +80 -0
- package/components/QFabAction.vue +48 -0
- package/components/QFilePicker.vue +294 -0
- package/components/QFooter.vue +72 -0
- package/components/QGallery.vue +280 -0
- package/components/QGrid.vue +54 -0
- package/components/QHeader.vue +38 -0
- package/components/QIcon.vue +53 -0
- package/components/QImagePicker.vue +248 -0
- package/components/QImagePreview.vue +225 -0
- package/components/QImagePreviewProvider.vue +35 -0
- package/components/QImg.vue +142 -0
- package/components/QInfiniteScroll.vue +109 -0
- package/components/QInnerLoading.vue +67 -0
- package/components/QInput.vue +289 -0
- package/components/QInputOtp.vue +238 -0
- package/components/QInputPassword.vue +67 -0
- package/components/QInputTag.vue +281 -0
- package/components/QIntersection.vue +119 -0
- package/components/QItem.vue +60 -0
- package/components/QItemSection.vue +47 -0
- package/components/QLinearProgress.vue +94 -0
- package/components/QList.vue +39 -0
- package/components/QLoading.vue +77 -0
- package/components/QLoadingProvider.vue +41 -0
- package/components/QMarquee.vue +56 -0
- package/components/QMessageScroller.vue +10 -0
- package/components/QMessageScrollerButton.vue +48 -0
- package/components/QMessageScrollerContent.vue +31 -0
- package/components/QMessageScrollerItem.vue +49 -0
- package/components/QMessageScrollerProvider.vue +198 -0
- package/components/QMessageScrollerViewport.vue +109 -0
- package/components/QNavMenu.vue +75 -0
- package/components/QNavMenuContent.vue +10 -0
- package/components/QNavMenuItem.vue +60 -0
- package/components/QNavMenuTrigger.vue +96 -0
- package/components/QNotifyProvider.vue +33 -0
- package/components/QNotifyToast.vue +60 -0
- package/components/QPage.vue +56 -0
- package/components/QPagination.vue +195 -0
- package/components/QParallax.vue +124 -0
- package/components/QPullToRefresh.vue +138 -0
- package/components/QRadio.vue +99 -0
- package/components/QRating.vue +105 -0
- package/components/QReorder.vue +191 -0
- package/components/QRollingText.vue +225 -0
- package/components/QRow.vue +23 -0
- package/components/QSafeArea.vue +39 -0
- package/components/QScrollArea.vue +118 -0
- package/components/QSelect.vue +698 -0
- package/components/QSeparator.vue +39 -0
- package/components/QSidebar.vue +153 -0
- package/components/QSidebarContent.vue +14 -0
- package/components/QSidebarFooter.vue +14 -0
- package/components/QSidebarHeader.vue +14 -0
- package/components/QSidebarMenu.vue +9 -0
- package/components/QSidebarMenuButton.vue +59 -0
- package/components/QSidebarMenuItem.vue +9 -0
- package/components/QSidebarTrigger.vue +32 -0
- package/components/QSkeleton.vue +95 -0
- package/components/QSlider.vue +205 -0
- package/components/QSpace.vue +10 -0
- package/components/QSpinner.vue +172 -0
- package/components/QSplitter.vue +145 -0
- package/components/QSticky.vue +181 -0
- package/components/QSwipeCell.vue +217 -0
- package/components/QSwiper.vue +102 -0
- package/components/QSwiperSlide.vue +19 -0
- package/components/QSyntax.vue +171 -0
- package/components/QTab.vue +175 -0
- package/components/QTabPanel.vue +50 -0
- package/components/QTabPanels.vue +106 -0
- package/components/QTable.vue +575 -0
- package/components/QTabs.vue +188 -0
- package/components/QText.vue +316 -0
- package/components/QTextCaption.vue +122 -0
- package/components/QToolbar.vue +28 -0
- package/components/QTooltip.vue +313 -0
- package/components/QUploader.vue +473 -0
- package/components/QVideo.vue +289 -0
- package/components/QVirtualScroll.vue +190 -0
- package/components/internal/QDateCalendar.vue +153 -0
- package/index.ts +151 -0
- package/lib/breakpoints.ts +44 -0
- package/lib/colors.ts +24 -0
- package/lib/config.ts +34 -0
- package/lib/countries.ts +261 -0
- package/lib/formula.ts +630 -0
- package/lib/icons.ts +38 -0
- package/lib/loading.ts +75 -0
- package/lib/overlayBack.ts +124 -0
- package/lib/platform.ts +146 -0
- package/lib/primitive.ts +25 -0
- package/lib/q.ts +287 -0
- package/lib/screen.ts +105 -0
- package/lib/search.ts +35 -0
- package/lib/themeVars.ts +22 -0
- package/lib/useComponentProps.ts +54 -0
- package/lib/utils.ts +7 -0
- package/module.ts +89 -0
- package/package.json +41 -0
- package/scripts/generate-exports.ts +56 -0
- package/styles/main.css +7627 -0
- 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>
|