@dnax/ui 0.0.11 → 0.0.12

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.
@@ -1,9 +1,14 @@
1
1
  <script setup lang="ts">
2
- // QBottomSheetFooter — zone d'actions en bas du panneau (équivalent DrawerFooter).
2
+ // QBottomSheetFooter — pied du panneau façon barre d'app (équivalent QFooter) :
3
+ // les actions sont embarquées dans un <q-toolbar> (min-height 50px, padding
4
+ // 0 12px) et alignées à droite (justify-content: flex-end — styles/main.css).
5
+ import QToolbar from "./QToolbar.vue"
3
6
  </script>
4
7
 
5
8
  <template>
6
9
  <div class="q-bottom-sheet__footer">
7
- <slot />
10
+ <q-toolbar>
11
+ <slot />
12
+ </q-toolbar>
8
13
  </div>
9
14
  </template>
@@ -1,9 +1,13 @@
1
1
  <script setup lang="ts">
2
- // QBottomSheetHeader — header du panneau : titre + description + bouton fermer (équivalent DrawerHeader/DrawerTitle).
2
+ // QBottomSheetHeader — header du panneau façon barre d'app (équivalent QHeader) :
3
+ // le contenu est embarqué dans un <q-toolbar> (min-height 50px, padding 0 12px),
4
+ // avec titre + description + bouton fermer (équivalent DrawerHeader/DrawerTitle).
3
5
  import { inject } from "vue"
4
6
  import { Icon } from "@iconify/vue"
5
7
  import { icons } from "../lib/icons"
6
8
  import { qBottomSheetKey } from "./QBottomSheet.vue"
9
+ import QToolbar from "./QToolbar.vue"
10
+ import QSpace from "./QSpace.vue"
7
11
 
