@dnax/ui 0.0.37 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/QSelect.vue +101 -14
- package/package.json +1 -1
- package/styles/main.css +20 -1
package/components/QSelect.vue
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
// QSelect — options spécifiques par mode d'ouverture.
|
|
3
3
|
// sheetOptions / modalOptions / inlineOptions : style, classes, hauteur, arrondi, placeholder de recherche.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Placement du popup `inline` (même vocabulaire que `DropdownPosition` de QBtnActions) :
|
|
7
|
+
*
|
|
8
|
+
* - `"auto"` — suit la place disponible : sous le champ, bascule au-dessus s'il n'y
|
|
9
|
+
* a pas la place. Le popup fait la largeur du champ.
|
|
10
|
+
* - `"top"` / `"bottom"` — force le côté (pas de bascule). Le popup fait la largeur
|
|
11
|
+
* du champ.
|
|
12
|
+
* - suffixe `"-start"` / `"-end"` — ancre le popup sur le bord GAUCHE / DROIT du
|
|
13
|
+
* champ ; il prend alors sa largeur naturelle (ou `inline-options.width`), donc il
|
|
14
|
+
* peut être plus étroit ou plus large que le champ.
|
|
15
|
+
*
|
|
16
|
+
* Les placements latéraux de `DropdownPosition` (`left`, `right`) ne sont pas repris :
|
|
17
|
+
* un popup de sélection s'ouvre toujours au-dessus ou au-dessous de son champ.
|
|
18
|
+
*/
|
|
19
|
+
export type SelectPopupPosition =
|
|
20
|
+
| "auto"
|
|
21
|
+
| "bottom" | "bottom-start" | "bottom-end"
|
|
22
|
+
| "top" | "top-start" | "top-end"
|
|
23
|
+
|
|
4
24
|
export interface QSelectModeOptions {
|
|
5
25
|
/** Largeur du panneau/popup (remplace width) */
|
|
6
26
|
width?: string
|
|
@@ -10,6 +30,10 @@ export interface QSelectModeOptions {
|
|
|
10
30
|
class?: string
|
|
11
31
|
/** Hauteur de la liste scrollable (remplace height) */
|
|
12
32
|
height?: string
|
|
33
|
+
/** Écart champ ↔ popup (mode inline) — remplace la prop `offset` */
|
|
34
|
+
offset?: number
|
|
35
|
+
/** Placement du popup (mode inline) — remplace la prop `position` */
|
|
36
|
+
position?: SelectPopupPosition
|
|
13
37
|
/** Arrondi du panneau (remplace rounded) */
|
|
14
38
|
rounded?: boolean | string | { tl?: string | number; tr?: string | number; br?: string | number; bl?: string | number }
|
|
15
39
|
/** Placeholder du champ de recherche pour ce mode */
|
|
@@ -95,6 +119,18 @@ interface Props {
|
|
|
95
119
|
height?: string
|
|
96
120
|
/** Arrondi du panneau : true (défaut du mode) | false (carré) | valeur CSS | objet { tl, tr, br, bl } */
|
|
97
121
|
rounded?: boolean | string | { tl?: string | number; tr?: string | number; br?: string | number; bl?: string | number }
|
|
122
|
+
/**
|
|
123
|
+
* Écart entre le champ et le popup inline, en px (défaut : 8).
|
|
124
|
+
* Réduit automatiquement (jusqu'à 0) si la place manque sous/au-dessus du champ.
|
|
125
|
+
* Surchargeable par mode : `inline-options="{ offset: 8 }"`.
|
|
126
|
+
*/
|
|
127
|
+
offset?: number
|
|
128
|
+
/**
|
|
129
|
+
* Placement du popup inline — voir `SelectPopupPosition`. Défaut : `"auto"`
|
|
130
|
+
* (bascule automatique selon la place disponible).
|
|
131
|
+
* Surchargeable par mode : `inline-options="{ position: 'top' }"`.
|
|
132
|
+
*/
|
|
133
|
+
position?: SelectPopupPosition
|
|
98
134
|
/** Options spécifiques au mode sheet (style, class, height, rounded, width, searchPlaceholder) */
|
|
99
135
|
sheetOptions?: QSelectModeOptions
|
|
100
136
|
/** Options spécifiques au mode modal */
|
|
@@ -125,6 +161,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
125
161
|
loading: false,
|
|
126
162
|
noOptionsLabel: "No options to display",
|
|
127
163
|
mode: "inline",
|
|
164
|
+
offset: 8,
|
|
165
|
+
position: "auto",
|
|
128
166
|
})
|
|
129
167
|
|
|
130
168
|
const emit = defineEmits<{
|
|
@@ -172,10 +210,10 @@ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
|
|
|
172
210
|
|
|
173
211
|
// — Popup inline : direction, écart et hauteur max calculés selon l'espace visible —
|
|
174
212
|
// Ouvre vers le bas ; s'il n'y a pas la place (POPUP_MIN_SPACE) et qu'au-dessus il y
|
|
175
|
-
//
|
|
176
|
-
// quand la place manque (jusqu'à 0) et la
|
|
177
|
-
// disponible : le popup ne dépasse jamais le bord
|
|
178
|
-
|
|
213
|
+
// a davantage de place, il bascule au-dessus du champ. L'écart avec le champ
|
|
214
|
+
// (`offset`, 8px par défaut) se réduit quand la place manque (jusqu'à 0) et la
|
|
215
|
+
// hauteur max est bornée à l'espace disponible : le popup ne dépasse jamais le bord
|
|
216
|
+
// de la fenêtre.
|
|
179
217
|
/** Espace vertical visé pour la liste : en dessous, on cherche de la place ailleurs */
|
|
180
218
|
const POPUP_MIN_SPACE = 96
|
|
181
219
|
/** Plafond historique de la liste scrollable */
|
|
@@ -184,34 +222,79 @@ const POPUP_MAX_HEIGHT = 240
|
|
|
184
222
|
const VIEWPORT_MARGIN = 8
|
|
185
223
|
|
|
186
224
|
const popupDirection = ref<"down" | "up">("down")
|
|
187
|
-
const popupGap = ref(POPUP_GAP)
|
|
188
225
|
const popupMaxHeight = ref(POPUP_MAX_HEIGHT)
|
|
226
|
+
/** Décalages en px par rapport à la RACINE (bloc conteneur du popup) — null avant mesure */
|
|
227
|
+
const popupTop = ref<number | null>(null)
|
|
228
|
+
const popupBottom = ref<number | null>(null)
|
|
229
|
+
|
|
230
|
+
/** Écart champ ↔ popup : `inline-options.offset` prioritaire sur la prop `offset` */
|
|
231
|
+
const popupOffset = computed(() => modeOptions.value?.offset ?? props.offset)
|
|
232
|
+
|
|
233
|
+
/** Placement demandé : `inline-options.position` prioritaire sur la prop `position` */
|
|
234
|
+
const popupPlacement = computed<SelectPopupPosition>(
|
|
235
|
+
() => modeOptions.value?.position ?? props.position,
|
|
236
|
+
)
|
|
189
237
|
|
|
190
238
|
const positionPopup = () => {
|
|
191
239
|
const el = rootEl.value
|
|
192
240
|
if (!el || typeof window === "undefined") return
|
|
193
241
|
const rect = el.getBoundingClientRect()
|
|
194
|
-
const below = window.innerHeight - rect.bottom - VIEWPORT_MARGIN
|
|
195
|
-
const above = rect.top - VIEWPORT_MARGIN
|
|
196
242
|
|
|
197
|
-
|
|
243
|
+
// Ancre = bas du CHAMP, pas bas de la racine : `.q-field__bottom` réserve
|
|
244
|
+
// ~24px (min-height 20px + padding-top 4px) même quand il est vide, ce qui
|
|
245
|
+
// éloignait le popup du champ. Quand un hint / une erreur est affiché, on
|
|
246
|
+
// garde le bas de la racine pour ne pas recouvrir le texte.
|
|
247
|
+
const control = el.querySelector<HTMLElement>(".q-field__control")
|
|
248
|
+
const hasBottomText = !!el.querySelector(".q-field__hint, .q-field__error")
|
|
249
|
+
const anchor = (!hasBottomText && control ? control : el).getBoundingClientRect()
|
|
250
|
+
|
|
251
|
+
const below = window.innerHeight - anchor.bottom - VIEWPORT_MARGIN
|
|
252
|
+
const above = anchor.top - VIEWPORT_MARGIN
|
|
253
|
+
|
|
254
|
+
// `auto` : sous le champ, bascule au-dessus s'il n'y a pas la place (et qu'il y en
|
|
255
|
+
// a plus au-dessus). `top` / `bottom` forcent le côté, sans bascule.
|
|
256
|
+
const placement = popupPlacement.value
|
|
257
|
+
const down =
|
|
258
|
+
placement === "auto"
|
|
259
|
+
? below >= POPUP_MIN_SPACE || below >= above
|
|
260
|
+
: !placement.startsWith("top")
|
|
198
261
|
const available = Math.max(down ? below : above, 0)
|
|
199
262
|
|
|
200
263
|
// Écart réduit quand la place manque, pour laisser le maximum à la liste
|
|
201
|
-
const gap = Math.max(0, Math.min(
|
|
264
|
+
const gap = Math.max(0, Math.min(popupOffset.value, available - POPUP_MIN_SPACE))
|
|
202
265
|
|
|
203
266
|
popupDirection.value = down ? "down" : "up"
|
|
204
|
-
popupGap.value = gap
|
|
205
267
|
popupMaxHeight.value = Math.max(0, Math.min(POPUP_MAX_HEIGHT, available - gap))
|
|
268
|
+
popupTop.value = Math.round(anchor.bottom - rect.top + gap)
|
|
269
|
+
popupBottom.value = Math.round(rect.bottom - anchor.top + gap)
|
|
206
270
|
}
|
|
207
271
|
|
|
208
272
|
const popupStyle = computed<Record<string, string>>(() => {
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
273
|
+
const up = popupDirection.value === "up"
|
|
274
|
+
const fallback = `calc(100% + ${popupOffset.value}px)`
|
|
275
|
+
const style: Record<string, string> = {
|
|
276
|
+
top: up ? "auto" : popupTop.value === null ? fallback : `${popupTop.value}px`,
|
|
277
|
+
bottom: up ? (popupBottom.value === null ? fallback : `${popupBottom.value}px`) : "auto",
|
|
213
278
|
maxHeight: `${Math.round(popupMaxHeight.value)}px`,
|
|
214
279
|
}
|
|
280
|
+
|
|
281
|
+
// Ancre horizontale : par défaut le popup couvre la largeur du champ. Les suffixes
|
|
282
|
+
// `-start` / `-end` (ou un `inline-options.width` explicite) l'ancrent sur un bord —
|
|
283
|
+
// l'effet est visible dès que le popup n'a pas la largeur du champ.
|
|
284
|
+
const placement = popupPlacement.value
|
|
285
|
+
const width = modeOptions.value?.width
|
|
286
|
+
const anchored = placement !== "auto" && placement.includes("-")
|
|
287
|
+
if (anchored || width) {
|
|
288
|
+
const alignEnd = placement.endsWith("-end")
|
|
289
|
+
style.left = alignEnd ? "auto" : "0"
|
|
290
|
+
style.right = alignEnd ? "0" : "auto"
|
|
291
|
+
style.width = width ?? "100%"
|
|
292
|
+
} else {
|
|
293
|
+
style.left = "0"
|
|
294
|
+
style.right = "0"
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return style
|
|
215
298
|
})
|
|
216
299
|
|
|
217
300
|
// Le champ peut bouger (scroll) ou la fenêtre changer de taille : on recalcule
|
|
@@ -219,6 +302,10 @@ const onPopupViewportChange = () => {
|
|
|
219
302
|
if (open.value && props.mode === "inline") positionPopup()
|
|
220
303
|
}
|
|
221
304
|
|
|
305
|
+
// Un changement de `position` / `offset` (prop ou options de mode) pendant que le
|
|
306
|
+
// popup est ouvert doit le replacer tout de suite (ex. sélecteur de placement).
|
|
307
|
+
watch([popupPlacement, popupOffset], onPopupViewportChange)
|
|
308
|
+
|
|
222
309
|
// — Options normalisées : les primitives (string/number) deviennent { value, label } —
|
|
223
310
|
const isObjectOption = (o: any): boolean => o !== null && typeof o === "object"
|
|
224
311
|
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -264,6 +264,25 @@ html.dark body {
|
|
|
264
264
|
color: rgb(255 255 255 / 0.65);
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/* Icônes de champ en dark : icône gauche + chevrons (select, autocomplete) +
|
|
268
|
+
loupe de recherche des listes déroulantes. Ces icônes ont une couleur NOIRE
|
|
269
|
+
codée en dur (`rgb(0 0 0 / …)` sur `.q-field__icon`, `.q-select__arrow`,
|
|
270
|
+
`.q-autocomplete__arrow`, `.q-select__search-icon`) : sans cette surcharge
|
|
271
|
+
elles restaient noires sur fond sombre → illisibles. */
|
|
272
|
+
.dark .q-field__icon,
|
|
273
|
+
.dark .q-select__arrow,
|
|
274
|
+
.dark .q-autocomplete__arrow,
|
|
275
|
+
.dark .q-select__search-icon,
|
|
276
|
+
.dark .q-autocomplete__search-icon {
|
|
277
|
+
color: var(--foreground);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* État vide des listes déroulantes en dark (texte noir codé en dur) */
|
|
281
|
+
.dark .q-select__empty,
|
|
282
|
+
.dark .q-autocomplete__empty {
|
|
283
|
+
color: rgb(255 255 255 / 0.65);
|
|
284
|
+
}
|
|
285
|
+
|
|
267
286
|
.dark .q-input .q-field__native::placeholder,
|
|
268
287
|
.dark .q-input-tag__native::placeholder,
|
|
269
288
|
.dark .q-input-otp__box::placeholder,
|
|
@@ -4709,7 +4728,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
4709
4728
|
|
|
4710
4729
|
.q-select__popup {
|
|
4711
4730
|
position: absolute;
|
|
4712
|
-
top: calc(100% +
|
|
4731
|
+
top: calc(100% + 8px);
|
|
4713
4732
|
left: 0;
|
|
4714
4733
|
right: 0;
|
|
4715
4734
|
z-index: 1000;
|