@dnax/ui 0.0.36 → 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.
@@ -44,11 +44,13 @@ export type DropdownPosition =
44
44
 
45
45
  <script setup lang="ts">
46
46
  import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
47
+ import type { StyleValue } from "vue"
47
48
  import { Icon } from "@iconify/vue"
48
49
  import { colorValue } from "../lib/colors"
49
50
  import { icons } from "../lib/icons"
50
51
  import { radiusStyle, useRadius } from "../lib/useComponentProps"
51
52
  import type { RadiusProp } from "../lib/useComponentProps"
53
+ import { cn } from "../lib/utils"
52
54
  import QBtn from "./QBtn.vue"
53
55
 
54
56
  interface Props {
@@ -71,8 +73,23 @@ interface Props {
71
73
  position?: DropdownPosition
72
74
  /** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
73
75
  offset?: number
74
- /** Largeur minimale du menu (ex. "200px") */
76
+ /** Largeur minimale du menu (ex. "200px") — sert aussi de plancher quand `fit` est actif */
75
77
  menuWidth?: string
78
+ /**
79
+ * Le panneau fait AU MOINS la largeur du déclencheur (défaut : true).
80
+ * Sémantique Quasar `QMenu.fit` : c'est un plancher, pas une largeur exacte —
81
+ * un item plus large (libellé long) élargit encore le panneau.
82
+ */
83
+ fit?: boolean
84
+ /** Classes attribuées au panneau téléporté (en plus de `q-btn-actions__panel`) */
85
+ contentClass?: string
86
+ /**
87
+ * Style(s) attribués au panneau téléporté — string, objet ou tableau (comme la
88
+ * prop `style` de Vue).
89
+ * PRIORITAIRE sur le style calculé (placement, min-width) : c'est la trappe de
90
+ * sortie pour forcer une largeur exacte ou une couleur de fond.
91
+ */
92
+ contentStyle?: StyleValue
76
93
  // — API déclencheur (transmise à QBtn) —
77
94
  color?: string
78
95
  textColor?: string
@@ -99,6 +116,9 @@ const props = withDefaults(defineProps<Props>(), {
99
116
  align: "right",
100
117
  offset: 4,
101
118
  menuWidth: "180px",
119
+ fit: true,
120
+ contentClass: "",
121
+ contentStyle: "",
102
122
  size: "md",
103
123
  color: "primary",
104
124
  flat: false,
@@ -124,6 +144,8 @@ const rootEl = ref<HTMLElement | null>(null)
124
144
  const panelEl = ref<HTMLElement | null>(null)
125
145
  /** Position fixed du panneau (téléporté dans <body>) */
126
146
  const panelPos = ref({ top: 0, left: 0, transform: "" })
147
+ /** Largeur mesurée du déclencheur — plancher du panneau quand `fit` est actif */
148
+ const triggerWidth = ref(0)
127
149
 
128
150
  const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
129
151
 
@@ -141,14 +163,27 @@ const triggerClasses = computed(() => [
141
163
  const effectiveRadius = useRadius("QBtnActions", () => props.radius)
142
164
  const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
143
165
 
166
+ // `fit` : min-width = max(menu-width, largeur du déclencheur). `max()` est calculé
167
+ // par le CSS → `menu-width` peut rester une longueur quelconque ("16rem", "40%").
168
+ const panelMinWidth = computed(() => {
169
+ const base = props.menuWidth
170
+ if (!props.fit || !triggerWidth.value) return base
171
+ const fitted = `${Math.round(triggerWidth.value)}px`
172
+ return base ? `max(${base}, ${fitted})` : fitted
173
+ })
174
+
144
175
  const panelStyle = computed<Record<string, string>>(() => ({
145
176
  top: `${panelPos.value.top}px`,
146
177
  left: `${panelPos.value.left}px`,
147
178
  transform: panelPos.value.transform,
148
- minWidth: props.menuWidth,
179
+ minWidth: panelMinWidth.value,
149
180
  ...(roundedStyle.value ?? {}),
150
181
  }))
151
182
 
183
+ // Le panneau est téléporté dans <body> : `content-class` / `content-style` sont
184
+ // donc la seule façon de le styler depuis le composant consommateur.
185
+ const panelClasses = computed(() => cn("q-btn-actions__panel", props.contentClass))
186
+
152
187
  // `position` explicite, sinon alias `align` (compat) → bottom-start / bottom-end
153
188
  const placement = computed<DropdownPosition>(
154
189
  () => props.position ?? (props.align === "left" ? "bottom-start" : "bottom-end"),
@@ -162,6 +197,7 @@ const placePanel = () => {
162
197
  const el = rootEl.value
163
198
  if (!el || typeof document === "undefined") return
164
199
  const rect = el.getBoundingClientRect()
200
+ triggerWidth.value = rect.width
165
201
  const [main, alt] = placement.value.split("-") as ["bottom" | "top" | "left" | "right", "start" | "end" | undefined]
166
202
  const off = props.offset
167
203
  const midX = (rect.left + rect.right) / 2
@@ -355,10 +391,10 @@ onBeforeUnmount(() => {
355
391
  <div
356
392
  v-if="open"
357
393
  ref="panelEl"
358
- class="q-btn-actions__panel"
394
+ :class="panelClasses"
359
395
  role="menu"
360
396
  :aria-label="label ?? 'Actions'"
361
- :style="panelStyle"
397
+ :style="[panelStyle, contentStyle]"
362
398
  @keydown="onKeydown"
363
399
  >
364
400
  <template v-for="row in menuRows" :key="row.key">
@@ -29,6 +29,7 @@ export interface DropdownItem {
29
29
 
30
30
  <script setup lang="ts">
31
31
  import { computed } from "vue"
32
+ import type { StyleValue } from "vue"
32
33
  import QBtnActions, { type DropdownPosition } from "./QBtnActions.vue"
33
34
  import type { RadiusProp } from "../lib/useComponentProps"
34
35
 
@@ -52,8 +53,21 @@ interface Props {
52
53
  position?: DropdownPosition
53
54
  /** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
54
55
  offset?: number
55
- /** Largeur minimale du menu (ex. "220px") */
56
+ /** Largeur minimale du menu (ex. "220px") — sert aussi de plancher quand `fit` est actif */
56
57
  menuWidth?: string
58
+ /**
59
+ * Le panneau fait AU MOINS la largeur du déclencheur (défaut : true).
60
+ * Sémantique Quasar `QMenu.fit` : c'est un plancher, pas une largeur exacte —
61
+ * un item plus large (libellé long) élargit encore le panneau.
62
+ */
63
+ fit?: boolean
64
+ /** Classes attribuées au panneau du menu (téléporté dans `<body>`) */
65
+ contentClass?: string
66
+ /**
67
+ * Style(s) attribués au panneau du menu — string, objet ou tableau (comme la
68
+ * prop `style` de Vue). PRIORITAIRE sur le style calculé (placement, min-width).
69
+ */
70
+ contentStyle?: StyleValue
57
71
  // — API déclencheur (transmise à QBtn) —
58
72
  color?: string
59
73
  textColor?: string
@@ -114,6 +128,9 @@ const onSelectAction = (value: any) => emit("select", value)
114
128
  :position="position"
115
129
  :offset="offset"
116
130
  :menu-width="menuWidth"
131
+ :fit="fit"
132
+ :content-class="contentClass"
133
+ :content-style="contentStyle"
117
134
  :color="color"
118
135
  :text-color="textColor"
119
136
  :size="size"
@@ -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<{
@@ -170,6 +208,104 @@ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
170
208
  return undefined
171
209
  })
172
210
 
211
+ // — Popup inline : direction, écart et hauteur max calculés selon l'espace visible —
212
+ // Ouvre vers le bas ; s'il n'y a pas la place (POPUP_MIN_SPACE) et qu'au-dessus il y
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.
217
+ /** Espace vertical visé pour la liste : en dessous, on cherche de la place ailleurs */
218
+ const POPUP_MIN_SPACE = 96
219
+ /** Plafond historique de la liste scrollable */
220
+ const POPUP_MAX_HEIGHT = 240
221
+ /** Marge conservée avec les bords de la fenêtre */
222
+ const VIEWPORT_MARGIN = 8
223
+
224
+ const popupDirection = ref<"down" | "up">("down")
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
+ )
237
+
238
+ const positionPopup = () => {
239
+ const el = rootEl.value
240
+ if (!el || typeof window === "undefined") return
241
+ const rect = el.getBoundingClientRect()
242
+
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")
261
+ const available = Math.max(down ? below : above, 0)
262
+
263
+ // Écart réduit quand la place manque, pour laisser le maximum à la liste
264
+ const gap = Math.max(0, Math.min(popupOffset.value, available - POPUP_MIN_SPACE))
265
+
266
+ popupDirection.value = down ? "down" : "up"
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)
270
+ }
271
+
272
+ const popupStyle = computed<Record<string, string>>(() => {
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",
278
+ maxHeight: `${Math.round(popupMaxHeight.value)}px`,
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
298
+ })
299
+
300
+ // Le champ peut bouger (scroll) ou la fenêtre changer de taille : on recalcule
301
+ const onPopupViewportChange = () => {
302
+ if (open.value && props.mode === "inline") positionPopup()
303
+ }
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
+
173
309
  // — Options normalisées : les primitives (string/number) deviennent { value, label } —
174
310
  const isObjectOption = (o: any): boolean => o !== null && typeof o === "object"
175
311
 
@@ -446,11 +582,23 @@ const onDocKeydown = (e: KeyboardEvent) => {
446
582
  if (e.key === "Escape" && open.value && props.mode !== "inline") closePopup()
447
583
  }
448
584
 
449
- // Verrouille le scroll du body quand un panneau modal est ouvert
585
+ // Verrouille le scroll du body quand un panneau modal est ouvert ;
586
+ // en mode inline, (re)calcule la direction/écart/hauteur du popup à l'ouverture
587
+ // et suit les changements de viewport tant qu'il est ouvert.
450
588
  watch(open, (v) => {
451
589
  if (typeof document !== "undefined" && props.mode !== "inline") {
452
590
  document.body.style.overflow = v ? "hidden" : ""
453
591
  }
592
+
593
+ if (typeof window === "undefined") return
594
+ if (v && props.mode === "inline") {
595
+ positionPopup()
596
+ window.addEventListener("resize", onPopupViewportChange)
597
+ window.addEventListener("scroll", onPopupViewportChange, true)
598
+ } else {
599
+ window.removeEventListener("resize", onPopupViewportChange)
600
+ window.removeEventListener("scroll", onPopupViewportChange, true)
601
+ }
454
602
  })
455
603
 
456
604
  onMounted(() => {
@@ -465,6 +613,10 @@ onBeforeUnmount(() => {
465
613
  document.removeEventListener("keydown", onDocKeydown)
466
614
  document.body.style.overflow = ""
467
615
  }
616
+ if (typeof window !== "undefined") {
617
+ window.removeEventListener("resize", onPopupViewportChange)
618
+ window.removeEventListener("scroll", onPopupViewportChange, true)
619
+ }
468
620
  })
469
621
 
470
622
  // — Styles du panneau (props globales fusionnées avec les options du mode) —
@@ -602,8 +754,8 @@ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions
602
754
  v-if="open && mode === 'inline'"
603
755
  ref="popupRef"
604
756
  class="q-select__popup"
605
- :class="inlineOptions?.class"
606
- :style="inlineOptions?.style"
757
+ :class="[inlineOptions?.class, popupDirection === 'up' && 'q-select__popup--up']"
758
+ :style="[popupStyle, inlineOptions?.style]"
607
759
  role="listbox"
608
760
  @mousedown.prevent
609
761
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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% + 4px);
4731
+ top: calc(100% + 8px);
4713
4732
  left: 0;
4714
4733
  right: 0;
4715
4734
  z-index: 1000;
@@ -4723,6 +4742,24 @@ a.q-breadcrumbs__el:hover {
4723
4742
  animation: q-popup-in 0.12s ease;
4724
4743
  }
4725
4744
 
4745
+ /* Bascule au-dessus du champ (pas assez de place en dessous) : l'animation
4746
+ vient d'en bas. `top`/`bottom`/`max-height` sont posés en inline par le script
4747
+ (`positionPopup()`), selon l'espace visible. */
4748
+ .q-select__popup--up {
4749
+ animation-name: q-popup-in-up;
4750
+ }
4751
+
4752
+ @keyframes q-popup-in-up {
4753
+ from {
4754
+ opacity: 0;
4755
+ transform: translateY(4px);
4756
+ }
4757
+ to {
4758
+ opacity: 1;
4759
+ transform: none;
4760
+ }
4761
+ }
4762
+
4726
4763
  /* — champ de recherche séparé (useSearch), affiché avant la liste — */
4727
4764
  .q-select__search {
4728
4765
  display: flex;