8
12
  interface Props {
9
13
  title?: string
@@ -16,20 +20,23 @@ const sheet = inject(qBottomSheetKey, null)
16
20
 
17
21
  <template>
18
22
  <div class="q-bottom-sheet__header">
19
- <div class="q-bottom-sheet__header-text">
20
- <h2 v-if="title" class="q-bottom-sheet__title">{{ title }}</h2>
21
- <slot name="title" />
22
- <p v-if="description" class="q-bottom-sheet__description">{{ description }}</p>
23
- <slot name="description" />
24
- </div>
25
- <button
26
- type="button"
27
- class="q-bottom-sheet__close"
28
- aria-label="Fermer"
29
- @click="sheet?.setOpen(false)"
30
- >
31
- <Icon :icon="icons.x" aria-hidden="true" />
32
- </button>
33
- <slot />
23
+ <q-toolbar>
24
+ <div class="q-bottom-sheet__header-text">
25
+ <h2 v-if="title" class="q-bottom-sheet__title">{{ title }}</h2>
26
+ <slot name="title" />
27
+ <p v-if="description" class="q-bottom-sheet__description">{{ description }}</p>
28
+ <slot name="description" />
29
+ </div>
30
+ <q-space />
31
+ <button
32
+ type="button"
33
+ class="q-bottom-sheet__close"
34
+ aria-label="Fermer"
35
+ @click="sheet?.setOpen(false)"
36
+ >
37
+ <Icon :icon="icons.x" aria-hidden="true" />
38
+ </button>
39
+ <slot />
40
+ </q-toolbar>
34
41
  </div>
35
42
  </template>
@@ -38,6 +38,19 @@ interface Props {
38
38
  waveColor?: string
39
39
  /** Couleur de la seconde vague (background-effect="wave", défaut violet) */
40
40
  waveColor2?: string
41
+ /** Style glassmorphism : fond translucide + flou d'arrière-plan + bordure claire */
42
+ glass?: boolean
43
+ /** URL d'une image de fond — rendue en couche derrière le contenu (cover) */
44
+ backgroundImage?: string
45
+ /** Taille de l'image : cover | contain | fill | none | scale-down, ou valeur
46
+ * CSS ("50%", "400px" → boîte centrée, image entière visible) */
47
+ backgroundImageSize?: string
48
+ /** Anime l'image de fond (Ken Burns indéterminé : zoom + panoramique lents en boucle) */
49
+ backgroundAnimated?: boolean
50
+ /** Direction de la boucle Ken Burns : alternate (défaut) | alternate-reverse | normal | reverse */
51
+ backgroundAnimationDirection?: "alternate" | "alternate-reverse" | "normal" | "reverse"
52
+ /** Durée d'une itération du Ken Burns (défaut 24s) */
53
+ backgroundAnimationDuration?: string
41
54
  class?: string
42
55
  }
43
56
 
@@ -47,6 +60,13 @@ const props = withDefaults(defineProps<Props>(), {
47
60
  padding: "16px",
48
61
  })
49
62
 
63
+ const IMG_FIT = new Set(["cover", "contain", "fill", "none", "scale-down"])
64
+
65
+ // Taille CSS libre ("50%", "400px"…) → boîte centrée (object-fit: contain)
66
+ const imgSized = computed(
67
+ () => !!props.backgroundImageSize && !IMG_FIT.has(props.backgroundImageSize),
68
+ )
69
+
50
70
  const containerStyle = computed<Record<string, string>>(() => {
51
71
  const style: Record<string, string> = {
52
72
  "--q-container-max": typeof props.maxWidth === "number" ? `${props.maxWidth}px` : props.maxWidth,
@@ -61,6 +81,18 @@ const containerStyle = computed<Record<string, string>>(() => {
61
81
  if (props.auroraColor2) style["--q-aurora-color-2"] = props.auroraColor2
62
82
  if (props.waveColor) style["--q-wave-color"] = props.waveColor
63
83
  if (props.waveColor2) style["--q-wave-color-2"] = props.waveColor2
84
+ if (props.backgroundImageSize && IMG_FIT.has(props.backgroundImageSize)) {
85
+ style["--q-container-img-fit"] = props.backgroundImageSize
86
+ }
87
+ if (imgSized.value && props.backgroundImageSize) {
88
+ style["--q-container-img-size"] = props.backgroundImageSize
89
+ }
90
+ if (props.backgroundAnimationDirection) {
91
+ style["--q-container-kenburns-direction"] = props.backgroundAnimationDirection
92
+ }
93
+ if (props.backgroundAnimationDuration) {
94
+ style["--q-container-kenburns-duration"] = props.backgroundAnimationDuration
95
+ }
64
96
  return style
65
97
  })
66
98
 
@@ -68,7 +100,8 @@ const containerClasses = computed(() =>
68
100
  cn(
69
101
  "q-container",
70
102
  props.fluid && "q-container--fluid",
71
- props.backgroundEffect && "q-container--bg",
103
+ (props.backgroundEffect || props.backgroundImage) && "q-container--bg",
104
+ props.glass && "q-container--glass",
72
105
  props.class,
73
106
  ),
74
107
  )
@@ -145,6 +178,19 @@ onBeforeUnmount(() => rootEl.value?.removeEventListener("mousemove", onMouseMove
145
178
 
146
179
  <template>
147
180
  <div ref="rootEl" class="q-container" :class="containerClasses" :style="containerStyle">
181
+ <img
182
+ v-if="backgroundImage"
183
+ :src="backgroundImage"
184
+ class="q-container__img"
185
+ :class="{
186
+ 'q-container__img--glass': glass,
187
+ 'q-container__img--animated': backgroundAnimated,
188
+ 'q-container__img--sized': imgSized,
189
+ }"
190
+ alt=""
191
+ aria-hidden="true"
192
+ draggable="false"
193
+ />
148
194
  <div
149
195
  v-if="backgroundEffect"
150
196
  class="q-container__bg"
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ // QDialogContent — section de corps de la modale (équivalent QCardSection).
3
+ // Le panneau <q-dialog> est en flex-column (max-height 90vh) : avec `scrollable`,
4
+ // le corps prend l'espace restant et défile en interne, entre un header et un
5
+ // footer fixes (essentiel en mode maximized, où le panneau est overflow: hidden).
6
+ interface Props {
7
+ /** Rendu scrollable : le corps défile entre header et footer fixes */
8
+ scrollable?: boolean
9
+ }
10
+
11
+ withDefaults(defineProps<Props>(), {
12
+ scrollable: false,
13
+ })
14
+ </script>
15
+
16
+ <template>
17
+ <div class="q-dialog__body" :class="{ 'q-dialog__body--scrollable': scrollable }">
18
+ <slot />
19
+ </div>
20
+ </template>
@@ -1,9 +1,14 @@
1
1
  <script setup lang="ts">
2
- // QDialogFooter — zone d'actions en bas de la modale (équivalent DialogFooter).
2
+ // QDialogFooter — pied de la modale façon barre d'app (équivalent QFooter) :
3
+ // les actions sont embarquées dans un <q-toolbar> (min-height 50px, padding
4
+ // 0 12px) et alignées à droite (justify-content: flex-end — styles/main.css).
5
+ import QToolbar from "./QToolbar.vue"
3
6
  </script>
4
7
 
5
8
  <template>
6
9
  <div class="q-dialog__footer">
7
- <slot />
10
+ <q-toolbar>
11
+ <slot />
12
+ </q-toolbar>
8
13
  </div>
9
14
  </template>
@@ -1,9 +1,13 @@
1
1
  <script setup lang="ts">
2
- // QDialogHeader — header de la modale : titre + description + bouton fermer (équivalent DialogHeader/Title/Close).
2
+ // QDialogHeader — header de la modale façon barre d'app (équivalent QHeader) :
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).
3
5
  import { inject } from "vue"
4
6
  import { Icon } from "@iconify/vue"
5
7
  import { icons } from "../lib/icons"
6
8
  import { qDialogKey } from "./QDialog.vue"
9
+ import QToolbar from "./QToolbar.vue"
10
+ import QSpace from "./QSpace.vue"
7
11
 
8
12
  interface Props {
9
13
  title?: string
@@ -21,21 +25,24 @@ const dialog = inject(qDialogKey, null)
21
25
 
22
26
  <template>
23
27
  <div class="q-dialog__header">
24
- <div class="q-dialog__header-text">
25
- <h2 v-if="title" class="q-dialog__title">{{ title }}</h2>
26
- <slot name="title" />
27
- <p v-if="description" class="q-dialog__description">{{ description }}</p>
28
- <slot name="description" />
29
- </div>
30
- <button
31
- v-if="showClose"
32
- type="button"
33
- class="q-dialog__close"
34
- aria-label="Fermer"
35
- @click="dialog?.setOpen(false)"
36
- >
37
- <Icon :icon="icons.x" aria-hidden="true" />
38
- </button>
39
- <slot />
28
+ <q-toolbar>
29
+ <div class="q-dialog__header-text">
30
+ <h2 v-if="title" class="q-dialog__title">{{ title }}</h2>
31
+ <slot name="title" />
32
+ <p v-if="description" class="q-dialog__description">{{ description }}</p>
33
+ <slot name="description" />
34
+ </div>
35
+ <q-space />
36
+ <button
37
+ v-if="showClose"
38
+ type="button"
39
+ class="q-dialog__close"
40
+ aria-label="Fermer"
41
+ @click="dialog?.setOpen(false)"
42
+ >
43
+ <Icon :icon="icons.x" aria-hidden="true" />
44
+ </button>
45
+ <slot />
46
+ </q-toolbar>
40
47
  </div>
41
48
  </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  // QFooter — barre basse (safe-area bottom appliquée par styles/main.css : .q-app .q-footer)
3
3
  import { computed, onBeforeUnmount, onMounted, ref } from "vue"
4
+ import { useFixedBarOffset } from "../lib/fixedLayout"
4
5
 
5
6
  interface Props {
6
7
  /** Fixe la barre en bas de l'écran (sort du flux) */
@@ -24,6 +25,11 @@ const props = withDefaults(defineProps<Props>(), {
24
25
  bordered: false,
25
26
  })
26
27
 
28
+ const rootEl = ref<HTMLElement | null>(null)
29
+
30
+ // Empilement : bottom = hauteur cumulée des footers fixed suivants (si fixed)
31
+ useFixedBarOffset(rootEl, "bar-bottom", () => props.fixed)
32
+
27
33
  const translucentStyle = computed<Record<string, string> | undefined>(() =>
28
34
  props.translucent === true || typeof props.translucent === "number"
29
35
  ? { "--q-translucent-opacity": `${typeof props.translucent === "number" ? props.translucent : 70}%` }
@@ -55,6 +61,7 @@ onBeforeUnmount(() => {
55
61
 
56
62
  <template>
57
63
  <footer
64
+ ref="rootEl"
58
65
  class="q-footer"
59
66
  :class="{
60
67
  'q-footer--fixed': fixed,
@@ -1,8 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  // QPage — zone de contenu. Prop `virtual` : active le virtual scroll
3
3
  // (rendu fenêtré via QVirtualScroll) quand on a beaucoup d'items.
4
- // Padding-top automatique quand des barres fixed (q-header / q-back-header)
5
- // précèdent la page : le contenu reste sous les barres (jamais masqué).
4
+ // Padding-top/padding-bottom automatiques quand des barres fixed
5
+ // (q-header / q-back-header avant, q-footer après) entourent la page : le
6
+ // contenu reste visible, jamais masqué par les barres au scroll.
6
7
  import { onBeforeUnmount, onMounted, ref } from "vue"
7
8
  import { useFixedBarOffset } from "../lib/fixedLayout"
8
9
  import QVirtualScroll from "./QVirtualScroll.vue"
@@ -95,9 +95,11 @@ const tabClasses = computed(() =>
95
95
  isActive.value && tabs?.activeClass.value,
96
96
  (props.noCaps || tabs?.noCaps.value) && "q-tab--no-caps",
97
97
  props.disable && "q-tab--disabled",
98
- tabs?.inlineLabel.value && "q-tab--inline-label",
98
+ // Mode replié : le label s'étend à droite de l'icône → forcer l'inline-label
99
+ // (et neutraliser le padding stacked-icon + le fallback :has) */
100
+ (tabs?.inlineLabel.value || tabs?.collapseInactive.value) && "q-tab--inline-label",
99
101
  // Icône empilée au-dessus du label : padding vertical pour l'aérer du cadre
100
- props.icon && !tabs?.inlineLabel.value && "q-tab--stacked-icon",
102
+ props.icon && !tabs?.inlineLabel.value && !tabs?.collapseInactive.value && "q-tab--stacked-icon",
101
103
  ),
102
104
  )
103
105
 
@@ -16,6 +16,8 @@ export interface QTabContext {
16
16
  inlineLabel: Readonly<Ref<boolean>>
17
17
  noCaps: Readonly<Ref<boolean>>
18
18
  activeClass: Readonly<Ref<string | undefined>>
19
+ /** Mode replié : seul le tab actif montre icône + label (les autres : icône seule) */
20
+ collapseInactive: Readonly<Ref<boolean>>
19
21
  }
20
22
 
21
23
  export const qTabsKey: InjectionKey<QTabContext> = Symbol("q-tabs")
@@ -49,6 +51,8 @@ interface Props {
49
51
  activeColor?: string
50
52
  /** Couleur de fond du tab actif */
51
53
  activeBgColor?: string
54
+ /** Couleur des tabs inactifs (token ou hex — défaut : couleur héritée) */
55
+ inactiveColor?: string
52
56
  /** Classe ajoutée au tab actif */
53
57
  activeClass?: string
54
58
  /** Couleur de l'indicateur (défaut : activeColor → primary) */
@@ -74,6 +78,19 @@ interface Props {
74
78
  contentClass?: string
75
79
  /** Anime les tabs au clic (appui scale + pop du tab actif) */
76
80
  animated?: boolean
81
+ /**
82
+ * Transition du changement de tab (requiert `animated`) :
83
+ * spring (défaut, rebond léger) | smooth (expo-out velouté, label qui monte)
84
+ * | elastic (rebond marqué).
85
+ */
86
+ transition?: "spring" | "smooth" | "elastic"
87
+ /** Durée des transitions en ms (indicateur, labels collapse, pop/rise) */
88
+ transitionDuration?: number
89
+ /**
90
+ * Mode replié (pattern mobile) : les tabs inactifs n'affichent que l'icône ;
91
+ * le tab actif s'étend (icône + label) avec une largeur animée.
92
+ */
93
+ collapseInactive?: boolean
77
94
  }
78
95
 
79
96
  const props = withDefaults(defineProps<Props>(), {
@@ -81,6 +98,7 @@ const props = withDefaults(defineProps<Props>(), {
81
98
  align: "center",
82
99
  activeColor: "",
83
100
  activeBgColor: "",
101
+ inactiveColor: "",
84
102
  activeClass: "",
85
103
  indicatorColor: "",
86
104
  dense: false,
@@ -92,6 +110,9 @@ const props = withDefaults(defineProps<Props>(), {
92
110
  stretch: false,
93
111
  contentClass: "",
94
112
  animated: false,
113
+ transition: "spring",
114
+ transitionDuration: undefined,
115
+ collapseInactive: false,
95
116
  })
96
117
 
97
118
  const emit = defineEmits<{ "update:modelValue": [value: string | number | null] }>()
@@ -107,6 +128,7 @@ const indicatorPos = computed<"top" | "bottom" | "none">(() => {
107
128
  // shallowRef : on ne veut PAS que Vue dé-unwrappe les Ref contenues dans les enregistrements
108
129
  const tabRegs = shallowRef<QTabRegistration[]>([])
109
130
  const tick = ref(0)
131
+ const rootEl = ref<HTMLElement | null>(null)
110
132
 
111
133
  // Recalcule la position de l'indicateur après chaque changement de DOM
112
134
  const measure = async () => {
@@ -114,14 +136,25 @@ const measure = async () => {
114
136
  tick.value++
115
137
  }
116
138
 
139
+ // collapse-inactive : le label s'étend/retombe via une transition max-width
140
+ // (0.3s) — à la fin, la largeur du tab actif change encore → re-mesurer,
141
+ // sinon l'indicateur reste calé sur la largeur repliée (mal positionné).
142
+ // transitionend bulle depuis le label jusqu'à la racine : un seul écouteur.
143
+ const onCollapseEnd = (e: Event) => {
144
+ if ((e as TransitionEvent).propertyName !== "max-width") return
145
+ measure()
146
+ }
147
+
117
148
  watch([() => props.modelValue, tabRegs], measure)
118
149
 
119
150
  onMounted(() => {
120
151
  measure()
121
152
  if (typeof window !== "undefined") window.addEventListener("resize", measure)
153
+ rootEl.value?.addEventListener("transitionend", onCollapseEnd)
122
154
  })
123
155
  onBeforeUnmount(() => {
124
156
  if (typeof window !== "undefined") window.removeEventListener("resize", measure)
157
+ rootEl.value?.removeEventListener("transitionend", onCollapseEnd)
125
158
  })
126
159
 
127
160
  const context: QTabContext = {
@@ -136,6 +169,7 @@ const context: QTabContext = {
136
169
  inlineLabel: computed(() => props.inlineLabel),
137
170
  noCaps: computed(() => props.noCaps),
138
171
  activeClass: computed(() => (props.activeClass ? props.activeClass : undefined)),
172
+ collapseInactive: computed(() => props.collapseInactive),
139
173
  }
140
174
 
141
175
  provide(qTabsKey, context)
@@ -170,6 +204,8 @@ const containerStyle = computed<Record<string, string>>(() => {
170
204
  // Token (primary…) → var(--primary) ; sinon valeur directe (hex, rgb…)
171
205
  if (props.activeColor) style["--q-tabs-active-color"] = colorValue(props.activeColor)
172
206
  if (props.activeBgColor) style["--q-tabs-active-bg"] = colorValue(props.activeBgColor)
207
+ if (props.inactiveColor) style["--q-tabs-inactive-color"] = colorValue(props.inactiveColor)
208
+ if (props.transitionDuration) style["--q-tabs-duration"] = `${props.transitionDuration}ms`
173
209
  return style
174
210
  })
175
211
 
@@ -184,12 +220,15 @@ const tabsClasses = computed(() =>
184
220
  props.shrink && "q-tabs--shrink",
185
221
  props.stretch && "q-tabs--stretch",
186
222
  props.animated && "q-tabs--animated",
223
+ props.animated && `q-tabs--anim-${props.transition}`,
224
+ props.collapseInactive && "q-tabs--collapse-inactive",
187
225
  ),
188
226
  )
189
227
  </script>
190
228
 
191
229
  <template>
192
230
  <div
231
+ ref="rootEl"
193
232
  class="q-tabs"
194
233
  :class="tabsClasses"
195
234
  :style="containerStyle"
package/index.ts CHANGED
@@ -39,6 +39,7 @@ export { default as QCountryPicker } from "./components/QCountryPicker.vue"
39
39
  export { default as QDataGrid } from "./components/QDataGrid.vue"
40
40
  export { default as QDatePicker } from "./components/QDatePicker.vue"
41
41
  export { default as QDialog } from "./components/QDialog.vue"
42
+ export { default as QDialogContent } from "./components/QDialogContent.vue"
42
43
  export { default as QDialogFooter } from "./components/QDialogFooter.vue"
43
44
  export { default as QDialogHeader } from "./components/QDialogHeader.vue"
44
45
  export { default as QDialogProvider } from "./components/QDialogProvider.vue"
@@ -1,11 +1,15 @@
1
- // fixedLayout — offset automatique des barres fixed (q-header, q-back-header)
2
- // et du q-page qui les suit.
1
+ // fixedLayout — offset automatique des barres fixed (q-header, q-back-header,
2
+ // q-footer) et du q-page qui les entoure.
3
3
  //
4
4
  // Problème : les barres fixed sortent du flux (position: fixed) → le contenu de
5
5
  // q-page passe dessous. Ce module :
6
6
  // - q-page (mode "page") reçoit un padding-top = hauteur cumulée des barres
7
- // fixed qui le précèdent (le contenu ne se retrouve plus sous le header)
7
+ // fixed HAUT qui le précèdent ET un padding-bottom = hauteur cumulée des
8
+ // barres fixed BAS qui le suivent (le contenu n'est masqué ni en haut ni en
9
+ // bas au scroll)
8
10
  // - chaque barre fixed suivante (mode "bar") s'empile sous la précédente (top)
11
+ // - chaque barre fixed basse (mode "bar-bottom") s'empile au-dessus de la
12
+ // suivante (bottom)
9
13
  //
10
14
  // Les barres fixed sont cherchées dans TOUT le sous-arbre de l'enveloppe q-app
11
15
  // (pas seulement parmi les frères directs) et ordonnées par position dans le
@@ -15,7 +19,12 @@
15
19
  import { onBeforeUnmount, onMounted, watch } from "vue"
16
20
  import type { Ref } from "vue"
17
21
 
18
- export const FIXED_BAR_SELECTOR = ".q-header--fixed, .q-back-header--fixed"
22
+ /** Barres fixed en haut de l'écran */
23
+ export const FIXED_TOP_SELECTOR = ".q-header--fixed, .q-back-header--fixed"
24
+ /** Barres fixed en bas de l'écran */
25
+ export const FIXED_BOTTOM_SELECTOR = ".q-footer--fixed"
26
+ /** Toutes les barres fixed (haut + bas) */
27
+ export const FIXED_BAR_SELECTOR = `${FIXED_TOP_SELECTOR}, ${FIXED_BOTTOM_SELECTOR}`
19
28
 
20
29
  /** Racine du layout : le .q-app le plus proche, sinon le parent direct. */
21
30
  function layoutRoot(el: HTMLElement): HTMLElement | null {
@@ -23,13 +32,13 @@ function layoutRoot(el: HTMLElement): HTMLElement | null {
23
32
  }
24
33
 
25
34
  /**
26
- * Hauteur cumulée des barres fixed qui précèdent `el` dans l'ordre du document,
27
- * sous la même enveloppe q-app. `el` lui-même (cas mode "bar") est exclu.
35
+ * Hauteur cumulée des barres fixed HAUT qui précèdent `el` dans l'ordre du
36
+ * document, sous la même enveloppe q-app. `el` lui-même (cas mode "bar") est exclu.
28
37
  */
29
38
  export function fixedBarsHeightBefore(el: HTMLElement): number {
30
39
  const root = layoutRoot(el)
31
40
  if (!root) return 0
32
- const bars = Array.from(root.querySelectorAll<HTMLElement>(FIXED_BAR_SELECTOR))
41
+ const bars = Array.from(root.querySelectorAll<HTMLElement>(FIXED_TOP_SELECTOR))
33
42
  let total = 0
34
43
  for (const bar of bars) {
35
44
  if (bar === el) continue
@@ -41,6 +50,26 @@ export function fixedBarsHeightBefore(el: HTMLElement): number {
41
50
  return total
42
51
  }
43
52
 
53
+ /**
54
+ * Hauteur cumulée des barres fixed BAS qui SUIVENT `el` dans l'ordre du
55
+ * document, sous la même enveloppe q-app. `el` lui-même (cas mode "bar-bottom")
56
+ * est exclu.
57
+ */
58
+ export function fixedBarsHeightAfter(el: HTMLElement): number {
59
+ const root = layoutRoot(el)
60
+ if (!root) return 0
61
+ const bars = Array.from(root.querySelectorAll<HTMLElement>(FIXED_BOTTOM_SELECTOR))
62
+ let total = 0
63
+ for (const bar of bars) {
64
+ if (bar === el) continue
65
+ // DOCUMENT_POSITION_PRECEDING = la barre est APRÈS el dans le document
66
+ if (bar.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) {
67
+ total += bar.offsetHeight
68
+ }
69
+ }
70
+ return total
71
+ }
72
+
44
73
  /** Toutes les barres fixed sous l'enveloppe q-app (pour l'observation). */
45
74
  function fixedBarsInRoot(el: HTMLElement): HTMLElement[] {
46
75
  const root = layoutRoot(el)
@@ -49,15 +78,19 @@ function fixedBarsInRoot(el: HTMLElement): HTMLElement[] {
49
78
  }
50
79
 
51
80
  /**
52
- * Applique l'offset lié aux barres fixed qui précèdent `el` :
53
- * - mode "page" : padding-top = hauteur cumulée (q-page) + variable --q-page-offset
54
- * - mode "bar" : top = hauteur cumulée (empilement des barres fixed)
81
+ * Applique l'offset lié aux barres fixed qui entourent `el` :
82
+ * - mode "page" : padding-top = barres HAUT précédentes,
83
+ * padding-bottom = barres BAS suivantes
84
+ * (+ variables --q-page-offset / --q-page-offset-bottom)
85
+ * - mode "bar" : top = hauteur cumulée (empilement des barres fixed HAUT)
86
+ * - mode "bar-bottom" : bottom = hauteur cumulée (empilement des footers fixed
87
+ * depuis le bas de l'écran)
55
88
  *
56
89
  * `enabled` permet de n'activer que dans certaines conditions (ex. props.fixed).
57
90
  */
58
91
  export function useFixedBarOffset(
59
92
  el: Ref<HTMLElement | null>,
60
- mode: "page" | "bar",
93
+ mode: "page" | "bar" | "bar-bottom",
61
94
  enabled: () => boolean = () => true,
62
95
  ) {
63
96
  let ro: ResizeObserver | null = null
@@ -75,14 +108,22 @@ export function useFixedBarOffset(
75
108
  const apply = () => {
76
109
  const node = el.value
77
110
  if (!node || !enabled()) return
78
- const h = fixedBarsHeightBefore(node)
79
111
  if (mode === "page") {
80
- node.style.setProperty("--q-page-offset", h ? `${h}px` : "0px")
81
- node.style.paddingTop = h ? `${h}px` : ""
112
+ const top = fixedBarsHeightBefore(node)
113
+ const bottom = fixedBarsHeightAfter(node)
114
+ node.style.setProperty("--q-page-offset", top ? `${top}px` : "0px")
115
+ node.style.setProperty("--q-page-offset-bottom", bottom ? `${bottom}px` : "0px")
116
+ node.style.paddingTop = top ? `${top}px` : ""
117
+ node.style.paddingBottom = bottom ? `${bottom}px` : ""
82
118
  }
83
- else {
119
+ else if (mode === "bar") {
120
+ const h = fixedBarsHeightBefore(node)
84
121
  node.style.top = h ? `${h}px` : ""
85
122
  }
123
+ else {
124
+ const h = fixedBarsHeightAfter(node)
125
+ node.style.bottom = h ? `${h}px` : ""
126
+ }
86
127
  }
87
128
 
88
129
  const clear = () => {
@@ -90,11 +131,16 @@ export function useFixedBarOffset(
90
131
  if (!node) return
91
132
  if (mode === "page") {
92
133
  node.style.removeProperty("--q-page-offset")
134
+ node.style.removeProperty("--q-page-offset-bottom")
93
135
  node.style.paddingTop = ""
136
+ node.style.paddingBottom = ""
94
137
  }
95
- else {
138
+ else if (mode === "bar") {
96
139
  node.style.top = ""
97
140
  }
141
+ else {
142
+ node.style.bottom = ""
143
+ }
98
144
  }
99
145
 
100
146
  onMounted(() => {
package/lib/platform.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // $q.platform — détection de plateforme (API groupée sous `is`)
2
2
  // + breakpoints réactifs (VueUse useBreakpoints, tailles Quasar).
3
+ // API alignée sur Quasar : https://quasar.dev/options/platform-detection
3
4
  import { useBreakpoints } from "@vueuse/core"
4
5
 
5
6
  export interface QPlatformIs {
@@ -9,6 +10,8 @@ export interface QPlatformIs {
9
10
  desktop: boolean
10
11
  /** mobile ET Capacitor/Cordova (app native) */
11
12
  nativeMobile: boolean
13
+ /** nom du wrapper natif : 'cordova' | 'capacitor' | undefined */
14
+ nativeMobileWrapper?: "cordova" | "capacitor"
12
15
  ios: boolean
13
16
  android: boolean
14
17
  iphone: boolean
@@ -22,12 +25,40 @@ export interface QPlatformIs {
22
25
  firefox: boolean
23
26
  opera: boolean
24
27
  safari: boolean
28
+ vivaldi: boolean
25
29
  ie: boolean
30
+ /** moteur WebKit (Safari, Chrome, Edge…) */
31
+ webkit: boolean
32
+ /** extension navigateur (BEX — Chrome/Firefox) */
33
+ bex: boolean
34
+ /** Chrome OS (Chromebook) */
35
+ cros: boolean
36
+ blackberry: boolean
37
+ winphone: boolean
38
+ /** Amazon Silk (Kindle) */
39
+ silk: boolean
26
40
  cordova: boolean
27
41
  capacitor: boolean
28
42
  electron: boolean
43
+ /** écran tactile */
29
44
  touch: boolean
45
+ /** pointeur précis (souris) disponible */
30
46
  mouse: boolean
47
+ /** nom du navigateur : 'chrome' | 'firefox' | 'safari' | … | 'generic' */
48
+ name: string
49
+ /** version complète du navigateur ('70.0.3538.110') */
50
+ version: string
51
+ /** version majeure du navigateur (70, ou -1 si inconnue) */
52
+ versionNumber: number
53
+ /** nom de l'OS : 'mac' | 'win' | 'linux' | 'ios' | 'android' | 'cros' | … */
54
+ platform: string
55
+ }
56
+
57
+ export interface QPlatformHas {
58
+ /** écran tactile capable */
59
+ touch: boolean
60
+ /** localStorage / sessionStorage disponibles */
61
+ webStorage: boolean
31
62
  }
32
63
 
33
64
  export interface QPlatform {
@@ -38,25 +69,111 @@ export interface QPlatform {
38
69
  isClient: boolean
39
70
  /** booléens de détection groupés : $q.platform.is.mobile, is.ios, … */
40
71
  is: QPlatformIs
72
+ /** capacités : has.touch, has.webStorage */
73
+ has: QPlatformHas
41
74
  within: { iframe: boolean }
42
75
  }
43
76
 
77
+ function emptyIs(): QPlatformIs {
78
+ return {
79
+ mobile: false,
80
+ tablet: false,
81
+ desktop: false,
82
+ nativeMobile: false,
83
+ nativeMobileWrapper: undefined,
84
+ ios: false,
85
+ android: false,
86
+ iphone: false,
87
+ ipad: false,
88
+ ipod: false,
89
+ mac: false,
90
+ win: false,
91
+ linux: false,
92
+ chrome: false,
93
+ edge: false,
94
+ firefox: false,
95
+ opera: false,
96
+ safari: false,
97
+ vivaldi: false,
98
+ ie: false,
99
+ webkit: false,
100
+ bex: false,
101
+ cros: false,
102
+ blackberry: false,
103
+ winphone: false,
104
+ silk: false,
105
+ cordova: false,
106
+ capacitor: false,
107
+ electron: false,
108
+ touch: false,
109
+ mouse: false,
110
+ name: "",
111
+ version: "",
112
+ versionNumber: -1,
113
+ platform: "",
114
+ }
115
+ }
116
+
117
+ /** Nom + version du navigateur depuis l'UA (ordre = spécificité) */
118
+ function detectBrowser(ua: string) {
119
+ const m = (re: RegExp) => ua.match(re)?.[1] ?? ""
120
+ let name = "generic"
121
+ let version = ""
122
+
123
+ if (/vivaldi\/([\d.]+)/i.test(ua)) {
124
+ name = "vivaldi"
125
+ version = m(/vivaldi\/([\d.]+)/i)
126
+ }
127
+ else if (/edg\/([\d.]+)/i.test(ua)) {
128
+ name = "edge"
129
+ version = m(/edg\/([\d.]+)/i)
130
+ }
131
+ else if (/edge\/([\d.]+)/i.test(ua)) {
132
+ name = "edge"
133
+ version = m(/edge\/([\d.]+)/i)
134
+ }
135
+ else if (/opr\/([\d.]+)/i.test(ua)) {
136
+ name = "opera"
137
+ version = m(/opr\/([\d.]+)/i)
138
+ }
139
+ else if (/opera\/([\d.]+)/i.test(ua)) {
140
+ name = "opera"
141
+ version = m(/opera\/([\d.]+)/i)
142
+ }
143
+ else if (/(?:chrome|crios)\/([\d.]+)/i.test(ua)) {
144
+ name = "chrome"
145
+ version = m(/(?:chrome|crios)\/([\d.]+)/i)
146
+ }
147
+ else if (/(?:firefox|fxios)\/([\d.]+)/i.test(ua)) {
148
+ name = "firefox"
149
+ version = m(/(?:firefox|fxios)\/([\d.]+)/i)
150
+ }
151
+ else if (/msie ([\d.]+)/i.test(ua)) {
152
+ name = "ie"
153
+ version = m(/msie ([\d.]+)/i)
154
+ }
155
+ else if (/trident\/.*rv:([\d.]+)/i.test(ua)) {
156
+ name = "ie"
157
+ version = m(/trident\/.*rv:([\d.]+)/i)
158
+ }
159
+ else if (/version\/([\d.]+)/i.test(ua)) {
160
+ name = "safari"
161
+ version = m(/version\/([\d.]+)/i)
162
+ }
163
+
164
+ const versionNumber = version ? parseInt(version.split(".")[0]!, 10) || -1 : -1
165
+ return { name, version, versionNumber }
166
+ }
167
+
44
168
  function detect(): QPlatform {
45
169
  if (typeof window === "undefined" || typeof navigator === "undefined") {
46
- const empty = Object.fromEntries(
47
- [
48
- "mobile", "tablet", "desktop", "nativeMobile", "ios", "android", "iphone",
49
- "ipad", "ipod", "mac", "win", "linux", "chrome", "edge", "firefox",
50
- "opera", "safari", "ie", "cordova", "capacitor", "electron", "touch", "mouse",
51
- ].map((k) => [k, false]),
52
- ) as unknown as QPlatformIs
53
-
54
170
  return {
55
171
  userAgent: "",
56
172
  platform: "",
57
173
  isServer: true,
58
174
  isClient: false,
59
- is: empty,
175
+ is: emptyIs(),
176
+ has: { touch: false, webStorage: false },
60
177
  within: { iframe: false },
61
178
  }
62
179
  }
@@ -71,17 +188,54 @@ function detect(): QPlatform {
71
188
  // iPad modernes : MacIntel + écran tactile multi-points
72
189
  const ipad = /ipad/i.test(ua) || (/macintosh|macintel/i.test(platform) && (nav.maxTouchPoints ?? 0) > 1)
73
190
  const android = /android/i.test(ua)
191
+ const blackberry = /blackberry/i.test(ua)
192
+ const winphone = /windows phone/i.test(ua)
193
+ // Chromebooks : navigator.platform vaut "CrOS x86_64" (ou "Linux x86_64" ancien)
194
+ const cros = /cros/i.test(platform)
195
+ const silk = /silk/i.test(ua)
74
196
 
75
197
  const touch = "ontouchstart" in window || (nav.maxTouchPoints ?? 0) > 0
76
198
  const mobileUA = /mobile|iphone|ipod|android|windows phone|blackberry/i.test(ua)
77
199
  const tablet = ipad || (android && !mobileUA)
78
200
  const mobile = mobileUA || (tablet ? false : touch && /android|ipad|iphone/i.test(ua))
79
201
 
202
+ const cordova = "cordova" in window
203
+ const capacitor = !!(window as any).Capacitor?.isNativePlatform?.()
204
+ const nativeMobileWrapper: "cordova" | "capacitor" | undefined = capacitor
205
+ ? "capacitor"
206
+ : cordova
207
+ ? "cordova"
208
+ : undefined
209
+
210
+ // nom de l'OS (miroir de l'API Quasar : is.platform)
211
+ let osName = "generic"
212
+ if (android) osName = "android"
213
+ else if (ios) osName = "ios"
214
+ else if (blackberry) osName = "blackberry"
215
+ else if (winphone) osName = "winphone"
216
+ else if (cros) osName = "cros"
217
+ else if (silk) osName = "silk"
218
+ else if (/mac|iphone|ipad|ipod/i.test(ua)) osName = "mac"
219
+ else if (/win/i.test(ua)) osName = "win"
220
+ else if (/linux/i.test(platform)) osName = "linux"
221
+
222
+ let webStorage = false
223
+ try {
224
+ webStorage = typeof window.localStorage !== "undefined"
225
+ }
226
+ catch {
227
+ webStorage = false
228
+ }
229
+
230
+ const browser = detectBrowser(ua)
231
+ const notChromium = !/(edg|opr|vivaldi)/i.test(ua)
232
+
80
233
  const is: QPlatformIs = {
81
234
  mobile,
82
235
  tablet,
83
236
  desktop: !mobileUA && !tablet,
84
- nativeMobile: false, // affiné après (Capacitor/Cordova)
237
+ nativeMobile: mobile && (capacitor || cordova),
238
+ nativeMobileWrapper,
85
239
  ios,
86
240
  android,
87
241
  iphone,
@@ -90,28 +244,40 @@ function detect(): QPlatform {
90
244
  mac: /mac|iphone|ipad|ipod/i.test(ua),
91
245
  win: /win/i.test(ua),
92
246
  linux: /linux/i.test(platform),
93
- chrome: /chrome|crios/i.test(ua) && !/edg/i.test(ua),
94
- edge: /edg/i.test(ua),
247
+ chrome: /chrome|crios/i.test(ua) && notChromium,
248
+ edge: /edg|edge/i.test(ua),
95
249
  firefox: /firefox|fxios/i.test(ua),
96
250
  opera: /opr|opera/i.test(ua),
97
- safari: /safari/i.test(ua) && !/chrome|crios|edg|opr/i.test(ua),
251
+ safari: /safari/i.test(ua) && notChromium && !/chrome|crios/i.test(ua),
252
+ vivaldi: /vivaldi/i.test(ua),
98
253
  ie: /msie|trident/i.test(ua),
99
- cordova: typeof window !== "undefined" && "cordova" in window,
100
- capacitor: typeof window !== "undefined" && !!(window as any).Capacitor?.isNativePlatform?.(),
254
+ webkit: /applewebkit/i.test(ua),
255
+ bex:
256
+ typeof window !== "undefined" &&
257
+ !!((window as any).chrome?.runtime?.id || (globalThis as any).browser?.runtime?.id),
258
+ cros,
259
+ blackberry,
260
+ winphone,
261
+ silk,
262
+ cordova,
263
+ capacitor,
101
264
  electron: /electron/i.test(ua),
102
265
  touch,
103
- mouse: typeof window.matchMedia === "function" && window.matchMedia("(pointer: fine)").matches,
266
+ mouse:
267
+ typeof window.matchMedia === "function" && window.matchMedia("(pointer: fine)").matches,
268
+ name: browser.name,
269
+ version: browser.version,
270
+ versionNumber: browser.versionNumber,
271
+ platform: osName,
104
272
  }
105
273
 
106
- // nativeMobile : mobile + Capacitor/Cordova
107
- is.nativeMobile = is.mobile && (is.capacitor || is.cordova)
108
-
109
274
  return {
110
275
  userAgent: ua,
111
276
  platform,
112
277
  isServer: false,
113
278
  isClient: true,
114
279
  is,
280
+ has: { touch, webStorage },
115
281
  within: { iframe: window.self !== window.top },
116
282
  }
117
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/styles/main.css CHANGED
@@ -66,9 +66,14 @@ body {
66
66
  /* Safe-area intégrées à la règle de base .q-footer (plus bas) */
67
67
 
68
68
  /* Contenu de page : pas de padding par défaut (barres dans le flux).
69
- Compensation UNIQUEMENT si une barre est fixée (:has) : 50px + safe-area */
69
+ Compensation UNIQUEMENT si une barre est fixée (:has) : 50px + safe-area.
70
+ Safe-area bas : le contenu scroillé jusqu'au bout ne passe pas sous la barre
71
+ d'accueil iOS. */
70
72
  .q-page {
71
73
  flex: 1 1 auto; /* prend l'espace restant */
74
+ padding-bottom: 0;
75
+ padding-bottom: constant(safe-area-inset-bottom);
76
+ padding-bottom: env(safe-area-inset-bottom);
72
77
  }
73
78
 
74
79
  .q-app:has(.q-header--fixed) .q-page {
@@ -708,11 +713,30 @@ html.dark body {
708
713
  height: 2px;
709
714
  background-color: var(--q-tabs-active-color);
710
715
  transition:
711
- transform 0.25s ease,
712
- width 0.25s ease;
716
+ transform var(--q-tabs-duration, 0.25s) ease,
717
+ width var(--q-tabs-duration, 0.25s) ease;
713
718
  will-change: transform, width;
714
719
  }
715
720
 
721
+ /* — transitions animées (prop animated + transition) —
722
+ L'indicateur est la star du changement de tab : chaque preset lui donne une
723
+ courbe différente (spring = léger overshoot, smooth = expo-out velouté,
724
+ elastic = rebond marqué). */
725
+ .q-tabs--animated .q-tabs__indicator {
726
+ transition-duration: var(--q-tabs-duration, 0.45s);
727
+ }
728
+ .q-tabs--anim-spring .q-tabs__indicator {
729
+ transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); /* back-out : dépasse puis se pose */
730
+ }
731
+ .q-tabs--anim-smooth .q-tabs__indicator {
732
+ transition-duration: var(--q-tabs-duration, 0.5s);
733
+ transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1); /* expo-out : glisse velouté */
734
+ }
735
+ .q-tabs--anim-elastic .q-tabs__indicator {
736
+ transition-duration: var(--q-tabs-duration, 0.6s);
737
+ transition-timing-function: cubic-bezier(0.68, -0.55, 0.27, 1.55); /* rebond prononcé */
738
+ }
739
+
716
740
  /* ===== QTabPanels / QTabPanel ===== */
717
741
  .q-tab-panels {
718
742
  position: relative;
@@ -801,7 +825,9 @@ html.dark body {
801
825
  padding: 0 16px;
802
826
  border: none;
803
827
  background: transparent;
804
- color: inherit;
828
+ /* Couleur des tabs inactifs : inactive-color (QTabs) sinon couleur héritée.
829
+ .q-tab--active (plus bas) reprend la main par ordre de source. */
830
+ color: var(--q-tabs-inactive-color, inherit);
805
831
  font: inherit;
806
832
  font-size: 14px;
807
833
  font-weight: 500;
@@ -822,9 +848,15 @@ html.dark body {
822
848
  transform: scale(0.94);
823
849
  }
824
850
 
825
- /* pop du tab actif (overshoot, prop animated) */
826
- .q-tabs--animated .q-tab--active {
827
- animation: q-tab-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
851
+ /* pop du tab actif (overshoot) spring (défaut) et elastic */
852
+ .q-tabs--anim-spring .q-tab--active,
853
+ .q-tabs--anim-elastic .q-tab--active {
854
+ animation: q-tab-pop var(--q-tabs-duration, 0.3s) cubic-bezier(0.34, 1.56, 0.64, 1);
855
+ }
856
+
857
+ /* smooth : le contenu du tab actif monte et apparaît (label + icône) */
858
+ .q-tabs--anim-smooth .q-tab--active .q-tab__content {
859
+ animation: q-tab-rise var(--q-tabs-duration, 0.32s) cubic-bezier(0.16, 1, 0.3, 1) both;
828
860
  }
829
861
 
830
862
  .q-tab--active {
@@ -837,8 +869,12 @@ html.dark body {
837
869
  outline-offset: -2px;
838
870
  }
839
871
 
840
- .q-tab:hover {
841
- background-color: rgb(0 0 0 / 0.04);
872
+ /* Mobile : le :hover reste collé après le tap (pas de souris) → réservé aux
873
+ devices avec hover réel (souris / trackpad) */
874
+ @media (hover: hover) and (pointer: fine) {
875
+ .q-tab:hover {
876
+ background-color: rgb(0 0 0 / 0.04);
877
+ }
842
878
  }
843
879
 
844
880
  .q-tab:disabled,
@@ -928,6 +964,63 @@ html.dark body {
928
964
  }
929
965
  }
930
966
 
967
+ @keyframes q-tab-rise {
968
+ from {
969
+ opacity: 0;
970
+ transform: translateY(6px);
971
+ }
972
+ to {
973
+ opacity: 1;
974
+ transform: none;
975
+ }
976
+ }
977
+
978
+ /* — collapse-inactive : les tabs inactifs ne montrent que l'icône ; le tab
979
+ actif s'étend (icône + label) avec une largeur animée (pattern mobile
980
+ bottom-nav). max-width 0 → 999px : la largeur utilisée = min(naturelle, max)
981
+ → le label se déplie jusqu'à sa largeur réelle, le tab suit.
982
+ PAS d'overflow: hidden : le texte n'est pas tronqué pendant l'ouverture —
983
+ il apparaît entier en FONDU (opacity), plus joli que le glissement. */
984
+ .q-tabs--collapse-inactive .q-tab__content {
985
+ gap: 0;
986
+ }
987
+ .q-tabs--collapse-inactive .q-tab__label {
988
+ max-width: 0;
989
+ opacity: 0;
990
+ white-space: nowrap; /* pas de retour à la ligne pendant l'ouverture */
991
+ transition:
992
+ max-width var(--q-tabs-duration, 0.3s) cubic-bezier(0.16, 1, 0.3, 1),
993
+ opacity var(--q-tabs-duration, 0.25s) ease;
994
+ }
995
+ .q-tabs--collapse-inactive .q-tab--active .q-tab__content {
996
+ gap: 6px;
997
+ }
998
+ .q-tabs--collapse-inactive .q-tab--active .q-tab__label {
999
+ max-width: 999px;
1000
+ opacity: 1;
1001
+ }
1002
+
1003
+ /* Crossfade : pendant que le label du tab inactif s'estompe, celui du tab
1004
+ actif apparaît en fondu — simultanément. Les animations d'entrée du tab
1005
+ (pop/rise) sont neutralisées en mode collapse pour ne pas geler ou masquer
1006
+ le fade du label entrant ; l'indicateur garde son spring (transition width). */
1007
+ .q-tabs--collapse-inactive.q-tabs--anim-spring .q-tab--active,
1008
+ .q-tabs--collapse-inactive.q-tabs--anim-elastic .q-tab--active,
1009
+ .q-tabs--collapse-inactive.q-tabs--anim-smooth .q-tab--active .q-tab__content {
1010
+ animation: none;
1011
+ }
1012
+
1013
+ @media (prefers-reduced-motion: reduce) {
1014
+ .q-tabs--animated .q-tabs__indicator,
1015
+ .q-tabs--anim-spring .q-tab--active,
1016
+ .q-tabs--anim-elastic .q-tab--active,
1017
+ .q-tabs--anim-smooth .q-tab--active .q-tab__content,
1018
+ .q-tabs--collapse-inactive .q-tab__label {
1019
+ transition-duration: 0s;
1020
+ animation: none;
1021
+ }
1022
+ }
1023
+
931
1024
  .q-tab__content {
932
1025
  display: flex;
933
1026
  flex-direction: inherit;
@@ -4492,11 +4585,8 @@ html.dark body {
4492
4585
  }
4493
4586
 
4494
4587
  .q-bottom-sheet__header {
4495
- display: flex;
4496
- align-items: flex-start;
4497
- justify-content: space-between;
4498
- gap: 12px;
4499
- padding: 12px 16px 8px;
4588
+ padding: 0; /* le padding est porté par le <q-toolbar> embarqué */
4589
+ border-bottom: 1px solid rgb(0 0 0 / 0.08);
4500
4590
  }
4501
4591
 
4502
4592
  .q-bottom-sheet__header-text {
@@ -4546,11 +4636,17 @@ html.dark body {
4546
4636
  }
4547
4637
 
4548
4638
  .q-bottom-sheet__footer {
4549
- display: flex;
4550
- align-items: center;
4551
- justify-content: flex-end;
4552
- gap: 8px;
4553
- padding: 8px 16px 16px;
4639
+ padding: 0; /* le padding est porté par le <q-toolbar> embarqué */
4640
+ border-top: 1px solid rgb(0 0 0 / 0.08);
4641
+ }
4642
+
4643
+ .q-bottom-sheet__footer .q-toolbar {
4644
+ justify-content: flex-end; /* actions alignées à droite (défaut) */
4645
+ }
4646
+
4647
+ .q-bottom-sheet__panel--dark .q-bottom-sheet__header,
4648
+ .q-bottom-sheet__panel--dark .q-bottom-sheet__footer {
4649
+ border-color: rgb(255 255 255 / 0.12);
4554
4650
  }
4555
4651
 
4556
4652
  /* — transitions (slide vertical type vaul) — */
@@ -5815,14 +5911,28 @@ html.dark body {
5815
5911
 
5816
5912
  /* safe-area : l'encoche en haut est gérée par le header, la barre d'accueil en bas par le footer */
5817
5913
  .q-dialog__content--maximized .q-dialog__header,
5818
- .q-dialog__content--top .q-dialog__header {
5914
+ .q-dialog__content--top .q-dialog__header,
5915
+ .q-dialog__content--left .q-dialog__header,
5916
+ .q-dialog__content--right .q-dialog__header {
5819
5917
  padding-top: 0;
5820
5918
  padding-top: constant(safe-area-inset-top);
5821
5919
  padding-top: env(safe-area-inset-top);
5822
5920
  }
5823
5921
 
5922
+ /* fullscreen en paysage : l'encoche latérale passe dans le header (comme QHeader) */
5923
+ .q-dialog__content--maximized .q-dialog__header {
5924
+ padding-left: 0;
5925
+ padding-left: constant(safe-area-inset-left);
5926
+ padding-left: env(safe-area-inset-left);
5927
+ padding-right: 0;
5928
+ padding-right: constant(safe-area-inset-right);
5929
+ padding-right: env(safe-area-inset-right);
5930
+ }
5931
+
5824
5932
  .q-dialog__content--maximized .q-dialog__footer,
5825
- .q-dialog__content--bottom .q-dialog__footer {
5933
+ .q-dialog__content--bottom .q-dialog__footer,
5934
+ .q-dialog__content--left .q-dialog__footer,
5935
+ .q-dialog__content--right .q-dialog__footer {
5826
5936
  padding-bottom: 0;
5827
5937
  padding-bottom: constant(safe-area-inset-bottom);
5828
5938
  padding-bottom: env(safe-area-inset-bottom);
@@ -5844,11 +5954,7 @@ html.dark body {
5844
5954
  position: sticky;
5845
5955
  top: 0;
5846
5956
  z-index: 1;
5847
- display: flex;
5848
- align-items: flex-start;
5849
- justify-content: space-between;
5850
- gap: 12px;
5851
- padding: 16px;
5957
+ padding: 0; /* le padding est porté par le <q-toolbar> embarqué */
5852
5958
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
5853
5959
  background-color: inherit; /* reste opaque pendant le scroll du body */
5854
5960
  }
@@ -5899,15 +6005,26 @@ html.dark body {
5899
6005
  position: sticky;
5900
6006
  bottom: 0;
5901
6007
  z-index: 1;
5902
- display: flex;
5903
- align-items: center;
5904
- justify-content: flex-end;
5905
- gap: 8px;
5906
- padding: 12px 16px 16px;
6008
+ padding: 0; /* le padding est porté par le <q-toolbar> embarqué */
5907
6009
  border-top: 1px solid rgb(0 0 0 / 0.08);
5908
6010
  background-color: inherit;
5909
6011
  }
5910
6012
 
6013
+ .q-dialog__footer .q-toolbar {
6014
+ justify-content: flex-end; /* actions alignées à droite (défaut) */
6015
+ }
6016
+
6017
+ .q-dialog__body {
6018
+ min-height: 0;
6019
+ padding: 4px 16px 16px;
6020
+ }
6021
+
6022
+ .q-dialog__body--scrollable {
6023
+ flex: 1 1 auto;
6024
+ overflow-y: auto;
6025
+ overscroll-behavior: contain;
6026
+ }
6027
+
5911
6028
  .q-dialog__trigger {
5912
6029
  display: inline-flex;
5913
6030
  align-items: center;
@@ -6321,11 +6438,80 @@ html.dark body {
6321
6438
  z-index: 0;
6322
6439
  pointer-events: none;
6323
6440
  }
6324
- .q-container--bg > :not(.q-container__bg) {
6441
+ .q-container--bg > :not(.q-container__bg, .q-container__img) {
6325
6442
  position: relative;
6326
6443
  z-index: 1;
6327
6444
  }
6328
6445
 
6446
+ /* — image de fond (prop background-image) : couche cover derrière le contenu — */
6447
+ .q-container__img {
6448
+ position: absolute;
6449
+ inset: 0;
6450
+ z-index: 0;
6451
+ width: 100%;
6452
+ height: 100%;
6453
+ object-fit: var(--q-container-img-fit, cover);
6454
+ pointer-events: none;
6455
+ user-select: none;
6456
+ }
6457
+
6458
+ /* taille CSS libre ("50%", "400px"…) : boîte centrée (translate séparé de
6459
+ transform → compose avec le Ken Burns), image entière visible */
6460
+ .q-container__img--sized {
6461
+ width: var(--q-container-img-size);
6462
+ height: var(--q-container-img-size);
6463
+ left: 50%;
6464
+ top: 50%;
6465
+ translate: -50% -50%;
6466
+ object-fit: contain;
6467
+ }
6468
+
6469
+ /* glassmorphisée : floutée + saturée + léger zoom pour masquer les bords du blur */
6470
+ .q-container__img--glass {
6471
+ filter: blur(14px) saturate(1.4);
6472
+ transform: scale(1.15);
6473
+ }
6474
+
6475
+ /* — Ken Burns indéterminé (prop background-animated) : zoom + panoramique
6476
+ lents qui bouclent — direction et durée configurables (défaut alternate 24s) */
6477
+ .q-container__img--animated {
6478
+ animation:
6479
+ q-container-kenburns var(--q-container-kenburns-duration, 24s) ease-in-out
6480
+ var(--q-container-kenburns-direction, alternate) infinite;
6481
+ will-change: transform;
6482
+ }
6483
+ @keyframes q-container-kenburns {
6484
+ 0% {
6485
+ transform: scale(1.06) translate(0%, 0%);
6486
+ }
6487
+ 30% {
6488
+ transform: scale(1.18) translate(-2%, -1.5%);
6489
+ }
6490
+ 60% {
6491
+ transform: scale(1.14) translate(2%, 1.5%);
6492
+ }
6493
+ 100% {
6494
+ transform: scale(1.22) translate(-1%, 2%);
6495
+ }
6496
+ }
6497
+
6498
+ /* — glassmorphism du conteneur (prop glass) : fond translucide + flou de ce
6499
+ qui est derrière + bordure claire (variables personnalisables) — */
6500
+ .q-container--glass {
6501
+ --q-container-glass-bg: rgb(255 255 255 / 0.14);
6502
+ --q-container-glass-border: rgb(255 255 255 / 0.3);
6503
+ --q-container-glass-blur: 14px;
6504
+ background-color: var(--q-container-glass-bg);
6505
+ border: 1px solid var(--q-container-glass-border);
6506
+ -webkit-backdrop-filter: blur(var(--q-container-glass-blur)) saturate(1.4);
6507
+ backdrop-filter: blur(var(--q-container-glass-blur)) saturate(1.4);
6508
+ box-shadow: 0 8px 32px rgb(0 0 0 / 0.12);
6509
+ }
6510
+ .dark .q-container--glass {
6511
+ --q-container-glass-bg: rgb(255 255 255 / 0.06);
6512
+ --q-container-glass-border: rgb(255 255 255 / 0.14);
6513
+ }
6514
+
6329
6515
  /* grid : grille de carrés qui s'estompe vers les bords */
6330
6516
  .q-container__bg--grid {
6331
6517
  background-image:
@@ -6537,6 +6723,9 @@ html.dark body {
6537
6723
  .q-container__wave {
6538
6724
  animation: none;
6539
6725
  }
6726
+ .q-container__img--animated {
6727
+ animation: none;
6728
+ }
6540
6729
  }
6541
6730
 
6542
6731
  /* ===== QMarquee — bandeau défilant infini ===== */