@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.
- package/components/QBreadcrumbs.vue +132 -0
- package/components/QBreadcrumbsEl.vue +84 -0
- package/components/QBtnActions.vue +113 -19
- package/components/QBtnDropdown.vue +134 -0
- package/components/_QBtnActionsLegacy.vue +254 -0
- package/components/internal/RenderNodes.vue +23 -0
- package/index.ts +5 -0
- package/lib/ripple.ts +322 -0
- package/module.ts +8 -2
- package/package.json +2 -2
- package/scripts/generate-exports.ts +3 -1
- package/styles/main.css +176 -0
|
@@ -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
|
|
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.
|
|
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": "
|
|
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"
|