@dnax/ui 0.0.60 → 0.0.62
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/QBottomSheetHeader.vue +12 -1
- package/components/QDialogHeader.vue +12 -1
- package/components/QFooter.vue +26 -5
- package/components/QHeader.vue +5 -3
- package/components/QSidebar.vue +12 -2
- package/lib/layout.test.ts +26 -1
- package/lib/layout.ts +21 -0
- package/lib/styleTokens.test.ts +123 -0
- package/package.json +1 -1
- package/styles/main.css +58 -15
|
@@ -56,6 +56,14 @@ const hasCustomContent = computed(() => {
|
|
|
56
56
|
return slotHasContent(slots.default?.() ?? [])
|
|
57
57
|
})
|
|
58
58
|
const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
|
|
59
|
+
|
|
60
|
+
// Une **description** (prop ou slot) rend le bloc titre+description plus haut : la toolbar
|
|
61
|
+
// (min-height 50px, `align-items: center`) collait le titre au bord haut. Le titre reçoit
|
|
62
|
+
// alors un peu de `padding-top` (cf. `.q-bottom-sheet__header-text--with-description`).
|
|
63
|
+
const hasDescription = computed(() => {
|
|
64
|
+
void refresh.value
|
|
65
|
+
return !!props.description || slotHasContent(slots.description?.() ?? [])
|
|
66
|
+
})
|
|
59
67
|
</script>
|
|
60
68
|
|
|
61
69
|
<template>
|
|
@@ -67,7 +75,10 @@ const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
|
|
|
67
75
|
<slot v-if="useCustom" />
|
|
68
76
|
|
|
69
77
|
<q-toolbar v-else>
|
|
70
|
-
<div
|
|
78
|
+
<div
|
|
79
|
+
class="q-bottom-sheet__header-text"
|
|
80
|
+
:class="{ 'q-bottom-sheet__header-text--with-description': hasDescription }"
|
|
81
|
+
>
|
|
71
82
|
<h2 v-if="title" class="q-bottom-sheet__title">{{ title }}</h2>
|
|
72
83
|
<slot name="title" />
|
|
73
84
|
<p v-if="description" class="q-bottom-sheet__description">{{ description }}</p>
|
|
@@ -64,6 +64,14 @@ const hasCustomContent = computed(() => {
|
|
|
64
64
|
return slotHasContent(slots.default?.() ?? [])
|
|
65
65
|
})
|
|
66
66
|
const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
|
|
67
|
+
|
|
68
|
+
// Une **description** (prop ou slot) rend le bloc titre+description plus haut : la toolbar
|
|
69
|
+
// (min-height 50px, `align-items: center`) collait le titre au bord haut. Le titre reçoit
|
|
70
|
+
// alors un peu de `padding-top` (cf. `.q-dialog__header-text--with-description`).
|
|
71
|
+
const hasDescription = computed(() => {
|
|
72
|
+
void refresh.value
|
|
73
|
+
return !!props.description || slotHasContent(slots.description?.() ?? [])
|
|
74
|
+
})
|
|
67
75
|
</script>
|
|
68
76
|
|
|
69
77
|
<template>
|
|
@@ -77,7 +85,10 @@ const useCustom = computed(() => !isStandard.value && hasCustomContent.value)
|
|
|
77
85
|
|
|
78
86
|
<!-- Mode standard : toolbar embarqué (title / description / close) -->
|
|
79
87
|
<q-toolbar v-else>
|
|
80
|
-
<div
|
|
88
|
+
<div
|
|
89
|
+
class="q-dialog__header-text"
|
|
90
|
+
:class="{ 'q-dialog__header-text--with-description': hasDescription }"
|
|
91
|
+
>
|
|
81
92
|
<h2 v-if="title" class="q-dialog__title">{{ title }}</h2>
|
|
82
93
|
<slot name="title" />
|
|
83
94
|
<p v-if="description" class="q-dialog__description">{{ description }}</p>
|
package/components/QFooter.vue
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
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, inject, onBeforeUnmount, onMounted, ref } from "vue"
|
|
3
|
+
import { computed, inject, onBeforeUnmount, onMounted, ref, watchEffect } from "vue"
|
|
4
4
|
import { useFixedBarOffset } from "../lib/fixedLayout"
|
|
5
|
-
import { qLayoutKey } from "../lib/layout"
|
|
5
|
+
import { barPin, qLayoutKey } from "../lib/layout"
|
|
6
6
|
|
|
7
7
|
interface Props {
|
|
8
8
|
/** Fixe la barre en bas de l'écran (sort du flux) */
|
|
9
9
|
fixed?: boolean
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Colle la barre **en fin de page** (rangée basse du grid). Dans le modèle scroll-document,
|
|
12
|
+
* la footer étant la **dernière rangée**, `F`/`f` sont **équivalents** et ce `sticky` n'a
|
|
13
|
+
* **pas d'effet visible**. Pour **épingler** la barre à l'écran, utiliser `fixed`.
|
|
14
|
+
*/
|
|
11
15
|
sticky?: boolean
|
|
12
16
|
/** Ombre portée vers le haut */
|
|
13
17
|
elevated?: boolean
|
|
@@ -38,11 +42,28 @@ const rootEl = ref<HTMLElement | null>(null)
|
|
|
38
42
|
// par la casse de la lettre (F).
|
|
39
43
|
const layout = inject(qLayoutKey, null)
|
|
40
44
|
const zone = computed(() => layout?.zones.value.footer)
|
|
41
|
-
|
|
45
|
+
// Épinglage vs collage : contrat centralisé dans `barPin` (la lettre `F` ne fait que « coller »,
|
|
46
|
+
// sans effet visible sur la dernière rangée).
|
|
47
|
+
const pin = computed(() => barPin(props, !!zone.value?.fixed))
|
|
48
|
+
const isSticky = computed(() => pin.value.sticky)
|
|
42
49
|
const layoutStyle = computed<Record<string, string | undefined>>(() => ({
|
|
43
50
|
gridArea: zone.value?.area,
|
|
44
51
|
}))
|
|
45
52
|
|
|
53
|
+
// Un `view` qui contient `F` (majuscule) laisse croire à un épinglage : la classe sticky est
|
|
54
|
+
// posée mais reste **sans effet visible**. On le dit une fois plutôt que de le faire taire.
|
|
55
|
+
let warnedNoopSticky = false
|
|
56
|
+
watchEffect(() => {
|
|
57
|
+
if (warnedNoopSticky) return
|
|
58
|
+
if (zone.value?.fixed && !props.fixed) {
|
|
59
|
+
warnedNoopSticky = true
|
|
60
|
+
console.warn(
|
|
61
|
+
'[q-footer] `view` "…F…" rend la footer *sticky*, sans effet visible ' +
|
|
62
|
+
'(dernière rangée du grid + scroll document). Pour épingler : <q-footer fixed>.',
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
46
67
|
// Empilement : bottom = hauteur cumulée des footers fixed suivants (si fixed)
|
|
47
68
|
useFixedBarOffset(rootEl, "bar-bottom", () => props.fixed)
|
|
48
69
|
|
|
@@ -80,7 +101,7 @@ onBeforeUnmount(() => {
|
|
|
80
101
|
ref="rootEl"
|
|
81
102
|
class="q-footer"
|
|
82
103
|
:class="{
|
|
83
|
-
'q-footer--fixed': fixed,
|
|
104
|
+
'q-footer--fixed': pin.fixed,
|
|
84
105
|
'q-footer--sticky': isSticky,
|
|
85
106
|
'q-footer--elevated': props.elevated,
|
|
86
107
|
'q-footer--bordered': props.bordered,
|
package/components/QHeader.vue
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// barre se décale sous la précédente automatiquement.
|
|
6
6
|
import { computed, inject, ref } from "vue"
|
|
7
7
|
import { useFixedBarOffset } from "../lib/fixedLayout"
|
|
8
|
-
import { qLayoutKey } from "../lib/layout"
|
|
8
|
+
import { barPin, qLayoutKey } from "../lib/layout"
|
|
9
9
|
|
|
10
10
|
interface Props {
|
|
11
11
|
/** Fixe la barre en haut de l'écran (sort du flux) */
|
|
@@ -29,7 +29,9 @@ const rootEl = ref<HTMLElement | null>(null)
|
|
|
29
29
|
// par la casse de la lettre (H) — la barre reste alors close dans sa cellule.
|
|
30
30
|
const layout = inject(qLayoutKey, null)
|
|
31
31
|
const zone = computed(() => layout?.zones.value.header)
|
|
32
|
-
|
|
32
|
+
// Même contrat que la footer : seule la prop `fixed` épingle ; la casse `H`/`F` « colle ».
|
|
33
|
+
const pin = computed(() => barPin(props, !!zone.value?.fixed))
|
|
34
|
+
const isSticky = computed(() => pin.value.sticky)
|
|
33
35
|
const layoutStyle = computed<Record<string, string | undefined>>(() => ({
|
|
34
36
|
gridArea: zone.value?.area,
|
|
35
37
|
}))
|
|
@@ -49,7 +51,7 @@ const translucentStyle = computed<Record<string, string> | undefined>(() =>
|
|
|
49
51
|
ref="rootEl"
|
|
50
52
|
class="q-header"
|
|
51
53
|
:class="{
|
|
52
|
-
'q-header--fixed': fixed,
|
|
54
|
+
'q-header--fixed': pin.fixed,
|
|
53
55
|
'q-header--sticky': isSticky,
|
|
54
56
|
'q-header--translucent': translucent === true || typeof translucent === 'number',
|
|
55
57
|
'q-header--glass': glass,
|
package/components/QSidebar.vue
CHANGED
|
@@ -180,7 +180,10 @@ let isHorizontal = false
|
|
|
180
180
|
|
|
181
181
|
const onPointerDown = (e: PointerEvent) => {
|
|
182
182
|
if (isStatic.value || !open.value || props.disable) return
|
|
183
|
-
|
|
183
|
+
// ⚠️ Ne **pas** `setPointerCapture` ici : capturer au `pointerdown` fait retarger le
|
|
184
|
+
// `click` qui suit vers l'`<aside>` → **aucun** handler interne ne se déclenche (ni le ×
|
|
185
|
+
// de fermeture, ni les items de menu). La capture est prise uniquement si le geste est
|
|
186
|
+
// **confirmé horizontal** (cf. `onPointerMove`), donc un vrai clic n'est jamais capturé.
|
|
184
187
|
startX = e.clientX
|
|
185
188
|
startY = e.clientY
|
|
186
189
|
currentDx = 0
|
|
@@ -198,6 +201,10 @@ const onPointerMove = (e: PointerEvent) => {
|
|
|
198
201
|
if (!isHorizontal) {
|
|
199
202
|
if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
|
|
200
203
|
isHorizontal = true
|
|
204
|
+
// Capture **au moment du swipe confirmé** (et non au pointerdown) : c'est ce qui
|
|
205
|
+
// laisse le `click` d'un vrai clic atteindre les handlers internes, tout en gardant
|
|
206
|
+
// les événements du swipe même si le pointeur sort du panneau.
|
|
207
|
+
rootEl.value.setPointerCapture(e.pointerId)
|
|
201
208
|
rootEl.value.style.transition = "none"
|
|
202
209
|
}
|
|
203
210
|
else return
|
|
@@ -210,11 +217,14 @@ const onPointerMove = (e: PointerEvent) => {
|
|
|
210
217
|
rootEl.value.style.transform = `translateX(${dir === 1 ? travel : -travel}px)`
|
|
211
218
|
}
|
|
212
219
|
|
|
213
|
-
const onPointerUp = () => {
|
|
220
|
+
const onPointerUp = (e: PointerEvent) => {
|
|
214
221
|
if (!dragging.value) return
|
|
215
222
|
dragging.value = false
|
|
216
223
|
const el = rootEl.value
|
|
217
224
|
if (!el) return
|
|
225
|
+
// Relâche la capture prise au swipe confirmé (le navigateur le fait aussi en fin de geste ;
|
|
226
|
+
// on l'explicite pour rester cohérent).
|
|
227
|
+
if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
|
218
228
|
el.style.transition = "" // restaure la transition CSS
|
|
219
229
|
const width = el.offsetWidth || parseInt(props.width, 10) || 260
|
|
220
230
|
if (currentDx > Math.min(80, width * 0.3)) {
|
package/lib/layout.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Tests unitaires de l'analyse du prop `view` de QLayout (matrice 3×3).
|
|
2
2
|
import { describe, expect, it } from "bun:test"
|
|
3
|
-
import { DEFAULT_LAYOUT_VIEW, parseView } from "./layout"
|
|
3
|
+
import { barPin, DEFAULT_LAYOUT_VIEW, parseView } from "./layout"
|
|
4
4
|
|
|
5
5
|
describe("parseView", () => {
|
|
6
6
|
it("applique la vue par défaut sans argument", () => {
|
|
@@ -123,3 +123,28 @@ describe("parseView", () => {
|
|
|
123
123
|
expect(v.gridTemplateAreas).toBe('"h h h" "l p r" "f f f"')
|
|
124
124
|
})
|
|
125
125
|
})
|
|
126
|
+
|
|
127
|
+
describe("barPin — contrat header / footer", () => {
|
|
128
|
+
it("seule la prop `fixed` épingle (position: fixed)", () => {
|
|
129
|
+
expect(barPin({}, false)).toEqual({ fixed: false, sticky: false })
|
|
130
|
+
expect(barPin({ fixed: true }, false)).toEqual({ fixed: true, sticky: false })
|
|
131
|
+
expect(barPin({ fixed: false }, true)).toEqual({ fixed: false, sticky: true })
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("la prop `sticky` colle (position: sticky)", () => {
|
|
135
|
+
expect(barPin({ sticky: true }, false)).toEqual({ fixed: false, sticky: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("la casse F/H de la zone colle, mais n'épingle JAMAIS", () => {
|
|
139
|
+
// `view="hHh LpR fFf"` : la zone footer est « fixed » au sens du parsing…
|
|
140
|
+
const zoneFixed = parseView("hHh LpR fFf").zones.footer.fixed
|
|
141
|
+
expect(zoneFixed).toBe(true)
|
|
142
|
+
// …et pourtant la barre ne doit PAS devenir `position: fixed` : seulement `sticky`
|
|
143
|
+
// (sans effet visible sur la dernière rangée du grid).
|
|
144
|
+
expect(barPin({}, zoneFixed)).toEqual({ fixed: false, sticky: true })
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("`fixed` l'emporte : épinglé et collé sont indépendants", () => {
|
|
148
|
+
expect(barPin({ fixed: true, sticky: true }, true)).toEqual({ fixed: true, sticky: true })
|
|
149
|
+
})
|
|
150
|
+
})
|
package/lib/layout.ts
CHANGED
|
@@ -179,3 +179,24 @@ export function parseView(view?: string): LayoutView {
|
|
|
179
179
|
}
|
|
180
180
|
return parsed
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Position d'une **barre** (`q-header` / `q-footer`) dans le modèle scroll-document :
|
|
185
|
+
* - **`fixed`** — *épinglée* (`position: fixed`), hors flux : **décidée par la seule prop
|
|
186
|
+
* `fixed`** ;
|
|
187
|
+
* - **`sticky`** — *collée* (`position: sticky`) : la prop `sticky` **ou** la casse `F`/`H`
|
|
188
|
+
* de la zone.
|
|
189
|
+
*
|
|
190
|
+
* ⚠️ **Contrat (ne pas inverser)** : pour la **footer**, la lettre `F` produit bien
|
|
191
|
+
* `sticky`, mais `position: sticky; bottom: 0` sur la **dernière rangée** du grid est
|
|
192
|
+
* **sans effet visible** (la barre tombe déjà en fin de page) — contrairement à `H` sur le
|
|
193
|
+
* header, qui reste collé en haut. Un `F` **minuscule ou majuscule** est donc équivalent.
|
|
194
|
+
* `zoneFixed` ne doit **jamais** être traduit en `fixed` : ce serait un changement de
|
|
195
|
+
* sémantique *breaking* (le contenu passerait sous la barre). Seul `fixed` épingle.
|
|
196
|
+
*/
|
|
197
|
+
export function barPin(
|
|
198
|
+
props: { fixed?: boolean; sticky?: boolean },
|
|
199
|
+
zoneFixed: boolean,
|
|
200
|
+
): { fixed: boolean; sticky: boolean } {
|
|
201
|
+
return { fixed: props.fixed === true, sticky: props.sticky === true || zoneFixed }
|
|
202
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Contrat des **styles** (`styles/main.css` et les composants) :
|
|
2
|
+
// 1. tokens de thème (couche `@layer dnax-tokens`) — voir plus bas ;
|
|
3
|
+
// 2. classes de style **partagées** entre un composant et la feuille : une divergence des deux
|
|
4
|
+
// côtés (classe renommée d'un seul côté) est un échec **silencieux** — aucune erreur, juste
|
|
5
|
+
// un style qui ne s'applique plus.
|
|
6
|
+
//
|
|
7
|
+
// Test de contenu de fichier : la seule vérification possible sans navigateur (règle projet).
|
|
8
|
+
import { describe, expect, it } from "bun:test"
|
|
9
|
+
|
|
10
|
+
const css = await Bun.file(new URL("../styles/main.css", import.meta.url)).text()
|
|
11
|
+
|
|
12
|
+
/** Contenu d'un bloc `selector { … }` (accolades équilibrées), à partir de `from`. */
|
|
13
|
+
function blockAfter(source: string, selector: string, from = 0): string | undefined {
|
|
14
|
+
const at = source.indexOf(selector, from)
|
|
15
|
+
if (at === -1) return undefined
|
|
16
|
+
const open = source.indexOf("{", at)
|
|
17
|
+
if (open === -1) return undefined
|
|
18
|
+
let depth = 0
|
|
19
|
+
for (let i = open; i < source.length; i++) {
|
|
20
|
+
if (source[i] === "{") depth++
|
|
21
|
+
else if (source[i] === "}") {
|
|
22
|
+
depth--
|
|
23
|
+
if (depth === 0) return source.slice(open + 1, i)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Propriétés personnalisées (`--x:`) déclarées dans un bloc. */
|
|
30
|
+
function tokensOf(block: string): Set<string> {
|
|
31
|
+
const out = new Set<string>()
|
|
32
|
+
for (const m of block.matchAll(/(--[\w-]+)\s*:/g)) out.add(m[1]!)
|
|
33
|
+
return out
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const tokensLayerAt = css.indexOf("@layer dnax-tokens")
|
|
37
|
+
const lightBlock = blockAfter(css, ":root", tokensLayerAt)
|
|
38
|
+
const darkBlock = blockAfter(css, ".dark", tokensLayerAt)
|
|
39
|
+
|
|
40
|
+
/** Tokens sémantiques (surfaces + texte) — doivent exister dans les deux thèmes. */
|
|
41
|
+
const SEMANTIC = ["--background", "--foreground", "--card", "--muted", "--muted-foreground", "--border"]
|
|
42
|
+
|
|
43
|
+
describe("tokens de thème (clair / sombre)", () => {
|
|
44
|
+
it("la couche @layer dnax-tokens contient les deux thèmes", () => {
|
|
45
|
+
expect(tokensLayerAt).toBeGreaterThan(-1)
|
|
46
|
+
expect(lightBlock).toBeDefined()
|
|
47
|
+
expect(darkBlock).toBeDefined()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("clair et sombre définissent les mêmes tokens sémantiques", () => {
|
|
51
|
+
const light = tokensOf(lightBlock!)
|
|
52
|
+
const dark = tokensOf(darkBlock!)
|
|
53
|
+
for (const token of SEMANTIC) {
|
|
54
|
+
expect({ token, inLight: light.has(token) }).toEqual({ token, inLight: true })
|
|
55
|
+
expect({ token, inDark: dark.has(token) }).toEqual({ token, inDark: true })
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("tout `var(--muted-foreground…)` a un token défini (pas seulement un fallback)", () => {
|
|
60
|
+
// Le token est référencé ailleurs dans la feuille : il doit exister en clair ET en sombre.
|
|
61
|
+
expect(css.includes("var(--muted-foreground")).toBe(true)
|
|
62
|
+
expect(tokensOf(lightBlock!)).toContain("--muted-foreground")
|
|
63
|
+
expect(tokensOf(darkBlock!)).toContain("--muted-foreground")
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("les descriptions de dialog et de bottom sheet lisent le token (plus de noir en dur)", () => {
|
|
67
|
+
for (const selector of [".q-dialog__description", ".q-bottom-sheet__description"]) {
|
|
68
|
+
const block = blockAfter(css, selector)
|
|
69
|
+
expect({ selector, defined: block !== undefined }).toEqual({ selector, defined: true })
|
|
70
|
+
expect({ selector, usesToken: block!.includes("var(--muted-foreground") }).toEqual({
|
|
71
|
+
selector,
|
|
72
|
+
usesToken: true,
|
|
73
|
+
})
|
|
74
|
+
expect({ selector, hardcodedBlack: block!.includes("color: rgb(0 0 0 / 0.6)") }).toEqual({
|
|
75
|
+
selector,
|
|
76
|
+
hardcodedBlack: false,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("plus aucune couleur de texte secondaire en noir codé en dur", () => {
|
|
82
|
+
// Le token clair vaut exactement `rgb(0 0 0 / 0.6)` → la migration est transparente en clair
|
|
83
|
+
// et corrige le sombre. On interdit donc la forme littérale (le fallback du token reste permis :
|
|
84
|
+
// il s'écrit `var(--muted-foreground, rgb(0 0 0 / 0.6))`).
|
|
85
|
+
// ⚠️ Lookbehind : `background-color: …` **contient** la sous-chaîne `color: …`.
|
|
86
|
+
expect(/(?<!-)color: rgb\(0 0 0 \/ 0\.6\)/.test(css)).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe("classes de style (composant ↔ CSS)", () => {
|
|
91
|
+
/** `[fichier du composant, classe, sélecteur CSS attendu]` */
|
|
92
|
+
const CONTRACTS: [string, string, string][] = [
|
|
93
|
+
[
|
|
94
|
+
"components/QDialogHeader.vue",
|
|
95
|
+
"q-dialog__header-text--with-description",
|
|
96
|
+
".q-dialog__header-text--with-description .q-dialog__title",
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
"components/QBottomSheetHeader.vue",
|
|
100
|
+
"q-bottom-sheet__header-text--with-description",
|
|
101
|
+
".q-bottom-sheet__header-text--with-description .q-bottom-sheet__title",
|
|
102
|
+
],
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
for (const [file, className, selector] of CONTRACTS) {
|
|
106
|
+
it(`« ${className} » est posée par le composant ET stylée par le CSS`, async () => {
|
|
107
|
+
const vue = await Bun.file(new URL(`../${file}`, import.meta.url)).text()
|
|
108
|
+
expect({ file, inComponent: vue.includes(className) }).toEqual({ file, inComponent: true })
|
|
109
|
+
expect({ selector, inCss: css.includes(selector) }).toEqual({ selector, inCss: true })
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe("mode sombre des contrôles", () => {
|
|
115
|
+
it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
|
|
116
|
+
// Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
|
|
117
|
+
// il faut aussi le **mode sombre du thème**) — sinon le contrôle est invisible en dark.
|
|
118
|
+
expect(css.includes("border: 2px solid rgb(0 0 0 / 0.45)")).toBe(true)
|
|
119
|
+
for (const selector of [".dark .q-checkbox__box", ".dark .q-radio__circle"]) {
|
|
120
|
+
expect({ selector, present: css.includes(selector) }).toEqual({ selector, present: true })
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
package/package.json
CHANGED
package/styles/main.css
CHANGED
|
@@ -155,14 +155,18 @@ body {
|
|
|
155
155
|
|
|
156
156
|
/* Fallback sans mesure JS (SSR, barres pas encore observées) : 50px + safe-area.
|
|
157
157
|
Écrit dans la **variable** d'offset — et non en padding — pour que la hauteur
|
|
158
|
-
mesurée (style inline, lib/fixedLayout.ts) la remplace dès l'hydratation.
|
|
159
|
-
.q-app
|
|
158
|
+
mesurée (style inline, lib/fixedLayout.ts) la remplace dès l'hydratation.
|
|
159
|
+
`:is(.q-app, .q-config-provider, .q-layout)` couvre les trois racines possibles :
|
|
160
|
+
l'installation **recommandée** (QConfigProvider, avec ou sans QLayout) n'utilise pas
|
|
161
|
+
forcément `.q-app`. Le chemin **mesuré** (`fixedLayout.ts`) retombe déjà sur
|
|
162
|
+
`.q-app` sinon le parent direct — seul ce fallback CSS était trop restrictif. */
|
|
163
|
+
:is(.q-app, .q-config-provider, .q-layout):has(.q-header--fixed) :is(.q-page, .q-page-container) {
|
|
160
164
|
--q-page-offset: 50px;
|
|
161
165
|
--q-page-offset: calc(constant(safe-area-inset-top) + 50px);
|
|
162
166
|
--q-page-offset: calc(env(safe-area-inset-top) + 50px);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
|
-
.q-app:has(.q-footer--fixed) :is(.q-page, .q-page-container) {
|
|
169
|
+
:is(.q-app, .q-config-provider, .q-layout):has(.q-footer--fixed) :is(.q-page, .q-page-container) {
|
|
166
170
|
--q-page-offset-bottom: 50px;
|
|
167
171
|
--q-page-offset-bottom: calc(constant(safe-area-inset-bottom) + 50px);
|
|
168
172
|
--q-page-offset-bottom: calc(env(safe-area-inset-bottom) + 50px);
|
|
@@ -342,6 +346,9 @@ body {
|
|
|
342
346
|
--foreground: #1d1d1d;
|
|
343
347
|
--card: #ffffff;
|
|
344
348
|
--muted: #f4f6f9;
|
|
349
|
+
/* Texte secondaire (descriptions, préfixes, curseurs…) — **défini ici ET en .dark** :
|
|
350
|
+
tout `var(--muted-foreground, …)` sans définition tombait sur son fallback sombre. */
|
|
351
|
+
--muted-foreground: rgb(0 0 0 / 0.6);
|
|
345
352
|
--border: rgb(0 0 0 / 0.12);
|
|
346
353
|
|
|
347
354
|
/* Palette des séries de <q-chart> (convention `--chart-N`, reprise de shadcn) :
|
|
@@ -362,6 +369,7 @@ body {
|
|
|
362
369
|
--foreground: #e6edf3;
|
|
363
370
|
--card: #161b22;
|
|
364
371
|
--muted: #1c2128;
|
|
372
|
+
--muted-foreground: rgb(255 255 255 / 0.62);
|
|
365
373
|
--border: rgb(255 255 255 / 0.12);
|
|
366
374
|
--q-table-border: rgb(255 255 255 / 0.12);
|
|
367
375
|
--q-table-hover-bg: rgb(255 255 255 / 0.06);
|
|
@@ -507,6 +515,11 @@ html.dark body {
|
|
|
507
515
|
border-color: var(--border);
|
|
508
516
|
}
|
|
509
517
|
|
|
518
|
+
/* Séparateur de fil d'Ariane : le noir translucide est invisible en sombre */
|
|
519
|
+
.dark .q-breadcrumbs {
|
|
520
|
+
--q-breadcrumbs-sep-color: rgb(255 255 255 / 0.42);
|
|
521
|
+
}
|
|
522
|
+
|
|
510
523
|
/* ===== QBtn ===== */
|
|
511
524
|
.q-btn {
|
|
512
525
|
--q-btn-bg: var(--primary);
|
|
@@ -2136,12 +2149,12 @@ html.dark body {
|
|
|
2136
2149
|
display: block;
|
|
2137
2150
|
margin-bottom: 2px;
|
|
2138
2151
|
font-size: 12px;
|
|
2139
|
-
color: rgb(0 0 0 / 0.6);
|
|
2152
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
2140
2153
|
}
|
|
2141
2154
|
|
|
2142
2155
|
.q-field__prefix,
|
|
2143
2156
|
.q-field__suffix {
|
|
2144
|
-
color: rgb(0 0 0 / 0.6);
|
|
2157
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
2145
2158
|
white-space: nowrap;
|
|
2146
2159
|
}
|
|
2147
2160
|
|
|
@@ -2335,7 +2348,7 @@ html.dark body {
|
|
|
2335
2348
|
font-size: 12px;
|
|
2336
2349
|
line-height: 1.2;
|
|
2337
2350
|
text-align: center;
|
|
2338
|
-
color: rgb(0 0 0 / 0.6);
|
|
2351
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
2339
2352
|
}
|
|
2340
2353
|
|
|
2341
2354
|
.q-image-picker__input {
|
|
@@ -2471,7 +2484,7 @@ html.dark body {
|
|
|
2471
2484
|
|
|
2472
2485
|
.q-file-picker__add-label {
|
|
2473
2486
|
font-size: 13px;
|
|
2474
|
-
color: rgb(0 0 0 / 0.6);
|
|
2487
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
2475
2488
|
}
|
|
2476
2489
|
|
|
2477
2490
|
.q-file-picker__input {
|
|
@@ -3453,7 +3466,7 @@ html.dark body {
|
|
|
3453
3466
|
display: flex;
|
|
3454
3467
|
align-items: center;
|
|
3455
3468
|
gap: 6px;
|
|
3456
|
-
color: rgb(0 0 0 / 0.6);
|
|
3469
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
3457
3470
|
}
|
|
3458
3471
|
|
|
3459
3472
|
.q-table--dark .q-table__rows-select {
|
|
@@ -3675,8 +3688,14 @@ html.dark body {
|
|
|
3675
3688
|
line-height: 1.4;
|
|
3676
3689
|
}
|
|
3677
3690
|
|
|
3691
|
+
/* Bordure du contrôle (état décoché) : noir 45 % en clair → **illisible en sombre**.
|
|
3692
|
+
Couvert par la variante `--dark` du composant (surface sombre explicite) ET par le
|
|
3693
|
+
**mode sombre du thème** (`.dark`), avec la même valeur.
|
|
3694
|
+
L'état coché/indéterminé est stylé en inline (couleur de la prop) — non concerné. */
|
|
3678
3695
|
.q-checkbox--dark .q-checkbox__box,
|
|
3679
|
-
.q-radio--dark .q-radio__circle
|
|
3696
|
+
.q-radio--dark .q-radio__circle,
|
|
3697
|
+
.dark .q-checkbox__box,
|
|
3698
|
+
.dark .q-radio__circle {
|
|
3680
3699
|
border-color: rgb(255 255 255 / 0.6);
|
|
3681
3700
|
}
|
|
3682
3701
|
|
|
@@ -3701,7 +3720,7 @@ html.dark body {
|
|
|
3701
3720
|
width: 18px;
|
|
3702
3721
|
height: 18px;
|
|
3703
3722
|
flex-shrink: 0;
|
|
3704
|
-
color: rgb(0 0 0 / 0.6);
|
|
3723
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
3705
3724
|
transition: transform 0.2s ease;
|
|
3706
3725
|
}
|
|
3707
3726
|
|
|
@@ -4164,7 +4183,7 @@ html.dark body {
|
|
|
4164
4183
|
/* ===== QBreadcrumbs ===== */
|
|
4165
4184
|
.q-breadcrumbs {
|
|
4166
4185
|
--q-breadcrumbs-gutter: 8px;
|
|
4167
|
-
--q-breadcrumbs-color: rgb(0 0 0 / 0.6);
|
|
4186
|
+
--q-breadcrumbs-color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
4168
4187
|
--q-breadcrumbs-sep-color: rgb(0 0 0 / 0.42);
|
|
4169
4188
|
--q-breadcrumbs-active-color: var(--primary);
|
|
4170
4189
|
|
|
@@ -4888,7 +4907,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
4888
4907
|
width: 18px;
|
|
4889
4908
|
height: 18px;
|
|
4890
4909
|
flex-shrink: 0;
|
|
4891
|
-
color: rgb(0 0 0 / 0.6);
|
|
4910
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
4892
4911
|
transition: transform 0.2s ease;
|
|
4893
4912
|
}
|
|
4894
4913
|
|
|
@@ -5324,6 +5343,12 @@ a.q-breadcrumbs__el:hover {
|
|
|
5324
5343
|
min-width: 0;
|
|
5325
5344
|
}
|
|
5326
5345
|
|
|
5346
|
+
/* Description présente → le bloc titre+description est plus haut que la toolbar centrée :
|
|
5347
|
+
un peu d'air au-dessus du titre évite qu'il colle au bord haut. */
|
|
5348
|
+
.q-bottom-sheet__header-text--with-description .q-bottom-sheet__title {
|
|
5349
|
+
padding-top: 4px;
|
|
5350
|
+
}
|
|
5351
|
+
|
|
5327
5352
|
.q-bottom-sheet__title {
|
|
5328
5353
|
margin: 0;
|
|
5329
5354
|
font-size: 17px;
|
|
@@ -5334,7 +5359,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
5334
5359
|
.q-bottom-sheet__description {
|
|
5335
5360
|
margin: 4px 0 0;
|
|
5336
5361
|
font-size: 14px;
|
|
5337
|
-
color: rgb(0 0 0 / 0.6);
|
|
5362
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
5338
5363
|
line-height: 1.4;
|
|
5339
5364
|
}
|
|
5340
5365
|
|
|
@@ -5655,7 +5680,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
5655
5680
|
width: 18px;
|
|
5656
5681
|
height: 18px;
|
|
5657
5682
|
flex-shrink: 0;
|
|
5658
|
-
color: rgb(0 0 0 / 0.6);
|
|
5683
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
5659
5684
|
}
|
|
5660
5685
|
|
|
5661
5686
|
.q-date-picker__overlay {
|
|
@@ -6946,6 +6971,12 @@ a.q-breadcrumbs__el:hover {
|
|
|
6946
6971
|
min-width: 0;
|
|
6947
6972
|
}
|
|
6948
6973
|
|
|
6974
|
+
/* Description présente → le bloc titre+description est plus haut que la toolbar centrée :
|
|
6975
|
+
un peu d'air au-dessus du titre évite qu'il colle au bord haut. */
|
|
6976
|
+
.q-dialog__header-text--with-description .q-dialog__title {
|
|
6977
|
+
padding-top: 4px;
|
|
6978
|
+
}
|
|
6979
|
+
|
|
6949
6980
|
.q-dialog__title {
|
|
6950
6981
|
margin: 0;
|
|
6951
6982
|
font-size: 17px;
|
|
@@ -6956,7 +6987,7 @@ a.q-breadcrumbs__el:hover {
|
|
|
6956
6987
|
.q-dialog__description {
|
|
6957
6988
|
margin: 4px 0 0;
|
|
6958
6989
|
font-size: 14px;
|
|
6959
|
-
color: rgb(0 0 0 / 0.6);
|
|
6990
|
+
color: var(--muted-foreground, rgb(0 0 0 / 0.6));
|
|
6960
6991
|
line-height: 1.4;
|
|
6961
6992
|
}
|
|
6962
6993
|
|
|
@@ -6979,6 +7010,18 @@ a.q-breadcrumbs__el:hover {
|
|
|
6979
7010
|
background-color: rgb(0 0 0 / 0.06);
|
|
6980
7011
|
}
|
|
6981
7012
|
|
|
7013
|
+
/* Mode sombre du dialog : bordure des zones sticky et survol du × — le noir
|
|
7014
|
+
codé en dur (bordure, hover) y est invisible. Convention du paquet :
|
|
7015
|
+
surfaces/hovers sombres en blanc translucide (cf. `.dark .q-btn-actions__item:hover`). */
|
|
7016
|
+
.dark .q-dialog__header,
|
|
7017
|
+
.dark .q-dialog__footer {
|
|
7018
|
+
border-color: var(--border);
|
|
7019
|
+
}
|
|
7020
|
+
|
|
7021
|
+
.dark .q-dialog__close:hover {
|
|
7022
|
+
background-color: rgb(255 255 255 / 0.08);
|
|
7023
|
+
}
|
|
7024
|
+
|
|
6982
7025
|
.q-dialog__close svg {
|
|
6983
7026
|
width: 16px;
|
|
6984
7027
|
height: 16px;
|