@dnax/ui 0.0.44 → 0.0.45

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>
@@ -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 */
@@ -97,6 +98,13 @@ const toggle = () => setOpen(!open.value)
97
98
 
98
99
  provide<SidebarContext>(qSidebarKey, { open, setOpen, toggle })
99
100
 
101
+ // — Placement dans un QLayout (facultatif) —
102
+ // En mode statique le panneau occupe sa cellule (« l » / « r » du `view`) et devient
103
+ // sticky si la lettre est en majuscule ; en offcanvas il est `fixed` (recouvrant),
104
+ // donc sa place dans la grille ne compte plus.
105
+ const layout = inject(qLayoutKey, null)
106
+ const layoutZone = computed(() => layout?.zones.value[props.side])
107
+
100
108
  // « Retour » navigateur → ferme la sidebar (mode offcanvas uniquement)
101
109
  const overlayOpen = computed(() => !isStatic.value && open.value)
102
110
  useOverlayBack(overlayOpen, () => setOpen(false), "QSidebar")
@@ -131,7 +139,8 @@ const rootClasses = computed(() =>
131
139
  isStatic.value ? "q-sidebar--static" : "q-sidebar--offcanvas",
132
140
  !isStatic.value && open.value && "q-sidebar--open",
133
141
  props.side === "right" && "q-sidebar--right",
134
- props.sticky && "q-sidebar--sticky",
142
+ // sticky : prop explicite, ou imposé par la casse de la lettre dans un QLayout
143
+ (props.sticky || !!layoutZone.value?.fixed) && "q-sidebar--sticky",
135
144
  props.bordered && "q-sidebar--bordered",
136
145
  props.elevated && "q-sidebar--elevated",
137
146
  props.dark && "q-sidebar--dark",
@@ -143,6 +152,14 @@ const rootStyle = computed<Record<string, string>>(() => {
143
152
  const style: Record<string, string> = { "--q-sidebar-w": props.width }
144
153
  if (props.height) style.height = props.height
145
154
  if (props.maxHeight) style.maxHeight = props.maxHeight
155
+ // Cellule de la grille QLayout (mode statique : en offcanvas le panneau est fixed)
156
+ const zone = layoutZone.value
157
+ if (isStatic.value && zone?.area) style.gridArea = zone.area
158
+ // Sticky : ne s'accroche qu'aux barres qui le surplombent (rangée 0 = au-dessus de lui)
159
+ if (isStatic.value && zone?.fixed) {
160
+ if (zone.cells.some(([row]) => row === 0)) style["--q-sidebar-sticky-top"] = "0px"
161
+ if (zone.cells.some(([row]) => row === 2)) style["--q-sidebar-sticky-bottom"] = "0px"
162
+ }
146
163
  return style
147
164
  })
148
165
 
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"
@@ -0,0 +1,125 @@
1
+ // Tests unitaires de l'analyse du prop `view` de QLayout (matrice 3×3).
2
+ import { describe, expect, it } from "bun:test"
3
+ import { DEFAULT_LAYOUT_VIEW, parseView } from "./layout"
4
+
5
+ describe("parseView", () => {
6
+ it("applique la vue par défaut sans argument", () => {
7
+ const v = parseView()
8
+
9
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
10
+ expect(v.view).toBe("hHh LpR fFf")
11
+ expect(v.error).toBeUndefined()
12
+ // Header et footer pleine largeur, drawers dans la rangée du milieu
13
+ expect(v.gridTemplateAreas).toBe('"h h h" "l p r" "f f f"')
14
+ })
15
+
16
+ it("déduit l'aire de chaque zone", () => {
17
+ const v = parseView("hHh LpR fFf")
18
+
19
+ expect(v.zones.header.area).toBe("h")
20
+ expect(v.zones.footer.area).toBe("f")
21
+ expect(v.zones.left.area).toBe("l")
22
+ expect(v.zones.right.area).toBe("r")
23
+ expect(v.zones.page.area).toBe("p")
24
+ expect(v.zones.page.cells).toEqual([[1, 1]])
25
+ })
26
+
27
+ it("lit la casse comme « reste en place » (majuscule) ou « défile » (minuscule)", () => {
28
+ const fixed = parseView("hHh LpR fFf")
29
+ expect(fixed.zones.header.fixed).toBe(true)
30
+ expect(fixed.zones.footer.fixed).toBe(true)
31
+ expect(fixed.zones.left.fixed).toBe(true)
32
+ expect(fixed.zones.right.fixed).toBe(true)
33
+
34
+ const flowing = parseView("hhh lpR fff")
35
+ expect(flowing.zones.header.fixed).toBe(false)
36
+ expect(flowing.zones.footer.fixed).toBe(false)
37
+ expect(flowing.zones.left.fixed).toBe(false)
38
+ expect(flowing.zones.right.fixed).toBe(true) // une seule majuscule suffit
39
+ expect(flowing.gridTemplateAreas).toBe('"h h h" "l p r" "f f f"')
40
+ })
41
+
42
+ it("fait démarrer le drawer gauche au-dessus du header (lhh)", () => {
43
+ const v = parseView("lhh LpR fff")
44
+
45
+ expect(v.gridTemplateAreas).toBe('"l h h" "l p r" "f f f"')
46
+ expect(v.zones.left.cells).toEqual([
47
+ [0, 0],
48
+ [1, 0],
49
+ ])
50
+ expect(v.zones.header.cells).toEqual([
51
+ [0, 1],
52
+ [0, 2],
53
+ ])
54
+ })
55
+
56
+ it("fait descendre le drawer droit jusqu'au footer (ffR)", () => {
57
+ const v = parseView("hhr lpR ffR")
58
+
59
+ expect(v.gridTemplateAreas).toBe('"h h r" "l p r" "f f r"')
60
+ expect(v.zones.right.cells).toEqual([
61
+ [0, 2],
62
+ [1, 2],
63
+ [2, 2],
64
+ ])
65
+ expect(v.zones.right.fixed).toBe(true)
66
+ })
67
+
68
+ it("accepte les cellules vides (.)", () => {
69
+ const v = parseView("hHh .p. fFf")
70
+
71
+ expect(v.gridTemplateAreas).toBe('"h h h" ". p ." "f f f"')
72
+ expect(v.zones.left.cells).toEqual([])
73
+ expect(v.zones.left.area).toBeUndefined()
74
+ expect(v.zones.right.cells).toEqual([])
75
+ })
76
+
77
+ it("marque la page « en place » si son p est majuscule", () => {
78
+ expect(parseView("hhh LPR fff").zones.page.fixed).toBe(true)
79
+ expect(parseView("hhh LpR fff").zones.page.fixed).toBe(false)
80
+ })
81
+
82
+ it("rejette une chaîne de mauvaise longueur", () => {
83
+ const v = parseView("hHh lpR")
84
+
85
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
86
+ expect(v.error).toContain("3 groupes de 3 caractères")
87
+ })
88
+
89
+ it("rejette une lettre inconnue", () => {
90
+ const v = parseView("hHh xpR fFf")
91
+
92
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
93
+ expect(v.error).toContain('lettre "x" inconnue')
94
+ })
95
+
96
+ it("rejette une zone hors de sa rangée", () => {
97
+ const v = parseView("fhf LpR fFf")
98
+
99
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
100
+ expect(v.error).toContain("rangée 1")
101
+ })
102
+
103
+ it("exige une page dans la rangée du milieu", () => {
104
+ const v = parseView("hHh lrr fFf")
105
+
106
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
107
+ expect(v.error).toContain('au moins un "p"')
108
+ })
109
+
110
+ it("rejette une zone non rectangulaire", () => {
111
+ // `l` en (0,0) et (2,0) sans (1,0) : grid-template-areas serait invalide
112
+ const v = parseView("lhr plr lfr")
113
+
114
+ expect(v.view).toBe(DEFAULT_LAYOUT_VIEW)
115
+ expect(v.error).toContain('zone "left"')
116
+ })
117
+
118
+ it("retombe toujours sur une vue utilisable", () => {
119
+ const v = parseView("n'importe quoi")
120
+
121
+ expect(v.error).toBeDefined()
122
+ expect(v.zones.page.cells).toEqual([[1, 1]])
123
+ expect(v.gridTemplateAreas).toBe('"h h h" "l p r" "f f f"')
124
+ })
125
+ })
package/lib/layout.ts ADDED
@@ -0,0 +1,181 @@
1
+ // Analyse du prop `view` de QLayout — matrice 3×3 de la fenêtre (modèle Quasar).
2
+ //
3
+ // `view` = 11 caractères : 3 (rangée header) + espace + 3 (rangée milieu) + espace +
4
+ // 3 (rangée footer). Chaque caractère dit quelle zone occupe cette cellule :
5
+ // l = drawer gauche, h = header, r = drawer droit, f = footer, p = page (centre),
6
+ // . = cellule vide.
7
+ // La casse compte : **majuscule = la zone reste en place** au scroll (Quasar :
8
+ // « position fixe »), **minuscule = elle défile avec la page**. Une zone qui occupe
9
+ // plusieurs cellules les occupe forcément en rectangle (exigence de
10
+ // `grid-template-areas`) ; `hHh lpR fFf` donne donc un header pleine largeur, un
11
+ // drawer gauche sous le header et un drawer droit pleine hauteur.
12
+ //
13
+ // Aucune dépendance au DOM : le SFC pose `grid-template-areas` sur `.q-layout`, et
14
+ // chaque enfant (q-header, q-footer, q-sidebar) lit sa cellule dans le contexte
15
+ // injecté — d'où l'export de `qLayoutKey`.
16
+ //
17
+ // Modèle retenu par dnax.ui (« A ») : **scroll du document + `position: sticky`**,
18
+ // pas le scroll interne de Quasar. Conséquence sur les lettres : la casse se voit
19
+ // sur le header (`H` reste collé / `h` défile) et sur les drawers (`L`/`R` collés et
20
+ // hauts de 100dvh / `l`/`r` dans le flux), alors que `F`/`f` sont **équivalents** (la
21
+ // rangée footer est la dernière : le footer tombe en fin de page dans les deux cas) et
22
+ // que `P` est sans effet (la page EST ce qui défile).
23
+
24
+ import type { InjectionKey, Ref } from "vue"
25
+
26
+ /** Zone de la matrice (une par composant concerné) */
27
+ export type LayoutZone = "left" | "header" | "right" | "page" | "footer"
28
+
29
+ /** Vue par défaut : header et footer pleine largeur, les deux drawers restent en place */
30
+ export const DEFAULT_LAYOUT_VIEW = "hHh LpR fFf"
31
+
32
+ /** Lettres acceptées par rangée — la matrice n'a de sens qu'avec ces zones-là */
33
+ const ROW_LETTERS: readonly (readonly string[])[] = [
34
+ ["l", "h", "r"], // rangée header : drawer gauche, header, drawer droit
35
+ ["l", "p", "r"], // rangée milieu : drawer gauche, page, drawer droit
36
+ ["l", "f", "r"], // rangée footer : drawer gauche, footer, drawer droit
37
+ ]
38
+
39
+ /** Lettre → zone */
40
+ const LETTER_ZONE: Record<string, LayoutZone> = {
41
+ l: "left",
42
+ h: "header",
43
+ r: "right",
44
+ p: "page",
45
+ f: "footer",
46
+ }
47
+
48
+ /** Cellule vide (aucune zone) */
49
+ const EMPTY_CELL = "."
50
+
51
+ export interface LayoutZonePlacement {
52
+ /** Nom d'aire CSS à passer à `grid-area` — `undefined` si la zone n'occupe aucune cellule */
53
+ area?: string
54
+ /** La zone reste en place au scroll (au moins une de ses lettres en majuscule) */
55
+ fixed: boolean
56
+ /** Cellules occupées, `[ligne, colonne]` en base 0 */
57
+ cells: Array<[number, number]>
58
+ }
59
+
60
+ export interface LayoutView {
61
+ /** Chaîne retenue — la vue par défaut quand la chaîne fournie est invalide */
62
+ view: string
63
+ /** `grid-template-areas` prêt à poser sur `.q-layout` */
64
+ gridTemplateAreas: string
65
+ /** Placement de chaque zone */
66
+ zones: Record<LayoutZone, LayoutZonePlacement>
67
+ /** Raison du rejet de la chaîne fournie (`undefined` si elle était valide) */
68
+ error?: string
69
+ }
70
+
71
+ /** Contexte fourni par QLayout à ses enfants (q-header, q-footer, q-sidebar) */
72
+ export interface QLayoutContext {
73
+ /** Placement de chaque zone, déduit de `view` (suit les changements de `view`) */
74
+ zones: Readonly<Ref<Record<LayoutZone, LayoutZonePlacement>>>
75
+ /** QLayout est utilisé comme conteneur (hauteur explicite requise) */
76
+ container: Readonly<Ref<boolean>>
77
+ }
78
+
79
+ /** Clé d'injection du contexte QLayout — lue par les composants enfants */
80
+ export const qLayoutKey: InjectionKey<QLayoutContext> = Symbol("q-layout")
81
+
82
+ const emptyZones = (): Record<LayoutZone, LayoutZonePlacement> => ({
83
+ left: { fixed: false, cells: [] },
84
+ header: { fixed: false, cells: [] },
85
+ right: { fixed: false, cells: [] },
86
+ page: { fixed: false, cells: [] },
87
+ footer: { fixed: false, cells: [] },
88
+ })
89
+
90
+ /** Les 3 rangées de 3 lettres d'une vue (chaîne déjà validée) */
91
+ const rowsOf = (view: string) => view.trim().split(/\s+/) as [string, string, string]
92
+
93
+ /**
94
+ * Construit le résultat à partir d'une vue **valide** : cellules de chaque zone,
95
+ * drapeau « reste en place », aires CSS.
96
+ */
97
+ function build(view: string): LayoutView {
98
+ const rows = rowsOf(view)
99
+ const zones = emptyZones()
100
+ const areas: string[][] = []
101
+
102
+ rows.forEach((row, r) => {
103
+ const cells = [...row]
104
+ areas.push(cells.map((char) => (char === EMPTY_CELL ? EMPTY_CELL : char.toLowerCase())))
105
+ cells.forEach((char, c) => {
106
+ if (char === EMPTY_CELL) return
107
+ const zone = LETTER_ZONE[char.toLowerCase()]!
108
+ zones[zone].cells.push([r, c])
109
+ // Casse : au moins une majuscule → la zone reste en place
110
+ if (char === char.toUpperCase()) zones[zone].fixed = true
111
+ })
112
+ })
113
+
114
+ for (const zone of Object.keys(zones) as LayoutZone[]) {
115
+ if (zones[zone].cells.length) {
116
+ const [r, c] = zones[zone].cells[0]!
117
+ zones[zone].area = areas[r]![c]!
118
+ }
119
+ }
120
+
121
+ return {
122
+ view: rows.join(" "),
123
+ gridTemplateAreas: areas.map((row) => `"${row.join(" ")}"`).join(" "),
124
+ zones,
125
+ }
126
+ }
127
+
128
+ /** Vérifie qu'une zone occupe bien un rectangle plein (sinon `grid-template-areas` est invalide) */
129
+ function isRectangle(cells: Array<[number, number]>): boolean {
130
+ const rows = cells.map(([r]) => r)
131
+ const cols = cells.map(([, c]) => c)
132
+ const height = Math.max(...rows) - Math.min(...rows) + 1
133
+ const width = Math.max(...cols) - Math.min(...cols) + 1
134
+ return new Set(rows).size === height && new Set(cols).size === width && height * width === cells.length
135
+ }
136
+
137
+ /** Vue par défaut + la raison du rejet, pour rester utilisable en production */
138
+ const fallback = (error: string): LayoutView => ({ ...build(DEFAULT_LAYOUT_VIEW), error })
139
+
140
+ /**
141
+ * Analyse le prop `view`. Toute chaîne invalide est **rejetée** (jamais de crash) :
142
+ * la vue par défaut s'applique et `error` porte la raison (affichée en dev par le SFC).
143
+ */
144
+ export function parseView(view?: string): LayoutView {
145
+ if (!view) return build(DEFAULT_LAYOUT_VIEW)
146
+
147
+ const groups = view.trim().split(/\s+/)
148
+ if (groups.length !== 3 || groups.some((group) => group.length !== 3)) {
149
+ return fallback(
150
+ `view "${view}" : attendu 3 groupes de 3 caractères (ex. "${DEFAULT_LAYOUT_VIEW}")`,
151
+ )
152
+ }
153
+
154
+ const rows = groups as [string, string, string]
155
+ for (let r = 0; r < 3; r++) {
156
+ for (const char of rows[r]!) {
157
+ if (char === EMPTY_CELL) continue
158
+ const letter = char.toLowerCase()
159
+ if (!LETTER_ZONE[letter]) {
160
+ return fallback(`view "${view}" : lettre "${char}" inconnue (attendu l, h, r, f, p ou .)`)
161
+ }
162
+ if (!ROW_LETTERS[r]!.includes(letter)) {
163
+ return fallback(
164
+ `view "${view}" : "${char}" n'a pas sa place dans la rangée ${r + 1} (attendu ${ROW_LETTERS[r]!.join(", ")} ou .)`,
165
+ )
166
+ }
167
+ }
168
+ }
169
+
170
+ const parsed = build(rows.join(" "))
171
+ if (!parsed.zones.page.cells.length) {
172
+ return fallback(`view "${view}" : la rangée du milieu doit contenir au moins un "p" (page)`)
173
+ }
174
+ for (const zone of Object.keys(parsed.zones) as LayoutZone[]) {
175
+ const cells = parsed.zones[zone].cells
176
+ if (cells.length > 1 && !isRectangle(cells)) {
177
+ return fallback(`view "${view}" : la zone "${zone}" n'occupe pas un rectangle plein`)
178
+ }
179
+ }
180
+ return parsed
181
+ }
package/module.ts CHANGED
@@ -20,15 +20,6 @@ export interface DnaxUiModuleOptions {
20
20
  overlayBack?: boolean
21
21
  }
22
22
 
23
- /** Custom elements du framework HTML Video.js v10 (@videojs/html) — balises non-Vue */
24
- const VIDEOJS_CUSTOM_ELEMENTS = [
25
- "video-player",
26
- "video-skin",
27
- "youtube-video",
28
- "hlsjs-video",
29
- "mux-video",
30
- ]
31
-
32
23
  export default defineNuxtModule<DnaxUiModuleOptions>({
33
24
  meta: {
34
25
  name: "@dnax/ui",
@@ -43,15 +34,6 @@ export default defineNuxtModule<DnaxUiModuleOptions>({
43
34
  setup(options, nuxt) {
44
35
  const { resolve } = createResolver(import.meta.url)
45
36
 
46
- // Les balises <video-player>/<youtube-video>… sont des custom elements @videojs/html,
47
- // pas des composants Vue → les exclure de la résolution (évite le warn
48
- // « Failed to resolve component »).
49
- nuxt.options.vue.compilerOptions ??= {}
50
- const existing = nuxt.options.vue.compilerOptions.isCustomElement
51
- nuxt.options.vue.compilerOptions.isCustomElement = (tag: string) =>
52
- VIDEOJS_CUSTOM_ELEMENTS.includes(tag) ||
53
- (typeof existing === "function" ? existing(tag) : false)
54
-
55
37
  if (options.css) {
56
38
  const cssPath = resolve("./styles/main.css")
57
39
  if (!nuxt.options.css.includes(cssPath)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.44",
3
+ "version": "0.0.45",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
@@ -40,7 +40,6 @@
40
40
  "@tiptap/starter-kit": "^3.31.3",
41
41
  "@tiptap/suggestion": "^3.31.3",
42
42
  "@tiptap/vue-3": "^3.31.3",
43
- "@videojs/html": "^10.0.0-beta.32",
44
43
  "@vueuse/core": "^14.4.0",
45
44
  "arktype": "^2.2.3",
46
45
  "class-variance-authority": "^0.7.1",
@@ -54,6 +54,15 @@ export {
54
54
  } from "./lib/overlayBack"
55
55
  export type { OverlayHandle } from "./lib/overlayBack"
56
56
  export { markOverlayClose, closeParentOverlay, vClose } from "./lib/closeOverlay"
57
+ export { placePopover } from "./lib/datePicker"
58
+ export type {
59
+ PlacePopoverOptions,
60
+ PopoverAnchor,
61
+ PopoverPlacement,
62
+ PopoverViewport,
63
+ } from "./lib/datePicker"
64
+ export { DEFAULT_LAYOUT_VIEW, parseView, qLayoutKey } from "./lib/layout"
65
+ export type { LayoutView, LayoutZone, LayoutZonePlacement, QLayoutContext } from "./lib/layout"
57
66
  export { vRipple } from "./lib/ripple"
58
67
  export type { RippleOptions, RippleValue } from "./lib/ripple"
59
68
  export { chartToECharts, channelValue, pickFromEvent, tableModel, textValue } from "./lib/chart"
package/styles/main.css CHANGED
@@ -52,8 +52,13 @@ body {
52
52
  .q-app {
53
53
  display: flex;
54
54
  flex-direction: column; /* header en haut, contenu au milieu, footer en bas */
55
- min-height: 100vh;
56
- min-height: 100dvh; /* barres de navigateur dynamiques (mobile) */
55
+ width: 100%; /* prend toute la largeur disponible */
56
+ /* Équivalent PUR CSS de `flex h-screen w-full` (les classes sont aussi sur la
57
+ div) : le conteneur fait exactement un écran. Ce qui dépasse doit donc défiler
58
+ dans un enfant (`min-height: 0` + `overflow: auto`), sinon le document scrolle
59
+ sous les barres. */
60
+ height: 100vh; /* fallback vieux navigateurs */
61
+ height: 100dvh; /* barres de navigateur dynamiques (mobile) */
57
62
  }
58
63
 
59
64
  /* Safe-area intégrées aux règles de base (pas de conflit de cascade) */
@@ -79,6 +84,16 @@ body {
79
84
  z-index: 100;
80
85
  }
81
86
 
87
+ /* sticky : opt-in (prop `sticky`, ou automatique dans un QLayout quand la lettre du
88
+ `view` est en majuscule) — la barre reste visible, le contenu défile dessous.
89
+ Dans un QLayout elle est close dans sa cellule : elle ne peut pas passer au-dessus
90
+ d'une colonne voisine (drawer, rangée du milieu). */
91
+ .q-header--sticky {
92
+ position: sticky;
93
+ top: 0;
94
+ z-index: 100;
95
+ }
96
+
82
97
  /* Barre d'action basse */
83
98
  /* Safe-area intégrées à la règle de base .q-footer (plus bas) */
84
99
 
@@ -105,6 +120,49 @@ body {
105
120
  padding-bottom: calc(env(safe-area-inset-bottom) + 50px);
106
121
  }
107
122
 
123
+ /* ===== QLayout — matrice 3×3 pilotée par `view` =====
124
+ Colonnes : drawer gauche / contenu / drawer droit. Rangées : header / milieu /
125
+ footer. `grid-template-areas` vient du prop `view` (variable CSS posée par le
126
+ composant). `minmax(0, 1fr)` sur la colonne du milieu = l'équivalent de
127
+ `min-width: 0` : un enfant large (tableau à colonnes nombreuses) ne pousse plus
128
+ les drawers hors de l'écran. */
129
+ .q-layout {
130
+ display: grid;
131
+ grid-template-columns: auto minmax(0, 1fr) auto;
132
+ grid-template-rows: auto minmax(0, 1fr) auto;
133
+ grid-template-areas: var(--q-layout-areas);
134
+ min-height: 100vh;
135
+ min-height: 100dvh;
136
+ }
137
+
138
+ /* Tout enfant direct sans cellule explicite (le contenu) va dans la cellule « page ».
139
+ q-header / q-footer / q-sidebar posent la leur en style inline — l'ordre
140
+ d'écriture dans le template est donc libre. */
141
+ .q-layout > * {
142
+ grid-area: var(--q-layout-page-area, p);
143
+ }
144
+
145
+ /* Mode conteneur (comme Quasar) : le layout gère son propre cadre — une hauteur
146
+ explicite est requise et le conteneur devient la zone de scroll (les barres
147
+ sticky s'y accrochent, pas à la fenêtre). */
148
+ .q-layout--container {
149
+ min-height: 0;
150
+ overflow: auto;
151
+ }
152
+
153
+ /* Drawer sticky dans un QLayout : accroché sous les barres qui le surplombent
154
+ seulement (les variables `--q-sidebar-sticky-*` sont posées par QSidebar d'après
155
+ les cellules de sa zone), sur la hauteur visible entre les deux barres — il ne
156
+ glisse donc jamais sous le header. */
157
+ .q-layout > .q-sidebar--static.q-sidebar--sticky {
158
+ top: var(--q-sidebar-sticky-top, var(--q-layout-header-h, 0px));
159
+ height: calc(
160
+ var(--q-layout-viewport-h, 100dvh) -
161
+ var(--q-sidebar-sticky-top, var(--q-layout-header-h, 0px)) -
162
+ var(--q-sidebar-sticky-bottom, var(--q-layout-footer-h, 0px))
163
+ );
164
+ }
165
+
108
166
  .q-toolbar {
109
167
  position: relative;
110
168
  display: flex;
@@ -653,6 +711,15 @@ html.dark body {
653
711
  z-index: 100;
654
712
  }
655
713
 
714
+ /* sticky (prop `sticky`, ou automatique dans un QLayout avec une lettre majuscule) :
715
+ la rangée footer étant la dernière, elle tombe en fin de page dans les deux cas —
716
+ `F` ne change donc rien de visible, contrairement à `H` sur le header. */
717
+ .q-footer--sticky {
718
+ position: sticky;
719
+ bottom: 0;
720
+ z-index: 100;
721
+ }
722
+
656
723
  /* no-padding : horizontal collé aux bords — safe-area conservées */
657
724
  .q-footer--no-padding {
658
725
  padding: 4px 0;
@@ -1,305 +0,0 @@
1
- <script setup lang="ts">
2
- // QVideo — lecteur vidéo basé sur le framework HTML de Video.js v10 (@videojs/html) :
3
- // <q-video src="movie.mp4" poster="…" ratio="16/9" />
4
- // Custom elements composables : <video-player> > <video-skin> > media element.
5
- // Le média est choisi selon la source : <video> (mp4/webm/ogg), <youtube-video>
6
- // (YouTube), <hlsjs-video> (.m3u8) — mux-video est aussi enregistré (prêt à l'emploi).
7
- // Les modules sont chargés uniquement côté client (SSR safe — customElements).
8
- // Events : @ready (media element), @play, @pause, @ended, @timeupdate, @loadedmetadata, @volumechange.
9
- // Méthodes exposées : play / pause / togglePlay / seek / getCurrentTime / getDuration / setVolume / setMuted / isPlaying.
10
- import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
11
- // Importé en texte brut + injecté côté client : passé dans le pipeline CSS normal, ce
12
- // fichier tiers (build Tailwind de @videojs/html) contient du `@layer`/`@scope` natif qui
13
- // casse le build des apps consommatrices en Tailwind v3 (PostCSS exige le `@tailwind`
14
- // correspondant). L'injection runtime l'exclut de PostCSS sans perdre les styles globaux.
15
- import skinCss from "@videojs/html/video/skin.css?raw"
16
-
17
- interface Props {
18
- /** URL de la vidéo (mp4, webm, ogg, HLS .m3u8, YouTube…) */
19
- src?: string
20
- /** Image d'affiche avant lecture */
21
- poster?: string
22
- /** Image de remplacement affichée tant que la vidéo n'est pas chargée (ou slot #placeholder) */
23
- placeholder?: string
24
- /** Texte alternatif de l'image placeholder (défaut "Video placeholder") */
25
- placeholderAlt?: string
26
- /** Affiche les contrôles (défaut true). Le skin par défaut gère ses contrôles
27
- * (état visible/masqué pendant la lecture). */
28
- controls?: boolean
29
- /** Démarre la lecture automatiquement (souvent combiné avec muted) */
30
- autoplay?: boolean
31
- /** Boucle à la fin */
32
- loop?: boolean
33
- /** Muet */
34
- muted?: boolean
35
- /** Chargement : "auto" | "metadata" (défaut) | "none" */
36
- preload?: string
37
- /** Ratio d'affichage : "16/9" (défaut), "4/3", "1/1", "none" (hauteur auto) */
38
- ratio?: string
39
- /** Largeur max du lecteur (défaut 100%) */
40
- width?: string
41
- }
42
-
43
- const props = withDefaults(defineProps<Props>(), {
44
- src: "",
45
- poster: "",
46
- placeholder: "",
47
- placeholderAlt: "Video placeholder",
48
- controls: true,
49
- autoplay: false,
50
- loop: false,
51
- muted: false,
52
- preload: "metadata",
53
- ratio: "16/9",
54
- width: "",
55
- })
56
-
57
- const emit = defineEmits<{
58
- ready: [media: any]
59
- play: []
60
- pause: []
61
- ended: []
62
- timeupdate: [payload: { currentTime: number; duration: number }]
63
- loadedmetadata: [payload: { duration: number }]
64
- volumechange: [payload: { muted: boolean; volume: number }]
65
- }>()
66
-
67
- /** URL YouTube (watch, shorts, embed, youtu.be) */
68
- const isYoutube = computed(() =>
69
- /(youtube\.com\/(watch\?v=|shorts\/|embed\/)|youtu\.be\/)/.test(props.src),
70
- )
71
- /** Flux HLS (.m3u8) → media element hlsjs-video */
72
- const isHls = computed(() => /\.m3u8(\?|$)/.test(props.src))
73
-
74
- const mediaEl = ref<HTMLElement | null>(null)
75
- const playing = ref(false)
76
- /** true dès que le média a chargé ses métadonnées (ou qu'il joue) → masque le placeholder */
77
- const metaLoaded = ref(false)
78
-
79
- const media = (): any => mediaEl.value
80
-
81
- const onPlay = () => {
82
- metaLoaded.value = true
83
- playing.value = true
84
- emit("play")
85
- }
86
- const onPause = () => {
87
- playing.value = false
88
- emit("pause")
89
- }
90
- const onEnded = () => emit("ended")
91
- const onTimeupdate = () =>
92
- emit("timeupdate", { currentTime: media()?.currentTime ?? 0, duration: media()?.duration ?? 0 })
93
- const onLoadedmetadata = () => {
94
- metaLoaded.value = true
95
- emit("loadedmetadata", { duration: media()?.duration ?? 0 })
96
- }
97
- const onVolumechange = () =>
98
- emit("volumechange", { muted: media()?.muted ?? false, volume: media()?.volume ?? 1 })
99
-
100
- const listeners: Array<[string, EventListener]> = [
101
- ["play", onPlay],
102
- ["pause", onPause],
103
- ["ended", onEnded],
104
- ["timeupdate", onTimeupdate],
105
- ["loadedmetadata", onLoadedmetadata],
106
- ["volumechange", onVolumechange],
107
- ]
108
-
109
- const attach = (el: HTMLElement) => {
110
- for (const [type, fn] of listeners) el.addEventListener(type, fn)
111
- }
112
-
113
- const detach = (el: HTMLElement | null) => {
114
- if (!el) return
115
- for (const [type, fn] of listeners) el.removeEventListener(type, fn)
116
- }
117
-
118
- // Attache les listeners dès que le media element existe (et à chaque changement de
119
- // type de média) — sinon loadedmetadata est émis avant l'init et le placeholder
120
- // resterait affiché, bloquant les contrôles.
121
- watch(mediaEl, (el, old) => {
122
- detach(old ?? null)
123
- if (el) attach(el)
124
- }, { immediate: true })
125
-
126
- // Clic sur le placeholder : masque-le et lance la lecture
127
- const onPlaceholderClick = () => {
128
- metaLoaded.value = true
129
- play()
130
- }
131
-
132
- let skinInjected = false
133
- const injectSkin = () => {
134
- if (skinInjected || typeof document === "undefined") return
135
- skinInjected = true
136
- if (document.getElementById("q-video-skin")) return
137
- const style = document.createElement("style")
138
- style.id = "q-video-skin"
139
- style.textContent = skinCss
140
- document.head.appendChild(style)
141
- }
142
-
143
- onMounted(async () => {
144
- injectSkin()
145
- // Charge le framework HTML (custom elements) — uniquement côté client
146
- await Promise.all([
147
- import("@videojs/html/video/player" as string),
148
- import("@videojs/html/video/skin" as string),
149
- import("@videojs/html/media/mux-video" as string),
150
- import("@videojs/html/media/youtube-video" as string),
151
- import("@videojs/html/media/hlsjs-video" as string),
152
- ])
153
- if (typeof customElements !== "undefined") {
154
- await customElements.whenDefined("video-player")
155
- }
156
- emit("ready", mediaEl.value)
157
- })
158
-
159
- onBeforeUnmount(() => detach(mediaEl.value ?? null))
160
-
161
- // — Méthodes exposées —
162
- const play = () => media()?.play()
163
- const pause = () => media()?.pause()
164
- const togglePlay = () => {
165
- const el = media()
166
- if (!el) return
167
- if (el.paused) el.play()
168
- else el.pause()
169
- }
170
- const seek = (t: number) => {
171
- if (media()) media().currentTime = t
172
- }
173
- const getCurrentTime = () => media()?.currentTime ?? 0
174
- const getDuration = () => media()?.duration ?? 0
175
- const setVolume = (v: number) => {
176
- if (media()) media().volume = v
177
- }
178
- const setMuted = (m: boolean) => {
179
- if (media()) media().muted = m
180
- }
181
- const isPlaying = () => playing.value
182
-
183
- defineExpose({
184
- play,
185
- pause,
186
- togglePlay,
187
- seek,
188
- getCurrentTime,
189
- getDuration,
190
- setVolume,
191
- setMuted,
192
- isPlaying,
193
- })
194
-
195
- const wrapperStyle = (): Record<string, string> => {
196
- const style: Record<string, string> = {}
197
- if (props.width) style.maxWidth = props.width
198
- if (props.ratio !== "none") style.aspectRatio = props.ratio.replace("/", " / ")
199
- else style.aspectRatio = "auto"
200
- return style
201
- }
202
- </script>
203
-
204
- <template>
205
- <div class="q-video" :style="wrapperStyle()">
206
- <!-- Custom elements @videojs/html : player > skin > media -->
207
- <video-player class="q-video__player">
208
- <video-skin>
209
- <video
210
- v-if="!isYoutube && !isHls"
211
- ref="mediaEl"
212
- :src="src"
213
- :poster="poster"
214
- :autoplay="autoplay"
215
- :loop="loop"
216
- :muted="muted"
217
- :preload="preload"
218
- playsinline
219
- />
220
- <youtube-video
221
- v-else-if="isYoutube"
222
- ref="mediaEl"
223
- :src="src"
224
- :poster="poster"
225
- :autoplay="autoplay"
226
- :loop="loop"
227
- :muted="muted"
228
- playsinline
229
- />
230
- <hlsjs-video
231
- v-else
232
- ref="mediaEl"
233
- :src="src"
234
- :poster="poster"
235
- :autoplay="autoplay"
236
- :loop="loop"
237
- :muted="muted"
238
- playsinline
239
- />
240
- </video-skin>
241
- </video-player>
242
-
243
- <!-- Placeholder tant que la vidéo n'est pas chargée (clic = lecture) -->
244
- <div
245
- v-if="!metaLoaded && (placeholder || $slots.placeholder)"
246
- class="q-video__placeholder"
247
- role="button"
248
- :aria-label="placeholderAlt"
249
- @click="onPlaceholderClick"
250
- >
251
- <img v-if="placeholder" :src="placeholder" :alt="placeholderAlt" />
252
- <slot name="placeholder" />
253
- </div>
254
- </div>
255
- </template>
256
-
257
- <style scoped>
258
- .q-video {
259
- position: relative;
260
- width: 100%;
261
- overflow: hidden;
262
- border-radius: var(--q-radius, 12px);
263
- background: #000;
264
- }
265
- /* Avant l'upgrade des custom elements : display block + remplir le cadre */
266
- .q-video video-player,
267
- .q-video video-skin,
268
- .q-video video,
269
- .q-video youtube-video,
270
- .q-video hlsjs-video {
271
- display: block;
272
- }
273
- .q-video video-player,
274
- .q-video video-skin {
275
- position: absolute;
276
- inset: 0;
277
- width: 100% !important;
278
- height: 100% !important;
279
- }
280
- .q-video video,
281
- .q-video youtube-video,
282
- .q-video hlsjs-video {
283
- width: 100%;
284
- height: 100%;
285
- object-fit: contain;
286
- }
287
-
288
- /* Placeholder de chargement : image ou contenu, cliquable pour lancer la lecture */
289
- .q-video__placeholder {
290
- position: absolute;
291
- inset: 0;
292
- z-index: 1;
293
- display: flex;
294
- align-items: center;
295
- justify-content: center;
296
- overflow: hidden;
297
- cursor: pointer;
298
- background: #000;
299
- }
300
- .q-video__placeholder img {
301
- width: 100%;
302
- height: 100%;
303
- object-fit: cover;
304
- }
305
- </style>