@dnax/ui 0.0.35 → 0.0.37

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.
@@ -44,11 +44,13 @@ export type DropdownPosition =
44
44
 
45
45
  <script setup lang="ts">
46
46
  import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
47
+ import type { StyleValue } from "vue"
47
48
  import { Icon } from "@iconify/vue"
48
49
  import { colorValue } from "../lib/colors"
49
50
  import { icons } from "../lib/icons"
50
51
  import { radiusStyle, useRadius } from "../lib/useComponentProps"
51
52
  import type { RadiusProp } from "../lib/useComponentProps"
53
+ import { cn } from "../lib/utils"
52
54
  import QBtn from "./QBtn.vue"
53
55
 
54
56
  interface Props {
@@ -71,8 +73,23 @@ interface Props {
71
73
  position?: DropdownPosition
72
74
  /** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
73
75
  offset?: number
74
- /** Largeur minimale du menu (ex. "200px") */
76
+ /** Largeur minimale du menu (ex. "200px") — sert aussi de plancher quand `fit` est actif */
75
77
  menuWidth?: string
78
+ /**
79
+ * Le panneau fait AU MOINS la largeur du déclencheur (défaut : true).
80
+ * Sémantique Quasar `QMenu.fit` : c'est un plancher, pas une largeur exacte —
81
+ * un item plus large (libellé long) élargit encore le panneau.
82
+ */
83
+ fit?: boolean
84
+ /** Classes attribuées au panneau téléporté (en plus de `q-btn-actions__panel`) */
85
+ contentClass?: string
86
+ /**
87
+ * Style(s) attribués au panneau téléporté — string, objet ou tableau (comme la
88
+ * prop `style` de Vue).
89
+ * PRIORITAIRE sur le style calculé (placement, min-width) : c'est la trappe de
90
+ * sortie pour forcer une largeur exacte ou une couleur de fond.
91
+ */
92
+ contentStyle?: StyleValue
76
93
  // — API déclencheur (transmise à QBtn) —
77
94
  color?: string
78
95
  textColor?: string
@@ -85,6 +102,7 @@ interface Props {
85
102
  round?: boolean
86
103
  square?: boolean
87
104
  noCaps?: boolean
105
+ /** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
88
106
  stretch?: boolean
89
107
  loading?: boolean
90
108
  disable?: boolean
@@ -98,6 +116,9 @@ const props = withDefaults(defineProps<Props>(), {
98
116
  align: "right",
99
117
  offset: 4,
100
118
  menuWidth: "180px",
119
+ fit: true,
120
+ contentClass: "",
121
+ contentStyle: "",
101
122
  size: "md",
102
123
  color: "primary",
103
124
  flat: false,
@@ -123,6 +144,8 @@ const rootEl = ref<HTMLElement | null>(null)
123
144
  const panelEl = ref<HTMLElement | null>(null)
124
145
  /** Position fixed du panneau (téléporté dans <body>) */
125
146
  const panelPos = ref({ top: 0, left: 0, transform: "" })
147
+ /** Largeur mesurée du déclencheur — plancher du panneau quand `fit` est actif */
148
+ const triggerWidth = ref(0)
126
149
 
127
150
  const canOpen = computed(() => !props.disable && !props.loading && props.actions.length > 0)
128
151
 
@@ -140,14 +163,27 @@ const triggerClasses = computed(() => [
140
163
  const effectiveRadius = useRadius("QBtnActions", () => props.radius)
141
164
  const roundedStyle = computed(() => radiusStyle(effectiveRadius.value))
142
165
 
166
+ // `fit` : min-width = max(menu-width, largeur du déclencheur). `max()` est calculé
167
+ // par le CSS → `menu-width` peut rester une longueur quelconque ("16rem", "40%").
168
+ const panelMinWidth = computed(() => {
169
+ const base = props.menuWidth
170
+ if (!props.fit || !triggerWidth.value) return base
171
+ const fitted = `${Math.round(triggerWidth.value)}px`
172
+ return base ? `max(${base}, ${fitted})` : fitted
173
+ })
174
+
143
175
  const panelStyle = computed<Record<string, string>>(() => ({
144
176
  top: `${panelPos.value.top}px`,
145
177
  left: `${panelPos.value.left}px`,
146
178
  transform: panelPos.value.transform,
147
- minWidth: props.menuWidth,
179
+ minWidth: panelMinWidth.value,
148
180
  ...(roundedStyle.value ?? {}),
149
181
  }))
150
182
 
183
+ // Le panneau est téléporté dans <body> : `content-class` / `content-style` sont
184
+ // donc la seule façon de le styler depuis le composant consommateur.
185
+ const panelClasses = computed(() => cn("q-btn-actions__panel", props.contentClass))
186
+
151
187
  // `position` explicite, sinon alias `align` (compat) → bottom-start / bottom-end
152
188
  const placement = computed<DropdownPosition>(
153
189
  () => props.position ?? (props.align === "left" ? "bottom-start" : "bottom-end"),
@@ -161,6 +197,7 @@ const placePanel = () => {
161
197
  const el = rootEl.value
162
198
  if (!el || typeof document === "undefined") return
163
199
  const rect = el.getBoundingClientRect()
200
+ triggerWidth.value = rect.width
164
201
  const [main, alt] = placement.value.split("-") as ["bottom" | "top" | "left" | "right", "start" | "end" | undefined]
165
202
  const off = props.offset
166
203
  const midX = (rect.left + rect.right) / 2
@@ -311,7 +348,12 @@ onBeforeUnmount(() => {
311
348
  </script>
312
349
 
313
350
  <template>
314
- <div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
351
+ <div
352
+ ref="rootEl"
353
+ class="q-btn-actions"
354
+ :class="[dark && 'dark', stretch && 'q-btn-actions--stretch']"
355
+ @keydown="onKeydown"
356
+ >
315
357
  <QBtn
316
358
  class="q-btn-actions__trigger"
317
359
  :class="triggerClasses"
@@ -349,10 +391,10 @@ onBeforeUnmount(() => {
349
391
  <div
350
392
  v-if="open"
351
393
  ref="panelEl"
352
- class="q-btn-actions__panel"
394
+ :class="panelClasses"
353
395
  role="menu"
354
396
  :aria-label="label ?? 'Actions'"
355
- :style="panelStyle"
397
+ :style="[panelStyle, contentStyle]"
356
398
  @keydown="onKeydown"
357
399
  >
358
400
  <template v-for="row in menuRows" :key="row.key">
@@ -29,6 +29,7 @@ export interface DropdownItem {
29
29
 
30
30
  <script setup lang="ts">
31
31
  import { computed } from "vue"
32
+ import type { StyleValue } from "vue"
32
33
  import QBtnActions, { type DropdownPosition } from "./QBtnActions.vue"
33
34
  import type { RadiusProp } from "../lib/useComponentProps"
34
35
 
@@ -52,8 +53,21 @@ interface Props {
52
53
  position?: DropdownPosition
53
54
  /** Distance entre le panneau et le déclencheur, en px (défaut : 4) */
54
55
  offset?: number
55
- /** Largeur minimale du menu (ex. "220px") */
56
+ /** Largeur minimale du menu (ex. "220px") — sert aussi de plancher quand `fit` est actif */
56
57
  menuWidth?: string
58
+ /**
59
+ * Le panneau fait AU MOINS la largeur du déclencheur (défaut : true).
60
+ * Sémantique Quasar `QMenu.fit` : c'est un plancher, pas une largeur exacte —
61
+ * un item plus large (libellé long) élargit encore le panneau.
62
+ */
63
+ fit?: boolean
64
+ /** Classes attribuées au panneau du menu (téléporté dans `<body>`) */
65
+ contentClass?: string
66
+ /**
67
+ * Style(s) attribués au panneau du menu — string, objet ou tableau (comme la
68
+ * prop `style` de Vue). PRIORITAIRE sur le style calculé (placement, min-width).
69
+ */
70
+ contentStyle?: StyleValue
57
71
  // — API déclencheur (transmise à QBtn) —
58
72
  color?: string
59
73
  textColor?: string
@@ -66,6 +80,7 @@ interface Props {
66
80
  round?: boolean
67
81
  square?: boolean
68
82
  noCaps?: boolean
83
+ /** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
69
84
  stretch?: boolean
70
85
  loading?: boolean
71
86
  disable?: boolean
@@ -113,6 +128,9 @@ const onSelectAction = (value: any) => emit("select", value)
113
128
  :position="position"
114
129
  :offset="offset"
115
130
  :menu-width="menuWidth"
131
+ :fit="fit"
132
+ :content-class="contentClass"
133
+ :content-style="contentStyle"
116
134
  :color="color"
117
135
  :text-color="textColor"
118
136
  :size="size"
@@ -170,6 +170,55 @@ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
170
170
  return undefined
171
171
  })
172
172
 
173
+ // — Popup inline : direction, écart et hauteur max calculés selon l'espace visible —
174
+ // Ouvre vers le bas ; s'il n'y a pas la place (POPUP_MIN_SPACE) et qu'au-dessus il y
175
+ // en a davantage, il bascule au-dessus du champ. L'écart avec le champ se réduit
176
+ // quand la place manque (jusqu'à 0) et la hauteur max est bornée à l'espace
177
+ // disponible : le popup ne dépasse jamais le bord de la fenêtre.
178
+ const POPUP_GAP = 4
179
+ /** Espace vertical visé pour la liste : en dessous, on cherche de la place ailleurs */
180
+ const POPUP_MIN_SPACE = 96
181
+ /** Plafond historique de la liste scrollable */
182
+ const POPUP_MAX_HEIGHT = 240
183
+ /** Marge conservée avec les bords de la fenêtre */
184
+ const VIEWPORT_MARGIN = 8
185
+
186
+ const popupDirection = ref<"down" | "up">("down")
187
+ const popupGap = ref(POPUP_GAP)
188
+ const popupMaxHeight = ref(POPUP_MAX_HEIGHT)
189
+
190
+ const positionPopup = () => {
191
+ const el = rootEl.value
192
+ if (!el || typeof window === "undefined") return
193
+ const rect = el.getBoundingClientRect()
194
+ const below = window.innerHeight - rect.bottom - VIEWPORT_MARGIN
195
+ const above = rect.top - VIEWPORT_MARGIN
196
+
197
+ const down = below >= POPUP_MIN_SPACE || below >= above
198
+ const available = Math.max(down ? below : above, 0)
199
+
200
+ // Écart réduit quand la place manque, pour laisser le maximum à la liste
201
+ const gap = Math.max(0, Math.min(POPUP_GAP, available - POPUP_MIN_SPACE))
202
+
203
+ popupDirection.value = down ? "down" : "up"
204
+ popupGap.value = gap
205
+ popupMaxHeight.value = Math.max(0, Math.min(POPUP_MAX_HEIGHT, available - gap))
206
+ }
207
+
208
+ const popupStyle = computed<Record<string, string>>(() => {
209
+ const edge = `calc(100% + ${popupGap.value}px)`
210
+ return {
211
+ top: popupDirection.value === "down" ? edge : "auto",
212
+ bottom: popupDirection.value === "up" ? edge : "auto",
213
+ maxHeight: `${Math.round(popupMaxHeight.value)}px`,
214
+ }
215
+ })
216
+
217
+ // Le champ peut bouger (scroll) ou la fenêtre changer de taille : on recalcule
218
+ const onPopupViewportChange = () => {
219
+ if (open.value && props.mode === "inline") positionPopup()
220
+ }
221
+
173
222
  // — Options normalisées : les primitives (string/number) deviennent { value, label } —
174
223
  const isObjectOption = (o: any): boolean => o !== null && typeof o === "object"
175
224
 
@@ -446,11 +495,23 @@ const onDocKeydown = (e: KeyboardEvent) => {
446
495
  if (e.key === "Escape" && open.value && props.mode !== "inline") closePopup()
447
496
  }
448
497
 
449
- // Verrouille le scroll du body quand un panneau modal est ouvert
498
+ // Verrouille le scroll du body quand un panneau modal est ouvert ;
499
+ // en mode inline, (re)calcule la direction/écart/hauteur du popup à l'ouverture
500
+ // et suit les changements de viewport tant qu'il est ouvert.
450
501
  watch(open, (v) => {
451
502
  if (typeof document !== "undefined" && props.mode !== "inline") {
452
503
  document.body.style.overflow = v ? "hidden" : ""
453
504
  }
505
+
506
+ if (typeof window === "undefined") return
507
+ if (v && props.mode === "inline") {
508
+ positionPopup()
509
+ window.addEventListener("resize", onPopupViewportChange)
510
+ window.addEventListener("scroll", onPopupViewportChange, true)
511
+ } else {
512
+ window.removeEventListener("resize", onPopupViewportChange)
513
+ window.removeEventListener("scroll", onPopupViewportChange, true)
514
+ }
454
515
  })
455
516
 
456
517
  onMounted(() => {
@@ -465,6 +526,10 @@ onBeforeUnmount(() => {
465
526
  document.removeEventListener("keydown", onDocKeydown)
466
527
  document.body.style.overflow = ""
467
528
  }
529
+ if (typeof window !== "undefined") {
530
+ window.removeEventListener("resize", onPopupViewportChange)
531
+ window.removeEventListener("scroll", onPopupViewportChange, true)
532
+ }
468
533
  })
469
534
 
470
535
  // — Styles du panneau (props globales fusionnées avec les options du mode) —
@@ -602,8 +667,8 @@ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions
602
667
  v-if="open && mode === 'inline'"
603
668
  ref="popupRef"
604
669
  class="q-select__popup"
605
- :class="inlineOptions?.class"
606
- :style="inlineOptions?.style"
670
+ :class="[inlineOptions?.class, popupDirection === 'up' && 'q-select__popup--up']"
671
+ :style="[popupStyle, inlineOptions?.style]"
607
672
  role="listbox"
608
673
  @mousedown.prevent
609
674
  >
package/index.ts CHANGED
@@ -166,6 +166,8 @@ export {
166
166
  } from "./lib/overlayBack"
167
167
  export type { OverlayHandle } from "./lib/overlayBack"
168
168
  export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
169
+ export { vRipple } from "./lib/ripple"
170
+ export type { RippleOptions, RippleValue } from "./lib/ripple"
169
171
  export { vTouchPan } from "./lib/touchPan"
170
172
  export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
171
173
  export { vTouchHold } from "./lib/touchHold"
package/lib/ripple.ts ADDED
@@ -0,0 +1,322 @@
1
+ // v-ripple — directive : onde « material ripple » au clic et au clavier,
2
+ // équivalent Quasar https://quasar.dev/vue-directives/material-ripple
3
+ //
4
+ // Usage :
5
+ // <div v-ripple /> → onde depuis le point cliqué
6
+ // <div v-ripple.center /> → onde depuis le centre
7
+ // <div v-ripple:primary /> → couleur (token ou couleur CSS)
8
+ // <div v-ripple="{ early: true, stop: true }" /> → déclenchement dès pointerdown
9
+ // <div v-ripple="false" /> → directive désactivée
10
+ // <div v-ripple="{ keyCodes: [13, 32], color: '#f00' }" />
11
+ // modifiers : center | early | stop
12
+ //
13
+ // Le conteneur `.q-ripple` (position absolute, 100 %×100 %, overflow hidden) est
14
+ // injecté dans l'élément : pas besoin de lui imposer `overflow: hidden`. En
15
+ // revanche il faut un bloc conteneur → si `position` calculée vaut `static`,
16
+ // la directive pose `position: relative` (et restaure la valeur au démontage).
17
+
18
+ import { colorValue } from "./colors"
19
+
20
+ /** Options de `v-ripple` (les modifiers `.center` / `.early` / `.stop` ont le même effet) */
21
+ export interface RippleOptions {
22
+ /** Déclenche l'onde dès `pointerdown` (au lieu du `click`), annulée si le geste devient un scroll/pan */
23
+ early?: boolean
24
+ /** Stoppe la propagation de l'événement déclencheur */
25
+ stop?: boolean
26
+ /** L'onde démarre au centre de l'élément au lieu du point d'interaction */
27
+ center?: boolean
28
+ /** Couleur de l'onde : token dnax.ui (`primary`, `negative`…) ou couleur CSS (`#f00`, `rgb()`, nom CSS) */
29
+ color?: string
30
+ /** Codes clavier déclenchant l'onde (défaut `[13, 32]` — Entrée, Espace) */
31
+ keyCodes?: number | number[]
32
+ }
33
+
34
+ /** `false` désactive la directive ; un objet configure les options */
35
+ export type RippleValue = boolean | RippleOptions | undefined
36
+
37
+ /** Attente avant peinture (tactile : le temps que le navigateur tranche tap vs scroll) */
38
+ const ENTER_DELAY = 50
39
+ const TOUCH_ENTER_DELAY = 100
40
+ /** Onde pleine puis extinction */
41
+ const HOLD_MS = 250
42
+ const LEAVE_MS = 275
43
+ /** Anti-rebond du déclenchement clavier (répétition de touche) */
44
+ const KEY_THROTTLE = 300
45
+ /** Entrée / Espace */
46
+ const DEFAULT_KEY_CODES = [13, 32]
47
+
48
+ /** Éléments que le navigateur active nativement au clavier (le `click` suffit : pas de doublon) */
49
+ const NATIVE_ACTIVATION = "button, a[href], input[type='button'], input[type='submit'], input[type='reset']"
50
+
51
+ interface RippleHandle {
52
+ /** Identifiant du pointeur tant que le geste peut encore devenir un clic (mode `early`) */
53
+ pointerId: number | null
54
+ /** Retire immédiatement l'onde (démontage) */
55
+ abort: () => void
56
+ /** Éteint l'onde en douceur (geste devenu scroll/pan) */
57
+ cancel: () => void
58
+ }
59
+
60
+ interface RippleContext {
61
+ enabled: boolean
62
+ early: boolean
63
+ stop: boolean
64
+ center: boolean
65
+ color?: string
66
+ keyCodes: number[]
67
+ keyTime: number
68
+ /** 0 = aucun listener, 1 = mode clic, 2 = mode early */
69
+ bound: 0 | 1 | 2
70
+ ripples: RippleHandle[]
71
+ /** Position inline d'origine (null = jamais touchée par la directive) */
72
+ prevPosition: string | null
73
+ }
74
+
75
+ interface RippleInstance {
76
+ ctx: RippleContext
77
+ /** (Re)branche les listeners selon `enabled` / `early` */
78
+ bind: () => void
79
+ /** Débranche tout et restaure les styles touchés */
80
+ dispose: () => void
81
+ }
82
+
83
+ interface RippleBinding {
84
+ value?: unknown
85
+ arg?: string
86
+ modifiers?: Record<string, boolean>
87
+ }
88
+
89
+ type QElement = HTMLElement & { __qRipple?: RippleInstance }
90
+ type EventFlags = Event & { qSkipRipple?: boolean; qKeyEvent?: boolean }
91
+
92
+ /** Point d'interaction d'un événement souris / tactile / clavier */
93
+ const eventPoint = (evt: Event): { left: number; top: number } => {
94
+ const e = evt as MouseEvent & { touches?: TouchList }
95
+ const touch = e.touches?.[0]
96
+ if (touch) return { left: touch.clientX, top: touch.clientY }
97
+ if (typeof e.clientX === "number") return { left: e.clientX, top: e.clientY }
98
+ return { left: 0, top: 0 }
99
+ }
100
+
101
+ const parseKeyCodes = (value: RippleOptions["keyCodes"]): number[] => {
102
+ if (value === undefined) return [...DEFAULT_KEY_CODES]
103
+ const list = (Array.isArray(value) ? value.flat() : [value]).map(Number)
104
+ return list.filter((n) => Number.isFinite(n))
105
+ }
106
+
107
+ /** Un élément activable nativement produit déjà un `click` au clavier */
108
+ const hasNativeActivation = (el: HTMLElement): boolean => {
109
+ try {
110
+ return el.matches(NATIVE_ACTIVATION)
111
+ } catch {
112
+ return false
113
+ }
114
+ }
115
+
116
+ /** Options effectives = valeur objet + modifiers + argument (couleur) */
117
+ const applyOptions = (ctx: RippleContext, binding: RippleBinding) => {
118
+ const opts: RippleOptions = typeof binding.value === "object" && binding.value !== null
119
+ ? (binding.value as RippleOptions)
120
+ : {}
121
+ const m = binding.modifiers ?? {}
122
+
123
+ ctx.early = opts.early === true || m.early === true
124
+ ctx.stop = opts.stop === true || m.stop === true
125
+ ctx.center = opts.center === true || m.center === true
126
+ ctx.color = opts.color ?? binding.arg
127
+ ctx.keyCodes = parseKeyCodes(opts.keyCodes)
128
+ }
129
+
130
+ /** Crée et lance l'onde (conteneur `.q-ripple` + pastille `.q-ripple__inner`) */
131
+ const spawnRipple = (el: HTMLElement, ctx: RippleContext, evt: Event, forceCenter: boolean) => {
132
+ if (ctx.stop) evt.stopPropagation()
133
+
134
+ const rect = el.getBoundingClientRect()
135
+ const diameter = Math.hypot(rect.width, rect.height)
136
+ if (!diameter) return
137
+
138
+ const radius = diameter / 2
139
+ const point = eventPoint(evt)
140
+ const centerX = (rect.width - diameter) / 2
141
+ const centerY = (rect.height - diameter) / 2
142
+ const fromCenter = ctx.center || forceCenter
143
+ const x = fromCenter ? centerX : point.left - rect.left - radius
144
+ const y = fromCenter ? centerY : point.top - rect.top - radius
145
+
146
+ const node = document.createElement("span")
147
+ node.className = "q-ripple"
148
+ node.setAttribute("dir", "ltr")
149
+ if (ctx.color) node.style.color = colorValue(ctx.color)
150
+
151
+ const inner = document.createElement("span")
152
+ inner.className = "q-ripple__inner"
153
+ inner.style.width = `${diameter}px`
154
+ inner.style.height = `${diameter}px`
155
+ inner.style.transform = `translate(${x}px, ${y}px) scale(0.2)`
156
+ inner.style.opacity = "0"
157
+
158
+ node.append(inner)
159
+ el.append(node)
160
+
161
+ let timer: ReturnType<typeof setTimeout> | undefined
162
+ // 0 = en attente, 1 = apparition, 2 = extinction
163
+ let phase: 0 | 1 | 2 = 0
164
+
165
+ const remove = () => {
166
+ clearTimeout(timer)
167
+ node.remove()
168
+ const index = ctx.ripples.indexOf(handle)
169
+ if (index !== -1) ctx.ripples.splice(index, 1)
170
+ }
171
+
172
+ const leave = () => {
173
+ phase = 2
174
+ inner.classList.remove("q-ripple__inner--enter")
175
+ inner.classList.add("q-ripple__inner--leave")
176
+ inner.style.opacity = "0"
177
+ timer = setTimeout(remove, LEAVE_MS)
178
+ }
179
+
180
+ const enter = () => {
181
+ phase = 1
182
+ inner.classList.add("q-ripple__inner--enter")
183
+ inner.style.transform = `translate(${centerX}px, ${centerY}px) scale(1)`
184
+ inner.style.opacity = "0.2"
185
+ timer = setTimeout(leave, HOLD_MS)
186
+ }
187
+
188
+ const handle: RippleHandle = {
189
+ pointerId: evt.type === "pointerdown" ? (evt as PointerEvent).pointerId : null,
190
+ abort() {
191
+ clearTimeout(timer)
192
+ node.remove()
193
+ },
194
+ cancel() {
195
+ handle.pointerId = null
196
+ if (phase === 2) return
197
+ clearTimeout(timer)
198
+ if (phase === 0) remove()
199
+ else leave()
200
+ },
201
+ }
202
+
203
+ ctx.ripples.push(handle)
204
+
205
+ const isTouch = evt.type === "pointerdown" && (evt as PointerEvent).pointerType === "touch"
206
+ timer = setTimeout(enter, isTouch ? TOUCH_ENTER_DELAY : ENTER_DELAY)
207
+ }
208
+
209
+ function setup(el: HTMLElement, binding: RippleBinding) {
210
+ const ctx: RippleContext = {
211
+ enabled: binding.value !== false,
212
+ early: false,
213
+ stop: false,
214
+ center: false,
215
+ keyCodes: [...DEFAULT_KEY_CODES],
216
+ keyTime: 0,
217
+ bound: 0,
218
+ ripples: [],
219
+ prevPosition: null,
220
+ }
221
+ applyOptions(ctx, binding)
222
+
223
+ // Le conteneur d'onde est en `position: absolute` → il lui faut un bloc conteneur.
224
+ const ensurePosition = () => {
225
+ if (!ctx.enabled) return
226
+ if (typeof getComputedStyle !== "function") return
227
+ if (getComputedStyle(el).position !== "static") return
228
+ if (ctx.prevPosition === null) ctx.prevPosition = el.style.position
229
+ el.style.position = "relative"
230
+ }
231
+ ensurePosition()
232
+
233
+ const onStart = (evt: Event) => {
234
+ const e = evt as EventFlags & MouseEvent
235
+ if (e.qSkipRipple) return
236
+ // `detail === 0` : clic synthétisé au clavier (pas de coordonnées fiables) → onde centrée
237
+ spawnRipple(el, ctx, evt, e.qKeyEvent === true || (evt.type === "click" && e.detail === 0))
238
+ }
239
+
240
+ const onKey = (evt: Event) => {
241
+ const e = evt as EventFlags & KeyboardEvent
242
+ if (e.qSkipRipple) return
243
+ if (!ctx.keyCodes.includes(e.keyCode)) return
244
+ // Sur un <button>/<a href>, le navigateur émet déjà un `click` (centré) : ne pas doubler
245
+ if (hasNativeActivation(el)) return
246
+ const now = Date.now()
247
+ if (now - ctx.keyTime < KEY_THROTTLE) return
248
+ ctx.keyTime = now
249
+ spawnRipple(el, ctx, evt, true)
250
+ }
251
+
252
+ // Le geste ne peut plus devenir un clic (scroll/pan, ou pointeur sorti)
253
+ const onCancel = (evt: Event) => {
254
+ const e = evt as PointerEvent
255
+ if (e.type === "pointerleave" && e.buttons === 0) return
256
+ for (let i = ctx.ripples.length - 1; i >= 0; i--) {
257
+ const ripple = ctx.ripples[i]
258
+ if (ripple.pointerId === e.pointerId) ripple.cancel()
259
+ }
260
+ }
261
+
262
+ const bind = () => {
263
+ const mode: 0 | 1 | 2 = ctx.enabled ? (ctx.early ? 2 : 1) : 0
264
+ if (mode === ctx.bound) return
265
+
266
+ if (ctx.bound === 1) {
267
+ el.removeEventListener("click", onStart)
268
+ el.removeEventListener("keyup", onKey)
269
+ } else if (ctx.bound === 2) {
270
+ el.removeEventListener("pointerdown", onStart)
271
+ el.removeEventListener("pointercancel", onCancel)
272
+ el.removeEventListener("pointerleave", onCancel)
273
+ el.removeEventListener("keydown", onKey)
274
+ }
275
+
276
+ if (mode === 1) {
277
+ el.addEventListener("click", onStart)
278
+ el.addEventListener("keyup", onKey)
279
+ } else if (mode === 2) {
280
+ el.addEventListener("pointerdown", onStart)
281
+ el.addEventListener("pointercancel", onCancel)
282
+ el.addEventListener("pointerleave", onCancel)
283
+ el.addEventListener("keydown", onKey)
284
+ }
285
+
286
+ ctx.bound = mode
287
+ }
288
+
289
+ bind()
290
+
291
+ ;(el as QElement).__qRipple = {
292
+ ctx,
293
+ bind,
294
+ dispose() {
295
+ ctx.ripples.forEach((ripple) => ripple.abort())
296
+ ctx.ripples.length = 0
297
+ ctx.enabled = false
298
+ bind()
299
+ if (ctx.prevPosition !== null) el.style.position = ctx.prevPosition
300
+ },
301
+ }
302
+ }
303
+
304
+ /** Directive `v-ripple` : <div v-ripple.center:primary /> */
305
+ export const vRipple = {
306
+ mounted(el: HTMLElement, binding: RippleBinding) {
307
+ setup(el, binding)
308
+ },
309
+ updated(el: HTMLElement, binding: RippleBinding) {
310
+ const instance = (el as QElement).__qRipple
311
+ if (!instance) return
312
+ instance.ctx.enabled = binding.value !== false
313
+ applyOptions(instance.ctx, binding)
314
+ instance.bind()
315
+ },
316
+ unmounted(el: HTMLElement) {
317
+ const instance = (el as QElement).__qRipple
318
+ if (!instance) return
319
+ instance.dispose()
320
+ delete (el as QElement).__qRipple
321
+ },
322
+ }
package/module.ts CHANGED
@@ -86,13 +86,18 @@ export default defineNuxtPlugin((nuxtApp) => {
86
86
  })
87
87
  }
88
88
 
89
- // Directive globale v-close : <q-btn v-close> ferme le dialog/sheet parent
89
+ // Directive globale v-close / v-ripple / v-touch-* / v-intersection :
90
+ // <q-btn v-close>, <div v-ripple.center>…
91
+ // Plugin UNIVERSEL (pas "client") : sans enregistrement côté serveur, tout
92
+ // rendu SSR/SSG d'une page utilisant une directive crash (Vue SSR lit
93
+ // `dir.getSSRProps` sur une directive non résolue → `undefined`).
94
+ // Les directives n'ont pas de getSSRProps : en SSR elles rendent `{}` (no-op).
90
95
  addPluginTemplate({
91
96
  filename: "dnax-ui-directives.mjs",
92
- mode: "client",
93
97
  getContents: () => `
94
98
  import { defineNuxtPlugin } from "#imports"
95
99
  import { vClose } from "@dnax/ui/runtime"
100
+ import { vRipple } from "@dnax/ui/runtime"
96
101
  import { vTouchPan } from "@dnax/ui/runtime"
97
102
  import { vTouchHold } from "@dnax/ui/runtime"
98
103
  import { vTouchSwipe } from "@dnax/ui/runtime"
@@ -101,6 +106,7 @@ import { vIntersection } from "@dnax/ui/runtime"
101
106
 
102
107
  export default defineNuxtPlugin((nuxtApp) => {
103
108
  nuxtApp.vueApp.directive("close", vClose)
109
+ nuxtApp.vueApp.directive("ripple", vRipple)
104
110
  nuxtApp.vueApp.directive("touch-pan", vTouchPan)
105
111
  nuxtApp.vueApp.directive("touch-hold", vTouchHold)
106
112
  nuxtApp.vueApp.directive("touch-swipe", vTouchSwipe)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.35",
3
+ "version": "0.0.37",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
@@ -54,6 +54,8 @@ export {
54
54
  } from "./lib/overlayBack"
55
55
  export type { OverlayHandle } from "./lib/overlayBack"
56
56
  export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
57
+ export { vRipple } from "./lib/ripple"
58
+ export type { RippleOptions, RippleValue } from "./lib/ripple"
57
59
  export { vTouchPan } from "./lib/touchPan"
58
60
  export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
59
61
  export { vTouchHold } from "./lib/touchHold"
package/styles/main.css CHANGED
@@ -2440,6 +2440,15 @@ html.dark body {
2440
2440
  margin-left: -1px;
2441
2441
  }
2442
2442
 
2443
+ .q-btn-group--stretch {
2444
+ /* stretch : la racine doit faire 100 % de large. Sinon `display: inline-flex`
2445
+ réduit le groupe à la taille de son contenu et `flex: 1 1 auto` sur les
2446
+ boutons (règle ci-dessous) n'a aucun effet. Même correctif que QTabs
2447
+ (`.q-tabs--stretch`) — un simple `class="w-full"` côté consommateur
2448
+ contournait le problème. */
2449
+ width: 100%;
2450
+ }
2451
+
2443
2452
  .q-btn-group--stretch > .q-btn {
2444
2453
  flex: 1 1 auto;
2445
2454
  }
@@ -2451,6 +2460,14 @@ html.dark body {
2451
2460
  vertical-align: middle;
2452
2461
  }
2453
2462
 
2463
+ .q-btn-actions--stretch {
2464
+ /* stretch : la racine doit elle-même faire 100 % de large, sinon son parent
2465
+ `inline-flex` « shrink-to-fit » l'épouse au contenu et le `width: 100%` du
2466
+ QBtn interne (`.q-btn--stretch`) n'a pas de référentiel → rien ne s'étire.
2467
+ Même correctif que QTabs (`.q-tabs--stretch`). */
2468
+ width: 100%;
2469
+ }
2470
+
2454
2471
  .q-btn-actions__caret {
2455
2472
  width: 1em;
2456
2473
  height: 1em;
@@ -4706,6 +4723,24 @@ a.q-breadcrumbs__el:hover {
4706
4723
  animation: q-popup-in 0.12s ease;
4707
4724
  }
4708
4725
 
4726
+ /* Bascule au-dessus du champ (pas assez de place en dessous) : l'animation
4727
+ vient d'en bas. `top`/`bottom`/`max-height` sont posés en inline par le script
4728
+ (`positionPopup()`), selon l'espace visible. */
4729
+ .q-select__popup--up {
4730
+ animation-name: q-popup-in-up;
4731
+ }
4732
+
4733
+ @keyframes q-popup-in-up {
4734
+ from {
4735
+ opacity: 0;
4736
+ transform: translateY(4px);
4737
+ }
4738
+ to {
4739
+ opacity: 1;
4740
+ transform: none;
4741
+ }
4742
+ }
4743
+
4709
4744
  /* — champ de recherche séparé (useSearch), affiché avant la liste — */
4710
4745
  .q-select__search {
4711
4746
  display: flex;
@@ -10767,3 +10802,46 @@ a.q-breadcrumbs__el:hover {
10767
10802
  .q-board--dense .q-board__cell-label {
10768
10803
  font-size: 9px;
10769
10804
  }
10805
+
10806
+ /* ===== v-ripple — onde « material ripple » =====
10807
+ Conteneur injecté par la directive dans l'élément hôte (position absolute,
10808
+ 100 %×100 %, overflow hidden + border-radius: inherit → l'onde est clippée à
10809
+ la forme de l'hôte, sans imposer `overflow: hidden` au consommateur).
10810
+ La pastille utilise `currentColor` par défaut ; `v-ripple:primary` ou
10811
+ `{ color }` posent la couleur en inline (tokens → var(--token)). */
10812
+ .q-ripple {
10813
+ position: absolute;
10814
+ top: 0;
10815
+ left: 0;
10816
+ width: 100%;
10817
+ height: 100%;
10818
+ z-index: 0;
10819
+ contain: strict;
10820
+ overflow: hidden;
10821
+ border-radius: inherit;
10822
+ color: inherit;
10823
+ pointer-events: none;
10824
+ }
10825
+
10826
+ .q-ripple__inner {
10827
+ position: absolute;
10828
+ top: 0;
10829
+ left: 0;
10830
+ border-radius: 50%;
10831
+ background: currentColor;
10832
+ opacity: 0;
10833
+ pointer-events: none;
10834
+ will-change: transform, opacity;
10835
+ }
10836
+
10837
+ .q-ripple__inner--enter {
10838
+ transition:
10839
+ transform 0.225s cubic-bezier(0.4, 0, 0.2, 1),
10840
+ opacity 0.1s cubic-bezier(0.4, 0, 0.2, 1);
10841
+ }
10842
+
10843
+ .q-ripple__inner--leave {
10844
+ transition:
10845
+ transform 0.225s cubic-bezier(0.4, 0, 0.2, 1),
10846
+ opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
10847
+ }