@dnax/ui 0.0.35 → 0.0.36

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.
@@ -85,6 +85,7 @@ interface Props {
85
85
  round?: boolean
86
86
  square?: boolean
87
87
  noCaps?: boolean
88
+ /** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
88
89
  stretch?: boolean
89
90
  loading?: boolean
90
91
  disable?: boolean
@@ -311,7 +312,12 @@ onBeforeUnmount(() => {
311
312
  </script>
312
313
 
313
314
  <template>
314
- <div ref="rootEl" class="q-btn-actions" :class="dark && 'dark'" @keydown="onKeydown">
315
+ <div
316
+ ref="rootEl"
317
+ class="q-btn-actions"
318
+ :class="[dark && 'dark', stretch && 'q-btn-actions--stretch']"
319
+ @keydown="onKeydown"
320
+ >
315
321
  <QBtn
316
322
  class="q-btn-actions__trigger"
317
323
  :class="triggerClasses"
@@ -66,6 +66,7 @@ interface Props {
66
66
  round?: boolean
67
67
  square?: boolean
68
68
  noCaps?: boolean
69
+ /** Pleine largeur : la racine `.q-btn-actions` s'étend sur 100 % (le déclencheur suit) */
69
70
  stretch?: boolean
70
71
  loading?: boolean
71
72
  disable?: boolean
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.36",
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;
@@ -10767,3 +10784,46 @@ a.q-breadcrumbs__el:hover {
10767
10784
  .q-board--dense .q-board__cell-label {
10768
10785
  font-size: 9px;
10769
10786
  }
10787
+
10788
+ /* ===== v-ripple — onde « material ripple » =====
10789
+ Conteneur injecté par la directive dans l'élément hôte (position absolute,
10790
+ 100 %×100 %, overflow hidden + border-radius: inherit → l'onde est clippée à
10791
+ la forme de l'hôte, sans imposer `overflow: hidden` au consommateur).
10792
+ La pastille utilise `currentColor` par défaut ; `v-ripple:primary` ou
10793
+ `{ color }` posent la couleur en inline (tokens → var(--token)). */
10794
+ .q-ripple {
10795
+ position: absolute;
10796
+ top: 0;
10797
+ left: 0;
10798
+ width: 100%;
10799
+ height: 100%;
10800
+ z-index: 0;
10801
+ contain: strict;
10802
+ overflow: hidden;
10803
+ border-radius: inherit;
10804
+ color: inherit;
10805
+ pointer-events: none;
10806
+ }
10807
+
10808
+ .q-ripple__inner {
10809
+ position: absolute;
10810
+ top: 0;
10811
+ left: 0;
10812
+ border-radius: 50%;
10813
+ background: currentColor;
10814
+ opacity: 0;
10815
+ pointer-events: none;
10816
+ will-change: transform, opacity;
10817
+ }
10818
+
10819
+ .q-ripple__inner--enter {
10820
+ transition:
10821
+ transform 0.225s cubic-bezier(0.4, 0, 0.2, 1),
10822
+ opacity 0.1s cubic-bezier(0.4, 0, 0.2, 1);
10823
+ }
10824
+
10825
+ .q-ripple__inner--leave {
10826
+ transition:
10827
+ transform 0.225s cubic-bezier(0.4, 0, 0.2, 1),
10828
+ opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1);
10829
+ }