@dnax/ui 0.0.34 → 0.0.36
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/QBreadcrumbs.vue +132 -0
- package/components/QBreadcrumbsEl.vue +84 -0
- package/components/QBtnActions.vue +113 -19
- package/components/QBtnDropdown.vue +134 -0
- package/components/_QBtnActionsLegacy.vue +254 -0
- package/components/internal/RenderNodes.vue +23 -0
- package/index.ts +5 -0
- package/lib/ripple.ts +322 -0
- package/module.ts +8 -2
- package/package.json +2 -2
- package/scripts/generate-exports.ts +3 -1
- package/styles/main.css +176 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QBreadcrumbs — fil d'ariane : <q-breadcrumbs separator="›" gutter="12px" active-color="secondary" align="center" dense />
|
|
3
|
+
// Famille Quasar QBreadcrumbs + QBreadcrumbsEl, rendu façon shadcn-vue Breadcrumb
|
|
4
|
+
// (nav > ol > li, aria-current="page" sur la page courante).
|
|
5
|
+
//
|
|
6
|
+
// Le conteneur insère AUTOMATIQUEMENT un séparateur entre chaque miette du slot par
|
|
7
|
+
// défaut (en général des <q-breadcrumbs-el>) et marque la dernière miette comme la
|
|
8
|
+
// page courante (couleur active + graisse). Les vnodes bruts du slot sont réaffichés
|
|
9
|
+
// via le helper interne RenderNodes (intercaler les séparateurs = impossible en
|
|
10
|
+
// template pur).
|
|
11
|
+
import { Comment, Text, useSlots, type VNode } from "vue"
|
|
12
|
+
import { Icon } from "@iconify/vue"
|
|
13
|
+
import { cn } from "../lib/utils"
|
|
14
|
+
import { colorValue } from "../lib/colors"
|
|
15
|
+
import RenderNodes from "./internal/RenderNodes.vue"
|
|
16
|
+
|
|
17
|
+
/** "lucide:chevron-right", "mdi:chevron-right"… → séparateur rendu en icône Iconify */
|
|
18
|
+
const ICON_PREFIX_RE = /^[\w-]+:/
|
|
19
|
+
const isIconName = (name: string) => ICON_PREFIX_RE.test(name)
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** Séparateur entre les miettes : texte ("/", "›", "·"…) ou icône Iconify ("lucide:chevron-right") */
|
|
23
|
+
separator?: string
|
|
24
|
+
/** Couleur du séparateur (token ou hex) */
|
|
25
|
+
separatorColor?: string
|
|
26
|
+
/** Couleur de la dernière miette — la page courante (token ou hex, défaut primary) */
|
|
27
|
+
activeColor?: string
|
|
28
|
+
/** Couleur des miettes non courantes (token ou hex — défaut : texte adouci) */
|
|
29
|
+
color?: string
|
|
30
|
+
/** Espace entre les éléments (valeur CSS, défaut "8px") */
|
|
31
|
+
gutter?: string
|
|
32
|
+
/** Alignement du fil d'ariane */
|
|
33
|
+
align?: "left" | "center" | "right"
|
|
34
|
+
/** Taille réduite */
|
|
35
|
+
dense?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
39
|
+
separator: "/",
|
|
40
|
+
activeColor: "primary",
|
|
41
|
+
color: "",
|
|
42
|
+
gutter: "8px",
|
|
43
|
+
align: "left",
|
|
44
|
+
dense: false,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{
|
|
48
|
+
/**
|
|
49
|
+
* Émis quand l'utilisateur clique une miette qui n'est pas la page courante
|
|
50
|
+
* (la dernière) — payload : index de la miette + événement souris d'origine.
|
|
51
|
+
*/
|
|
52
|
+
change: [index: number, event: MouseEvent]
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
const slots = useSlots()
|
|
56
|
+
|
|
57
|
+
interface CrumbRow {
|
|
58
|
+
crumb: VNode
|
|
59
|
+
isLast: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Miettes du slot par défaut (commentaires et nœuds texte purs ignorés), avec le marqueur de dernière. */
|
|
63
|
+
const rows = (): CrumbRow[] => {
|
|
64
|
+
const list = (slots.default?.() ?? []).filter((v) => {
|
|
65
|
+
const t = v.type
|
|
66
|
+
return t !== Comment && t !== Text
|
|
67
|
+
})
|
|
68
|
+
return list.map((crumb, i) => ({ crumb, isLast: i === list.length - 1 }))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const containerStyle = (): Record<string, string> => {
|
|
72
|
+
const style: Record<string, string> = {}
|
|
73
|
+
if (props.separatorColor) style["--q-breadcrumbs-sep-color"] = colorValue(props.separatorColor)
|
|
74
|
+
if (props.activeColor) style["--q-breadcrumbs-active-color"] = colorValue(props.activeColor)
|
|
75
|
+
if (props.color) style["--q-breadcrumbs-color"] = colorValue(props.color)
|
|
76
|
+
if (props.gutter) style["--q-breadcrumbs-gutter"] = props.gutter
|
|
77
|
+
return style
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const navClasses = (): string =>
|
|
81
|
+
cn(
|
|
82
|
+
"q-breadcrumbs",
|
|
83
|
+
props.align === "center" && "q-breadcrumbs--center",
|
|
84
|
+
props.align === "right" && "q-breadcrumbs--right",
|
|
85
|
+
props.dense && "q-breadcrumbs--dense",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
// Délégation de clic sur le <nav> : le clic DOM remonte jusqu'ici même pour les
|
|
89
|
+
// miettes-lien (l'émission Vue de QBreadcrumbsEl ne « bulle » pas). On ignore la
|
|
90
|
+
// page courante (dernière miette) et les séparateurs.
|
|
91
|
+
const onNavClick = (e: MouseEvent) => {
|
|
92
|
+
const target = e.target
|
|
93
|
+
if (!(target instanceof Element)) return
|
|
94
|
+
const li = target.closest(".q-breadcrumbs__item")
|
|
95
|
+
if (!li || li.classList.contains("q-breadcrumbs__item--last")) return
|
|
96
|
+
const items = Array.from(li.parentElement?.children ?? []).filter((el) =>
|
|
97
|
+
el.classList.contains("q-breadcrumbs__item"),
|
|
98
|
+
)
|
|
99
|
+
const index = items.indexOf(li)
|
|
100
|
+
if (index >= 0) emit("change", index, e)
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<nav
|
|
106
|
+
class="q-breadcrumbs"
|
|
107
|
+
:class="navClasses()"
|
|
108
|
+
:style="containerStyle()"
|
|
109
|
+
aria-label="Breadcrumb"
|
|
110
|
+
@click="onNavClick"
|
|
111
|
+
>
|
|
112
|
+
<ol class="q-breadcrumbs__list">
|
|
113
|
+
<template v-for="(row, i) in rows()" :key="i">
|
|
114
|
+
<li
|
|
115
|
+
class="q-breadcrumbs__item"
|
|
116
|
+
:class="{ 'q-breadcrumbs__item--last': row.isLast }"
|
|
117
|
+
:aria-current="row.isLast ? 'page' : undefined"
|
|
118
|
+
>
|
|
119
|
+
<render-nodes :nodes="row.crumb" />
|
|
120
|
+
</li>
|
|
121
|
+
<li
|
|
122
|
+
v-if="!row.isLast && separator !== ''"
|
|
123
|
+
class="q-breadcrumbs__separator"
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
>
|
|
126
|
+
<Icon v-if="isIconName(separator)" :icon="separator" />
|
|
127
|
+
<template v-else>{{ separator }}</template>
|
|
128
|
+
</li>
|
|
129
|
+
</template>
|
|
130
|
+
</ol>
|
|
131
|
+
</nav>
|
|
132
|
+
</template>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// QBreadcrumbsEl — une miette du fil d'ariane : <q-breadcrumbs-el label="Home" icon="lucide:home" to="/" />
|
|
3
|
+
// Rendu comme lien routeur (<a>, prop `to`/`href`) ou simple texte (<span>) sinon —
|
|
4
|
+
// la dernière miette du <q-breadcrumbs> parent est la page courante (couleur active
|
|
5
|
+
// + aria-current gérés par le conteneur).
|
|
6
|
+
import { computed } from "vue"
|
|
7
|
+
import { Icon } from "@iconify/vue"
|
|
8
|
+
import { cn } from "../lib/utils"
|
|
9
|
+
import { useRouter } from "vue-router"
|
|
10
|
+
import type { RouteLocationRaw } from "vue-router"
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Texte de la miette (sinon contenu du slot) */
|
|
14
|
+
label?: string | number
|
|
15
|
+
/** Icône Iconify affichée à gauche (ex. : "lucide:home") */
|
|
16
|
+
icon?: string
|
|
17
|
+
/** Destination routeur : rend un lien <a> qui navigue au clic (router.push/replace) */
|
|
18
|
+
to?: RouteLocationRaw
|
|
19
|
+
/** router.replace au lieu de router.push */
|
|
20
|
+
replace?: boolean
|
|
21
|
+
/** Lien natif (<a href>) — sans routeur */
|
|
22
|
+
href?: string
|
|
23
|
+
/** Cible du lien natif (ex. "_blank") */
|
|
24
|
+
target?: string
|
|
25
|
+
/** Désactive la miette (clics ignorés) */
|
|
26
|
+
disable?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
30
|
+
replace: false,
|
|
31
|
+
disable: false,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{ click: [event: MouseEvent] }>()
|
|
35
|
+
|
|
36
|
+
// Routeur optionnel : sans router installé, `to` dégrade en simple <span> (l'API
|
|
37
|
+
// `href` reste disponible pour les liens natifs).
|
|
38
|
+
let router: ReturnType<typeof useRouter> | undefined
|
|
39
|
+
try {
|
|
40
|
+
router = useRouter()
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
router = undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const resolvedHref = computed(() => {
|
|
47
|
+
if (props.href) return props.href
|
|
48
|
+
if (props.to && router) return router.resolve(props.to).href
|
|
49
|
+
return undefined
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const tag = computed(() => (resolvedHref.value ? "a" : "span"))
|
|
53
|
+
|
|
54
|
+
const elClasses = computed(() =>
|
|
55
|
+
cn("q-breadcrumbs__el", props.disable && "q-breadcrumbs__el--disabled"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const onClick = (e: MouseEvent) => {
|
|
59
|
+
if (props.disable) return
|
|
60
|
+
if (props.to && router && !e.defaultPrevented) {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
if (props.replace) router.replace(props.to)
|
|
63
|
+
else router.push(props.to)
|
|
64
|
+
}
|
|
65
|
+
emit("click", e)
|
|
66
|
+
}
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<template>
|
|
70
|
+
<component
|
|
71
|
+
:is="tag"
|
|
72
|
+
:href="resolvedHref || undefined"
|
|
73
|
+
:target="tag === 'a' ? target || undefined : undefined"
|
|
74
|
+
:rel="tag === 'a' && target === '_blank' ? 'noopener' : undefined"
|
|
75
|
+
:tabindex="disable && tag === 'a' ? -1 : undefined"
|
|
76
|
+
:aria-disabled="disable || undefined"
|
|
77
|
+
:class="elClasses"
|
|
78
|
+
@click="onClick"
|
|
79
|
+
>
|
|
80
|
+
<Icon v-if="icon" :icon="icon" class="q-breadcrumbs__el-icon" aria-hidden="true" />
|
|
81
|
+
<span v-if="label !== undefined" class="q-breadcrumbs__el-label">{{ label }}</span>
|
|
82
|
+
<slot v-else />
|
|
83
|
+
</component>
|
|
84
|
+
</template>
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
// contextes d'empilement/overflow (cellules sticky de table, scroll containers).
|
|
8
8
|
|
|
9
9
|
export interface BtnAction {
|
|
10
|
-
/** Libellé affiché dans le menu */
|
|
11
|
-
label
|
|
10
|
+
/** Libellé affiché dans le menu (absent pour une entrée « séparateur seul ») */
|
|
11
|
+
label?: string
|
|
12
12
|
/** Valeur émise par @select-action ; si absente, l'action elle-même est émise */
|
|
13
13
|
value?: any
|
|
14
14
|
/** Icône Iconify à gauche (ex. : "lucide:pencil") */
|
|
@@ -26,6 +26,20 @@ export interface BtnAction {
|
|
|
26
26
|
/** Rappel local au clic (en plus de l'événement @select-action) */
|
|
27
27
|
onClick?: (action: BtnAction) => void
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Placement du panneau autour du déclencheur (côté + alignement sur l'axe
|
|
32
|
+
* perpendiculaire) :
|
|
33
|
+
* - "bottom-start" / "bottom-end" : sous le bouton, bord gauche / droit aligné ;
|
|
34
|
+
* - "top-start" / "top-end" : au-dessus du bouton ;
|
|
35
|
+
* - "left-start"… / "right-start"… : sur le côté (start = haut, end = bas) ;
|
|
36
|
+
* - sans suffixe ("bottom", "top", "left", "right") : centré sur l'axe croisé.
|
|
37
|
+
*/
|
|
38
|
+
export type DropdownPosition =
|
|
39
|
+
| "bottom-start" | "bottom-end" | "bottom"
|
|
40
|
+
| "top-start" | "top-end" | "top"
|
|
41
|
+
| "left-start" | "left-end" | "left"
|
|
42
|
+
| "right-start" | "right-end" | "right"
|
|
29
43
|
</script>
|
|
30
44
|
|
|
31
45
|
<script setup lang="ts">
|
|
@@ -48,8 +62,15 @@ interface Props {
|
|
|
48
62
|
noCaret?: boolean
|
|
49
63
|
/** Icône de la flèche déroulante */
|
|
50
64
|
dropdownIcon?: string
|
|
51
|
-
/** Alignement du menu
|
|
65
|
+
/** Alignement du menu sous le bouton : "left" = bottom-start, "right" = bottom-end (alias de `position`) */
|
|
52
66
|
align?: "left" | "right"
|
|
67
|
+
/**
|
|
68
|
+
* Placement du panneau autour du déclencheur — ex. "bottom-end" (défaut),
|
|
69
|
+
* "top-start", "right", "left-end"… (voir DropdownPosition). Prioritaire sur `align`.
|
|
70
|
+
*/
|
|
71
|
+
position?: DropdownPosition
|
|
72
|
+
/** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
|
|
73
|
+
offset?: number
|
|
53
74
|
/** Largeur minimale du menu (ex. "200px") */
|
|
54
75
|
menuWidth?: string
|
|
55
76
|
// — API déclencheur (transmise à QBtn) —
|
|
@@ -64,6 +85,7 @@ interface Props {
|
|
|
64
85
|
round?: boolean
|
|
65
86
|
square?: boolean
|
|
66
87
|
noCaps?: boolean
|
|
88
|
+
/** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
|
|
67
89
|
stretch?: boolean
|
|
68
90
|
loading?: boolean
|
|
69
91
|
disable?: boolean
|
|
@@ -75,6 +97,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
75
97
|
noCaret: false,
|
|
76
98
|
dropdownIcon: "lucide:chevron-down",
|
|
77
99
|
align: "right",
|
|
100
|
+
offset: 4,
|
|
78
101
|
menuWidth: "180px",
|
|
79
102
|
size: "md",
|
|
80
103
|
color: "primary",
|
|
@@ -126,17 +149,68 @@ const panelStyle = computed<Record<string, string>>(() => ({
|
|
|
126
149
|
...(roundedStyle.value ?? {}),
|
|
127
150
|
}))
|
|
128
151
|
|
|
152
|
+
// `position` explicite, sinon alias `align` (compat) → bottom-start / bottom-end
|
|
153
|
+
const placement = computed<DropdownPosition>(
|
|
154
|
+
() => props.position ?? (props.align === "left" ? "bottom-start" : "bottom-end"),
|
|
155
|
+
)
|
|
156
|
+
|
|
129
157
|
// ═════════ Positionnement (fixed, téléporté) ═════════
|
|
158
|
+
// Placement exprimé en « point d'ancrage » du panneau (top/left fixed) + un
|
|
159
|
+
// décalage par translation en % de la taille du panneau (−100% = bord opposé,
|
|
160
|
+
// −50% = centré) : aucun calcul de largeur/hauteur du panneau nécessaire.
|
|
130
161
|
const placePanel = () => {
|
|
131
162
|
const el = rootEl.value
|
|
132
163
|
if (!el || typeof document === "undefined") return
|
|
133
164
|
const rect = el.getBoundingClientRect()
|
|
134
|
-
|
|
135
|
-
|
|
165
|
+
const [main, alt] = placement.value.split("-") as ["bottom" | "top" | "left" | "right", "start" | "end" | undefined]
|
|
166
|
+
const off = props.offset
|
|
167
|
+
const midX = (rect.left + rect.right) / 2
|
|
168
|
+
const midY = (rect.top + rect.bottom) / 2
|
|
169
|
+
|
|
170
|
+
let top = 0
|
|
171
|
+
let left = 0
|
|
172
|
+
let tx = "0%"
|
|
173
|
+
let ty = "0%"
|
|
174
|
+
|
|
175
|
+
if (main === "bottom" || main === "top") {
|
|
176
|
+
// Axe horizontal : bord gauche (start), bord droit (end) ou centre
|
|
177
|
+
if (alt === "start") left = rect.left
|
|
178
|
+
else if (alt === "end") {
|
|
179
|
+
left = rect.right
|
|
180
|
+
tx = "-100%"
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
left = midX
|
|
184
|
+
tx = "-50%"
|
|
185
|
+
}
|
|
186
|
+
if (main === "bottom") top = rect.bottom + off
|
|
187
|
+
else {
|
|
188
|
+
top = rect.top - off
|
|
189
|
+
ty = "-100%"
|
|
190
|
+
}
|
|
136
191
|
}
|
|
137
192
|
else {
|
|
138
|
-
//
|
|
139
|
-
|
|
193
|
+
// Axe vertical : haut (start), bas (end) ou centre
|
|
194
|
+
if (alt === "start") top = rect.top
|
|
195
|
+
else if (alt === "end") {
|
|
196
|
+
top = rect.bottom
|
|
197
|
+
ty = "-100%"
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
top = midY
|
|
201
|
+
ty = "-50%"
|
|
202
|
+
}
|
|
203
|
+
if (main === "right") left = rect.right + off
|
|
204
|
+
else {
|
|
205
|
+
left = rect.left - off
|
|
206
|
+
tx = "-100%"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
panelPos.value = {
|
|
211
|
+
top,
|
|
212
|
+
left,
|
|
213
|
+
transform: tx === "0%" && ty === "0%" ? "none" : `translate(${tx}, ${ty})`,
|
|
140
214
|
}
|
|
141
215
|
}
|
|
142
216
|
|
|
@@ -213,6 +287,21 @@ const isSeparatorOnly = (action: BtnAction): boolean =>
|
|
|
213
287
|
const itemStyle = (action: BtnAction) =>
|
|
214
288
|
action.color ? { color: colorValue(action.color) } : undefined
|
|
215
289
|
|
|
290
|
+
// — Liste aplatie du menu : un nœud stable PAR ligne (séparateur OU item), jamais
|
|
291
|
+
// deux frères conditionnels pour une même action. Évite la désynchronisation de
|
|
292
|
+
// l'arbre de blocs de Vue (crash `emitsOptions` null dans shouldUpdateComponent)
|
|
293
|
+
// quand le menu (Teleport + Transition) est patché avec un nombre variable d'enfants.
|
|
294
|
+
type MenuRow = { type: "separator"; key: string } | { type: "item"; key: string; action: BtnAction }
|
|
295
|
+
|
|
296
|
+
const menuRows = computed<MenuRow[]>(() => {
|
|
297
|
+
const rows: MenuRow[] = []
|
|
298
|
+
props.actions.forEach((action, i) => {
|
|
299
|
+
if (action.separator) rows.push({ type: "separator", key: `${i}-sep` })
|
|
300
|
+
if (!isSeparatorOnly(action)) rows.push({ type: "item", key: `${i}-item`, action })
|
|
301
|
+
})
|
|
302
|
+
return rows
|
|
303
|
+
})
|
|
304
|
+
|
|
216
305
|
onMounted(() => {
|
|
217
306
|
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
218
307
|
})
|
|
@@ -223,7 +312,12 @@ onBeforeUnmount(() => {
|
|
|
223
312
|
</script>
|
|
224
313
|
|
|
225
314
|
<template>
|
|
226
|
-
<div
|
|
315
|
+
<div
|
|
316
|
+
ref="rootEl"
|
|
317
|
+
class="q-btn-actions"
|
|
318
|
+
:class="[dark && 'dark', stretch && 'q-btn-actions--stretch']"
|
|
319
|
+
@keydown="onKeydown"
|
|
320
|
+
>
|
|
227
321
|
<QBtn
|
|
228
322
|
class="q-btn-actions__trigger"
|
|
229
323
|
:class="triggerClasses"
|
|
@@ -267,24 +361,24 @@ onBeforeUnmount(() => {
|
|
|
267
361
|
:style="panelStyle"
|
|
268
362
|
@keydown="onKeydown"
|
|
269
363
|
>
|
|
270
|
-
<template v-for="
|
|
271
|
-
<div v-if="
|
|
364
|
+
<template v-for="row in menuRows" :key="row.key">
|
|
365
|
+
<div v-if="row.type === 'separator'" class="q-btn-actions__separator" role="separator" />
|
|
272
366
|
<button
|
|
273
|
-
v-
|
|
367
|
+
v-else
|
|
274
368
|
type="button"
|
|
275
369
|
role="menuitem"
|
|
276
370
|
class="q-btn-actions__item"
|
|
277
|
-
:class="{ 'q-btn-actions__item--disabled': action.disable }"
|
|
278
|
-
:disabled="action.disable"
|
|
279
|
-
:style="itemStyle(action)"
|
|
280
|
-
@click="onSelect(action)"
|
|
371
|
+
:class="{ 'q-btn-actions__item--disabled': row.action.disable }"
|
|
372
|
+
:disabled="row.action.disable"
|
|
373
|
+
:style="itemStyle(row.action)"
|
|
374
|
+
@click="onSelect(row.action)"
|
|
281
375
|
>
|
|
282
|
-
<Icon
|
|
376
|
+
<Icon v-if="row.action.icon" :icon="row.action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
283
377
|
<span class="q-btn-actions__item-text">
|
|
284
|
-
<span class="q-btn-actions__item-label">{{ action.label }}</span>
|
|
285
|
-
<span v-if="action.description" class="q-btn-actions__item-desc">{{ action.description }}</span>
|
|
378
|
+
<span class="q-btn-actions__item-label">{{ row.action.label }}</span>
|
|
379
|
+
<span v-if="row.action.description" class="q-btn-actions__item-desc">{{ row.action.description }}</span>
|
|
286
380
|
</span>
|
|
287
|
-
<Icon
|
|
381
|
+
<Icon v-if="row.action.iconRight" :icon="row.action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
288
382
|
</button>
|
|
289
383
|
</template>
|
|
290
384
|
</div>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QBtnDropdown — dropdown Quasar : <q-btn-dropdown label="…" :items="…" @select="…" />.
|
|
3
|
+
// Le déclencheur est un QBtn (+ flèche caret) ; le menu liste les `items`, chacun
|
|
4
|
+
// avec une icône GAUCHE (`leftIcon`) et une icône DROITE (`rightIcon`).
|
|
5
|
+
// Implémentation : réutilise le moteur de QBtnActions (panneau téléporté fixed,
|
|
6
|
+
// clavier, séparateurs, clic extérieur) — zéro duplication.
|
|
7
|
+
|
|
8
|
+
export interface DropdownItem {
|
|
9
|
+
/** Libellé affiché (absent pour une entrée « séparateur seul ») */
|
|
10
|
+
label?: string
|
|
11
|
+
/** Valeur émise par @select ; si absente, l'item lui-même est émis */
|
|
12
|
+
value?: any
|
|
13
|
+
/** Icône Iconify à GAUCHE du label (ex. "lucide:settings") */
|
|
14
|
+
leftIcon?: string
|
|
15
|
+
/** Icône Iconify à DROITE du label (ex. "lucide:chevron-right" pour un sous-menu) */
|
|
16
|
+
rightIcon?: string
|
|
17
|
+
/** Couleur du texte/icône (token — ex. "negative" — ou hex) */
|
|
18
|
+
color?: string
|
|
19
|
+
/** Sous-texte descriptif sous le label */
|
|
20
|
+
description?: string
|
|
21
|
+
/** Ligne de séparation affichée au-dessus de l'item */
|
|
22
|
+
separator?: boolean
|
|
23
|
+
/** Item désactivé */
|
|
24
|
+
disable?: boolean
|
|
25
|
+
/** Rappel local au clic (en plus de l'événement @select) */
|
|
26
|
+
onClick?: (item: DropdownItem) => void
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { computed } from "vue"
|
|
32
|
+
import QBtnActions, { type DropdownPosition } from "./QBtnActions.vue"
|
|
33
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
34
|
+
|
|
35
|
+
interface Props {
|
|
36
|
+
/** Items du menu (icône gauche + icône droite par item) */
|
|
37
|
+
items?: DropdownItem[]
|
|
38
|
+
/** Libellé du bouton déclencheur (sinon icône seule) */
|
|
39
|
+
label?: string
|
|
40
|
+
/** Icône Iconify du déclencheur (défaut "lucide:ellipsis" si pas de label) */
|
|
41
|
+
icon?: string
|
|
42
|
+
/** Masque la flèche déroulante */
|
|
43
|
+
noCaret?: boolean
|
|
44
|
+
/** Icône de la flèche déroulante */
|
|
45
|
+
dropdownIcon?: string
|
|
46
|
+
/** Alignement du menu sous le bouton : "left" = bottom-start, "right" = bottom-end (alias de `position`) */
|
|
47
|
+
align?: "left" | "right"
|
|
48
|
+
/**
|
|
49
|
+
* Placement du panneau autour du déclencheur — ex. "bottom-end" (défaut),
|
|
50
|
+
* "top-start", "right", "left-end"… Prioritaire sur `align`.
|
|
51
|
+
*/
|
|
52
|
+
position?: DropdownPosition
|
|
53
|
+
/** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
|
|
54
|
+
offset?: number
|
|
55
|
+
/** Largeur minimale du menu (ex. "220px") */
|
|
56
|
+
menuWidth?: string
|
|
57
|
+
// — API déclencheur (transmise à QBtn) —
|
|
58
|
+
color?: string
|
|
59
|
+
textColor?: string
|
|
60
|
+
size?: string
|
|
61
|
+
radius?: RadiusProp
|
|
62
|
+
flat?: boolean
|
|
63
|
+
outline?: boolean
|
|
64
|
+
unelevated?: boolean
|
|
65
|
+
dense?: boolean
|
|
66
|
+
round?: boolean
|
|
67
|
+
square?: boolean
|
|
68
|
+
noCaps?: boolean
|
|
69
|
+
/** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
|
|
70
|
+
stretch?: boolean
|
|
71
|
+
loading?: boolean
|
|
72
|
+
disable?: boolean
|
|
73
|
+
dark?: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
77
|
+
items: () => [],
|
|
78
|
+
align: "right",
|
|
79
|
+
offset: 4,
|
|
80
|
+
menuWidth: "220px",
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const emit = defineEmits<{
|
|
84
|
+
/** Item sélectionné — payload : `value` de l'item (ou l'item si pas de value) */
|
|
85
|
+
select: [value: any]
|
|
86
|
+
}>()
|
|
87
|
+
|
|
88
|
+
// leftIcon/rightIcon → icon/iconRight du moteur QBtnActions
|
|
89
|
+
const mappedActions = computed(() =>
|
|
90
|
+
(props.items ?? []).map((item) => ({
|
|
91
|
+
label: item.label,
|
|
92
|
+
value: item.value,
|
|
93
|
+
icon: item.leftIcon,
|
|
94
|
+
iconRight: item.rightIcon,
|
|
95
|
+
color: item.color,
|
|
96
|
+
description: item.description,
|
|
97
|
+
separator: item.separator,
|
|
98
|
+
disable: item.disable,
|
|
99
|
+
onClick: item.onClick ? () => item.onClick!(item) : undefined,
|
|
100
|
+
})),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const onSelectAction = (value: any) => emit("select", value)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<QBtnActions
|
|
108
|
+
:actions="mappedActions"
|
|
109
|
+
:label="label"
|
|
110
|
+
:icon="icon"
|
|
111
|
+
:no-caret="noCaret"
|
|
112
|
+
:dropdown-icon="dropdownIcon"
|
|
113
|
+
:align="align"
|
|
114
|
+
:position="position"
|
|
115
|
+
:offset="offset"
|
|
116
|
+
:menu-width="menuWidth"
|
|
117
|
+
:color="color"
|
|
118
|
+
:text-color="textColor"
|
|
119
|
+
:size="size"
|
|
120
|
+
:radius="radius"
|
|
121
|
+
:flat="flat"
|
|
122
|
+
:outline="outline"
|
|
123
|
+
:unelevated="unelevated"
|
|
124
|
+
:dense="dense"
|
|
125
|
+
:round="round"
|
|
126
|
+
:square="square"
|
|
127
|
+
:no-caps="noCaps"
|
|
128
|
+
:stretch="stretch"
|
|
129
|
+
:loading="loading"
|
|
130
|
+
:disable="disable"
|
|
131
|
+
:dark="dark"
|
|
132
|
+
@select-action="onSelectAction"
|
|
133
|
+
/>
|
|
134
|
+
</template>
|