@dnax/ui 0.0.34 → 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.
@@ -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"
@@ -163,6 +166,8 @@ export {
163
166
  } from "./lib/overlayBack"
164
167
  export type { OverlayHandle } from "./lib/overlayBack"
165
168
  export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
169
+ export { vRipple } from "./lib/ripple"
170
+ export type { RippleOptions, RippleValue } from "./lib/ripple"
166
171
  export { vTouchPan } from "./lib/touchPan"
167
172
  export type { PanDetails, TouchPanHandler, PanDirection } from "./lib/touchPan"
168
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.34",
3
+ "version": "0.0.36",
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
@@ -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"