@dnax/ui 0.0.7 → 0.0.10
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/QSelect.vue +24 -6
- package/components/QSidebar.vue +86 -1
- package/package.json +1 -1
- package/styles/main.css +29 -3
package/components/QSelect.vue
CHANGED
|
@@ -168,11 +168,28 @@ const modeOptions = computed<QSelectModeOptions | undefined>(() => {
|
|
|
168
168
|
return undefined
|
|
169
169
|
})
|
|
170
170
|
|
|
171
|
+
// — Options normalisées : les primitives (string/number) deviennent { value, label } —
|
|
172
|
+
const isObjectOption = (o: any): boolean => o !== null && typeof o === "object"
|
|
173
|
+
|
|
174
|
+
const normalizedOptions = computed<any[]>(() =>
|
|
175
|
+
props.options.map((o) => {
|
|
176
|
+
if (isObjectOption(o)) return o
|
|
177
|
+
// Objet normalisé : garde la valeur d'origine (non-énumérable → invisible
|
|
178
|
+
// pour l'affichage, la recherche fuse et les clés de rendu)
|
|
179
|
+
const norm: any = { value: o, label: String(o) }
|
|
180
|
+
Object.defineProperty(norm, "__raw", { value: o, enumerable: false })
|
|
181
|
+
return norm
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
|
|
171
185
|
// — Valeurs des options —
|
|
172
|
-
const getOptionValue = (opt: any): any =>
|
|
173
|
-
|
|
186
|
+
const getOptionValue = (opt: any): any => {
|
|
187
|
+
if (!isObjectOption(opt)) return opt
|
|
188
|
+
return typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
|
|
189
|
+
}
|
|
174
190
|
|
|
175
191
|
const getOptionLabel = (opt: any): string => {
|
|
192
|
+
if (!isObjectOption(opt)) return opt === undefined || opt === null ? "" : String(opt)
|
|
176
193
|
const v = typeof props.optionLabel === "function" ? props.optionLabel(opt) : opt?.[props.optionLabel]
|
|
177
194
|
return v === undefined || v === null ? "" : String(v)
|
|
178
195
|
}
|
|
@@ -183,7 +200,7 @@ const selectedOptions = computed<any[]>(() => {
|
|
|
183
200
|
const list = props.multiple ? (props.modelValue as any[]) : [props.modelValue]
|
|
184
201
|
return list
|
|
185
202
|
.map((v) => {
|
|
186
|
-
if (props.emitValue) return
|
|
203
|
+
if (props.emitValue) return normalizedOptions.value.find((o) => getOptionValue(o) === v)
|
|
187
204
|
return v
|
|
188
205
|
})
|
|
189
206
|
.filter((o) => o !== undefined)
|
|
@@ -194,7 +211,8 @@ const isSelected = (opt: any) =>
|
|
|
194
211
|
|
|
195
212
|
const select = (opt: any) => {
|
|
196
213
|
if (props.disable || props.readonly) return
|
|
197
|
-
|
|
214
|
+
// Primitives normalisées : émettre la valeur d'origine (ex. "red"), pas l'objet
|
|
215
|
+
const value = props.emitValue ? getOptionValue(opt) : (opt?.__raw !== undefined ? opt.__raw : opt)
|
|
198
216
|
|
|
199
217
|
if (props.multiple) {
|
|
200
218
|
if (isSelected(opt)) {
|
|
@@ -253,7 +271,7 @@ const searchKeys = computed<string[]>(() => {
|
|
|
253
271
|
})
|
|
254
272
|
|
|
255
273
|
const searcher = computed(() =>
|
|
256
|
-
createSearcher(
|
|
274
|
+
createSearcher(normalizedOptions.value, {
|
|
257
275
|
keys: searchKeys.value,
|
|
258
276
|
threshold: typeof props.useSearch === "object" ? (props.useSearch.threshold ?? 0.4) : 0.4,
|
|
259
277
|
}),
|
|
@@ -261,7 +279,7 @@ const searcher = computed(() =>
|
|
|
261
279
|
|
|
262
280
|
// — Options affichées (recherche floue, filtre simple, ou serveur) —
|
|
263
281
|
const displayOptions = computed(() => {
|
|
264
|
-
let list =
|
|
282
|
+
let list = normalizedOptions.value
|
|
265
283
|
if (!hasFilter.value) {
|
|
266
284
|
if (searchEnabled.value && searchKeys.value.length > 0) {
|
|
267
285
|
list = searcher.value.search(query.value)
|
package/components/QSidebar.vue
CHANGED
|
@@ -14,7 +14,7 @@ export const qSidebarKey: InjectionKey<SidebarContext> = Symbol("q-sidebar")
|
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<script setup lang="ts">
|
|
17
|
-
import { computed, onBeforeUnmount, onMounted, provide, ref } from "vue"
|
|
17
|
+
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue"
|
|
18
18
|
import type { StyleValue } from "vue"
|
|
19
19
|
import { cn } from "../lib/utils"
|
|
20
20
|
import { useOverlayBack } from "../lib/overlayBack"
|
|
@@ -113,6 +113,18 @@ onBeforeUnmount(() => {
|
|
|
113
113
|
if (typeof document !== "undefined") document.removeEventListener("keydown", onDocKeydown)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
// Verrouille le scroll du body quand la sidebar offcanvas est ouverte : le body
|
|
117
|
+
// ne bouge plus en arrière-plan (et le swipe ne part pas sur la page)
|
|
118
|
+
watch(
|
|
119
|
+
() => !isStatic.value && open.value,
|
|
120
|
+
(locked) => {
|
|
121
|
+
if (typeof document !== "undefined") {
|
|
122
|
+
document.body.style.overflow = locked ? "hidden" : ""
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
{ immediate: true },
|
|
126
|
+
)
|
|
127
|
+
|
|
116
128
|
const rootClasses = computed(() =>
|
|
117
129
|
cn(
|
|
118
130
|
"q-sidebar",
|
|
@@ -133,14 +145,87 @@ const rootStyle = computed<Record<string, string>>(() => {
|
|
|
133
145
|
if (props.maxHeight) style.maxHeight = props.maxHeight
|
|
134
146
|
return style
|
|
135
147
|
})
|
|
148
|
+
|
|
149
|
+
// — Swipe pour fermer (mode offcanvas, pattern bottom-sheet) —
|
|
150
|
+
const rootEl = ref<HTMLElement | null>(null)
|
|
151
|
+
const dragging = ref(false)
|
|
152
|
+
let startX = 0
|
|
153
|
+
let startY = 0
|
|
154
|
+
let currentDx = 0
|
|
155
|
+
let isHorizontal = false
|
|
156
|
+
|
|
157
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
158
|
+
if (isStatic.value || !open.value || props.disable) return
|
|
159
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
160
|
+
startX = e.clientX
|
|
161
|
+
startY = e.clientY
|
|
162
|
+
currentDx = 0
|
|
163
|
+
isHorizontal = false
|
|
164
|
+
dragging.value = true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
168
|
+
if (!dragging.value || !rootEl.value) return
|
|
169
|
+
const dx = e.clientX - startX
|
|
170
|
+
const dy = e.clientY - startY
|
|
171
|
+
|
|
172
|
+
// Ne capture le geste que s'il est majoritairement horizontal (le scroll
|
|
173
|
+
// vertical du contenu continue de fonctionner)
|
|
174
|
+
if (!isHorizontal) {
|
|
175
|
+
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
|
|
176
|
+
isHorizontal = true
|
|
177
|
+
rootEl.value.style.transition = "none"
|
|
178
|
+
}
|
|
179
|
+
else return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Sens : left → glisser vers la gauche (dx négatif) ; right → vers la droite
|
|
183
|
+
const dir = props.side === "right" ? 1 : -1
|
|
184
|
+
const travel = Math.max(0, dir * dx)
|
|
185
|
+
currentDx = travel
|
|
186
|
+
rootEl.value.style.transform = `translateX(${dir === 1 ? travel : -travel}px)`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const onPointerUp = () => {
|
|
190
|
+
if (!dragging.value) return
|
|
191
|
+
dragging.value = false
|
|
192
|
+
const el = rootEl.value
|
|
193
|
+
if (!el) return
|
|
194
|
+
el.style.transition = "" // restaure la transition CSS
|
|
195
|
+
const width = el.offsetWidth || parseInt(props.width, 10) || 260
|
|
196
|
+
if (currentDx > Math.min(80, width * 0.3)) {
|
|
197
|
+
// Fermer : l'animation continue depuis la position de swipe jusqu'au bord
|
|
198
|
+
const final = props.side === "right" ? "100%" : "-100%"
|
|
199
|
+
el.style.transform = `translateX(${final})`
|
|
200
|
+
setOpen(false)
|
|
201
|
+
}
|
|
202
|
+
else if (el) {
|
|
203
|
+
el.style.transform = "" // rebond : la transition CSS ramène à l'ouverture
|
|
204
|
+
}
|
|
205
|
+
currentDx = 0
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Nettoie le transform inline laissé par le swipe à la réouverture, pour que
|
|
209
|
+
// le panneau reparte de la position CSS (pas de l'endroit où on a relâché)
|
|
210
|
+
watch(open, (v) => {
|
|
211
|
+
const el = rootEl.value
|
|
212
|
+
if (!el) return
|
|
213
|
+
el.style.transition = ""
|
|
214
|
+
if (v) el.style.transform = ""
|
|
215
|
+
})
|
|
136
216
|
</script>
|
|
137
217
|
|
|
138
218
|
<template>
|
|
139
219
|
<aside
|
|
220
|
+
ref="rootEl"
|
|
140
221
|
class="q-sidebar"
|
|
141
222
|
:class="rootClasses"
|
|
142
223
|
:style="[rootStyle, props.style]"
|
|
143
224
|
:aria-hidden="!isStatic && !open ? 'true' : undefined"
|
|
225
|
+
@pointerdown="onPointerDown"
|
|
226
|
+
@pointermove="onPointerMove"
|
|
227
|
+
@pointerup="onPointerUp"
|
|
228
|
+
@pointercancel="onPointerUp"
|
|
144
229
|
>
|
|
145
230
|
<slot />
|
|
146
231
|
</aside>
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -859,9 +859,19 @@ html.dark body {
|
|
|
859
859
|
}
|
|
860
860
|
|
|
861
861
|
/* Icône empilée au-dessus du label : aérer l'icône du haut du cadre (le tab
|
|
862
|
-
n'a que du padding horizontal par défaut) et le label du bas
|
|
863
|
-
.q-tab
|
|
864
|
-
|
|
862
|
+
n'a que du padding horizontal par défaut) et le label du bas.
|
|
863
|
+
Le padding est mis sur le CONTENU (pas le .q-tab qui a height:100% en
|
|
864
|
+
stretch → le padding y serait absorbé). */
|
|
865
|
+
.q-tab--stacked-icon .q-tab__content {
|
|
866
|
+
padding-top: 12px;
|
|
867
|
+
padding-bottom: 12px;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/* Fallback : tout tab avec icône et NON inline-label (indépendant de la classe
|
|
871
|
+
JS, au cas où l'icône est rendue via slot) */
|
|
872
|
+
.q-tab:not(.q-tab--inline-label):has(> .q-tab__content > .q-tab__icon) .q-tab__content {
|
|
873
|
+
padding-top: 12px;
|
|
874
|
+
padding-bottom: 12px;
|
|
865
875
|
}
|
|
866
876
|
|
|
867
877
|
.q-tab__icon {
|
|
@@ -3611,6 +3621,13 @@ html.dark body {
|
|
|
3611
3621
|
transition:
|
|
3612
3622
|
transform 0.25s ease,
|
|
3613
3623
|
visibility 0.25s;
|
|
3624
|
+
/* Scroll vertical natif autorisé, gestes horizontaux (swipe pour fermer) au JS.
|
|
3625
|
+
touch-action n'est PAS hérité → on l'applique à tous les descendants : le
|
|
3626
|
+
geste peut démarrer sur le contenu scrollable (.q-sidebar__content) sans que
|
|
3627
|
+
le navigateur ne prenne le swipe pour le body. */
|
|
3628
|
+
touch-action: pan-y;
|
|
3629
|
+
/* Empêche le scroll-chaining : swiper la sidebar ne fait plus bouger le body */
|
|
3630
|
+
overscroll-behavior: contain;
|
|
3614
3631
|
/* Safe-area : encoche, barre d'accueil et côtés en paysage */
|
|
3615
3632
|
padding-top: 0;
|
|
3616
3633
|
padding-top: constant(safe-area-inset-top);
|
|
@@ -3623,6 +3640,13 @@ html.dark body {
|
|
|
3623
3640
|
padding-left: env(safe-area-inset-left);
|
|
3624
3641
|
}
|
|
3625
3642
|
|
|
3643
|
+
/* Tous les descendants (contenu scrollable, menu, boutons…) : gestes horizontaux
|
|
3644
|
+
au JS, scroll vertical natif uniquement */
|
|
3645
|
+
.q-sidebar--offcanvas,
|
|
3646
|
+
.q-sidebar--offcanvas * {
|
|
3647
|
+
touch-action: pan-y;
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3626
3650
|
.q-sidebar--offcanvas.q-sidebar--right {
|
|
3627
3651
|
left: auto;
|
|
3628
3652
|
right: 0;
|
|
@@ -3690,6 +3714,8 @@ html.dark body {
|
|
|
3690
3714
|
overflow-y: auto;
|
|
3691
3715
|
padding: 8px;
|
|
3692
3716
|
scrollbar-width: thin;
|
|
3717
|
+
/* Le scroll du contenu ne doit pas se propager au body (swipe-to-close) */
|
|
3718
|
+
overscroll-behavior: contain;
|
|
3693
3719
|
}
|
|
3694
3720
|
|
|
3695
3721
|
.q-sidebar__footer {
|