@dnax/ui 0.0.44 → 0.0.46

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,8 +1,15 @@
1
+ <script setup lang="ts">
2
+ // QApp — conteneur racine de l'application : une div + le slot, en colonne flex sur
3
+ // toute la largeur et une hauteur d'écran. C'est le pendant **pur CSS** de
4
+ // `flex h-screen w-full` (cf. `styles/main.css` : `display: flex; flex-direction: column;
5
+ // width: 100%; height: 100dvh`) — les classes sont aussi posées sur la div pour les
6
+ // apps Tailwind.
7
+ // Les barres (`fixed` ou non) et leurs offsets sont résolus dans son sous-arbre par
8
+ // `lib/fixedLayout.ts`, d'où le `<slot />` qui doit rester son unique enfant.
9
+ </script>
10
+
1
11
  <template>
2
- <div class="q-app">
3
- <slot></slot>
4
- </div>
12
+ <div class="q-app flex h-screen w-full" v-bind="$attrs">
13
+ <slot />
14
+ </div>
5
15
  </template>
6
- <script setup>
7
-
8
- </script>
@@ -1,11 +1,14 @@
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
- import { computed, onBeforeUnmount, onMounted, ref } from "vue"
3
+ import { computed, inject, onBeforeUnmount, onMounted, ref } from "vue"
4
4
  import { useFixedBarOffset } from "../lib/fixedLayout"
5
+ import { qLayoutKey } from "../lib/layout"
5
6
 
