@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.
@@ -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: string
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
- ;(rootEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
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 (rootEl.value && !rootEl.value.contains(e.target as Node)) close()
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
- rootEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
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
- <Transition name="q-popup">
227
- <div
228
- v-if="open"
229
- class="q-btn-actions__panel"
230
- role="menu"
231
- :aria-label="label ?? 'Actions'"
232
- :style="panelStyle"
233
- >
234
- <template v-for="(action, i) in actions" :key="i">
235
- <div v-if="action.separator" class="q-btn-actions__separator" role="separator" />
236
- <button
237
- v-if="!isSeparatorOnly(action)"
238
- type="button"
239
- role="menuitem"
240
- class="q-btn-actions__item"
241
- :class="{ 'q-btn-actions__item--disabled': action.disable }"
242
- :disabled="action.disable"
243
- :style="itemStyle(action)"
244
- @click="onSelect(action)"
245
- >
246
- <Icon :icon="action.icon" v-if="action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
247
- <span class="q-btn-actions__item-text">
248
- <span class="q-btn-actions__item-label">{{ action.label }}</span>
249
- <span v-if="action.description" class="q-btn-actions__item-desc">{{ action.description }}</span>
250
- </span>
251
- <Icon :icon="action.iconRight" v-if="action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
252
- </button>
253
- </template>
254
- </div>
255
- </Transition>
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.33",
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": "^4.5.2",
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",
@@ -14,7 +14,7 @@ const toPascalCase = (name: string) =>
14
14
  .join("")
15
15
 
16
16
  const files = (await readdir(componentsDir))
17
- .filter((file) => file.endsWith(".vue"))
17
+ .filter((file) => file.endsWith(".vue") && !file.startsWith("_"))
18
18
  .sort()
19
19
 
20
20
  const exports = files
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: absolute;
2467
- top: calc(100% + 4px);
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;