@dnax/ui 0.0.36 → 0.0.37

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"
@@ -170,6 +170,55 @@ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
170
170
  return undefined
171
171
  })
172
172
 
173
+ // — Popup inline : direction, écart et hauteur max calculés selon l'espace visible —
174
+ // Ouvre vers le bas ; s'il n'y a pas la place (POPUP_MIN_SPACE) et qu'au-dessus il y
175
+ // en a davantage, il bascule au-dessus du champ. L'écart avec le champ se réduit
176
+ // quand la place manque (jusqu'à 0) et la hauteur max est bornée à l'espace
177
+ // disponible : le popup ne dépasse jamais le bord de la fenêtre.
178
+ const POPUP_GAP = 4
179
+ /** Espace vertical visé pour la liste : en dessous, on cherche de la place ailleurs */
180
+ const POPUP_MIN_SPACE = 96
181
+ /** Plafond historique de la liste scrollable */
182
+ const POPUP_MAX_HEIGHT = 240
183
+ /** Marge conservée avec les bords de la fenêtre */
184
+ const VIEWPORT_MARGIN = 8
185
+
186
+ const popupDirection = ref<"down" | "up">("down")
187
+ const popupGap = ref(POPUP_GAP)
188
+ const popupMaxHeight = ref(POPUP_MAX_HEIGHT)
189
+
190
+ const positionPopup = () => {
191
+ const el = rootEl.value
192
+ if (!el || typeof window === "undefined") return
193
+ const rect = el.getBoundingClientRect()
194
+ const below = window.innerHeight - rect.bottom - VIEWPORT_MARGIN
195
+ const above = rect.top - VIEWPORT_MARGIN
196
+
197
+ const down = below >= POPUP_MIN_SPACE || below >= above
198
+ const available = Math.max(down ? below : above, 0)
199
+
200
+ // Écart réduit quand la place manque, pour laisser le maximum à la liste
201
+ const gap = Math.max(0, Math.min(POPUP_GAP, available - POPUP_MIN_SPACE))
202
+
203
+ popupDirection.value = down ? "down" : "up"
204
+ popupGap.value = gap
205
+ popupMaxHeight.value = Math.max(0, Math.min(POPUP_MAX_HEIGHT, available - gap))
206
+ }
207
+
208
+ const popupStyle = computed<Record<string, string>>(() => {
209
+ const edge = `calc(100% + ${popupGap.value}px)`
210
+ return {
211
+ top: popupDirection.value === "down" ? edge : "auto",
212
+ bottom: popupDirection.value === "up" ? edge : "auto",
213
+ maxHeight: `${Math.round(popupMaxHeight.value)}px`,
214
+ }
215
+ })
216
+
217
+ // Le champ peut bouger (scroll) ou la fenêtre changer de taille : on recalcule
218
+ const onPopupViewportChange = () => {
219
+ if (open.value && props.mode === "inline") positionPopup()
220
+ }
221
+
173
222
  // — Options normalisées : les primitives (string/number) deviennent { value, label } —
174
223
  const isObjectOption = (o: any): boolean => o !== null && typeof o === "object"
175
224
 
@@ -446,11 +495,23 @@ const onDocKeydown = (e: KeyboardEvent) => {
446
495
  if (e.key === "Escape" && open.value && props.mode !== "inline") closePopup()
447
496
  }
448
497
 
449
- // Verrouille le scroll du body quand un panneau modal est ouvert
498
+ // Verrouille le scroll du body quand un panneau modal est ouvert ;
499
+ // en mode inline, (re)calcule la direction/écart/hauteur du popup à l'ouverture
500
+ // et suit les changements de viewport tant qu'il est ouvert.
450
501
  watch(open, (v) => {
451
502
  if (typeof document !== "undefined" && props.mode !== "inline") {
452
503
  document.body.style.overflow = v ? "hidden" : ""
453
504
  }
505
+
506
+ if (typeof window === "undefined") return
507
+ if (v && props.mode === "inline") {
508
+ positionPopup()
509
+ window.addEventListener("resize", onPopupViewportChange)
510
+ window.addEventListener("scroll", onPopupViewportChange, true)
511
+ } else {
512
+ window.removeEventListener("resize", onPopupViewportChange)
513
+ window.removeEventListener("scroll", onPopupViewportChange, true)
514
+ }
454
515
  })
455
516
 
456
517
  onMounted(() => {
@@ -465,6 +526,10 @@ onBeforeUnmount(() => {
465
526
  document.removeEventListener("keydown", onDocKeydown)
466
527
  document.body.style.overflow = ""
467
528
  }
529
+ if (typeof window !== "undefined") {
530
+ window.removeEventListener("resize", onPopupViewportChange)
531
+ window.removeEventListener("scroll", onPopupViewportChange, true)
532
+ }
468
533
  })
469
534
 
470
535
  // — Styles du panneau (props globales fusionnées avec les options du mode) —
@@ -602,8 +667,8 @@ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions
602
667
  v-if="open && mode === 'inline'"
603
668
  ref="popupRef"
604
669
  class="q-select__popup"
605
- :class="inlineOptions?.class"
606
- :style="inlineOptions?.style"
670
+ :class="[inlineOptions?.class, popupDirection === 'up' && 'q-select__popup--up']"
671
+ :style="[popupStyle, inlineOptions?.style]"
607
672
  role="listbox"
608
673
  @mousedown.prevent
609
674
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.36",
3
+ "version": "0.0.37",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -4723,6 +4723,24 @@ a.q-breadcrumbs__el:hover {
4723
4723
  animation: q-popup-in 0.12s ease;
4724
4724
  }
4725
4725
 
4726
+ /* Bascule au-dessus du champ (pas assez de place en dessous) : l'animation
4727
+ vient d'en bas. `top`/`bottom`/`max-height` sont posés en inline par le script
4728
+ (`positionPopup()`), selon l'espace visible. */
4729
+ .q-select__popup--up {
4730
+ animation-name: q-popup-in-up;
4731
+ }
4732
+
4733
+ @keyframes q-popup-in-up {
4734
+ from {
4735
+ opacity: 0;
4736
+ transform: translateY(4px);
4737
+ }
4738
+ to {
4739
+ opacity: 1;
4740
+ transform: none;
4741
+ }
4742
+ }
4743
+
4726
4744
  /* — champ de recherche séparé (useSearch), affiché avant la liste — */
4727
4745
  .q-select__search {
4728
4746
  display: flex;