@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.
- package/components/QBtnActions.vue +40 -4
- package/components/QBtnDropdown.vue +18 -1
- package/components/QSelect.vue +68 -3
- package/package.json +1 -1
- package/styles/main.css +18 -0
|
@@ -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:
|
|
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="
|
|
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"
|
package/components/QSelect.vue
CHANGED
|
@@ -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
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;
|