@dnax/ui 0.0.32 → 0.0.34

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,294 @@
1
+ <script lang="ts">
2
+ // QBtnActions — bouton + menu d'actions intégré (type QBtnDropdown Quasar, mais
3
+ // piloté par données : plus besoin de compose un QMenu/QList séparé).
4
+ // <q-btn-actions label="Actions" :actions="acts" @select-action="onSelect" />
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).
8
+
9
+ export interface BtnAction {
10
+ /** Libellé affiché dans le menu */
11
+ label: string
12
+ /** Valeur émise par @select-action ; si absente, l'action elle-même est émise */
13
+ value?: any
14
+ /** Icône Iconify à gauche (ex. : "lucide:pencil") */
15
+ icon?: string
16
+ /** Icône Iconify à droite */
17
+ iconRight?: string
18
+ /** Couleur du texte/icône (token — ex. "negative" pour une suppression — ou hex) */
19
+ color?: string
20
+ /** Sous-texte descriptif sous le label */
21
+ description?: string
22
+ /** Ligne de séparation affichée au-dessus de l'action */
23
+ separator?: boolean
24
+ /** Action désactivée */
25
+ disable?: boolean
26
+ /** Rappel local au clic (en plus de l'événement @select-action) */
27
+ onClick?: (action: BtnAction) => void
28
+ }
29
+ </script>
30
+
31
+ <script setup lang="ts">
32
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
33
+ import { Icon } from "@iconify/vue"
34
+ import { colorValue } from "../lib/colors"
35
+ import { icons } from "../lib/icons"
36
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
37
+ import type { RadiusProp } from "../lib/useComponentProps"
38
+ import QBtn from "./QBtn.vue"
39
+
40
+ interface Props {
41
+ /** Liste des actions du menu */
42
+ actions?: BtnAction[]
43
+ /** Libellé du bouton déclencheur (sinon icône seule) */
44
+ label?: string
45
+ /** Icône Iconify du déclencheur (défaut "lucide:ellipsis" si pas de label) */
46
+ icon?: string
47
+ /** Masque la flèche déroulante (n'apparaît de toute façon que s'il y a un label) */
48
+ noCaret?: boolean
49
+ /** Icône de la flèche déroulante */
50
+ dropdownIcon?: string
51
+ /** Alignement du menu : bord gauche du bouton ("left") ou bord droit ("right") */
52
+ align?: "left" | "right"
53
+ /** Largeur minimale du menu (ex. "200px") */
54
+ menuWidth?: string
55
+ // — API déclencheur (transmise à QBtn) —
56
+ color?: string
57
+ textColor?: string
58
+ size?: string
59
+ radius?: RadiusProp
60
+ flat?: boolean
61
+ outline?: boolean
62
+ unelevated?: boolean
63
+ dense?: boolean
64
+ round?: boolean
65
+ square?: boolean
66
+ noCaps?: boolean
67
+ stretch?: boolean
68
+ loading?: boolean
69
+ disable?: boolean
70
+ dark?: boolean
71
+ }
72
+
73
+ const props = withDefaults(defineProps<Props>(), {
74
+ actions: () => [],
75
+ noCaret: false,
76
+ dropdownIcon: "lucide:chevron-down",
77
+ align: "right",
78
+ menuWidth: "180px",
79
+ size: "md",
80
+ color: "primary",
81
+ flat: false,
82
+ outline: false,
83
+ unelevated: false,
84
+ dense: false,
85
+ round: false,
86
+ square: false,
87
+ noCaps: false,
88
+ stretch: false,
89
+ loading: false,
90
+ disable: false,
91
+ dark: false,
92
+ })
93
+
94
+ const emit = defineEmits<{
95
+ /** Action sélectionnée — payload : `value` de l'action (ou l'action si pas de value) */
96
+ "select-action": [value: any]
97
+ }>()
98
+
99
+ const open = ref(false)
100
+ const rootEl = ref<HTMLElement | null>(null)
101
+ const panelEl = ref<HTMLElement | null>(null)
102
+ /** Position fixed du panneau (téléporté dans <body>) */
103
+ const panelPos = ref({ top: 0, left: 0, transform: "" })
104
+
105
+ const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
106
+
107
+ // Icône par défaut : « … » si le déclencheur est en icône seule (pas de label)
108
+ const triggerIcon = computed(() => props.icon ?? (props.label ? undefined : icons.ellipsis))
109
+
110
+ // Flèche déroulante : seulement avec un label (bouton icône seule = pas de caret)
111
+ const showCaret = computed(() => !props.noCaret && !!props.label)
112
+
113
+ const triggerClasses = computed(() => [
114
+ "q-btn-actions__trigger",
115
+ open.value && "q-btn-actions__trigger--open",
116
+ ])
117
+
118
+ const effectiveRadius = useRadius("QBtnActions", () => props.radius)
119
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
120
+
121
+ const panelStyle = computed<Record<string, string>>(() => ({
122
+ top: `${panelPos.value.top}px`,
123
+ left: `${panelPos.value.left}px`,
124
+ transform: panelPos.value.transform,
125
+ minWidth: props.menuWidth,
126
+ ...(roundedStyle.value ?? {}),
127
+ }))
128
+
129
+ // ═════════ Positionnement (fixed, téléporté) ═════════
130
+ const placePanel = () => {
131
+ const el = rootEl.value
132
+ if (!el || typeof document === "undefined") return
133
+ const rect = el.getBoundingClientRect()
134
+ if (props.align === "left") {
135
+ panelPos.value = { top: rect.bottom + 4, left: rect.left, transform: "none" }
136
+ }
137
+ else {
138
+ // bord droit du panneau aligné sur le bord droit du bouton
139
+ panelPos.value = { top: rect.bottom + 4, left: rect.right, transform: "translateX(-100%)" }
140
+ }
141
+ }
142
+
143
+ const enableTracking = () => {
144
+ window.addEventListener("resize", placePanel)
145
+ window.addEventListener("scroll", placePanel, true)
146
+ }
147
+ const disableTracking = () => {
148
+ window.removeEventListener("resize", placePanel)
149
+ window.removeEventListener("scroll", placePanel, true)
150
+ }
151
+
152
+ // ═════════ Ouverture / fermeture ═════════
153
+ const toggle = () => (open.value ? close() : openMenu())
154
+
155
+ const openMenu = async () => {
156
+ if (!canOpen.value) return
157
+ placePanel()
158
+ open.value = true
159
+ enableTracking()
160
+ await nextTick()
161
+ ;(panelEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
162
+ }
163
+
164
+ const close = () => {
165
+ if (!open.value) return
166
+ open.value = false
167
+ disableTracking()
168
+ }
169
+
170
+ // Clic extérieur (bouton ET panneau téléporté) → ferme
171
+ const onDocMousedown = (e: MouseEvent) => {
172
+ if (!open.value) return
173
+ const target = e.target as Node
174
+ if (rootEl.value?.contains(target) || panelEl.value?.contains(target)) return
175
+ close()
176
+ }
177
+
178
+ // Clavier : flèches/Home/End dans le menu, Échap ferme (focus revient au trigger)
179
+ const onKeydown = (e: KeyboardEvent) => {
180
+ if (!open.value) return
181
+ if (e.key === "Escape") {
182
+ e.preventDefault()
183
+ close()
184
+ ;(rootEl.value?.querySelector(".q-btn-actions__trigger") as HTMLElement | null)?.focus()
185
+ return
186
+ }
187
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return
188
+ const items = Array.from(
189
+ panelEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
190
+ ) as HTMLElement[]
191
+ if (!items.length) return
192
+ const current = items.indexOf(document.activeElement as HTMLElement)
193
+ let next = current
194
+ if (e.key === "ArrowDown") next = (current + 1) % items.length
195
+ else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length
196
+ else if (e.key === "Home") next = 0
197
+ else if (e.key === "End") next = items.length - 1
198
+ e.preventDefault()
199
+ items[next]?.focus()
200
+ }
201
+
202
+ const onSelect = (action: BtnAction) => {
203
+ if (action.disable) return
204
+ action.onClick?.(action)
205
+ close()
206
+ emit("select-action", action.value !== undefined ? action.value : action)
207
+ }
208
+
209
+ /** Entrée « séparateur seul » ({ separator: true } sans label/icône) : simple trait, pas de bouton cliquable */
210
+ const isSeparatorOnly = (action: BtnAction): boolean =>
211
+ !!action.separator && !action.label && !action.icon && !action.iconRight && !action.description
212
+
213
+ const itemStyle = (action: BtnAction) =>
214
+ action.color ? { color: colorValue(action.color) } : undefined
215
+
216
+ onMounted(() => {
217
+ if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
218
+ })
219
+ onBeforeUnmount(() => {
220
+ if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
221
+ disableTracking()
222
+ })
223
+ </script>
224
+
225
+ <template>
226
+ <div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
227
+ <QBtn
228
+ class="q-btn-actions__trigger"
229
+ :class="triggerClasses"
230
+ :label="label"
231
+ :icon="triggerIcon"
232
+ :color="color"
233
+ :text-color="textColor"
234
+ :size="size"
235
+ :radius="radius"
236
+ :flat="flat"
237
+ :outline="outline"
238
+ :unelevated="unelevated"
239
+ :dense="dense"
240
+ :round="round"
241
+ :square="square"
242
+ :no-caps="noCaps"
243
+ :stretch="stretch"
244
+ :loading="loading"
245
+ :disable="disable"
246
+ :aria-haspopup="'menu'"
247
+ :aria-expanded="open ? 'true' : 'false'"
248
+ @click="toggle"
249
+ >
250
+ <Icon
251
+ v-if="showCaret"
252
+ :icon="dropdownIcon"
253
+ class="q-btn-actions__caret"
254
+ :class="{ 'q-btn-actions__caret--open': open }"
255
+ aria-hidden="true"
256
+ />
257
+ </QBtn>
258
+
259
+ <Teleport to="body">
260
+ <Transition name="q-popup">
261
+ <div
262
+ v-if="open"
263
+ ref="panelEl"
264
+ class="q-btn-actions__panel"
265
+ role="menu"
266
+ :aria-label="label ?? 'Actions'"
267
+ :style="panelStyle"
268
+ @keydown="onKeydown"
269
+ >
270
+ <template v-for="(action, i) in actions" :key="i">
271
+ <div v-if="action.separator" class="q-btn-actions__separator" role="separator" />
272
+ <button
273
+ v-if="!isSeparatorOnly(action)"
274
+ type="button"
275
+ role="menuitem"
276
+ 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)"
281
+ >
282
+ <Icon :icon="action.icon" v-if="action.icon" class="q-btn-actions__item-icon" aria-hidden="true" />
283
+ <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>
286
+ </span>
287
+ <Icon :icon="action.iconRight" v-if="action.iconRight" class="q-btn-actions__item-icon" aria-hidden="true" />
288
+ </button>
289
+ </template>
290
+ </div>
291
+ </Transition>
292
+ </Teleport>
293
+ </div>
294
+ </template>
package/index.ts CHANGED
@@ -17,6 +17,7 @@ export { default as QBottomSheetHeader } from "./components/QBottomSheetHeader.v
17
17
  export { default as QBottomSheetProvider } from "./components/QBottomSheetProvider.vue"
