@dnax/ui 0.0.33 → 0.0.35
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 +168 -43
- package/components/QBtnDropdown.vue +133 -0
- package/components/_QBtnActionsLegacy.vue +254 -0
- package/components/internal/RenderNodes.vue +23 -0
- package/index.ts +3 -0
- package/package.json +2 -2
- package/scripts/generate-exports.ts +1 -1
- package/styles/main.css +129 -13
|
@@ -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>
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// piloté par données : plus besoin de compose un QMenu/QList séparé).
|
|
4
4
|
// <q-btn-actions label="Actions" :actions="acts" @select-action="onSelect" />
|
|
5
5
|
// Chaque action : { label, value?, icon?, color?, description?, onClick? }.
|
|
6
|
+
// Le panneau est téléporté dans <body> en position fixed : il survit aux
|
|
7
|
+
// contextes d'empilement/overflow (cellules sticky de table, scroll containers).
|
|
6
8
|
|
|
7
9
|
export interface BtnAction {
|
|
8
|
-
/** Libellé affiché dans le menu */
|
|
9
|
-
label
|
|
10
|
+
/** Libellé affiché dans le menu (absent pour une entrée « séparateur seul ») */
|
|
11
|
+
label?: string
|
|
10
12
|
/** Valeur émise par @select-action ; si absente, l'action elle-même est émise */
|
|
11
13
|
value?: any
|
|
12
14
|
/** Icône Iconify à gauche (ex. : "lucide:pencil") */
|
|
@@ -24,6 +26,20 @@ export interface BtnAction {
|
|
|
24
26
|
/** Rappel local au clic (en plus de l'événement @select-action) */
|
|
25
27
|
onClick?: (action: BtnAction) => void
|
|
26
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"
|
|
27
43
|
</script>
|
|
28
44
|
|
|
29
45
|
<script setup lang="ts">
|
|
@@ -46,8 +62,15 @@ interface Props {
|
|
|
46
62
|
noCaret?: boolean
|
|
47
63
|
/** Icône de la flèche déroulante */
|
|
48
64
|
dropdownIcon?: string
|
|
49
|
-
/** Alignement du menu sous le bouton */
|
|
65
|
+
/** Alignement du menu sous le bouton : "left" = bottom-start, "right" = bottom-end (alias de `position`) */
|
|
50
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
|
|
51
74
|
/** Largeur minimale du menu (ex. "200px") */
|
|
52
75
|
menuWidth?: string
|
|
53
76
|
// — API déclencheur (transmise à QBtn) —
|
|
@@ -73,6 +96,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
73
96
|
noCaret: false,
|
|
74
97
|
dropdownIcon: "lucide:chevron-down",
|
|
75
98
|
align: "right",
|
|
99
|
+
offset: 4,
|
|
76
100
|
menuWidth: "180px",
|
|
77
101
|
size: "md",
|
|
78
102
|
color: "primary",
|
|
@@ -96,6 +120,9 @@ const emit = defineEmits<{
|
|
|
96
120
|
|
|
97
121
|
const open = ref(false)
|
|
98
122
|
const rootEl = ref<HTMLElement | null>(null)
|
|
123
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
124
|
+
/** Position fixed du panneau (téléporté dans <body>) */
|
|
125
|
+
const panelPos = ref({ top: 0, left: 0, transform: "" })
|
|
99
126
|
|
|
100
127
|
const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
|
|
101
128
|
|
|
@@ -114,28 +141,111 @@ const effectiveRadius = useRadius("QBtnActions", () => props.radius)
|
|
|
114
141
|
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
115
142
|
|
|
116
143
|
const panelStyle = computed<Record<string, string>>(() => ({
|
|
144
|
+
top: `${panelPos.value.top}px`,
|
|
145
|
+
left: `${panelPos.value.left}px`,
|
|
146
|
+
transform: panelPos.value.transform,
|
|
117
147
|
minWidth: props.menuWidth,
|
|
118
148
|
...(roundedStyle.value ?? {}),
|
|
119
149
|
}))
|
|
120
150
|
|
|
151
|
+
// `position` explicite, sinon alias `align` (compat) → bottom-start / bottom-end
|
|
152
|
+
const placement = computed<DropdownPosition>(
|
|
153
|
+
() => props.position ?? (props.align === "left" ? "bottom-start" : "bottom-end"),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
// ═════════ Positionnement (fixed, téléporté) ═════════
|
|
157
|
+
// Placement exprimé en « point d'ancrage » du panneau (top/left fixed) + un
|
|
158
|
+
// décalage par translation en % de la taille du panneau (−100% = bord opposé,
|
|
159
|
+
// −50% = centré) : aucun calcul de largeur/hauteur du panneau nécessaire.
|
|
160
|
+
const placePanel = () => {
|
|
161
|
+
const el = rootEl.value
|
|
162
|
+
if (!el || typeof document === "undefined") return
|
|
163
|
+
const rect = el.getBoundingClientRect()
|
|
164
|
+
const [main, alt] = placement.value.split("-") as ["bottom" | "top" | "left" | "right", "start" | "end" | undefined]
|
|
165
|
+
const off = props.offset
|
|
166
|
+
const midX = (rect.left + rect.right) / 2
|
|
167
|
+
const midY = (rect.top + rect.bottom) / 2
|
|
168
|
+
|
|
169
|
+
let top = 0
|
|
170
|
+
let left = 0
|
|
171
|
+
let tx = "0%"
|
|
172
|
+
let ty = "0%"
|
|
173
|
+
|
|
174
|
+
if (main === "bottom" || main === "top") {
|
|
175
|
+
// Axe horizontal : bord gauche (start), bord droit (end) ou centre
|
|
176
|
+
if (alt === "start") left = rect.left
|
|
177
|
+
else if (alt === "end") {
|
|
178
|
+
left = rect.right
|
|
179
|
+
tx = "-100%"
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
left = midX
|
|
183
|
+
tx = "-50%"
|
|
184
|
+
}
|
|
185
|
+
if (main === "bottom") top = rect.bottom + off
|
|
186
|
+
else {
|
|
187
|
+
top = rect.top - off
|
|
188
|
+
ty = "-100%"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
// Axe vertical : haut (start), bas (end) ou centre
|
|
193
|
+
if (alt === "start") top = rect.top
|
|
194
|
+
else if (alt === "end") {
|
|
195
|
+
top = rect.bottom
|
|
196
|
+
ty = "-100%"
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
top = midY
|
|
200
|
+
ty = "-50%"
|
|
201
|
+
}
|
|
202
|
+
if (main === "right") left = rect.right + off
|
|
203
|
+
else {
|
|
204
|
+
left = rect.left - off
|
|
205
|
+
tx = "-100%"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
panelPos.value = {
|
|
210
|
+
top,
|
|
211
|
+
left,
|
|
212
|
+
transform: tx === "0%" && ty === "0%" ? "none" : `translate(${tx}, ${ty})`,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const enableTracking = () => {
|
|
217
|
+
window.addEventListener("resize", placePanel)
|
|
218
|
+
window.addEventListener("scroll", placePanel, true)
|
|
219
|
+
}
|
|
220
|
+
const disableTracking = () => {
|
|
221
|
+
window.removeEventListener("resize", placePanel)
|
|
222
|
+
window.removeEventListener("scroll", placePanel, true)
|
|
223
|
+
}
|
|
224
|
+
|
|
121
225
|
// ═════════ Ouverture / fermeture ═════════
|
|
122
226
|
const toggle = () => (open.value ? close() : openMenu())
|
|
123
227
|
|
|
124
228
|
const openMenu = async () => {
|
|
125
229
|
if (!canOpen.value) return
|
|
230
|
+
placePanel()
|
|
126
231
|
open.value = true
|
|
232
|
+
enableTracking()
|
|
127
233
|
await nextTick()
|
|
128
|
-
;(
|
|
234
|
+
;(panelEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
|
|
129
235
|
}
|
|
130
236
|
|
|
131
237
|
const close = () => {
|
|
132
238
|
if (!open.value) return
|
|
133
239
|
open.value = false
|
|
240
|
+
disableTracking()
|
|
134
241
|
}
|
|
135
242
|
|
|
136
|
-
// Clic extérieur → ferme
|
|
243
|
+
// Clic extérieur (bouton ET panneau téléporté) → ferme
|
|
137
244
|
const onDocMousedown = (e: MouseEvent) => {
|
|
138
|
-
if (
|
|
245
|
+
if (!open.value) return
|
|
246
|
+
const target = e.target as Node
|
|
247
|
+
if (rootEl.value?.contains(target) || panelEl.value?.contains(target)) return
|
|
248
|
+
close()
|
|
139
249
|
}
|
|
140
250
|
|
|
141
251
|
// Clavier : flèches/Home/End dans le menu, Échap ferme (focus revient au trigger)
|
|
@@ -149,7 +259,7 @@ const onKeydown = (e: KeyboardEvent) => {
|
|
|
149
259
|
}
|
|
150
260
|
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return
|
|
151
261
|
const items = Array.from(
|
|
152
|
-
|
|
262
|
+
panelEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
|
|
153
263
|
) as HTMLElement[]
|
|
154
264
|
if (!items.length) return
|
|
155
265
|
const current = items.indexOf(document.activeElement as HTMLElement)
|
|
@@ -176,21 +286,32 @@ const isSeparatorOnly = (action: BtnAction): boolean =>
|
|
|
176
286
|
const itemStyle = (action: BtnAction) =>
|
|
177
287
|
action.color ? { color: colorValue(action.color) } : undefined
|
|
178
288
|
|
|
289
|
+
// — Liste aplatie du menu : un nœud stable PAR ligne (séparateur OU item), jamais
|
|
290
|
+
// deux frères conditionnels pour une même action. Évite la désynchronisation de
|
|
291
|
+
// l'arbre de blocs de Vue (crash `emitsOptions` null dans shouldUpdateComponent)
|
|
292
|
+
// quand le menu (Teleport + Transition) est patché avec un nombre variable d'enfants.
|
|
293
|
+
type MenuRow = { type: "separator"; key: string } | { type: "item"; key: string; action: BtnAction }
|
|
294
|
+
|
|
295
|
+
const menuRows = computed<MenuRow[]>(() => {
|
|
296
|
+
const rows: MenuRow[] = []
|
|
297
|
+
props.actions.forEach((action, i) => {
|
|
298
|
+
if (action.separator) rows.push({ type: "separator", key: `${i}-sep` })
|
|
299
|
+
if (!isSeparatorOnly(action)) rows.push({ type: "item", key: `${i}-item`, action })
|
|
300
|
+
})
|
|
301
|
+
return rows
|
|
302
|
+
})
|
|
303
|
+
|
|
179
304
|
onMounted(() => {
|
|
180
305
|
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
181
306
|
})
|
|
182
307
|
onBeforeUnmount(() => {
|
|
183
308
|
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
309
|
+
disableTracking()
|
|
184
310
|
})
|
|
185
311
|
</script>
|
|
186
312
|
|
|
187
313
|
<template>
|
|
188
|
-
<div
|
|
189
|
-
ref="rootEl"
|
|
190
|
-
class="q-btn-actions"
|
|
191
|
-
:class="[`q-btn-actions--align-${align}`, dark && 'dark']"
|
|
192
|
-
@keydown="onKeydown"
|
|
193
|
-
>
|
|
314
|
+
<div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
|
|
194
315
|
<QBtn
|
|
195
316
|
class="q-btn-actions__trigger"
|
|
196
317
|
:class="triggerClasses"
|
|
@@ -223,35 +344,39 @@ onBeforeUnmount(() => {
|
|
|
223
344
|
/>
|
|
224
345
|
</QBtn>
|
|
225
346
|
|
|
226
|
-
<
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
type="
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
<
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
347
|
+
<Teleport to="body">
|
|
348
|
+
<Transition name="q-popup">
|
|
349
|
+
<div
|
|
350
|
+
v-if="open"
|
|
351
|
+
ref="panelEl"
|
|
352
|
+
class="q-btn-actions__panel"
|
|
353
|
+
role="menu"
|
|
354
|
+
:aria-label="label ?? 'Actions'"
|
|
355
|
+
:style="panelStyle"
|
|
356
|
+
@keydown="onKeydown"
|
|
357
|
+
>
|
|
358
|
+
<template v-for="row in menuRows" :key="row.key">
|
|
359
|
+
<div v-if="row.type === 'separator'" class="q-btn-actions__separator" role="separator" />
|
|
360
|
+
<button
|
|
361
|
+
v-else
|
|
362
|
+
type="button"
|
|
363
|
+
role="menuitem"
|
|
364
|
+
class="q-btn-actions__item"
|
|
365
|
+
:class="{ 'q-btn-actions__item--disabled': row.action.disable }"
|
|
366
|
+
:disabled="row.action.disable"
|
|
367
|
+
:style="itemStyle(row.action)"
|
|
368
|
+
@click="onSelect(row.action)"
|
|
369
|
+
>
|
|
370
|
+
<Icon v-if="row.action.icon" :icon="row.action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
371
|
+
<span class="q-btn-actions__item-text">
|
|
372
|
+
<span class="q-btn-actions__item-label">{{ row.action.label }}</span>
|
|
373
|
+
<span v-if="row.action.description" class="q-btn-actions__item-desc">{{ row.action.description }}</span>
|
|
374
|
+
</span>
|
|
375
|
+
<Icon v-if="row.action.iconRight" :icon="row.action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
376
|
+
</button>
|
|
377
|
+
</template>
|
|
378
|
+
</div>
|
|
379
|
+
</Transition>
|
|
380
|
+
</Teleport>
|
|
256
381
|
</div>
|
|
257
382
|
</template>
|
|
@@ -0,0 +1,133 @@
|
|
|
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
|
+
stretch?: boolean
|
|
70
|
+
loading?: boolean
|
|
71
|
+
disable?: boolean
|
|
72
|
+
dark?: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
76
|
+
items: () => [],
|
|
77
|
+
align: "right",
|
|
78
|
+
offset: 4,
|
|
79
|
+
menuWidth: "220px",
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits<{
|
|
83
|
+
/** Item sélectionné — payload : `value` de l'item (ou l'item si pas de value) */
|
|
84
|
+
select: [value: any]
|
|
85
|
+
}>()
|
|
86
|
+
|
|
87
|
+
// leftIcon/rightIcon → icon/iconRight du moteur QBtnActions
|
|
88
|
+
const mappedActions = computed(() =>
|
|
89
|
+
(props.items ?? []).map((item) => ({
|
|
90
|
+
label: item.label,
|
|
91
|
+
value: item.value,
|
|
92
|
+
icon: item.leftIcon,
|
|
93
|
+
iconRight: item.rightIcon,
|
|
94
|
+
color: item.color,
|
|
95
|
+
description: item.description,
|
|
96
|
+
separator: item.separator,
|
|
97
|
+
disable: item.disable,
|
|
98
|
+
onClick: item.onClick ? () => item.onClick!(item) : undefined,
|
|
99
|
+
})),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
const onSelectAction = (value: any) => emit("select", value)
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<template>
|
|
106
|
+
<QBtnActions
|
|
107
|
+
:actions="mappedActions"
|
|
108
|
+
:label="label"
|
|
109
|
+
:icon="icon"
|
|
110
|
+
:no-caret="noCaret"
|
|
111
|
+
:dropdown-icon="dropdownIcon"
|
|
112
|
+
:align="align"
|
|
113
|
+
:position="position"
|
|
114
|
+
:offset="offset"
|
|
115
|
+
:menu-width="menuWidth"
|
|
116
|
+
:color="color"
|
|
117
|
+
:text-color="textColor"
|
|
118
|
+
:size="size"
|
|
119
|
+
:radius="radius"
|
|
120
|
+
:flat="flat"
|
|
121
|
+
:outline="outline"
|
|
122
|
+
:unelevated="unelevated"
|
|
123
|
+
:dense="dense"
|
|
124
|
+
:round="round"
|
|
125
|
+
:square="square"
|
|
126
|
+
:no-caps="noCaps"
|
|
127
|
+
:stretch="stretch"
|
|
128
|
+
:loading="loading"
|
|
129
|
+
:disable="disable"
|
|
130
|
+
:dark="dark"
|
|
131
|
+
@select-action="onSelectAction"
|
|
132
|
+
/>
|
|
133
|
+
</template>
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// QBtnActions — LEGACY (motif cassé) : v-for + DEUX frères v-if indépendants.
|
|
3
|
+
// Copie temporaire pour harnais de test — ne pas utiliser.
|
|
4
|
+
|
|
5
|
+
export interface BtnAction {
|
|
6
|
+
label?: string
|
|
7
|
+
value?: any
|
|
8
|
+
icon?: string
|
|
9
|
+
iconRight?: string
|
|
10
|
+
color?: string
|
|
11
|
+
description?: string
|
|
12
|
+
separator?: boolean
|
|
13
|
+
disable?: boolean
|
|
14
|
+
onClick?: (action: BtnAction) => void
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
|
|
20
|
+
import { Icon } from "@iconify/vue"
|
|
21
|
+
import { colorValue } from "../lib/colors"
|
|
22
|
+
import { icons } from "../lib/icons"
|
|
23
|
+
import { radiusStyle, useRadius } from "../lib/useComponentProps"
|
|
24
|
+
import type { RadiusProp } from "../lib/useComponentProps"
|
|
25
|
+
import QBtn from "./QBtn.vue"
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
actions?: BtnAction[]
|
|
29
|
+
label?: string
|
|
30
|
+
icon?: string
|
|
31
|
+
noCaret?: boolean
|
|
32
|
+
dropdownIcon?: string
|
|
33
|
+
align?: "left" | "right"
|
|
34
|
+
menuWidth?: string
|
|
35
|
+
color?: string
|
|
36
|
+
textColor?: string
|
|
37
|
+
size?: string
|
|
38
|
+
radius?: RadiusProp
|
|
39
|
+
flat?: boolean
|
|
40
|
+
outline?: boolean
|
|
41
|
+
unelevated?: boolean
|
|
42
|
+
dense?: boolean
|
|
43
|
+
round?: boolean
|
|
44
|
+
square?: boolean
|
|
45
|
+
noCaps?: boolean
|
|
46
|
+
stretch?: boolean
|
|
47
|
+
loading?: boolean
|
|
48
|
+
disable?: boolean
|
|
49
|
+
dark?: boolean
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
53
|
+
actions: () => [],
|
|
54
|
+
noCaret: false,
|
|
55
|
+
dropdownIcon: "lucide:chevron-down",
|
|
56
|
+
align: "right",
|
|
57
|
+
menuWidth: "180px",
|
|
58
|
+
size: "md",
|
|
59
|
+
color: "primary",
|
|
60
|
+
flat: false,
|
|
61
|
+
outline: false,
|
|
62
|
+
unelevated: false,
|
|
63
|
+
dense: false,
|
|
64
|
+
round: false,
|
|
65
|
+
square: false,
|
|
66
|
+
noCaps: false,
|
|
67
|
+
stretch: false,
|
|
68
|
+
loading: false,
|
|
69
|
+
disable: false,
|
|
70
|
+
dark: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const open = ref(false)
|
|
74
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
75
|
+
const panelEl = ref<HTMLElement | null>(null)
|
|
76
|
+
const panelPos = ref({ top: 0, left: 0, transform: "" })
|
|
77
|
+
|
|
78
|
+
const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
|
|
79
|
+
const triggerIcon = computed(() => props.icon ?? (props.label ? undefined : icons.ellipsis))
|
|
80
|
+
const showCaret = computed(() => !props.noCaret && !!props.label)
|
|
81
|
+
|
|
82
|
+
const triggerClasses = computed(() => [
|
|
83
|
+
"q-btn-actions__trigger",
|
|
84
|
+
open.value && "q-btn-actions__trigger--open",
|
|
85
|
+
])
|
|
86
|
+
|
|
87
|
+
const effectiveRadius = useRadius("QBtnActions", () => props.radius)
|
|
88
|
+
const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
|
|
89
|
+
|
|
90
|
+
const panelStyle = computed<Record<string, string>>(() => ({
|
|
91
|
+
top: `${panelPos.value.top}px`,
|
|
92
|
+
left: `${panelPos.value.left}px`,
|
|
93
|
+
transform: panelPos.value.transform,
|
|
94
|
+
minWidth: props.menuWidth,
|
|
95
|
+
...(roundedStyle.value ?? {}),
|
|
96
|
+
}))
|
|
97
|
+
|
|
98
|
+
const placePanel = () => {
|
|
99
|
+
const el = rootEl.value
|
|
100
|
+
if (!el || typeof document === "undefined") return
|
|
101
|
+
const rect = el.getBoundingClientRect()
|
|
102
|
+
if (props.align === "left") {
|
|
103
|
+
panelPos.value = { top: rect.bottom + 4, left: rect.left, transform: "none" }
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
panelPos.value = { top: rect.bottom + 4, left: rect.right, transform: "translateX(-100%)" }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const enableTracking = () => {
|
|
111
|
+
window.addEventListener("resize", placePanel)
|
|
112
|
+
window.addEventListener("scroll", placePanel, true)
|
|
113
|
+
}
|
|
114
|
+
const disableTracking = () => {
|
|
115
|
+
window.removeEventListener("resize", placePanel)
|
|
116
|
+
window.removeEventListener("scroll", placePanel, true)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const toggle = () => (open.value ? close() : openMenu())
|
|
120
|
+
|
|
121
|
+
const openMenu = async () => {
|
|
122
|
+
if (!canOpen.value) return
|
|
123
|
+
placePanel()
|
|
124
|
+
open.value = true
|
|
125
|
+
enableTracking()
|
|
126
|
+
await nextTick()
|
|
127
|
+
;(panelEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const close = () => {
|
|
131
|
+
if (!open.value) return
|
|
132
|
+
open.value = false
|
|
133
|
+
disableTracking()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const onDocMousedown = (e: MouseEvent) => {
|
|
137
|
+
if (!open.value) return
|
|
138
|
+
const target = e.target as Node
|
|
139
|
+
if (rootEl.value?.contains(target) || panelEl.value?.contains(target)) return
|
|
140
|
+
close()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const onKeydown = (e: KeyboardEvent) => {
|
|
144
|
+
if (!open.value) return
|
|
145
|
+
if (e.key === "Escape") {
|
|
146
|
+
e.preventDefault()
|
|
147
|
+
close()
|
|
148
|
+
;(rootEl.value?.querySelector(".q-btn-actions__trigger") as HTMLElement | null)?.focus()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return
|
|
152
|
+
const items = Array.from(
|
|
153
|
+
panelEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
|
|
154
|
+
) as HTMLElement[]
|
|
155
|
+
if (!items.length) return
|
|
156
|
+
const current = items.indexOf(document.activeElement as HTMLElement)
|
|
157
|
+
let next = current
|
|
158
|
+
if (e.key === "ArrowDown") next = (current + 1) % items.length
|
|
159
|
+
else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length
|
|
160
|
+
else if (e.key === "Home") next = 0
|
|
161
|
+
else if (e.key === "End") next = items.length - 1
|
|
162
|
+
e.preventDefault()
|
|
163
|
+
items[next]?.focus()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const onSelect = (action: BtnAction) => {
|
|
167
|
+
if (action.disable) return
|
|
168
|
+
action.onClick?.(action)
|
|
169
|
+
close()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const isSeparatorOnly = (action: BtnAction): boolean =>
|
|
173
|
+
!!action.separator && !action.label && !action.icon && !action.iconRight && !action.description
|
|
174
|
+
|
|
175
|
+
const itemStyle = (action: BtnAction) =>
|
|
176
|
+
action.color ? { color: colorValue(action.color) } : undefined
|
|
177
|
+
|
|
178
|
+
onMounted(() => {
|
|
179
|
+
if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
|
|
180
|
+
})
|
|
181
|
+
onBeforeUnmount(() => {
|
|
182
|
+
if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
|
|
183
|
+
disableTracking()
|
|
184
|
+
})
|
|
185
|
+
</script>
|
|
186
|
+
|
|
187
|
+
<template>
|
|
188
|
+
<div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
|
|
189
|
+
<QBtn
|
|
190
|
+
class="q-btn-actions__trigger"
|
|
191
|
+
:class="triggerClasses"
|
|
192
|
+
:label="label"
|
|
193
|
+
:icon="triggerIcon"
|
|
194
|
+
:color="color"
|
|
195
|
+
:text-color="textColor"
|
|
196
|
+
:size="size"
|
|
197
|
+
:radius="radius"
|
|
198
|
+
:flat="flat"
|
|
199
|
+
:outline="outline"
|
|
200
|
+
:unelevated="unelevated"
|
|
201
|
+
:dense="dense"
|
|
202
|
+
:round="round"
|
|
203
|
+
:square="square"
|
|
204
|
+
:no-caps="noCaps"
|
|
205
|
+
:stretch="stretch"
|
|
206
|
+
:loading="loading"
|
|
207
|
+
:disable="disable"
|
|
208
|
+
@click="toggle"
|
|
209
|
+
>
|
|
210
|
+
<Icon
|
|
211
|
+
v-if="showCaret"
|
|
212
|
+
:icon="dropdownIcon"
|
|
213
|
+
class="q-btn-actions__caret"
|
|
214
|
+
:class="{ 'q-btn-actions__caret--open': open }"
|
|
215
|
+
aria-hidden="true"
|
|
216
|
+
/>
|
|
217
|
+
</QBtn>
|
|
218
|
+
|
|
219
|
+
<Teleport to="body">
|
|
220
|
+
<Transition name="q-popup">
|
|
221
|
+
<div
|
|
222
|
+
v-if="open"
|
|
223
|
+
ref="panelEl"
|
|
224
|
+
class="q-btn-actions__panel"
|
|
225
|
+
role="menu"
|
|
226
|
+
:aria-label="label ?? 'Actions'"
|
|
227
|
+
:style="panelStyle"
|
|
228
|
+
@keydown="onKeydown"
|
|
229
|
+
>
|
|
230
|
+
<template v-for="(action, i) in actions" :key="i">
|
|
231
|
+
<div v-if="action.separator" class="q-btn-actions__separator" role="separator" />
|
|
232
|
+
<button
|
|
233
|
+
v-if="!isSeparatorOnly(action)"
|
|
234
|
+
type="button"
|
|
235
|
+
role="menuitem"
|
|
236
|
+
class="q-btn-actions__item"
|
|
237
|
+
:class="{ 'q-btn-actions__item--disabled': action.disable }"
|
|
238
|
+
:disabled="action.disable"
|
|
239
|
+
:style="itemStyle(action)"
|
|
240
|
+
@click="onSelect(action)"
|
|
241
|
+
>
|
|
242
|
+
<Icon :icon="action.icon" v-if="action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
243
|
+
<span class="q-btn-actions__item-text">
|
|
244
|
+
<span class="q-btn-actions__item-label">{{ action.label }}</span>
|
|
245
|
+
<span v-if="action.description" class="q-btn-actions__item-desc">{{ action.description }}</span>
|
|
246
|
+
</span>
|
|
247
|
+
<Icon :icon="action.iconRight" v-if="action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
|
|
248
|
+
</button>
|
|
249
|
+
</template>
|
|
250
|
+
</div>
|
|
251
|
+
</Transition>
|
|
252
|
+
</Teleport>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// RenderNodes — rend des vnodes bruts passés en prop (usage interne).
|
|
3
|
+
// Permet à un composant à template (ex. QBreadcrumbs) de réafficher les vnodes du
|
|
4
|
+
// slot par défaut en les enveloppant / les intercalant — impossible en template pur.
|
|
5
|
+
import { defineComponent, type PropType, type VNode } from "vue"
|
|
6
|
+
|
|
7
|
+
export default defineComponent({
|
|
8
|
+
name: "RenderNodes",
|
|
9
|
+
props: {
|
|
10
|
+
nodes: {
|
|
11
|
+
type: [Object, Array] as PropType<VNode | VNode[] | null>,
|
|
12
|
+
default: null,
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
setup(props) {
|
|
16
|
+
return () => {
|
|
17
|
+
const nodes = props.nodes
|
|
18
|
+
if (!nodes) return []
|
|
19
|
+
return Array.isArray(nodes) ? nodes : [nodes]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
</script>
|
package/index.ts
CHANGED
|
@@ -16,8 +16,11 @@ export { default as QBottomSheetFooter } from "./components/QBottomSheetFooter.v
|
|
|
16
16
|
export { default as QBottomSheetHeader } from "./components/QBottomSheetHeader.vue"
|
|
17
17
|
export { default as QBottomSheetProvider } from "./components/QBottomSheetProvider.vue"
|
|
18
18
|
export { default as QBottomSheetTrigger } from "./components/QBottomSheetTrigger.vue"
|
|
19
|
+
export { default as QBreadcrumbs } from "./components/QBreadcrumbs.vue"
|
|
20
|
+
export { default as QBreadcrumbsEl } from "./components/QBreadcrumbsEl.vue"
|
|
19
21
|
export { default as QBtn } from "./components/QBtn.vue"
|
|
20
22
|
export { default as QBtnActions } from "./components/QBtnActions.vue"
|
|
23
|
+
export { default as QBtnDropdown } from "./components/QBtnDropdown.vue"
|
|
21
24
|
export { default as QBtnGroup } from "./components/QBtnGroup.vue"
|
|
22
25
|
export { default as QBubble } from "./components/QBubble.vue"
|
|
23
26
|
export { default as QBubbleContent } from "./components/QBubbleContent.vue"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dnax/ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.35",
|
|
4
4
|
"module": "index.ts",
|
|
5
5
|
"description": "UI design System",
|
|
6
6
|
"type": "module",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@editorjs/paragraph": "^2.11.7",
|
|
32
32
|
"@editorjs/quote": "^2.7.6",
|
|
33
33
|
"@iconify/vue": "^5.0.1",
|
|
34
|
-
"@nuxt/kit": "
|
|
34
|
+
"@nuxt/kit": "4.5.2",
|
|
35
35
|
"@tiptap/extension-bubble-menu": "^3.31.3",
|
|
36
36
|
"@tiptap/extension-drag-handle": "^3.31.3",
|
|
37
37
|
"@tiptap/extension-drag-handle-vue-3": "^3.31.3",
|
package/styles/main.css
CHANGED
|
@@ -2455,6 +2455,11 @@ html.dark body {
|
|
|
2455
2455
|
width: 1em;
|
|
2456
2456
|
height: 1em;
|
|
2457
2457
|
flex-shrink: 0;
|
|
2458
|
+
/* Caret toujours à l'extrémité DROITE du bouton : quand le bouton est plus
|
|
2459
|
+
large que son contenu (stretch / largeur fixe type 400px), la marge auto
|
|
2460
|
+
absorbe l'espace libre et colle le chevron au bord — sans toucher aux
|
|
2461
|
+
boutons à taille naturelle (aucun espace libre → aucun effet). */
|
|
2462
|
+
margin-left: auto;
|
|
2458
2463
|
transition: transform 0.2s ease;
|
|
2459
2464
|
}
|
|
2460
2465
|
|
|
@@ -2463,9 +2468,8 @@ html.dark body {
|
|
|
2463
2468
|
}
|
|
2464
2469
|
|
|
2465
2470
|
.q-btn-actions__panel {
|
|
2466
|
-
position:
|
|
2467
|
-
|
|
2468
|
-
z-index: 1200;
|
|
2471
|
+
position: fixed;
|
|
2472
|
+
z-index: 3000;
|
|
2469
2473
|
padding: 4px;
|
|
2470
2474
|
background-color: #fff;
|
|
2471
2475
|
border: 1px solid rgb(0 0 0 / 0.12);
|
|
@@ -2473,14 +2477,6 @@ html.dark body {
|
|
|
2473
2477
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
|
|
2474
2478
|
}
|
|
2475
2479
|
|
|
2476
|
-
.q-btn-actions--align-left .q-btn-actions__panel {
|
|
2477
|
-
left: 0;
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
.q-btn-actions--align-right .q-btn-actions__panel {
|
|
2481
|
-
right: 0;
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
2480
|
.q-btn-actions__item {
|
|
2485
2481
|
display: flex;
|
|
2486
2482
|
align-items: center;
|
|
@@ -2541,11 +2537,20 @@ html.dark body {
|
|
|
2541
2537
|
}
|
|
2542
2538
|
|
|
2543
2539
|
.q-popup-enter-active {
|
|
2544
|
-
animation: q-popup-in 0.12s ease;
|
|
2540
|
+
animation: q-popup-fade-in 0.12s ease;
|
|
2545
2541
|
}
|
|
2546
2542
|
|
|
2547
2543
|
.q-popup-leave-active {
|
|
2548
|
-
animation: q-popup-in 0.1s ease reverse;
|
|
2544
|
+
animation: q-popup-fade-in 0.1s ease reverse;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
@keyframes q-popup-fade-in {
|
|
2548
|
+
from {
|
|
2549
|
+
opacity: 0;
|
|
2550
|
+
}
|
|
2551
|
+
to {
|
|
2552
|
+
opacity: 1;
|
|
2553
|
+
}
|
|
2549
2554
|
}
|
|
2550
2555
|
|
|
2551
2556
|
/* ===== QImg ===== */
|
|
@@ -3954,6 +3959,117 @@ html.dark body {
|
|
|
3954
3959
|
height: 2px;
|
|
3955
3960
|
}
|
|
3956
3961
|
|
|
3962
|
+
/* ===== QBreadcrumbs ===== */
|
|
3963
|
+
.q-breadcrumbs {
|
|
3964
|
+
--q-breadcrumbs-gutter: 8px;
|
|
3965
|
+
--q-breadcrumbs-color: rgb(0 0 0 / 0.6);
|
|
3966
|
+
--q-breadcrumbs-sep-color: rgb(0 0 0 / 0.42);
|
|
3967
|
+
--q-breadcrumbs-active-color: var(--primary);
|
|
3968
|
+
|
|
3969
|
+
font-size: 14px;
|
|
3970
|
+
line-height: 1.5;
|
|
3971
|
+
color: var(--foreground);
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
.dark .q-breadcrumbs {
|
|
3975
|
+
--q-breadcrumbs-color: rgb(255 255 255 / 0.66);
|
|
3976
|
+
--q-breadcrumbs-sep-color: rgb(255 255 255 / 0.42);
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
.q-breadcrumbs__list {
|
|
3980
|
+
display: flex;
|
|
3981
|
+
align-items: center;
|
|
3982
|
+
flex-wrap: wrap;
|
|
3983
|
+
gap: var(--q-breadcrumbs-gutter);
|
|
3984
|
+
margin: 0;
|
|
3985
|
+
padding: 0;
|
|
3986
|
+
list-style: none;
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
.q-breadcrumbs--center .q-breadcrumbs__list {
|
|
3990
|
+
justify-content: center;
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
.q-breadcrumbs--right .q-breadcrumbs__list {
|
|
3994
|
+
justify-content: flex-end;
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
.q-breadcrumbs__item {
|
|
3998
|
+
display: inline-flex;
|
|
3999
|
+
align-items: center;
|
|
4000
|
+
min-width: 0;
|
|
4001
|
+
color: var(--q-breadcrumbs-color);
|
|
4002
|
+
transition: color 0.15s ease;
|
|
4003
|
+
}
|
|
4004
|
+
|
|
4005
|
+
.q-breadcrumbs__item--last {
|
|
4006
|
+
color: var(--q-breadcrumbs-active-color);
|
|
4007
|
+
font-weight: 600;
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
.q-breadcrumbs__el {
|
|
4011
|
+
display: inline-flex;
|
|
4012
|
+
align-items: center;
|
|
4013
|
+
gap: 6px;
|
|
4014
|
+
max-width: 100%;
|
|
4015
|
+
color: inherit;
|
|
4016
|
+
text-decoration: none;
|
|
4017
|
+
cursor: default;
|
|
4018
|
+
transition: color 0.15s ease;
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
.q-breadcrumbs__el svg {
|
|
4022
|
+
width: 1.15em;
|
|
4023
|
+
height: 1.15em;
|
|
4024
|
+
flex-shrink: 0;
|
|
4025
|
+
}
|
|
4026
|
+
|
|
4027
|
+
.q-breadcrumbs__el-icon {
|
|
4028
|
+
display: inline-flex;
|
|
4029
|
+
}
|
|
4030
|
+
|
|
4031
|
+
a.q-breadcrumbs__el {
|
|
4032
|
+
cursor: pointer;
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
a.q-breadcrumbs__el:hover {
|
|
4036
|
+
color: var(--q-breadcrumbs-active-color);
|
|
4037
|
+
text-decoration: underline;
|
|
4038
|
+
text-underline-offset: 3px;
|
|
4039
|
+
}
|
|
4040
|
+
|
|
4041
|
+
.q-breadcrumbs__el:focus-visible {
|
|
4042
|
+
outline: 2px solid var(--q-breadcrumbs-active-color);
|
|
4043
|
+
outline-offset: 2px;
|
|
4044
|
+
border-radius: 4px;
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
.q-breadcrumbs__el--disabled {
|
|
4048
|
+
opacity: 0.45;
|
|
4049
|
+
pointer-events: none;
|
|
4050
|
+
}
|
|
4051
|
+
|
|
4052
|
+
.q-breadcrumbs__separator {
|
|
4053
|
+
display: inline-flex;
|
|
4054
|
+
align-items: center;
|
|
4055
|
+
flex-shrink: 0;
|
|
4056
|
+
color: var(--q-breadcrumbs-sep-color);
|
|
4057
|
+
user-select: none;
|
|
4058
|
+
}
|
|
4059
|
+
|
|
4060
|
+
.q-breadcrumbs__separator svg {
|
|
4061
|
+
width: 1em;
|
|
4062
|
+
height: 1em;
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
.q-breadcrumbs--dense {
|
|
4066
|
+
font-size: 12.5px;
|
|
4067
|
+
}
|
|
4068
|
+
|
|
4069
|
+
.q-breadcrumbs--dense .q-breadcrumbs__el {
|
|
4070
|
+
gap: 4px;
|
|
4071
|
+
}
|
|
4072
|
+
|
|
3957
4073
|
/* ===== QSeparator ===== */
|
|
3958
4074
|
.q-separator {
|
|
3959
4075
|
border: none;
|