@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.
@@ -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
- typeof props.optionValue === "function" ? props.optionValue(opt) : opt?.[props.optionValue]
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 props.options.find((o) => getOptionValue(o) === v)
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
- const value = props.emitValue ? getOptionValue(opt) : opt
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(props.options, {
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 = props.options
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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.7",
3
+ "version": "0.0.10",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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--stacked-icon {
864
- padding: 8px 16px;
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 {