6
7
  interface Props {
7
8
  /** Fixe la barre en bas de l'écran (sort du flux) */
8
9
  fixed?: boolean
10
+ /** Colle la barre en bas pendant le scroll (automatique dans un QLayout avec une lettre majuscule) */
11
+ sticky?: boolean
9
12
  /** Ombre portée vers le haut */
10
13
  elevated?: boolean
11
14
  /** Masqué au scroll vers le bas, réaffiché au scroll vers le haut */
@@ -22,6 +25,7 @@ interface Props {
22
25
 
23
26
  const props = withDefaults(defineProps<Props>(), {
24
27
  fixed: false,
28
+ sticky: false,
25
29
  elevated: false,
26
30
  reveal: false,
27
31
  bordered: false,
@@ -30,6 +34,15 @@ const props = withDefaults(defineProps<Props>(), {
30
34
 
31
35
  const rootEl = ref<HTMLElement | null>(null)
32
36
 
37
+ // Placement dans un QLayout (facultatif) : cellule « f » du `view`, et sticky imposé
38
+ // par la casse de la lettre (F).
39
+ const layout = inject(qLayoutKey, null)
40
+ const zone = computed(() => layout?.zones.value.footer)
41
+ const isSticky = computed(() => props.sticky || !!zone.value?.fixed)
42
+ const layoutStyle = computed<Record<string, string | undefined>>(() => ({
43
+ gridArea: zone.value?.area,
44
+ }))
45
+
33
46
  // Empilement : bottom = hauteur cumulée des footers fixed suivants (si fixed)
34
47
  useFixedBarOffset(rootEl, "bar-bottom", () => props.fixed)
35
48
 
@@ -68,6 +81,7 @@ onBeforeUnmount(() => {
68
81
  class="q-footer"
69
82
  :class="{
70
83
  'q-footer--fixed': fixed,
84
+ 'q-footer--sticky': isSticky,
71
85
  'q-footer--elevated': props.elevated,
72
86
  'q-footer--bordered': props.bordered,
73
87
  'q-footer--reveal': props.reveal,
@@ -76,7 +90,7 @@ onBeforeUnmount(() => {
76
90
  'q-footer--glass': props.glass,
77
91
  'q-footer--no-padding': props.noPadding,
78
92
  }"
79
- :style="translucentStyle"
93
+ :style="[translucentStyle, layoutStyle]"
80
94
  v-bind="$attrs"
81
95
  >
82
96
  <slot />
@@ -3,12 +3,15 @@
3
3
  // Safe-area top/left/right appliquées toujours (styles/main.css).
4
4
  // Quand plusieurs barres fixed sont empilées (q-back-header + q-header), chaque
5
5
  // barre se décale sous la précédente automatiquement.
6
- import { computed, ref } from "vue"
6
+ import { computed, inject, ref } from "vue"
7
7
  import { useFixedBarOffset } from "../lib/fixedLayout"
8
+ import { qLayoutKey } from "../lib/layout"
8
9
 
9
10
  interface Props {
10
11
  /** Fixe la barre en haut de l'écran (sort du flux) */
11
12
  fixed?: boolean
13
+ /** Colle la barre en haut pendant le scroll du document (automatique dans un QLayout avec une lettre majuscule) */
14
+ sticky?: boolean
12
15
  /** Fond translucide (frosted glass) : true = 70%, ou valeur % ("70") */
13
16
  translucent?: boolean | number
14
17
  /** Glassmorphism marqué : fond très translucide + flou fort + bordure claire */
@@ -17,10 +20,20 @@ interface Props {
17
20
 
18
21
  const props = withDefaults(defineProps<Props>(), {
19
22
  fixed: false,
23
+ sticky: false,
20
24
  })
21
25
 
22
26
  const rootEl = ref<HTMLElement | null>(null)
23
27
 
28
+ // Placement dans un QLayout (facultatif) : cellule « h » du `view`, et sticky imposé
29
+ // par la casse de la lettre (H) — la barre reste alors close dans sa cellule.
30
+ const layout = inject(qLayoutKey, null)
31
+ const zone = computed(() => layout?.zones.value.header)
32
+ const isSticky = computed(() => props.sticky || !!zone.value?.fixed)
33
+ const layoutStyle = computed<Record<string, string | undefined>>(() => ({
34
+ gridArea: zone.value?.area,
35
+ }))
36
+
24
37
  // Empilement : top = hauteur cumulée des barres fixed précédentes (si fixed)
25
38
  useFixedBarOffset(rootEl, "bar", () => props.fixed)
26
39
 
@@ -37,10 +50,11 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
37
50
  class="q-header"
38
51
  :class="{
39
52
  'q-header--fixed': fixed,
53
+ 'q-header--sticky': isSticky,
40
54
  'q-header--translucent': translucent === true || typeof translucent === 'number',
41
55
  'q-header--glass': glass,
42
56
  }"
43
- :style="translucentStyle"
57
+ :style="[translucentStyle, layoutStyle]"
44
58
  v-bind="$attrs"
45
59
  >
46
60
  <slot />
@@ -0,0 +1,138 @@
1
+ <script setup lang="ts">
2
+ // QLayout — matrice 3×3 pilotée par le prop `view` (modèle Quasar). Les enfants
3
+ // **directs** se placent eux-mêmes : q-header en cellule « h », q-footer en « f »,
4
+ // q-sidebar en « l » / « r », et tout le reste — donc le contenu — en cellule « p ».
5
+ //
6
+ // <q-layout view="hHh LpR fFf">
7
+ // <q-header>…</q-header>
8
+ // <q-sidebar side="left" show-if-above>…</q-sidebar>
9
+ // <q-page>…</q-page> ← un SEUL enfant de contenu
10
+ // <q-footer>…</q-footer>
11
+ // </q-layout>
12
+ //
13
+ // Majuscule = la zone reste en place au scroll (`position: sticky`), minuscule =
14
+ // elle défile avec la page. L'analyse de `view` vit dans `lib/layout.ts` (pure) ;
15
+ // ici : le rendu de la grille et la mesure.
16
+ //
17
+ // Deux différences assumées avec Quasar : le scroll reste celui du **document**
18
+ // (pas de scroll interne, donc `QFooter reveal`, `QSidebar` statique et les
19
+ // safe-areas continuent de fonctionner), et un header sticky est **clos dans sa
20
+ // cellule** — il ne peut pas passer au-dessus du drawer voisin.
21
+ //
22
+ // Les hauteurs des barres sont mesurées en continu et publiées en variables CSS
23
+ // (`--q-layout-header-h` / `--q-layout-footer-h` / `--q-layout-viewport-h`) : les
24
+ // drawers sticky s'y accrochent sans glisser sous le header, et la hauteur de
25
+ // référence suit le mode (fenêtre, ou conteneur en mode `container`).
26
+ import { computed, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from "vue"
27
+ import type { StyleValue } from "vue"
28
+ import { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "../lib/layout"
29
+ import { cn } from "../lib/utils"
30
+
31
+ interface Props {
32
+ /** Matrice 3×3 : 3 groupes de 3 lettres (l, h, r, f, p, ou .) + casse = la zone reste en place */
33
+ view?: string
34
+ /** Layout conteneur (comme Quasar) : ne gère pas la fenêtre, une hauteur explicite est requise */
35
+ container?: boolean
36
+ /** Classe(s) additionnelle(s) */
37
+ class?: string
38
+ /** Styles CSS additionnels */
39
+ style?: StyleValue
40
+ }
41
+
42
+ const props = withDefaults(defineProps<Props>(), {
43
+ view: DEFAULT_LAYOUT_VIEW,
44
+ container: false,
45
+ class: "",
46
+ style: undefined,
47
+ })
48
+
49
+ const parsed = computed(() => parseView(props.view))
50
+
51
+ // Une `view` invalide n'est jamais fatale : la vue par défaut s'applique et, en dev,
52
+ // la raison est affichée (l'analyse est pure, le composant ne fait que rapporter).
53
+ watch(
54
+ parsed,
55
+ (v) => {
56
+ if (v.error) console.warn(`[q-layout] ${v.error} — vue par défaut appliquée`)
57
+ },
58
+ { immediate: true },
59
+ )
60
+
61
+ // Les enfants (q-header, q-footer, q-sidebar) lisent leur cellule ici.
62
+ provide(qLayoutKey, { zones: computed(() => parsed.value.zones), container: toRef(props, "container") })
63
+
64
+ const rootEl = ref<HTMLElement | null>(null)
65
+
66
+ // — Mesure des barres : hauteur cumulée des headers/footers enfants directs —
67
+ let ro: ResizeObserver | null = null
68
+ let mo: MutationObserver | null = null
69
+
70
+ const bars = (): HTMLElement[] =>
71
+ Array.from(rootEl.value?.children ?? []).filter(
72
+ (el): el is HTMLElement =>
73
+ el instanceof HTMLElement && el.matches(".q-header, .q-back-header, .q-footer"),
74
+ )
75
+
76
+ const measure = () => {
77
+ const root = rootEl.value
78
+ if (!root || typeof window === "undefined") return
79
+ let header = 0
80
+ let footer = 0
81
+ for (const bar of bars()) {
82
+ if (bar.matches(".q-footer")) footer += bar.offsetHeight
83
+ else header += bar.offsetHeight
84
+ }
85
+ // Hauteur de référence des zones sticky : la fenêtre, ou le conteneur (mode `container`)
86
+ const viewport = props.container ? root.clientHeight : window.innerHeight
87
+ root.style.setProperty("--q-layout-header-h", `${header}px`)
88
+ root.style.setProperty("--q-layout-footer-h", `${footer}px`)
89
+ root.style.setProperty("--q-layout-viewport-h", `${viewport}px`)
90
+ }
91
+
92
+ // Les barres peuvent être rendues conditionnellement (v-if) → on ré-observe.
93
+ const observe = () => {
94
+ ro?.disconnect()
95
+ if (typeof ResizeObserver === "undefined") return
96
+ ro = new ResizeObserver(measure)
97
+ bars().forEach((bar) => ro!.observe(bar))
98
+ // En mode conteneur la hauteur de référence est celle du conteneur : on la suit
99
+ if (props.container && rootEl.value) ro.observe(rootEl.value)
100
+ }
101
+
102
+ onMounted(() => {
103
+ measure()
104
+ observe()
105
+ if (typeof MutationObserver !== "undefined" && rootEl.value) {
106
+ mo = new MutationObserver(() => {
107
+ observe()
108
+ measure()
109
+ })
110
+ mo.observe(rootEl.value, { childList: true })
111
+ }
112
+ window.addEventListener("resize", measure)
113
+ })
114
+
115
+ onBeforeUnmount(() => {
116
+ ro?.disconnect()
117
+ mo?.disconnect()
118
+ window.removeEventListener("resize", measure)
119
+ })
120
+
121
+ // Cellules de la grille (`grid-template-areas` + cellule par défaut du contenu).
122
+ const rootStyle = computed<Record<string, string>>(() => ({
123
+ "--q-layout-areas": parsed.value.gridTemplateAreas,
124
+ "--q-layout-page-area": parsed.value.zones.page.area ?? "p",
125
+ }))
126
+ </script>
127
+
128
+ <template>
129
+ <div
130
+ ref="rootEl"
131
+ class="q-layout"
132
+ :class="cn(container && 'q-layout--container', props.class)"
133
+ :style="[rootStyle, props.style]"
134
+ v-bind="$attrs"
135
+ >
136
+ <slot />
137
+ </div>
138
+ </template>
@@ -56,7 +56,7 @@ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref
56
56
  import { Icon } from "@iconify/vue"
57
57
  import { icons } from "../lib/icons"
58
58
  import { cn } from "../lib/utils"
59
- import { radiusStyle, useRadius } from "../lib/useComponentProps"
59
+ import { radiusStyle, radiusValue, useRadius } from "../lib/useComponentProps"
60
60
  import type { RadiusProp } from "../lib/useComponentProps"
61
61
  import { useOverlayBack } from "../lib/overlayBack"
62
62
  import { createSearcher } from "../lib/search"
@@ -184,6 +184,11 @@ defineSlots<{
184
184
  hint?: () => any
185
185
  }>()
186
186
 
187
+ // Multi-racines (le champ + les <Teleport> du popup et du panneau) : Vue ne peut pas
188
+ // hériter les attributs tout seul (avertissement « fragment or text or teleport root
189
+ // nodes »). Ils sont posés explicitement sur le champ (racine principale).
190
+ defineOptions({ inheritAttrs: false })
191
+
187
192
  const instance = getCurrentInstance()
188
193
  const hasFilter = computed(() => !!instance?.vnode.props?.onFilter)
189
194
 
@@ -223,9 +228,11 @@ const VIEWPORT_MARGIN = 8
223
228
 
224
229
  const popupDirection = ref<"down" | "up">("down")
225
230
  const popupMaxHeight = ref(POPUP_MAX_HEIGHT)
226
- /** Décalages en px par rapport à la RACINE (bloc conteneur du popup) — null avant mesure */
231
+ /** Coordonnées **fenêtre** : le popup est téléporté dans <body> (`position: fixed`) — null avant mesure */
227
232
  const popupTop = ref<number | null>(null)
228
233
  const popupBottom = ref<number | null>(null)
234
+ /** Rect du champ (coordonnées fenêtre) : ancre horizontale du popup */
235
+ const popupAnchor = ref<{ left: number; right: number; width: number } | null>(null)
229
236
 
230
237
  /** Écart champ ↔ popup : `inline-options.offset` prioritaire sur la prop `offset` */
231
238
  const popupOffset = computed(() => modeOptions.value?.offset ?? props.offset)
@@ -265,17 +272,32 @@ const positionPopup = () => {
265
272
 
266
273
  popupDirection.value = down ? "down" : "up"
267
274
  popupMaxHeight.value = Math.max(0, Math.min(POPUP_MAX_HEIGHT, available - gap))
268
- popupTop.value = Math.round(anchor.bottom - rect.top + gap)
269
- popupBottom.value = Math.round(rect.bottom - anchor.top + gap)
275
+ // Tout en coordonnées fenêtre : le popup est `fixed` dans <body>, il n'est donc
276
+ // plus positionné par rapport à la racine du composant. Horizontalement on garde la
277
+ // boîte du CHAMP (c'était `left: 0; right: 0` sur la racine), verticalement l'ancre.
278
+ popupAnchor.value = { left: rect.left, right: rect.right, width: rect.width }
279
+ popupTop.value = Math.round(anchor.bottom + gap)
280
+ popupBottom.value = Math.round(window.innerHeight - anchor.top + gap)
270
281
  }
271
282
 
272
283
  const popupStyle = computed<Record<string, string>>(() => {
273
284
  const up = popupDirection.value === "up"
274
- const fallback = `calc(100% + ${popupOffset.value}px)`
285
+ const anchor = popupAnchor.value
275
286
  const style: Record<string, string> = {
276
- top: up ? "auto" : popupTop.value === null ? fallback : `${popupTop.value}px`,
277
- bottom: up ? (popupBottom.value === null ? fallback : `${popupBottom.value}px`) : "auto",
278
287
  maxHeight: `${Math.round(popupMaxHeight.value)}px`,
288
+ // Arrondi : l'échelle de radius du composant (composantProps / prop), sinon la
289
+ // variable globale. Le popup est TÉLÉPORTÉ dans <body> : il n'hérite plus de
290
+ // `--q-radius` posé sur le champ, d'où la valeur matérialisée ici.
291
+ borderRadius: radiusValue(effectiveFieldRadius.value) ?? "var(--q-radius, 4px)",
292
+ }
293
+
294
+ // Vertical : sous le champ, ou au-dessus (bascule / placement forcé)
295
+ if (up) {
296
+ style.bottom = popupBottom.value === null ? "0px" : `${popupBottom.value}px`
297
+ style.top = "auto"
298
+ } else {
299
+ style.top = popupTop.value === null ? "0px" : `${popupTop.value}px`
300
+ style.bottom = "auto"
279
301
  }
280
302
 
281
303
  // Ancre horizontale : par défaut le popup couvre la largeur du champ. Les suffixes
@@ -284,14 +306,19 @@ const popupStyle = computed<Record<string, string>>(() => {
284
306
  const placement = popupPlacement.value
285
307
  const width = modeOptions.value?.width
286
308
  const anchored = placement !== "auto" && placement.includes("-")
309
+ const left = anchor?.left ?? 0
310
+ const right =
311
+ anchor && typeof window !== "undefined" ? window.innerWidth - anchor.right : 0
312
+
287
313
  if (anchored || width) {
288
314
  const alignEnd = placement.endsWith("-end")
289
- style.left = alignEnd ? "auto" : "0"
290
- style.right = alignEnd ? "0" : "auto"
291
- style.width = width ?? "100%"
315
+ style.left = alignEnd ? "auto" : `${left}px`
316
+ style.right = alignEnd ? `${right}px` : "auto"
317
+ style.width = width ?? "auto"
292
318
  } else {
293
- style.left = "0"
294
- style.right = "0"
319
+ style.left = `${left}px`
320
+ style.right = "auto"
321
+ style.width = anchor ? `${anchor.width}px` : "auto"
295
322
  }
296
323
 
297
324
  return style
@@ -575,6 +602,8 @@ const onDocMousedown = (e: MouseEvent) => {
575
602
  const target = e.target as Node
576
603
  if (rootEl.value?.contains(target)) return
577
604
  if (sheetRef.value?.contains(target)) return
605
+ // Popup inline : téléporté dans <body>, donc HORS de la racine du composant
606
+ if (popupRef.value?.contains(target)) return
578
607
  closePopup()
579
608
  }
580
609
 
@@ -643,7 +672,10 @@ const sheetRadiusStyle = computed<Record<string, string> | undefined>(() => {
643
672
  const rounded = modeOptions.value?.rounded ?? props.rounded
644
673
  if (rounded === false) return { borderRadius: "0" }
645
674
 
646
- const defaultRadius = props.mode === "modal" ? "20px" : "12px"
675
+ // Défaut : l'échelle de radius du composant (composantProps), sinon le défaut
676
+ // historique du mode — le panneau est téléporté, il ne peut pas lire `--q-radius`.
677
+ const defaultRadius =
678
+ radiusValue(effectiveFieldRadius.value) ?? (props.mode === "modal" ? "20px" : "12px")
647
679
  const value = typeof rounded === "string" ? rounded : defaultRadius
648
680
  const asPx = (v: string | number) => (typeof v === "number" ? `${v}px` : v)
649
681
 
@@ -672,7 +704,7 @@ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions
672
704
  </script>
673
705
 
674
706
  <template>
675
- <div ref="rootEl" class="q-select" :class="fieldClasses" :style="fieldRadiusStyle">
707
+ <div ref="rootEl" class="q-select" :class="fieldClasses" :style="fieldRadiusStyle" v-bind="$attrs">
676
708
  <label v-if="label" class="q-field__label-stack">{{ label }}</label>
677
709
  <div class="q-field__control" @click="onControlClick">
678
710
  <slot name="prepend">
@@ -751,50 +783,53 @@ const sheetClass = computed(() => [`q-select__sheet--${props.mode}`, modeOptions
751
783
  </div>
752
784
  </div>
753
785
 
754
- <!-- Popup inline (mode inline) -->
755
- <div
756
- v-if="open && mode === 'inline'"
757
- ref="popupRef"
758
- class="q-select__popup"
759
- :class="[inlineOptions?.class, popupDirection === 'up' && 'q-select__popup--up']"
760
- :style="[popupStyle, inlineOptions?.style]"
761
- role="listbox"
762
- @mousedown.prevent
763
- >
764
- <div v-if="searchEnabled && !useInput" class="q-select__search" @mousedown.stop>
765
- <Icon :icon="icons.search" class="q-select__search-icon" aria-hidden="true" />
766
- <input
767
- class="q-select__search-input"
768
- :value="query"
769
- :placeholder="effectiveSearchPlaceholder"
770
- @input="onInput"
771
- @keydown="onKeydown"
772
- />
773
- </div>
774
- <template v-if="displayOptions.length">
775
- <div
776
- v-for="(opt, i) in displayOptions"
777
- :key="String(getOptionValue(opt))"
778
- role="option"
779
- :aria-selected="isSelected(opt) ? 'true' : 'false'"
780
- class="q-select__option"
781
- :class="{
782
- 'q-select__option--active': i === activeIndex,
783
- 'q-select__option--selected': isSelected(opt),
784
- }"
785
- @mouseenter="activeIndex = i"
786
- @mousedown.prevent="select(opt)"
787
- >
788
- <slot :opt="opt" :index="i" :selected="isSelected(opt)">
789
- <span class="q-select__option-label">{{ getOptionLabel(opt) }}</span>
790
- </slot>
791
- <Icon :icon="icons.check" v-if="multiple && isSelected(opt)" class="q-select__check" aria-hidden="true" />
786
+ <!-- Popup inline (mode inline) : téléporté dans <body> + `position: fixed`,
787
+ pour échapper au `overflow: hidden` d'un q-card (cf. positionPopup) -->
788
+ <Teleport to="body">
789
+ <div
790
+ v-if="open && mode === 'inline'"
791
+ ref="popupRef"
792
+ class="q-select__popup"
793
+ :class="[inlineOptions?.class, popupDirection === 'up' && 'q-select__popup--up']"
794
+ :style="[popupStyle, inlineOptions?.style]"
795
+ role="listbox"
796
+ @mousedown.prevent
797
+ >
798
+ <div v-if="searchEnabled && !useInput" class="q-select__search" @mousedown.stop>
799
+ <Icon :icon="icons.search" class="q-select__search-icon" aria-hidden="true" />
800
+ <input
801
+ class="q-select__search-input"
802
+ :value="query"
803
+ :placeholder="effectiveSearchPlaceholder"
804
+ @input="onInput"
805
+ @keydown="onKeydown"
806
+ />
807
+ </div>
808
+ <template v-if="displayOptions.length">
809
+ <div
810
+ v-for="(opt, i) in displayOptions"
811
+ :key="String(getOptionValue(opt))"
812
+ role="option"
813
+ :aria-selected="isSelected(opt) ? 'true' : 'false'"
814
+ class="q-select__option"
815
+ :class="{
816
+ 'q-select__option--active': i === activeIndex,
817
+ 'q-select__option--selected': isSelected(opt),
818
+ }"
819
+ @mouseenter="activeIndex = i"
820
+ @mousedown.prevent="select(opt)"
821
+ >
822
+ <slot :opt="opt" :index="i" :selected="isSelected(opt)">
823
+ <span class="q-select__option-label">{{ getOptionLabel(opt) }}</span>
824
+ </slot>
825
+ <Icon :icon="icons.check" v-if="multiple && isSelected(opt)" class="q-select__check" aria-hidden="true" />
826
+ </div>
827
+ </template>
828
+ <div v-else class="q-select__empty">
829
+ <slot name="noOption">{{ noOptionsLabel }}</slot>
792
830
  </div>
793
- </template>
794
- <div v-else class="q-select__empty">
795
- <slot name="noOption">{{ noOptionsLabel }}</slot>
796
831
  </div>
797
- </div>
832
+ </Teleport>
798
833
 
799
834
  <!-- Mode modal / sheet / dialog : panneau téléporté -->
800
835
  <Teleport to="body">
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  // QSidebar — panneau latéral type Sidebar shadcn-vue, API QDrawer Quasar :
3
- // <q-sidebar v-model="open" side="left" width="260px" bordered show-if-above breakpoint="1023">
3
+ // <q-sidebar v-model="open" side="left" width="260px" bordered show-if-above :breakpoint="1023">
4
4
  // Fournit le contexte (toggle) aux QSidebarTrigger enfants.
5
5
  import type { InjectionKey, Ref } from "vue"
6
6
 
@@ -14,10 +14,11 @@ 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, watch } from "vue"
17
+ import { computed, inject, 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"
21
+ import { qLayoutKey } from "../lib/layout"
21
22
 
22
23
  interface Props {
23
24
  /** Ouvert (v-model) — mode offcanvas ; ignoré en mode statique */
@@ -68,6 +69,13 @@ const props = withDefaults(defineProps<Props>(), {
68
69
 
69
70
  const emit = defineEmits<{ "update:modelValue": [value: boolean] }>()
70
71
 
72
+ // Multi-racines (<aside> + backdrop) : Vue ne peut pas hériter les attributs tout seul
73
+ // (avertissement « could not be automatically inherited because component renders
74
+ // fragment … »). On les applique explicitement sur le panneau, la seule racine qui
75
+ // compte : `id`, `data-*`, `aria-label`… arrivent donc sur l'<aside> (l'accessibilité
76
+ // et les sélecteurs de test sont préservés). `class` / `style` restent des props.
77
+ defineOptions({ inheritAttrs: false })
78
+
71
79
  // — Mode statique (viewport >= breakpoint && show-if-above) —
72
80
  const isAbove = ref(false)
73
81
  let mql: MediaQueryList | null = null
@@ -97,6 +105,13 @@ const toggle = () => setOpen(!open.value)
97
105
 
98
106
  provide<SidebarContext>(qSidebarKey, { open, setOpen, toggle })
99
107
 
108
+ // — Placement dans un QLayout (facultatif) —
109
+ // En mode statique le panneau occupe sa cellule (« l » / « r » du `view`) et devient
110
+ // sticky si la lettre est en majuscule ; en offcanvas il est `fixed` (recouvrant),
111
+ // donc sa place dans la grille ne compte plus.
112
+ const layout = inject(qLayoutKey, null)
113
+ const layoutZone = computed(() => layout?.zones.value[props.side])
114
+
100
115
  // « Retour » navigateur → ferme la sidebar (mode offcanvas uniquement)
101
116
  const overlayOpen = computed(() => !isStatic.value && open.value)
102
117
  useOverlayBack(overlayOpen, () => setOpen(false), "QSidebar")
@@ -131,7 +146,8 @@ const rootClasses = computed(() =>
131
146
  isStatic.value ? "q-sidebar--static" : "q-sidebar--offcanvas",
132
147
  !isStatic.value && open.value && "q-sidebar--open",
133
148
  props.side === "right" && "q-sidebar--right",
134
- props.sticky && "q-sidebar--sticky",
149
+ // sticky : prop explicite, ou imposé par la casse de la lettre dans un QLayout
150
+ (props.sticky || !!layoutZone.value?.fixed) && "q-sidebar--sticky",
135
151
  props.bordered && "q-sidebar--bordered",
136
152
  props.elevated && "q-sidebar--elevated",
137
153
  props.dark && "q-sidebar--dark",
@@ -143,6 +159,14 @@ const rootStyle = computed<Record<string, string>>(() => {
143
159
  const style: Record<string, string> = { "--q-sidebar-w": props.width }
144
160
  if (props.height) style.height = props.height
145
161
  if (props.maxHeight) style.maxHeight = props.maxHeight
162
+ // Cellule de la grille QLayout (mode statique : en offcanvas le panneau est fixed)
163
+ const zone = layoutZone.value
164
+ if (isStatic.value && zone?.area) style.gridArea = zone.area
165
+ // Sticky : ne s'accroche qu'aux barres qui le surplombent (rangée 0 = au-dessus de lui)
166
+ if (isStatic.value && zone?.fixed) {
167
+ if (zone.cells.some(([row]) => row === 0)) style["--q-sidebar-sticky-top"] = "0px"
168
+ if (zone.cells.some(([row]) => row === 2)) style["--q-sidebar-sticky-bottom"] = "0px"
169
+ }
146
170
  return style
147
171
  })
148
172
 
@@ -221,6 +245,7 @@ watch(open, (v) => {
221
245
  class="q-sidebar"
222
246
  :class="rootClasses"
223
247
  :style="[rootStyle, props.style]"
248
+ v-bind="$attrs"
224
249
  :aria-hidden="!isStatic && !open ? 'true' : undefined"
225
250
  @pointerdown="onPointerDown"
226
251
  @pointermove="onPointerMove"
@@ -816,6 +816,7 @@ defineExpose({
816
816
  :disable="!editor.isEditable"
817
817
  aria-label="Font size"
818
818
  class="q-tiptap__font-size"
819
+ :inline-options="{ class: 'q-tiptap__font-size-popup', width: '100px' }"
819
820
  @update:model-value="onFontSizeUpdate"
820
821
  />
821
822
  </div>
package/index.ts CHANGED
@@ -74,6 +74,7 @@ export { default as QInteract } from "./components/QInteract.vue"
74
74
  export { default as QIntersection } from "./components/QIntersection.vue"
75
75
  export { default as QItem } from "./components/QItem.vue"
76
76
  export { default as QItemSection } from "./components/QItemSection.vue"
77
+ export { default as QLayout } from "./components/QLayout.vue"
77
78
  export { default as QLinearProgress } from "./components/QLinearProgress.vue"
78
79
  export { default as QList } from "./components/QList.vue"
79
80
  export { default as QLoading } from "./components/QLoading.vue"
@@ -135,7 +136,6 @@ export { default as QTiptap } from "./components/QTiptap.vue"
135
136
  export { default as QToolbar } from "./components/QToolbar.vue"
136
137
  export { default as QTooltip } from "./components/QTooltip.vue"
137
138
  export { default as QUploader } from "./components/QUploader.vue"
138
- export { default as QVideo } from "./components/QVideo.vue"
139
139
  export { default as QVirtualScroll } from "./components/QVirtualScroll.vue"
140
140
 
141
141
  export { $q, usePlugin, useQ, QPlugin, dialogStack, closeDialog, bottomSheetStack, closeBottomSheet, useDialogPluginComponent, useBottomSheetPluginComponent } from "./lib/q"
@@ -173,6 +173,8 @@ export type {
173
173
  PopoverPlacement,
174
174
  PopoverViewport,
175
175
  } from "./lib/datePicker"
176
+ export { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "./lib/layout"
177
+ export type { LayoutView, LayoutZone, LayoutZonePlacement, QLayoutContext } from "./lib/layout"
176
178
  export { vRipple } from "./lib/ripple"
177
179
  export type { RippleOptions, RippleValue } from "./lib/ripple"
178
180
  export { chartToECharts, channelValue, pickFromEvent, tableModel, textValue } from "./lib/chart"