@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.
- package/components/QApp.vue +13 -6
- package/components/QFooter.vue +16 -2
- package/components/QHeader.vue +16 -2
- package/components/QLayout.vue +138 -0
- package/components/QSidebar.vue +20 -3
- package/index.ts +3 -1
- package/lib/layout.test.ts +125 -0
- package/lib/layout.ts +181 -0
- package/module.ts +0 -18
- package/package.json +1 -2
- package/scripts/generate-exports.ts +9 -0
- package/styles/main.css +69 -2
- package/components/QVideo.vue +0 -305
package/components/QApp.vue
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/components/QFooter.vue
CHANGED
|
@@ -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 />
|
package/components/QHeader.vue
CHANGED
|
@@ -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>
|
package/components/QSidebar.vue
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
56
|
-
|
|
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;
|
package/components/QVideo.vue
DELETED
|
@@ -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>
|