@dnax/ui 0.0.31 → 0.0.33

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,257 @@
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
+
7
+ export interface BtnAction {
8
+ /** Libellé affiché dans le menu */
9
+ label: string
10
+ /** Valeur émise par @select-action ; si absente, l'action elle-même est émise */
11
+ value?: any
12
+ /** Icône Iconify à gauche (ex. : "lucide:pencil") */
13
+ icon?: string
14
+ /** Icône Iconify à droite */
15
+ iconRight?: string
16
+ /** Couleur du texte/icône (token — ex. "negative" pour une suppression — ou hex) */
17
+ color?: string
18
+ /** Sous-texte descriptif sous le label */
19
+ description?: string
20
+ /** Ligne de séparation affichée au-dessus de l'action */
21
+ separator?: boolean
22
+ /** Action désactivée */
23
+ disable?: boolean
24
+ /** Rappel local au clic (en plus de l'événement @select-action) */
25
+ onClick?: (action: BtnAction) => void
26
+ }
27
+ </script>
28
+
29
+ <script setup lang="ts">
30
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
31
+ import { Icon } from "@iconify/vue"
32
+ import { colorValue } from "../lib/colors"
33
+ import { icons } from "../lib/icons"
34
+ import { radiusStyle, useRadius } from "../lib/useComponentProps"
35
+ import type { RadiusProp } from "../lib/useComponentProps"
36
+ import QBtn from "./QBtn.vue"
37
+
38
+ interface Props {
39
+ /** Liste des actions du menu */
40
+ actions?: BtnAction[]
41
+ /** Libellé du bouton déclencheur (sinon icône seule) */
42
+ label?: string
43
+ /** Icône Iconify du déclencheur (défaut "lucide:ellipsis" si pas de label) */
44
+ icon?: string
45
+ /** Masque la flèche déroulante (n'apparaît de toute façon que s'il y a un label) */
46
+ noCaret?: boolean
47
+ /** Icône de la flèche déroulante */
48
+ dropdownIcon?: string
49
+ /** Alignement du menu sous le bouton */
50
+ align?: "left" | "right"
51
+ /** Largeur minimale du menu (ex. "200px") */
52
+ menuWidth?: string
53
+ // — API déclencheur (transmise à QBtn) —
54
+ color?: string
55
+ textColor?: string
56
+ size?: string
57
+ radius?: RadiusProp
58
+ flat?: boolean
59
+ outline?: boolean
60
+ unelevated?: boolean
61
+ dense?: boolean
62
+ round?: boolean
63
+ square?: boolean
64
+ noCaps?: boolean
65
+ stretch?: boolean
66
+ loading?: boolean
67
+ disable?: boolean
68
+ dark?: boolean
69
+ }
70
+
71
+ const props = withDefaults(defineProps<Props>(), {
72
+ actions: () => [],
73
+ noCaret: false,
74
+ dropdownIcon: "lucide:chevron-down",
75
+ align: "right",
76
+ menuWidth: "180px",
77
+ size: "md",
78
+ color: "primary",
79
+ flat: false,
80
+ outline: false,
81
+ unelevated: false,
82
+ dense: false,
83
+ round: false,
84
+ square: false,
85
+ noCaps: false,
86
+ stretch: false,
87
+ loading: false,
88
+ disable: false,
89
+ dark: false,
90
+ })
91
+
92
+ const emit = defineEmits<{
93
+ /** Action sélectionnée — payload : `value` de l'action (ou l'action si pas de value) */
94
+ "select-action": [value: any]
95
+ }>()
96
+
97
+ const open = ref(false)
98
+ const rootEl = ref<HTMLElement | null>(null)
99
+
100
+ const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
101
+
102
+ // Icône par défaut : « … » si le déclencheur est en icône seule (pas de label)
103
+ const triggerIcon = computed(() => props.icon ?? (props.label ? undefined : icons.ellipsis))
104
+
105
+ // Flèche déroulante : seulement avec un label (bouton icône seule = pas de caret)
106
+ const showCaret = computed(() => !props.noCaret && !!props.label)
107
+
108
+ const triggerClasses = computed(() => [
109
+ "q-btn-actions__trigger",
110
+ open.value && "q-btn-actions__trigger--open",
111
+ ])
112
+
113
+ const effectiveRadius = useRadius("QBtnActions", () => props.radius)
114
+ const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
115
+
116
+ const panelStyle = computed<Record<string, string>>(() => ({
117
+ minWidth: props.menuWidth,
118
+ ...(roundedStyle.value ?? {}),
119
+ }))
120
+
121
+ // ═════════ Ouverture / fermeture ═════════
122
+ const toggle = () => (open.value ? close() : openMenu())
123
+
124
+ const openMenu = async () => {
125
+ if (!canOpen.value) return
126
+ open.value = true
127
+ await nextTick()
128
+ ;(rootEl.value?.querySelector(".q-btn-actions__item:not(:disabled)") as HTMLElement | null)?.focus()
129
+ }
130
+
131
+ const close = () => {
132
+ if (!open.value) return
133
+ open.value = false
134
+ }
135
+
136
+ // Clic extérieur → ferme
137
+ const onDocMousedown = (e: MouseEvent) => {
138
+ if (rootEl.value && !rootEl.value.contains(e.target as Node)) close()
139
+ }
140
+
141
+ // Clavier : flèches/Home/End dans le menu, Échap ferme (focus revient au trigger)
142
+ const onKeydown = (e: KeyboardEvent) => {
143
+ if (!open.value) return
144
+ if (e.key === "Escape") {
145
+ e.preventDefault()
146
+ close()
147
+ ;(rootEl.value?.querySelector(".q-btn-actions__trigger") as HTMLElement | null)?.focus()
148
+ return
149
+ }
150
+ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return
151
+ const items = Array.from(
152
+ rootEl.value?.querySelectorAll(".q-btn-actions__item:not(:disabled)") ?? [],
153
+ ) as HTMLElement[]
154
+ if (!items.length) return
155
+ const current = items.indexOf(document.activeElement as HTMLElement)
156
+ let next = current
157
+ if (e.key === "ArrowDown") next = (current + 1) % items.length
158
+ else if (e.key === "ArrowUp") next = (current - 1 + items.length) % items.length
159
+ else if (e.key === "Home") next = 0
160
+ else if (e.key === "End") next = items.length - 1
161
+ e.preventDefault()
162
+ items[next]?.focus()
163
+ }
164
+
165
+ const onSelect = (action: BtnAction) => {
166
+ if (action.disable) return
167
+ action.onClick?.(action)
168
+ close()
169
+ emit("select-action", action.value !== undefined ? action.value : action)
170
+ }
171
+
172
+ /** Entrée « séparateur seul » ({ separator: true } sans label/icône) : simple trait, pas de bouton cliquable */
173
+ const isSeparatorOnly = (action: BtnAction): boolean =>
174
+ !!action.separator && !action.label && !action.icon && !action.iconRight && !action.description
175
+
176
+ const itemStyle = (action: BtnAction) =>
177
+ action.color ? { color: colorValue(action.color) } : undefined
178
+
179
+ onMounted(() => {
180
+ if (typeof document !== "undefined") document.addEventListener("mousedown", onDocMousedown)
181
+ })
182
+ onBeforeUnmount(() => {
183
+ if (typeof document !== "undefined") document.removeEventListener("mousedown", onDocMousedown)
184
+ })
185
+ </script>
186
+
187
+ <template>
188
+ <div
189
+ ref="rootEl"
190
+ class="q-btn-actions"
191
+ :class="[`q-btn-actions--align-${align}`, dark && 'dark']"
192
+ @keydown="onKeydown"
193
+ >
194
+ <QBtn
195
+ class="q-btn-actions__trigger"
196
+ :class="triggerClasses"
197
+ :label="label"
198
+ :icon="triggerIcon"
199
+ :color="color"
200
+ :text-color="textColor"
201
+ :size="size"
202
+ :radius="radius"
203
+ :flat="flat"
204
+ :outline="outline"
205
+ :unelevated="unelevated"
206
+ :dense="dense"
207
+ :round="round"
208
+ :square="square"
209
+ :no-caps="noCaps"
210
+ :stretch="stretch"
211
+ :loading="loading"
212
+ :disable="disable"
213
+ :aria-haspopup="'menu'"
214
+ :aria-expanded="open ? 'true' : 'false'"
215
+ @click="toggle"
216
+ >
217
+ <Icon
218
+ v-if="showCaret"
219
+ :icon="dropdownIcon"
220
+ class="q-btn-actions__caret"
221
+ :class="{ 'q-btn-actions__caret--open': open }"
222
+ aria-hidden="true"
223
+ />
224
+ </QBtn>
225
+
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>
256
+ </div>
257
+ </template>
@@ -1,16 +1,26 @@
1
1
  <script setup lang="ts">
