@dnax/ui 0.0.61 → 0.0.63

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.
@@ -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 class="q-bottom-sheet__header-text">
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>
@@ -23,6 +23,11 @@ interface Props {
23
23
  iconRight?: string
24
24
  /** Icône Iconify du chevron (défaut : lucide:chevron-down) */
25
25
  expandIcon?: string
26
+ /**
27
+ * **Guide vertical** à gauche du contenu (« tirets longs »), aligné sous l'icône du header —
28
+ * pour une arborescence (groupe + enfants). Suit le thème (`--border`).
29
+ */
30
+ guide?: boolean
26
31
  headerClass?: string
27
32
  /** Hauteur réduite */
28
33
  dense?: boolean
@@ -32,6 +37,7 @@ interface Props {
32
37
 
33
38
  const props = withDefaults(defineProps<Props>(), {
34
39
  defaultOpened: false,
40
+ guide: false,
35
41
  dense: false,
36
42
  disable: false,
37
43
  headerClass: "",
@@ -98,7 +104,7 @@ onMounted(measure)
98
104
  </script>
99
105
 
100
106
  <template>
101
- <div class="q-collapse">
107
+ <div class="q-collapse" :class="{ 'q-collapse--guide': guide }">
102
108
  <button
103
109
  type="button"
104
110
  class="q-collapse__header"
@@ -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 class="q-dialog__header-text">
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>
@@ -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
- /** Colle la barre en bas pendant le scroll (automatique dans un QLayout avec une lettre majuscule) */
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
- const isSticky = computed(() => props.sticky || !!zone.value?.fixed)
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,
@@ -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
- const isSticky = computed(() => props.sticky || !!zone.value?.fixed)
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,
@@ -60,7 +60,7 @@ import { radiusStyle, radiusValue, useRadius } from "../lib/useComponentProps"
60
60
  import type { RadiusProp } from "../lib/useComponentProps"
61
61
  import { useOverlayBack } from "../lib/overlayBack"
62
62
  import { createSearcher } from "../lib/search"
63
- import { optionsOf, selectionOf } from "../lib/select"
63
+ import { optionsOf, selectionOf, shouldClearSearchOnClose } from "../lib/select"
64
64
 
65
65
  interface Props {
66
66
  /** Valeur sélectionnée : option, valeur (emit-value), ou tableau (multiple) */
@@ -112,6 +112,12 @@ interface Props {
112
112
  useSearch?: boolean | UseSearchOptions
113
113
  /** Placeholder du champ de recherche (useSearch) */
114
114
  searchPlaceholder?: string
115
+ /**
116
+ * Efface la recherche saisie à la **fermeture sans sélection** (clic extérieur, backdrop,
117
+ * Échap, ×). **Désactivé par défaut** : la recherche est conservée et réaffichée à la
118
+ * réouverture. Même activé, elle n'est effacée qu'en l'absence de valeur sélectionnée.
119
+ */
120
+ clearSearchOnClose?: boolean
115
121
  /** Mode d'ouverture : inline (dropdown) | modal (centré) | sheet (bottom sheet) | dialog (plein écran) */
116
122
  mode?: "inline" | "modal" | "sheet" | "dialog"
117
123
  /** Largeur du panneau (modal/sheet) : "300px", "80%"… (par défaut : modal 480px / 90% si écran < 768px, sheet 640px) */
@@ -164,6 +170,8 @@ const props = withDefaults(defineProps<Props>(), {
164
170
  mode: "inline",
165
171
  offset: 8,
166
172
  position: "auto",
173
+ // Comportement de "reset" de la recherche à la fermeture : **désactivé par défaut**
174
+ clearSearchOnClose: false,
167
175
  })
168
176
 
169
177
  const emit = defineEmits<{
@@ -532,9 +540,10 @@ const openPopup = () => {
532
540
 
533
541
  const closePopup = () => {
534
542
  open.value = false
535
- // Fermeture sans sélection (clic extérieur, backdrop, Échap, ×) : on abandonne la
536
- // recherche saisie pour ne pas laisser un filtre fantôme au prochain affichage.
537
- if (query.value !== "" && !hasValue.value) {
543
+ // Fermeture sans sélection (clic extérieur, backdrop, Échap, ×) : la recherche est
544
+ // **conservée par défaut** (opt-in via `clearSearchOnClose`) — le filtre ne "disparaît"
545
+ // donc plus tout seul à la réouverture (cf. `shouldClearSearchOnClose`).
546
+ if (shouldClearSearchOnClose(props.clearSearchOnClose, query.value, hasValue.value)) {
538
547
  query.value = ""
539
548
  emit("update:inputValue", "")
540
549
  }
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  // Régression : `multiple` + modèle scalaire (`false`) faisait jeter `list.map` et
3
3
  // empêchait le popup entier de se rendre.
4
4
  import { describe, expect, it } from "bun:test"
5
- import { optionsOf, selectionOf } from "./select"
5
+ import { optionsOf, selectionOf, shouldClearSearchOnClose } from "./select"
6
6
 
7
7
  describe("optionsOf", () => {
8
8
  it("laisse passer un tableau (même vide)", () => {
@@ -40,3 +40,17 @@ describe("selectionOf", () => {
40
40
  expect(selectionOf(undefined, false)).toEqual([])
41
41
  })
42
42
  })
43
+
44
+ describe("shouldClearSearchOnClose", () => {
45
+ it("désactivé (défaut) → la recherche est CONSERVÉE", () => {
46
+ expect(shouldClearSearchOnClose(false, "ada", false)).toBe(false)
47
+ expect(shouldClearSearchOnClose(false, "ada", true)).toBe(false)
48
+ expect(shouldClearSearchOnClose(false, "", false)).toBe(false)
49
+ })
50
+
51
+ it("activé → efface seulement si une recherche est saisie et aucune valeur choisie", () => {
52
+ expect(shouldClearSearchOnClose(true, "ada", false)).toBe(true)
53
+ expect(shouldClearSearchOnClose(true, "", false)).toBe(false) // rien à effacer
54
+ expect(shouldClearSearchOnClose(true, "ada", true)).toBe(false) // valeur choisie → label
55
+ })
56
+ })
package/lib/select.ts CHANGED
@@ -28,3 +28,18 @@ export const selectionOf = (value: unknown, multiple: boolean): any[] => {
28
28
  if (multiple) return Array.isArray(value) ? value : []
29
29
  return [value]
30
30
  }
31
+
32
+ /**
33
+ * La recherche saisie doit-elle être **effacée** à la fermeture du panneau (clic extérieur,
34
+ * backdrop, Échap, ×) ?
35
+ *
36
+ * **Opt-in** (prop `clearSearchOnClose`, désactivée par défaut) : par défaut la recherche est
37
+ * **conservée** — un `inputValue` vidé à la fermeture n'est pas toujours souhaité (formulaire
38
+ * qui reprend la saisie, filtre persistant…). Même sous opt-in, on n'efface que s'il n'y a **pas**
39
+ * de valeur : sinon le champ d'affichage reprendrait le label et la recherche n'aurait plus cours.
40
+ */
41
+ export const shouldClearSearchOnClose = (
42
+ enabled: boolean,
43
+ query: string,
44
+ hasValue: boolean,
45
+ ): boolean => enabled && query !== "" && !hasValue
@@ -0,0 +1,165 @@
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
+ ["components/QCollapse.vue", "q-collapse--guide", ".q-collapse--guide .q-collapse__inner"],
104
+ ]
105
+
106
+ for (const [file, className, selector] of CONTRACTS) {
107
+ it(`« ${className} » est posée par le composant ET stylée par le CSS`, async () => {
108
+ const vue = await Bun.file(new URL(`../${file}`, import.meta.url)).text()
109
+ expect({ file, inComponent: vue.includes(className) }).toEqual({ file, inComponent: true })
110
+ expect({ selector, inCss: css.includes(selector) }).toEqual({ selector, inCss: true })
111
+ })
112
+ }
113
+
114
+ it("une description ajoute aussi de l'air AVANT le séparateur (padding-bottom)", () => {
115
+ // Le séparateur est le `border-bottom` de l'en-tête : sans ce padding, le trait colle
116
+ // au texte de la description.
117
+ for (const selector of [
118
+ ".q-dialog__header-text--with-description",
119
+ ".q-bottom-sheet__header-text--with-description",
120
+ ]) {
121
+ const block = blockAfter(css, selector)
122
+ expect({ selector, defined: block !== undefined }).toEqual({ selector, defined: true })
123
+ expect({ selector, padded: block!.includes("padding-bottom") }).toEqual({
124
+ selector,
125
+ padded: true,
126
+ })
127
+ }
128
+ })
129
+ })
130
+
131
+ describe("mode sombre des contrôles", () => {
132
+ it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
133
+ // Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
134
+ // il faut aussi le **mode sombre du thème**) — sinon le contrôle est invisible en dark.
135
+ expect(css.includes("border: 2px solid rgb(0 0 0 / 0.45)")).toBe(true)
136
+ for (const selector of [".dark .q-checkbox__box", ".dark .q-radio__circle"]) {
137
+ expect({ selector, present: css.includes(selector) }).toEqual({ selector, present: true })
138
+ }
139
+ })
140
+ })
141
+
142
+ describe("en-têtes de panneau : safe-area", () => {
143
+ // La safe-area doit **s'ajouter** au padding de base, jamais le remplacer : sinon, sans
144
+ // encoche (`env()` = 0), le titre et le × collent au bord haut.
145
+ const PANEL_HEADERS = [
146
+ ".q-select__sheet--dialog .q-select__sheet-header",
147
+ ".q-date-picker__sheet--dialog .q-date-picker__sheet-header",
148
+ ".q-country-picker__sheet--dialog .q-country-picker__sheet-header",
149
+ ]
150
+
151
+ for (const selector of PANEL_HEADERS) {
152
+ it(`« ${selector} » ajoute la safe-area à un padding de base`, () => {
153
+ const block = blockAfter(css, selector)
154
+ expect({ selector, defined: block !== undefined }).toEqual({ selector, defined: true })
155
+ expect({ selector, adds: block!.includes("calc(env(safe-area-inset-top) + 16px)") }).toEqual({
156
+ selector,
157
+ adds: true,
158
+ })
159
+ expect({ selector, bare: block!.includes("padding-top: env(safe-area-inset-top);") }).toEqual({
160
+ selector,
161
+ bare: false,
162
+ })
163
+ })
164
+ }
165
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
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:has(.q-header--fixed) :is(.q-page, .q-page-container) {
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 {
@@ -3596,6 +3609,15 @@ html.dark body {
3596
3609
  padding: 0 16px;
3597
3610
  }
3598
3611
 
3612
+ /* Guide vertical (« tirets longs ») à gauche du contenu : trait aligné sous l'**icône** du
3613
+ header (padding 16px + icône 18px / 2) et contenu décalé d'autant. Suit le thème via
3614
+ `--border` (invisible nulle part : clair comme sombre). */
3615
+ .q-collapse--guide .q-collapse__inner {
3616
+ margin-left: 25px;
3617
+ padding-left: 12px;
3618
+ border-left: 1px solid var(--border);
3619
+ }
3620
+
3599
3621
  /* ===== QCheckbox / QRadio ===== */
3600
3622
  .q-checkbox,
3601
3623
  .q-radio {
@@ -3675,8 +3697,14 @@ html.dark body {
3675
3697
  line-height: 1.4;
3676
3698
  }
3677
3699
 
3700
+ /* Bordure du contrôle (état décoché) : noir 45 % en clair → **illisible en sombre**.
3701
+ Couvert par la variante `--dark` du composant (surface sombre explicite) ET par le
3702
+ **mode sombre du thème** (`.dark`), avec la même valeur.
3703
+ L'état coché/indéterminé est stylé en inline (couleur de la prop) — non concerné. */
3678
3704
  .q-checkbox--dark .q-checkbox__box,
3679
- .q-radio--dark .q-radio__circle {
3705
+ .q-radio--dark .q-radio__circle,
3706
+ .dark .q-checkbox__box,
3707
+ .dark .q-radio__circle {
3680
3708
  border-color: rgb(255 255 255 / 0.6);
3681
3709
  }
3682
3710
 
@@ -3701,7 +3729,7 @@ html.dark body {
3701
3729
  width: 18px;
3702
3730
  height: 18px;
3703
3731
  flex-shrink: 0;
3704
- color: rgb(0 0 0 / 0.6);
3732
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
3705
3733
  transition: transform 0.2s ease;
3706
3734
  }
3707
3735
 
@@ -3836,7 +3864,7 @@ html.dark body {
3836
3864
  align-items: center;
3837
3865
  justify-content: space-between;
3838
3866
  gap: 8px;
3839
- padding: 14px 16px;
3867
+ padding: 16px 16px 14px;
3840
3868
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
3841
3869
  font-weight: 600;
3842
3870
  font-size: 15px;
@@ -4164,7 +4192,7 @@ html.dark body {
4164
4192
  /* ===== QBreadcrumbs ===== */
4165
4193
  .q-breadcrumbs {
4166
4194
  --q-breadcrumbs-gutter: 8px;
4167
- --q-breadcrumbs-color: rgb(0 0 0 / 0.6);
4195
+ --q-breadcrumbs-color: var(--muted-foreground, rgb(0 0 0 / 0.6));
4168
4196
  --q-breadcrumbs-sep-color: rgb(0 0 0 / 0.42);
4169
4197
  --q-breadcrumbs-active-color: var(--primary);
4170
4198
 
@@ -4888,7 +4916,7 @@ a.q-breadcrumbs__el:hover {
4888
4916
  width: 18px;
4889
4917
  height: 18px;
4890
4918
  flex-shrink: 0;
4891
- color: rgb(0 0 0 / 0.6);
4919
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
4892
4920
  transition: transform 0.2s ease;
4893
4921
  }
4894
4922
 
@@ -5088,7 +5116,9 @@ a.q-breadcrumbs__el:hover {
5088
5116
  align-items: center;
5089
5117
  justify-content: space-between;
5090
5118
  gap: 8px;
5091
- padding: 14px 16px;
5119
+ /* Un peu plus d'air en haut (titre + bouton de fermeture) : le panneau a un coin
5120
+ arrondi, 14px des deux côtés collait le titre au bord. Bas inchangé. */
5121
+ padding: 16px 16px 14px;
5092
5122
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
5093
5123
  font-weight: 600;
5094
5124
  font-size: 15px;
@@ -5324,6 +5354,17 @@ a.q-breadcrumbs__el:hover {
5324
5354
  min-width: 0;
5325
5355
  }
5326
5356
 
5357
+ /* Description présente → le bloc titre+description est plus haut que la toolbar centrée :
5358
+ un peu d'air au-dessus du titre, **et un peu avant le séparateur** (sinon le trait colle
5359
+ au texte de la description). */
5360
+ .q-bottom-sheet__header-text--with-description {
5361
+ padding-bottom: 6px;
5362
+ }
5363
+
5364
+ .q-bottom-sheet__header-text--with-description .q-bottom-sheet__title {
5365
+ padding-top: 4px;
5366
+ }
5367
+
5327
5368
  .q-bottom-sheet__title {
5328
5369
  margin: 0;
5329
5370
  font-size: 17px;
@@ -5334,7 +5375,7 @@ a.q-breadcrumbs__el:hover {
5334
5375
  .q-bottom-sheet__description {
5335
5376
  margin: 4px 0 0;
5336
5377
  font-size: 14px;
5337
- color: rgb(0 0 0 / 0.6);
5378
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
5338
5379
  line-height: 1.4;
5339
5380
  }
5340
5381
 
@@ -5382,10 +5423,17 @@ a.q-breadcrumbs__el:hover {
5382
5423
  }
5383
5424
 
5384
5425
  .q-bottom-sheet__panel--dark .q-bottom-sheet__header,
5385
- .q-bottom-sheet__panel--dark .q-bottom-sheet__footer {
5426
+ .q-bottom-sheet__panel--dark .q-bottom-sheet__footer,
5427
+ /* Thème sombre : le noir 8 % codé en dur est invisible (même classe que le dialog). */
5428
+ .dark .q-bottom-sheet__header,
5429
+ .dark .q-bottom-sheet__footer {
5386
5430
  border-color: rgb(255 255 255 / 0.12);
5387
5431
  }
5388
5432
 
5433
+ .dark .q-bottom-sheet__close:hover {
5434
+ background-color: rgb(255 255 255 / 0.08);
5435
+ }
5436
+
5389
5437
  /* — transitions (slide vertical type vaul) — durées configurables via
5390
5438
  transition-duration (vars --q-bs-duration-enter/leave) */
5391
5439
  .q-bs-enter-active,
@@ -5655,7 +5703,7 @@ a.q-breadcrumbs__el:hover {
5655
5703
  width: 18px;
5656
5704
  height: 18px;
5657
5705
  flex-shrink: 0;
5658
- color: rgb(0 0 0 / 0.6);
5706
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
5659
5707
  }
5660
5708
 
5661
5709
  .q-date-picker__overlay {
@@ -5708,9 +5756,11 @@ a.q-breadcrumbs__el:hover {
5708
5756
  }
5709
5757
 
5710
5758
  .q-date-picker__sheet--dialog .q-date-picker__sheet-header {
5711
- padding-top: 0;
5712
- padding-top: constant(safe-area-inset-top);
5713
- padding-top: env(safe-area-inset-top);
5759
+ /* La safe-area **s'ajoute** au padding de base — sinon le titre et le × collent au bord
5760
+ haut (0 px) sur tout appareil sans encoche (`env()` vaut 0). */
5761
+ padding-top: 16px;
5762
+ padding-top: calc(constant(safe-area-inset-top) + 16px);
5763
+ padding-top: calc(env(safe-area-inset-top) + 16px);
5714
5764
  }
5715
5765
 
5716
5766
  .q-date-picker__sheet-header {
@@ -5718,7 +5768,7 @@ a.q-breadcrumbs__el:hover {
5718
5768
  align-items: center;
5719
5769
  justify-content: space-between;
5720
5770
  gap: 8px;
5721
- padding: 14px 16px;
5771
+ padding: 16px 16px 14px;
5722
5772
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
5723
5773
  font-weight: 600;
5724
5774
  font-size: 15px;
@@ -6186,16 +6236,17 @@ a.q-breadcrumbs__el:hover {
6186
6236
  box-shadow: none;
6187
6237
  }
6188
6238
  .q-country-picker__sheet--dialog .q-country-picker__sheet-header {
6189
- padding-top: 0;
6190
- padding-top: constant(safe-area-inset-top);
6191
- padding-top: env(safe-area-inset-top);
6239
+ /* Safe-area **ajoutée** au padding de base (cf. date-picker). */
6240
+ padding-top: 16px;
6241
+ padding-top: calc(constant(safe-area-inset-top) + 16px);
6242
+ padding-top: calc(env(safe-area-inset-top) + 16px);
6192
6243
  }
6193
6244
  .q-country-picker__sheet-header {
6194
6245
  display: flex;
6195
6246
  align-items: center;
6196
6247
  justify-content: space-between;
6197
6248
  gap: 8px;
6198
- padding: 14px 16px;
6249
+ padding: 16px 16px 14px;
6199
6250
  border-bottom: 1px solid rgb(0 0 0 / 0.08);
6200
6251
  font-weight: 600;
6201
6252
  font-size: 15px;
@@ -6946,6 +6997,17 @@ a.q-breadcrumbs__el:hover {
6946
6997
  min-width: 0;
6947
6998
  }
6948
6999
 
7000
+ /* Description présente → le bloc titre+description est plus haut que la toolbar centrée :
7001
+ un peu d'air au-dessus du titre, **et un peu avant le séparateur** (sinon le trait colle
7002
+ au texte de la description). */
7003
+ .q-dialog__header-text--with-description {
7004
+ padding-bottom: 6px;
7005
+ }
7006
+
7007
+ .q-dialog__header-text--with-description .q-dialog__title {
7008
+ padding-top: 4px;
7009
+ }
7010
+
6949
7011
  .q-dialog__title {
6950
7012
  margin: 0;
6951
7013
  font-size: 17px;
@@ -6956,7 +7018,7 @@ a.q-breadcrumbs__el:hover {
6956
7018
  .q-dialog__description {
6957
7019
  margin: 4px 0 0;
6958
7020
  font-size: 14px;
6959
- color: rgb(0 0 0 / 0.6);
7021
+ color: var(--muted-foreground, rgb(0 0 0 / 0.6));
6960
7022
  line-height: 1.4;
6961
7023
  }
6962
7024
 
@@ -6979,6 +7041,18 @@ a.q-breadcrumbs__el:hover {
6979
7041
  background-color: rgb(0 0 0 / 0.06);
6980
7042
  }
6981
7043
 
7044
+ /* Mode sombre du dialog : bordure des zones sticky et survol du × — le noir
7045
+ codé en dur (bordure, hover) y est invisible. Convention du paquet :
7046
+ surfaces/hovers sombres en blanc translucide (cf. `.dark .q-btn-actions__item:hover`). */
7047
+ .dark .q-dialog__header,
7048
+ .dark .q-dialog__footer {
7049
+ border-color: var(--border);
7050
+ }
7051
+
7052
+ .dark .q-dialog__close:hover {
7053
+ background-color: rgb(255 255 255 / 0.08);
7054
+ }
7055
+
6982
7056
  .q-dialog__close svg {
6983
7057
  width: 16px;
6984
7058
  height: 16px;
@@ -8103,9 +8177,11 @@ a.q-breadcrumbs__el:hover {
8103
8177
  En mode sheet avec recherche en bas, c'est le champ qui gère l'inset ;
8104
8178
  sinon (liste seule) c'est la liste. */
8105
8179
  .q-select__sheet--dialog .q-select__sheet-header {
8106
- padding-top: 0;
8107
- padding-top: constant(safe-area-inset-top);
8108
- padding-top: env(safe-area-inset-top);
8180
+ /* Safe-area **ajoutée** au padding de base — en mode plein écran le titre et le ×
8181
+ sont au bord haut ; sans encoche `env()` vaut 0, d'où le padding de repli. */
8182
+ padding-top: 16px;
8183
+ padding-top: calc(constant(safe-area-inset-top) + 16px);
8184
+ padding-top: calc(env(safe-area-inset-top) + 16px);
8109
8185
  }
8110
8186
 
8111
8187
  .q-select__sheet--dialog .q-select__sheet-list,