@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.
- package/components/QBtnActions.vue +47 -5
- package/components/QBtnDropdown.vue +19 -1
- package/components/QSelect.vue +68 -3
- package/index.ts +2 -0
- package/lib/ripple.ts +322 -0
- package/module.ts +8 -2
- package/package.json +1 -1
- package/scripts/generate-exports.ts +2 -0
- package/styles/main.css +78 -0
|
@@ -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:
|
|
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
|
|
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="
|
|
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"
|
package/components/QSelect.vue
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
+
}
|