2
2
  // QSidebarMenuButton — bouton/lien de menu (équivalent SidebarMenuButton).
3
+ // Avec `to`, le bouton devient un lien routeur : navigation au clic + état actif
4
+ // automatique selon la route courante (pattern QRouteTab).
3
5
  import { computed } from "vue"
4
6
  import { Icon } from "@iconify/vue"
5
7
  import { cn } from "../lib/utils"
8
+ import { useRoute, useRouter } from "vue-router"
9
+ import type { RouteLocationRaw } from "vue-router"
6
10
 
7
11
  interface Props {
8
12
  label?: string
9
13
  /** Icône Iconify (ex. : "lucide:settings") */
10
14
  icon?: string
11
- /** Lien natif (<a>) ; sinon bouton */
15
+ /** Lien natif (<a href>) — sans routeur */
12
16
  href?: string
13
- /** État actif */
17
+ /** Destination routeur : rend un lien et active le bouton selon la route courante */
18
+ to?: RouteLocationRaw
19
+ /** Match exact (path + hash) — sinon préfixe de segment de chemin */
20
+ exact?: boolean
21
+ /** router.replace au lieu de router.push */
22
+ replace?: boolean
23
+ /** État actif manuel (ignoré quand `to` est fourni — l'activation suit la route) */
14
24
  active?: boolean
15
25
  activeClass?: string
16
26
  disable?: boolean
@@ -20,35 +30,75 @@ interface Props {
20
30
 
21
31
  const props = withDefaults(defineProps<Props>(), {
22
32
  active: false,
33
+ exact: false,
34
+ replace: false,
23
35
  activeClass: "",
24
36
  disable: false,
25
37
  })
26
38
 
27
39
  const emit = defineEmits<{ click: [event: MouseEvent] }>()
28
40
 
41
+ // Routeur optionnel : sans router installé, `to` dégrade en simple bouton (@click)
42
+ let router: ReturnType<typeof useRouter> | undefined
43
+ let route: ReturnType<typeof useRoute> | undefined
44
+ try {
45
+ router = useRouter()
46
+ route = useRoute()
47
+ }
48
+ catch {
49
+ router = undefined
50
+ route = undefined
51
+ }
52
+
53
+ // La route courante matche-t-elle `to` ? (même logique que QRouteTab)
54
+ const matches = computed(() => {
55
+ if (!router || !route || !props.to) return false
56
+ const resolved = router.resolve(props.to)
57
+ if (props.exact) {
58
+ return resolved.path === route.path && resolved.hash === route.hash
59
+ }
60
+ const prefix = resolved.path.endsWith("/") ? resolved.path : resolved.path + "/"
61
+ return route.path === resolved.path || route.path.startsWith(prefix)
62
+ })
63
+
64
+ const effectiveActive = computed(() => (props.to ? matches.value : props.active))
65
+
66
+ const resolvedHref = computed(() => {
67
+ if (props.href) return props.href
68
+ if (props.to && router) return router.resolve(props.to).href
69
+ return undefined
70
+ })
71
+
72
+ const tag = computed(() => (resolvedHref.value ? "a" : "button"))
73
+
29
74
  const buttonClasses = computed(() =>
30
75
  cn(
31
76
  "q-sidebar__menu-button",
32
- props.active && "q-sidebar__menu-button--active",
33
- props.active && props.activeClass,
77
+ effectiveActive.value && "q-sidebar__menu-button--active",
78
+ effectiveActive.value && props.activeClass,
34
79
  props.disable && "q-sidebar__menu-button--disabled",
35
80
  ),
36
81
  )
37
82
 
38
83
  const onClick = (e: MouseEvent) => {
39
84
  if (props.disable) return
85
+ if (props.to && router && !e.defaultPrevented) {
86
+ e.preventDefault()
87
+ if (props.replace) router.replace(props.to)
88
+ else router.push(props.to)
89
+ }
40
90
  emit("click", e)
41
91
  }
42
92
  </script>
43
93
 
44
94
  <template>
45
95
  <component
46
- :is="href ? 'a' : 'button'"
47
- :href="href || undefined"
48
- :disabled="href ? undefined : disable"
96
+ :is="tag"
97
+ :href="resolvedHref"
98
+ :disabled="tag === 'button' ? disable : undefined"
49
99
  class="q-sidebar__menu-button"
50
100
  :class="buttonClasses"
51
- :aria-current="active ? 'page' : undefined"
101
+ :aria-current="effectiveActive ? 'page' : undefined"
52
102
  @click="onClick"
53
103
  >
54
104
  <Icon :icon="icon" v-if="icon" class="q-sidebar__menu-button-icon" aria-hidden="true" />
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.31",
3
+ "version": "0.0.33",
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: absolute;
2467
+ top: calc(100% + 4px);
2468
+ z-index: 1200;
2469
+ padding: 4px;
2470
+ background-color: #fff;
2471
+ border: 1px solid rgb(0 0 0 / 0.12);
2472
+ border-radius: var(--q-radius, 6px);
2473
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.15);
2474
+ }
2475
+
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
+ .q-btn-actions__item {
2485
+ display: flex;
2486
+ align-items: center;
2487
+ gap: 8px;
2488
+ width: 100%;
2489
+ padding: 7px 8px;
2490
+ border: none;
2491
+ border-radius: 4px;
2492
+ background: transparent;
2493
+ color: inherit;
2494
+ font: inherit;
2495
+ font-size: 14px;
2496
+ line-height: 1.35;
2497
+ text-align: left;
2498
+ cursor: pointer;
2499
+ user-select: none;
2500
+ }
2501
+
2502
+ .q-btn-actions__item:hover,
2503
+ .q-btn-actions__item:focus-visible {
2504
+ background-color: rgb(0 0 0 / 0.06);
2505
+ outline: none;
2506
+ }
2507
+
2508
+ .q-btn-actions__item--disabled {
2509
+ opacity: 0.5;
2510
+ pointer-events: none;
2511
+ }
2512
+
2513
+ .q-btn-actions__item-icon {
2514
+ width: 16px;
2515
+ height: 16px;
2516
+ flex-shrink: 0;
2517
+ }
2518
+
2519
+ .q-btn-actions__item-text {
2520
+ flex: 1;
2521
+ min-width: 0;
2522
+ display: flex;
2523
+ flex-direction: column;
2524
+ }
2525
+
2526
+ .q-btn-actions__item-label {
2527
+ overflow: hidden;
2528
+ text-overflow: ellipsis;
2529
+ white-space: nowrap;
2530
+ }
2531
+
2532
+ .q-btn-actions__item-desc {
2533
+ font-size: 12px;
2534
+ opacity: 0.6;
2535
+ }
2536
+
2537
+ .q-btn-actions__separator {
2538
+ height: 1px;
2539
+ margin: 4px 6px;
2540
+ background-color: rgb(0 0 0 / 0.1);
2541
+ }
2542
+
2543
+ .q-popup-enter-active {
2544
+ animation: q-popup-in 0.12s ease;
2545
+ }
2546
+
2547
+ .q-popup-leave-active {
2548
+ animation: q-popup-in 0.1s ease reverse;
2549
+ }
2550
+
2437
2551
  /* ===== QImg ===== */
2438
2552
  .q-img {
2439
2553
  position: relative;