@dnax/ui 0.0.23 → 0.0.26

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.
@@ -16,6 +16,7 @@ export const qBottomSheetKey: InjectionKey<BottomSheetContext> = Symbol("q-botto
16
16
  import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from "vue"
17
17
  import { cn } from "../lib/utils"
18
18
  import { useOverlayBack } from "../lib/overlayBack"
19
+ import { markOverlayClose } from "../lib/closeOverlay"
19
20
 
20
21
  interface Props {
21
22
  /** Ouvert (v-model) */
@@ -33,6 +34,10 @@ interface Props {
33
34
  translucent?: boolean | number
34
35
  /** Seuil de drag (px) au-delà duquel on ferme */
35
36
  dragThreshold?: number
37
+ /** Animation d'ouverture : slide-up (défaut) | fade | zoom */
38
+ transition?: "slide-up" | "fade" | "zoom"
39
+ /** Durée des transitions d'entrée/sortie en ms */
40
+ transitionDuration?: number
36
41
  /** Style du panneau (variables de thème pour les contenus téléportés) */
37
42
  contentStyle?: Record<string, string>
38
43
  }
@@ -63,6 +68,11 @@ provide<BottomSheetContext>(qBottomSheetKey, {
63
68
  // « Retour » navigateur → ferme le bottom sheet au lieu de naviguer
64
69
  useOverlayBack(open, () => { open.value = false }, "QBottomSheet")
65
70
 
71
+ // v-close : marque l'overlay comme fermable (la directive remonte jusqu'ici)
72
+ const markOverlay = (el: unknown) => {
73
+ markOverlayClose(el as HTMLElement | null, () => { open.value = false })
74
+ }
75
+
66
76
  // Échap + verrouillage du scroll du body
67
77
  const onDocKeydown = (e: KeyboardEvent) => {
68
78
  if (e.key === "Escape" && open.value && !props.persistent) open.value = false
@@ -120,6 +130,21 @@ const radiusStyle = computed<Record<string, string> | undefined>(() => {
120
130
  return undefined
121
131
  })
122
132
 
133
+ // Nom de transition : q-bs (défaut slide-up) | q-bs-fade | q-bs-zoom
134
+ const transitionName = computed(() =>
135
+ props.transition ? `q-bs-${props.transition}` : "q-bs",
136
+ )
137
+
138
+ // Durée (ms) → variables consommées par les transitions/animations CSS
139
+ const transitionStyle = computed<Record<string, string> | undefined>(() =>
140
+ props.transitionDuration
141
+ ? {
142
+ "--q-bs-duration-enter": `${props.transitionDuration}ms`,
143
+ "--q-bs-duration-leave": `${props.transitionDuration}ms`,
144
+ }
145
+ : undefined,
146
+ )
147
+
123
148
  const panelClasses = computed(() =>
124
149
  cn(
125
150
  "q-bottom-sheet__panel",
@@ -143,9 +168,10 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
143
168
  </span>
144
169
 
145
170
  <Teleport to="body">
146
- <Transition name="q-bs">
171
+ <Transition :name="transitionName" :duration="transitionDuration">
147
172
  <div
148
173
  v-if="open"
174
+ :ref="markOverlay"
149
175
  class="q-bottom-sheet__overlay"
150
176
  @click="!persistent && (open = false)"
151
177
  >
@@ -153,7 +179,7 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
153
179
  ref="panelRef"
154
180
  class="q-bottom-sheet__panel"
155
181
  :class="panelClasses"
156
- :style="[panelStyle, radiusStyle, translucentStyle, props.contentStyle]"
182
+ :style="[panelStyle, radiusStyle, translucentStyle, transitionStyle, props.contentStyle]"
157
183
  role="dialog"
158
184
  aria-modal="true"
159
185
  @click.stop
@@ -2,8 +2,9 @@
2
2
  // QBottomSheetHeader — header du panneau façon barre d'app (équivalent QHeader) :
3
3
  // le contenu est embarqué dans un <q-toolbar> (min-height 50px, padding 0 12px),
4
4
  // avec titre + description + bouton fermer (équivalent DrawerHeader/DrawerTitle).
5
- // no-padding retire le padding horizontal du toolbar.
6
- import { inject } from "vue"
5
+ // Si un CONTENU custom est fourni (slot par défaut, sans title/description),
6
+ // il n'est PAS enveloppé dans un toolbar — l'utilisateur fournit son layout.
7
+ import { Comment, Fragment, Text, computed, inject, onUpdated, ref, useSlots } from "vue"
7
8
  import { Icon } from "@iconify/vue"
8
9
  import { icons } from "../lib/icons"
9
10
  import { qBottomSheetKey } from "./QBottomSheet.vue"
@@ -17,11 +18,44 @@ interface Props {
17
18
  noPadding?: boolean
18
19
  }
19
20
 
20
- withDefaults(defineProps<Props>(), {
21
+ const props = withDefaults(defineProps<Props>(), {
21
22
  noPadding: false,
22
23
  })
23
24
 
24
25
  const sheet = inject(qBottomSheetKey, null)
26
+
27
+ const slots = useSlots()
28
+
29
+ const refresh = ref(0)
30
+ onUpdated(() => {
31
+ refresh.value++
32
+ })
33
+
34
+ function slotHasContent(nodes: unknown[]): boolean {
35
+ for (const n of nodes as any[]) {
36
+ if (!n) continue
37
+ if (n.type === Comment) continue
38
+ if (n.type === Text) {
39
+ if (String(n.children ?? "").trim() !== "") return true
40
+ continue
41
+ }
42
+ if (n.type === Fragment) {
43
+ if (slotHasContent(n.children ?? [])) return true
44
+ continue
45
+ }
46
+ return true
47
+ }
48
+ return false
49
+ }
50
+
51
+ const isStandard = computed(
52
+ () => props.title !== undefined || props.description !== undefined,
53
+ )
54
+ const hasCustomContent = computed(() => {
55
+ void refresh.value
56
+ return slotHasContent(slots.default?.() ?? [])
57
+ })
58
+ const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
25
59
  </script>
26
60
 
27
61
  <template>
@@ -30,7 +64,9 @@ const sheet = inject(qBottomSheetKey, null)
30
64
  :class="{ 'q-bottom-sheet__header--no-padding': noPadding }"
31
65
  v-bind="$attrs"
32
66
  >
33
- <q-toolbar>
67
+ <slot v-if="useCustom" />
68
+
69
+ <q-toolbar v-else>
34
70
  <div class="q-bottom-sheet__header-text">
35
71
  <h2 v-if="title" class="q-bottom-sheet__title">{{ title }}</h2>
36
72
  <slot name="title" />
@@ -2,11 +2,19 @@
2
2
  // QBottomSheetProvider — rend la pile de bottom sheets programmatiques ($q.bottomSheet).
3
3
  // Intégré dans QConfigProvider (rendu automatiquement par le plus externe),
4
4
  // utilisable aussi en autonome : <q-bottom-sheet-provider><slot /></q-bottom-sheet-provider>.
5
+ //
6
+ // Deux patterns (comme le plugin dialog) :
7
+ // - SANS title/description → le composant passé EST le sheet (racine
8
+ // <q-bottom-sheet v-model="open"> pilotée par useBottomSheetPluginComponent,
9
+ // open déjà true) — rendu tel quel via QBottomSheetHost.
10
+ // - AVEC title/description → le provider enveloppe dans un q-bottom-sheet +
11
+ // header ; le composant fournit le corps et émet @ok/@cancel/@dismiss.
5
12
  import { computed, inject } from "vue"
6
13
  import { bottomSheetStack, closeBottomSheet } from "../lib/q"
7
14
  import type { BottomSheetController } from "../lib/q"
8
15
  import { qConfigKey } from "../lib/config"
9
16
  import { themeVarsStyle } from "../lib/themeVars"
17
+ import QBottomSheetHost from "./internal/QBottomSheetHost.vue"
10
18
 
11
19
  const sheets = bottomSheetStack
12
20
 
@@ -31,32 +39,49 @@ const handleDismiss = (ctrl: BottomSheetController) => {
31
39
 
32
40
  <template>
33
41
  <slot />
34
- <q-bottom-sheet
35
- v-for="(sheet, idx) in sheets"
36
- :key="idx"
37
- :model-value="true"
38
- :persistent="sheet._opts.persistent"
39
- :width="sheet._opts.width"
40
- :height="sheet._opts.height"
41
- :rounded="sheet._opts.rounded"
42
- :dark="sheet._opts.dark"
43
- :translucent="sheet._opts.translucent"
44
- :drag-threshold="sheet._opts.dragThreshold"
45
- :content-style="sheetThemeStyle"
46
- @update:model-value="(v: boolean) => !v && handleDismiss(sheet)"
47
- >
48
- <q-bottom-sheet-header
49
- v-if="sheet._opts.title || sheet._opts.description"
50
- :title="sheet._opts.title"
51
- :description="sheet._opts.description"
52
- />
53
- <component
54
- :is="sheet._opts.component"
55
- v-bind="sheet._opts.componentProps"
42
+ <template v-for="(sheet, idx) in sheets" :key="idx">
43
+ <!-- Pattern auto : le composant est lui-même un <q-bottom-sheet> -->
44
+ <q-bottom-sheet-host
45
+ v-if="!sheet._opts.title && !sheet._opts.description"
46
+ :ctrl="sheet"
47
+ :on-ok="(data?: unknown) => handleOk(sheet, data)"
48
+ :on-cancel="() => handleCancel(sheet)"
49
+ :on-hide="() => handleDismiss(sheet)"
56
50
  @ok="(data?: unknown) => handleOk(sheet, data)"
57
51
  @cancel="handleCancel(sheet)"
58
52
  @dismiss="handleDismiss(sheet)"
59
53
  @close="handleDismiss(sheet)"
60
54
  />
61
- </q-bottom-sheet>
55
+ <!-- Pattern enveloppe : le provider rend le panneau + header, le composant
56
+ fournit le corps (events legacy ok/cancel/dismiss/close) -->
57
+ <q-bottom-sheet
58
+ v-else
59
+ :model-value="true"
60
+ :persistent="sheet._opts.persistent"
61
+ :width="sheet._opts.width"
62
+ :height="sheet._opts.height"
63
+ :rounded="sheet._opts.rounded"
64
+ :dark="sheet._opts.dark"
65
+ :translucent="sheet._opts.translucent"
66
+ :drag-threshold="sheet._opts.dragThreshold"
67
+ :transition="sheet._opts.transition"
68
+ :transition-duration="sheet._opts.transitionDuration"
69
+ :content-style="sheetThemeStyle"
70
+ @update:model-value="(v: boolean) => !v && handleDismiss(sheet)"
71
+ >
72
+ <q-bottom-sheet-header
73
+ v-if="sheet._opts.title || sheet._opts.description"
74
+ :title="sheet._opts.title"
75
+ :description="sheet._opts.description"
76
+ />
77
+ <component
78
+ :is="sheet._opts.component"
79
+ v-bind="sheet._opts.componentProps"
80
+ @ok="(data?: unknown) => handleOk(sheet, data)"
81
+ @cancel="handleCancel(sheet)"
82
+ @dismiss="handleDismiss(sheet)"
83
+ @close="handleDismiss(sheet)"
84
+ />
85
+ </q-bottom-sheet>
86
+ </template>
62
87
  </template>
@@ -17,6 +17,7 @@ import { cn } from "../lib/utils"
17
17
  import { radiusStyle, useRadius } from "../lib/useComponentProps"
18
18
  import type { RadiusProp } from "../lib/useComponentProps"
19
19
  import { useOverlayBack } from "../lib/overlayBack"
20
+ import { markOverlayClose } from "../lib/closeOverlay"
20
21
 
21
22
  type DialogPosition = "standard" | "top" | "right" | "bottom" | "left"
22
23
 
@@ -88,6 +89,11 @@ const onDocKeydown = (e: KeyboardEvent) => {
88
89
  // « Retour » navigateur → ferme le dialog au lieu de naviguer
89
90
  useOverlayBack(open, () => { open.value = false }, "QDialog")
90
91
 
92
+ // v-close : marque l'overlay comme fermable (la directive remonte jusqu'ici)
93
+ const markOverlay = (el: unknown) => {
94
+ markOverlayClose(el as HTMLElement | null, () => { open.value = false })
95
+ }
96
+
91
97
  // — Verrou du scroll de la page derrière le dialog —
92
98
  // Compteur module-level + suivi par instance : chaque dialog verrouille au plus
93
99
  // une fois et déverrouille au plus une fois → pile de dialogs ($q.dialog) sans
@@ -169,6 +175,7 @@ const durationStyle = computed<StyleValue>(() =>
169
175
  <Transition :name="transitionName" @after-enter="emit('show')" @after-leave="emit('hide')">
170
176
  <div
171
177
  v-if="open"
178
+ :ref="markOverlay"
172
179
  class="q-dialog__overlay"
173
180
  :class="overlayClasses"
174
181
  :style="durationStyle"
@@ -1,9 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  // QDialogHeader — header de la modale façon barre d'app (équivalent QHeader) :
3
3
  // le contenu est embarqué dans un <q-toolbar> (min-height 50px, padding 0 12px),
4
- // avec titre + description + bouton fermer (équivalent DialogHeader/Title/Close).
5
- // no-padding retire le padding horizontal du toolbar.
6
- import { inject } from "vue"
4
+ // avec titre + description + bouton fermer OPT-IN (show-close).
5
+ // Si un CONTENU custom est fourni (slot par défaut, sans title/description/
6
+ // show-close), il n'est PAS enveloppé dans un toolbar — l'utilisateur fournit
7
+ // son propre layout (ex. <q-toolbar>) directement dans la barre.
8
+ import { Comment, Fragment, Text, computed, inject, onUpdated, ref, useSlots } from "vue"
7
9
  import { Icon } from "@iconify/vue"
8
10
  import { icons } from "../lib/icons"
9
11
  import { qDialogKey } from "./QDialog.vue"
@@ -13,18 +15,55 @@ import QSpace from "./QSpace.vue"
13
15
  interface Props {
14
16
  title?: string
15
17
  description?: string
16
- /** Bouton fermer */
18
+ /** Affiche le bouton fermer (opt-in — sans la prop, aucun ×) */
17
19
  showClose?: boolean
18
20
  /** Supprime le padding du toolbar (contenu collé aux bords) */
19
21
  noPadding?: boolean
20
22
  }
21
23
 
22
24
  const props = withDefaults(defineProps<Props>(), {
23
- showClose: true,
25
+ showClose: false,
24
26
  noPadding: false,
25
27
  })
26
28
 
27
29
  const dialog = inject(qDialogKey, null)
30
+
31
+ const slots = useSlots()
32
+
33
+ // Force le recalcul de la détection à chaque update (le contenu du slot peut
34
+ // changer dynamiquement côté parent)
35
+ const refresh = ref(0)
36
+ onUpdated(() => {
37
+ refresh.value++
38
+ })
39
+
40
+ function slotHasContent(nodes: unknown[]): boolean {
41
+ for (const n of nodes as any[]) {
42
+ if (!n) continue
43
+ if (n.type === Comment) continue
44
+ if (n.type === Text) {
45
+ if (String(n.children ?? "").trim() !== "") return true
46
+ continue
47
+ }
48
+ if (n.type === Fragment) {
49
+ if (slotHasContent(n.children ?? [])) return true
50
+ continue
51
+ }
52
+ return true
53
+ }
54
+ return false
55
+ }
56
+
57
+ // Mode « custom » : pas de title/description/close et un contenu de slot → on
58
+ // rend le slot directement (sans toolbar d'enveloppe)
59
+ const isStandard = computed(
60
+ () => props.title !== undefined || props.description !== undefined || props.showClose,
61
+ )
62
+ const hasCustomContent = computed(() => {
63
+ void refresh.value
64
+ return slotHasContent(slots.default?.() ?? [])
65
+ })
66
+ const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
28
67
  </script>
29
68
 
30
69
  <template>
@@ -33,7 +72,11 @@ const dialog = inject(qDialogKey, null)
33
72
  :class="{ 'q-dialog__header--no-padding': noPadding }"
34
73
  v-bind="$attrs"
35
74
  >
36
- <q-toolbar>
75
+ <!-- Contenu custom : l'utilisateur fournit son propre layout (q-toolbar…) -->
76
+ <slot v-if="useCustom" />
77
+
78
+ <!-- Mode standard : toolbar embarqué (title / description / close) -->
79
+ <q-toolbar v-else>
37
80
  <div class="q-dialog__header-text">
38
81
  <h2 v-if="title" class="q-dialog__title">{{ title }}</h2>
39
82
  <slot name="title" />
@@ -31,19 +31,6 @@ interface Props {
31
31
 
32
32
  defineProps<Props>()
33
33
 
34
- // Slots typés (mode virtual) : même contrat que QVirtualScroll
35
- // (item, index, ref pour la mesure ; before/after pour les zones sticky)
36
- defineSlots<{
37
- default?: (props: { item: any; index: number; ref: (el: unknown) => void }) => any
38
- before?: () => any
39
- after?: () => any
40
- }>()
41
-
42
- // L'inférence du scope ne traverse pas la branche v-if → cast explicite
43
- // (le scope reçu EST le scope du QVirtualScroll)
44
- type VirtualScope = { item: any; index: number; ref: (el: unknown) => void }
45
- const forwardScope = (scope: unknown) => scope as VirtualScope
46
-
47
34
  const rootEl = ref<HTMLElement | null>(null)
48
35
  // ref fonctionnel : sur un élément → l'élément ; sur q-virtual-scroll (composant) → .$el
49
36
  const setRoot = (el: unknown) => {
@@ -71,7 +58,7 @@ useFixedBarOffset(rootEl, "page")
71
58
  v-bind="$attrs"
72
59
  >
73
60
  <template #default="scope">
74
- <slot v-bind="forwardScope(scope)" />
61
+ <slot v-bind="scope" />
75
62
  </template>
76
63
  <template #before>
77
64
  <slot name="before" />
@@ -24,7 +24,7 @@ export const qTabsKey: InjectionKey<QTabContext> = Symbol("q-tabs")
24
24
  </script>
25
25
 
26
26
  <script setup lang="ts">
27
- import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from "vue"
27
+ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from "vue"
28
28
  import { cva } from "class-variance-authority"
29
29
  import { cn } from "../lib/utils"
30
30
  import { colorValue } from "../lib/colors"
@@ -117,6 +117,24 @@ const props = withDefaults(defineProps<Props>(), {
117
117
 
118
118
  const emit = defineEmits<{ "update:modelValue": [value: string | number | null] }>()
119
119
 
120
+ // — Mode non-contrôlé : sans v-model (pas de listener update:modelValue), QTabs
121
+ // maintient son propre état interne — indispensable pour QRouteTab (le tab
122
+ // actif suit la route sans v-model parent) et pour tout usage sans v-model.
123
+ const instance = getCurrentInstance()
124
+ const controlled = computed(
125
+ () => !!instance && "onUpdate:modelValue" in (instance.vnode.props ?? {}),
126
+ )
127
+ const internal = ref<string | number | null>(null)
128
+
129
+ const activeName = computed<string | number | null>(() =>
130
+ controlled.value ? (props.modelValue ?? null) : internal.value,
131
+ )
132
+
133
+ const setActive = (name: string | number) => {
134
+ if (controlled.value) emit("update:modelValue", name)
135
+ else internal.value = name
136
+ }
137
+
120
138
  // Position effective de l'indicateur : "top" | "bottom" (défaut) | "none" (masqué)
121
139
  const indicatorPos = computed<"top" | "bottom" | "none">(() => {
122
140
  const p = props.switchIndicatorPosition
@@ -145,7 +163,7 @@ const onCollapseEnd = (e: Event) => {
145
163
  measure()
146
164
  }
147
165
 
148
- watch([() => props.modelValue, tabRegs], measure)
166
+ watch([activeName, tabRegs], measure)
149
167
 
150
168
  onMounted(() => {
151
169
  measure()
@@ -158,8 +176,8 @@ onBeforeUnmount(() => {
158
176
  })
159
177
 
160
178
  const context: QTabContext = {
161
- activeName: computed(() => props.modelValue),
162
- setActive: (name) => emit("update:modelValue", name),
179
+ activeName,
180
+ setActive,
163
181
  register: (tab) => {
164
182
  tabRegs.value = [...tabRegs.value, tab]
165
183
  },
@@ -175,7 +193,7 @@ const context: QTabContext = {
175
193
  provide(qTabsKey, context)
176
194
 
177
195
  const activeTabEl = computed(
178
- () => tabRegs.value.find((t) => t.name === props.modelValue)?.el.value ?? null,
196
+ () => tabRegs.value.find((t) => t.name === activeName.value)?.el.value ?? null,
179
197
  )
180
198
 
181
199
  const indicatorStyle = computed<Record<string, string>>(() => {
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ // QBottomSheetHost — host interne du $q.bottomSheet (pattern Quasar, comme
3
+ // QDialogHost) : rend le composant passé tel quel et lui fournit le contexte
4
+ // plugin. Le composant DOIT contenir un <q-bottom-sheet v-model="open"
5
+ // @hide="onDialogHide"> comme racine, piloté via
6
+ // useBottomSheetPluginComponent() — open est déjà true (pas de v-model à
7
+ // mettre). À la fermeture (@hide) ou via onDialogOK / onDialogCancel, les
8
+ // résolveurs sont appelés et l'entrée retirée.
9
+ import { provide, ref } from "vue"
10
+ import { qBottomSheetPluginKey } from "../../lib/q"
11
+ import type { BottomSheetController } from "../../lib/q"
12
+
13
+ const props = defineProps<{
14
+ ctrl: BottomSheetController
15
+ onOk: (data?: unknown) => void
16
+ onCancel: () => void
17
+ onHide: () => void
18
+ }>()
19
+
20
+ const open = ref(true)
21
+
22
+ provide(qBottomSheetPluginKey, {
23
+ open,
24
+ onDialogOK: (data?: unknown) => props.onOk(data),
25
+ onDialogCancel: () => props.onCancel(),
26
+ onDialogHide: () => props.onHide(),
27
+ })
28
+
29
+ // Le composant émet aussi les events legacy (ok/cancel/dismiss/close)
30
+ const emit = defineEmits<{
31
+ ok: [data?: unknown]
32
+ cancel: []
33
+ dismiss: []
34
+ close: []
35
+ }>()
36
+ </script>
37
+
38
+ <template>
39
+ <component
40
+ :is="props.ctrl._opts.component"
41
+ v-bind="props.ctrl._opts.componentProps"
42
+ @ok="emit('ok', $event)"
43
+ @cancel="emit('cancel')"
44
+ @dismiss="emit('dismiss')"
45
+ @close="emit('close')"
46
+ />
47
+ </template>
package/index.ts CHANGED
@@ -128,12 +128,13 @@ export { default as QUploader } from "./components/QUploader.vue"
128
128
  export { default as QVideo } from "./components/QVideo.vue"
129
129
  export { default as QVirtualScroll } from "./components/QVirtualScroll.vue"
130
130
 
131
- export { $q, usePlugin, useQ, QPlugin, dialogStack, closeDialog, bottomSheetStack, closeBottomSheet, useDialogPluginComponent } from "./lib/q"
131
+ export { $q, usePlugin, useQ, QPlugin, dialogStack, closeDialog, bottomSheetStack, closeBottomSheet, useDialogPluginComponent, useBottomSheetPluginComponent } from "./lib/q"
132
132
  export type {
133
133
  DialogOptions,
134
134
  DialogController,
135
135
  BottomSheetOptions,
136
136
  BottomSheetController,
137
+ BottomSheetPluginContext,
137
138
  NotifyOptions,
138
139
  NotifyController,
139
140
  } from "./lib/q"
@@ -152,3 +153,4 @@ export {
152
153
  installOverlayBackHandler,
153
154
  } from "./lib/overlayBack"
154
155
  export type { OverlayHandle } from "./lib/overlayBack"
156
+ export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
@@ -0,0 +1,48 @@
1
+ // v-close — directive : ferme l'overlay (dialog, bottom sheet, …) le plus
2
+ // proche au clic (équivalent Quasar v-close-popup).
3
+ // Les overlays s'enregistrent en posant el.__qClose sur leur élément racine
4
+ // (markOverlayClose) ; la directive remonte depuis l'élément cliqué.
5
+
6
+ interface QCloseMarker extends HTMLElement {
7
+ __qClose?: () => void
8
+ __qVClose?: () => void
9
+ }
10
+
11
+ /** Marque un élément comme « overlay fermable » (posé par QDialog, QBottomSheet…) */
12
+ export function markOverlayClose(el: HTMLElement | null, close: () => void) {
13
+ if (!el) return
14
+ ;(el as QCloseMarker).__qClose = close
15
+ }
16
+
17
+ /** Remonte jusqu'à l'overlay le plus proche portant __qClose */
18
+ function closestOverlay(el: Element): QCloseMarker | null {
19
+ let node: Element | null = el
20
+ while (node) {
21
+ const marker = node as QCloseMarker
22
+ if (typeof marker.__qClose === "function") return marker
23
+ node = node.parentElement
24
+ }
25
+ return null
26
+ }
27
+
28
+ /** Ferme l'overlay parent le plus proche de `el` */
29
+ export function closeParentOverlay(el: Element) {
30
+ closestOverlay(el)?.__qClose?.()
31
+ }
32
+
33
+ /** Directive : <q-btn v-close> → ferme le dialog/sheet parent au clic.
34
+ * v-close="false" désactive la fermeture. */
35
+ export const vClose = {
36
+ mounted(el: HTMLElement, binding: { value?: unknown }) {
37
+ const onClick = () => {
38
+ if (binding.value === false) return
39
+ closeParentOverlay(el)
40
+ }
41
+ el.addEventListener("click", onClick)
42
+ ;(el as QCloseMarker).__qVClose = onClick
43
+ },
44
+ unmounted(el: HTMLElement) {
45
+ const fn = (el as QCloseMarker).__qVClose
46
+ if (fn) el.removeEventListener("click", fn)
47
+ },
48
+ }
package/lib/q.ts CHANGED
@@ -112,7 +112,7 @@ export interface BottomSheetOptions {
112
112
  component: Component | string
113
113
  /** Props passées au composant */
114
114
  componentProps?: Record<string, any>
115
- /** Titre affiché par QBottomSheetHeader (optionnel) */
115
+ /** Titre affiché par QBottomSheetHeader (optionnel — sinon le composant rend son propre header) */
116
116
  title?: string
117
117
  description?: string
118
118
  /** Largeur max du panneau (défaut : 640px) */
@@ -128,8 +128,28 @@ export interface BottomSheetOptions {
128
128
  persistent?: boolean
129
129
  /** Seuil de drag (px) au-delà duquel on ferme */
130
130
  dragThreshold?: number
131
+ /** Animation d'ouverture : slide-up (défaut) | fade | zoom */
132
+ transition?: "slide-up" | "fade" | "zoom"
133
+ /** Durée des transitions d'entrée/sortie en ms */
134
+ transitionDuration?: number
131
135
  }
132
136
 
137
+ /**
138
+ * Contexte plugin bottom sheet — fourni par QBottomSheetHost quand le composant
139
+ * passé à $q.bottomSheet.open() est lui-même un <q-bottom-sheet> (pattern
140
+ * Quasar, identique à $q.dialog) : open est déjà true, pas besoin de v-model.
141
+ */
142
+ export interface BottomSheetPluginContext {
143
+ /** Ref d'ouverture (v-model du <q-bottom-sheet> du composant, déjà true) */
144
+ open: Ref<boolean>
145
+ onDialogOK: (data?: unknown) => void
146
+ onDialogCancel: () => void
147
+ onDialogHide: () => void
148
+ }
149
+
150
+ export const qBottomSheetPluginKey: InjectionKey<BottomSheetPluginContext> =
151
+ Symbol("q-bottom-sheet-plugin")
152
+
133
153
  export interface BottomSheetController {
134
154
  onOK: (cb: (data?: any) => void | Promise<void>) => BottomSheetController
135
155
  onCancel: (cb: () => void | Promise<void>) => BottomSheetController
@@ -142,6 +162,22 @@ export interface BottomSheetController {
142
162
  _opts: BottomSheetOptions
143
163
  }
144
164
 
165
+ /**
166
+ * Composable Quasar-style pour les composants passés à $q.bottomSheet.open() :
167
+ * le composant rend un <q-bottom-sheet v-model="open" @hide="onDialogHide">
168
+ * comme racine. open est déjà true (le panneau s'affiche à l'ouverture), et
169
+ * onDialogOK / onDialogCancel résolvent les callbacks du controller.
170
+ */
171
+ export function useBottomSheetPluginComponent() {
172
+ const ctx = inject(qBottomSheetPluginKey, null)
173
+ return {
174
+ open: ctx?.open ?? ref(false),
175
+ onDialogHide: () => ctx?.onDialogHide(),
176
+ onDialogOK: (data?: unknown) => ctx?.onDialogOK(data),
177
+ onDialogCancel: () => ctx?.onDialogCancel(),
178
+ }
179
+ }
180
+
145
181
  // — Pile de bottom sheets (singleton module-level, client-side) —
146
182
  const bottomSheets: Ref<BottomSheetController[]> = ref([])
147
183
 
package/module.ts CHANGED
@@ -85,5 +85,19 @@ export default defineNuxtPlugin((nuxtApp) => {
85
85
  `,
86
86
  })
87
87
  }
88
+
89
+ // Directive globale v-close : <q-btn v-close> ferme le dialog/sheet parent
90
+ addPluginTemplate({
91
+ filename: "dnax-ui-directives.mjs",
92
+ mode: "client",
93
+ getContents: () => `
94
+ import { defineNuxtPlugin } from "#imports"
95
+ import { vClose } from "@dnax/ui/runtime"
96
+
97
+ export default defineNuxtPlugin((nuxtApp) => {
98
+ nuxtApp.vueApp.directive("close", vClose)
99
+ })
100
+ `,
101
+ })
88
102
  },
89
103
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.23",
3
+ "version": "0.0.26",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
@@ -26,12 +26,13 @@ const exports = files
26
26
 
27
27
  // Exports manuels (composables / helpers) — conservés à chaque régénération
28
28
  const manualExports = `
29
- export { $q, usePlugin, useQ, QPlugin, dialogStack, closeDialog, bottomSheetStack, closeBottomSheet, useDialogPluginComponent } from "./lib/q"
29
+ export { $q, usePlugin, useQ, QPlugin, dialogStack, closeDialog, bottomSheetStack, closeBottomSheet, useDialogPluginComponent, useBottomSheetPluginComponent } from "./lib/q"
30
30
  export type {
31
31
  DialogOptions,
32
32
  DialogController,
33
33
  BottomSheetOptions,
34
34
  BottomSheetController,
35
+ BottomSheetPluginContext,
35
36
  NotifyOptions,
36
37
  NotifyController,
37
38
  } from "./lib/q"
@@ -50,6 +51,7 @@ export {
50
51
  installOverlayBackHandler,
51
52
  } from "./lib/overlayBack"
52
53
  export type { OverlayHandle } from "./lib/overlayBack"
54
+ export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
53
55
  `
54
56
 
55
57
  await Bun.write(indexFile, `${exports}\n${manualExports}`)
package/styles/main.css CHANGED
@@ -4868,25 +4868,37 @@ html.dark body {
4868
4868
  border-color: rgb(255 255 255 / 0.12);
4869
4869
  }
4870
4870
 
4871
- /* — transitions (slide vertical type vaul) — */
4871
+ /* — transitions (slide vertical type vaul) — durées configurables via
4872
+ transition-duration (vars --q-bs-duration-enter/leave) */
4872
4873
  .q-bs-enter-active,
4873
- .q-bs-leave-active {
4874
- transition: opacity 0.25s ease;
4874
+ .q-bs-leave-active,
4875
+ .q-bs-fade-enter-active,
4876
+ .q-bs-fade-leave-active,
4877
+ .q-bs-zoom-enter-active,
4878
+ .q-bs-zoom-leave-active {
4879
+ transition: opacity var(--q-bs-duration-enter, 0.25s) ease;
4880
+ }
4881
+ .q-bs-leave-active,
4882
+ .q-bs-fade-leave-active,
4883
+ .q-bs-zoom-leave-active {
4884
+ transition: opacity var(--q-bs-duration-leave, 0.2s) ease;
4875
4885
  }
4876
-
4877
4886
  .q-bs-enter-from,
4878
- .q-bs-leave-to {
4887
+ .q-bs-leave-to,
4888
+ .q-bs-fade-enter-from,
4889
+ .q-bs-fade-leave-to,
4890
+ .q-bs-zoom-enter-from,
4891
+ .q-bs-zoom-leave-to {
4879
4892
  opacity: 0;
4880
4893
  }
4881
4894
 
4895
+ /* slide-up (défaut) : le panneau glisse depuis le bas */
4882
4896
  .q-bs-enter-active .q-bottom-sheet__panel {
4883
- animation: q-bs-in 0.25s ease;
4897
+ animation: q-bs-in var(--q-bs-duration-enter, 0.25s) ease;
4884
4898
  }
4885
-
4886
4899
  .q-bs-leave-active .q-bottom-sheet__panel {
4887
- animation: q-bs-out 0.2s ease forwards;
4900
+ animation: q-bs-out var(--q-bs-duration-leave, 0.2s) ease forwards;
4888
4901
  }
4889
-
4890
4902
  @keyframes q-bs-in {
4891
4903
  from {
4892
4904
  transform: translateY(100%);
@@ -4895,13 +4907,36 @@ html.dark body {
4895
4907
  transform: none;
4896
4908
  }
4897
4909
  }
4898
-
4899
4910
  @keyframes q-bs-out {
4900
4911
  to {
4901
4912
  transform: translateY(100%);
4902
4913
  }
4903
4914
  }
4904
4915
 
4916
+ /* zoom : le panneau scale légèrement depuis le centre-bas */
4917
+ .q-bs-zoom-enter-active .q-bottom-sheet__panel {
4918
+ animation: q-bs-zoom-in var(--q-bs-duration-enter, 0.25s) ease;
4919
+ }
4920
+ .q-bs-zoom-leave-active .q-bottom-sheet__panel {
4921
+ animation: q-bs-zoom-out var(--q-bs-duration-leave, 0.2s) ease forwards;
4922
+ }
4923
+ @keyframes q-bs-zoom-in {
4924
+ from {
4925
+ opacity: 0;
4926
+ transform: scale(0.96) translateY(8px);
4927
+ }
4928
+ to {
4929
+ opacity: 1;
4930
+ transform: none;
4931
+ }
4932
+ }
4933
+ @keyframes q-bs-zoom-out {
4934
+ to {
4935
+ opacity: 0;
4936
+ transform: scale(0.96) translateY(8px);
4937
+ }
4938
+ }
4939
+
4905
4940
  /* ===== QBackHeader ===== */
4906
4941
  .q-back-header {
4907
4942
  position: relative;