18
18
  export { default as QBottomSheetTrigger } from "./components/QBottomSheetTrigger.vue"
19
19
  export { default as QBtn } from "./components/QBtn.vue"
20
+ export { default as QBtnActions } from "./components/QBtnActions.vue"
20
21
  export { default as QBtnGroup } from "./components/QBtnGroup.vue"
21
22
  export { default as QBubble } from "./components/QBubble.vue"
22
23
  export { default as QBubbleContent } from "./components/QBubbleContent.vue"
package/lib/icons.ts CHANGED
@@ -14,6 +14,8 @@ export const icons = {
14
14
  chevronUp: "lucide:chevron-up",
15
15
  chevronsLeft: "lucide:chevrons-left",
16
16
  chevronsRight: "lucide:chevrons-right",
17
+ ellipsis: "lucide:ellipsis",
18
+ ellipsisVertical: "lucide:ellipsis-vertical",
17
19
  copy: "lucide:copy",
18
20
  eye: "lucide:eye",
19
21
  eyeOff: "lucide:eye-off",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.32",
3
+ "version": "0.0.34",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -306,6 +306,7 @@ html.dark body {
306
306
  .dark .q-select__popup,
307
307
  .dark .q-autocomplete__popup,
308
308
  .dark .q-nav-menu__panel,
309
+ .dark .q-btn-actions__panel,
309
310
  .dark .q-dialog__content,
310
311
  .dark .q-loading__box,
311
312
  .dark .q-file-picker__row {
@@ -313,6 +314,15 @@ html.dark body {
313
314
  color: var(--foreground);
314
315
  }
315
316
 
317
+ .dark .q-btn-actions__item:hover,
318
+ .dark .q-btn-actions__item:focus-visible {
319
+ background-color: rgb(255 255 255 / 0.08);
320
+ }
321
+
322
+ .dark .q-btn-actions__separator {
323
+ background-color: rgb(255 255 255 / 0.14);
324
+ }
325
+
316
326
  .dark .q-table {
317
327
  color: var(--foreground);
318
328
  }
@@ -2434,6 +2444,110 @@ html.dark body {
2434
2444
  flex: 1 1 auto;
2435
2445
  }
2436
2446
 
2447
+ /* ===== QBtnActions ===== */
2448
+ .q-btn-actions {
2449
+ position: relative;
2450
+ display: inline-flex;
2451
+ vertical-align: middle;
2452
+ }
2453
+
2454
+ .q-btn-actions__caret {
2455
+ width: 1em;
2456
+ height: 1em;
2457
+ flex-shrink: 0;
2458
+ transition: transform 0.2s ease;
2459
+ }
2460
+
2461
+ .q-btn-actions__caret--open {
2462
+ transform: rotate(180deg);
2463
+ }
2464
+
2465
+ .q-btn-actions__panel {
2466
+ position: fixed;
2467
+ z-index: 3000;
2468
+ padding: 4px;
2469
+ background-color: #fff;
2470
+ border: 1px solid rgb(0 0 0 / 0.12);
2471
+ border-radius: var(--q-radius, 6px);
2472
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
2473
+ }
2474
+
2475
+ .q-btn-actions__item {
2476
+ display: flex;
2477
+ align-items: center;
2478
+ gap: 8px;
2479
+ width: 100%;
2480
+ padding: 7px 8px;
2481
+ border: none;
2482
+ border-radius: 4px;
2483
+ background: transparent;
2484
+ color: inherit;
2485
+ font: inherit;
2486
+ font-size: 14px;
2487
+ line-height: 1.35;
2488
+ text-align: left;
2489
+ cursor: pointer;
2490
+ user-select: none;
2491
+ }
2492
+
2493
+ .q-btn-actions__item:hover,
2494
+ .q-btn-actions__item:focus-visible {
2495
+ background-color: rgb(0 0 0 / 0.06);
2496
+ outline: none;
2497
+ }
2498
+
2499
+ .q-btn-actions__item--disabled {
2500
+ opacity: 0.5;
2501
+ pointer-events: none;
2502
+ }
2503
+
2504
+ .q-btn-actions__item-icon {
2505
+ width: 16px;
2506
+ height: 16px;
2507
+ flex-shrink: 0;
2508
+ }
2509
+
2510
+ .q-btn-actions__item-text {
2511
+ flex: 1;
2512
+ min-width: 0;
2513
+ display: flex;
2514
+ flex-direction: column;
2515
+ }
2516
+
2517
+ .q-btn-actions__item-label {
2518
+ overflow: hidden;
2519
+ text-overflow: ellipsis;
2520
+ white-space: nowrap;
2521
+ }
2522
+
2523
+ .q-btn-actions__item-desc {
2524
+ font-size: 12px;
2525
+ opacity: 0.6;
2526
+ }
2527
+
2528
+ .q-btn-actions__separator {
2529
+ height: 1px;
2530
+ margin: 4px 6px;
2531
+ background-color: rgb(0 0 0 / 0.1);
2532
+ }
2533
+
2534
+ .q-popup-enter-active {
2535
+ animation: q-popup-fade-in 0.12s ease;
2536
+ }
2537
+
2538
+ .q-popup-leave-active {
2539
+ animation: q-popup-fade-in 0.1s ease reverse;
2540
+ }
2541
+
2542
+ @keyframes q-popup-fade-in {
2543
+ from {
2544
+ opacity: 0;
2545
+ }
2546
+ to {
2547
+ opacity: 1;
2548
+ }
2549
+ }
2550
+
2437
2551
  /* ===== QImg ===== */
2438
2552
  .q-img {
2439
2553
  position: relative;