@dnax/ui 0.0.63 → 0.0.65

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.
@@ -0,0 +1,199 @@
1
+ // $q.network — état de connexion : logique pure + fabrique testable hors DOM.
2
+ // (La règle projet interdit le navigateur : on injecte `navigator` et la cible d'événements.)
3
+ import { describe, expect, it } from "bun:test"
4
+ import { effect } from "vue"
5
+ import { createNetwork, onlineAfterEvent, resolveOnline } from "./network"
6
+
7
+ /** Fausse cible d'événements (window) + compteur d'écouteurs. */
8
+ function fakeTarget() {
9
+ const listeners = new Map<string, Set<(event: { type: string }) => void>>()
10
+ return {
11
+ addEventListener(type: string, cb: (event: { type: string }) => void) {
12
+ if (!listeners.has(type)) listeners.set(type, new Set())
13
+ listeners.get(type)!.add(cb)
14
+ },
15
+ removeEventListener(type: string, cb: (event: { type: string }) => void) {
16
+ listeners.get(type)?.delete(cb)
17
+ },
18
+ /** Émet un événement comme le navigateur. */
19
+ emit(type: string) {
20
+ for (const cb of [...(listeners.get(type) ?? [])]) cb({ type })
21
+ },
22
+ /** Nombre d'écouteurs encore attachés pour ce type. */
23
+ count(type: string) {
24
+ return listeners.get(type)?.size ?? 0
25
+ },
26
+ }
27
+ }
28
+
29
+ describe("resolveOnline", () => {
30
+ it("considère « en ligne » quand l'information manque (serveur, navigateur sans API)", () => {
31
+ expect(resolveOnline(undefined)).toBe(true)
32
+ expect(resolveOnline(null)).toBe(true)
33
+ expect(resolveOnline({})).toBe(true)
34
+ })
35
+
36
+ it("suit navigator.onLine quand il est un booléen", () => {
37
+ expect(resolveOnline({ onLine: true })).toBe(true)
38
+ expect(resolveOnline({ onLine: false })).toBe(false)
39
+ })
40
+ })
41
+
42
+ describe("onlineAfterEvent", () => {
43
+ it("online → connecté, offline → déconnecté", () => {
44
+ expect(onlineAfterEvent("online", false)).toBe(true)
45
+ expect(onlineAfterEvent("offline", true)).toBe(false)
46
+ })
47
+
48
+ it("un autre événement ne change rien", () => {
49
+ expect(onlineAfterEvent("focus", false)).toBe(false)
50
+ expect(onlineAfterEvent("focus", true)).toBe(true)
51
+ })
52
+ })
53
+
54
+ describe("createNetwork", () => {
55
+ it("lit l'état initial dans navigator.onLine", () => {
56
+ const nav = { onLine: false }
57
+ const net = createNetwork({ navigator: nav, target: null })
58
+ expect(net.online).toBe(false)
59
+ expect(net.offline).toBe(true)
60
+ })
61
+
62
+ it("sans cible (SSR), aucune écoute n'est posée et l'état reste stable", () => {
63
+ const net = createNetwork({ navigator: null, target: null })
64
+ expect(net.online).toBe(true)
65
+ })
66
+
67
+ it("suit les événements online / offline de la cible", () => {
68
+ const target = fakeTarget()
69
+ const net = createNetwork({ navigator: { onLine: true }, target })
70
+
71
+ target.emit("offline")
72
+ expect(net.online).toBe(false)
73
+ expect(net.offline).toBe(true)
74
+
75
+ target.emit("online")
76
+ expect(net.online).toBe(true)
77
+ expect(net.offline).toBe(false)
78
+ })
79
+
80
+ it("est réactif : un effet est rejoué au changement (template `$q.network.online`)", () => {
81
+ const target = fakeTarget()
82
+ const net = createNetwork({ navigator: { onLine: true }, target })
83
+ const seen: boolean[] = []
84
+
85
+ effect(() => {
86
+ seen.push(net.online)
87
+ })
88
+
89
+ target.emit("offline")
90
+ target.emit("online")
91
+
92
+ expect(seen).toEqual([true, false, true])
93
+ })
94
+
95
+ it("ne notifie qu'un vrai changement d'état", () => {
96
+ const target = fakeTarget()
97
+ const net = createNetwork({ navigator: { onLine: true }, target })
98
+ let offlineCalls = 0
99
+ let onlineCalls = 0
100
+ net.on("offline", () => offlineCalls++)
101
+ net.on("online", () => onlineCalls++)
102
+
103
+ target.emit("offline")
104
+ target.emit("offline")
105
+ target.emit("online")
106
+ target.emit("online")
107
+
108
+ expect(offlineCalls).toBe(1)
109
+ expect(onlineCalls).toBe(1)
110
+ })
111
+
112
+ it("on() cible le bon événement et rend un désabonnement", () => {
113
+ const target = fakeTarget()
114
+ const net = createNetwork({ navigator: { onLine: true }, target })
115
+ const seen: string[] = []
116
+ const stopOnline = net.on("online", () => seen.push("online"))
117
+ net.on("offline", () => seen.push("offline"))
118
+
119
+ target.emit("offline")
120
+ target.emit("online")
121
+ stopOnline()
122
+ target.emit("offline")
123
+ target.emit("online")
124
+
125
+ expect(seen).toEqual(["offline", "online", "offline"])
126
+ })
127
+
128
+ it("on() ignore un type inconnu (JS non typé)", () => {
129
+ const target = fakeTarget()
130
+ const net = createNetwork({ navigator: { onLine: true }, target })
131
+ const stop = net.on("focus" as never, () => {
132
+ throw new Error("ne doit jamais être appelé")
133
+ })
134
+
135
+ target.emit("offline")
136
+ expect(typeof stop).toBe("function")
137
+ stop()
138
+ })
139
+
140
+ it("les abonnements se désabonnent", () => {
141
+ const target = fakeTarget()
142
+ const net = createNetwork({ navigator: { onLine: true }, target })
143
+ let calls = 0
144
+ const off = net.on("offline", () => calls++)
145
+
146
+ target.emit("offline")
147
+ off()
148
+ target.emit("online")
149
+ target.emit("offline")
150
+
151
+ expect(calls).toBe(1)
152
+ })
153
+
154
+ it("un abonné qui jette ne bloque pas les suivants", () => {
155
+ const target = fakeTarget()
156
+ const net = createNetwork({ navigator: { onLine: true }, target })
157
+ let calls = 0
158
+ net.on("offline", () => {
159
+ throw new Error("boom")
160
+ })
161
+ net.on("offline", () => calls++)
162
+
163
+ target.emit("offline")
164
+
165
+ expect(calls).toBe(1)
166
+ })
167
+
168
+ it("refresh() relit navigator.onLine (valeur devenue obsolète)", () => {
169
+ const nav = { onLine: true }
170
+ const net = createNetwork({ navigator: nav, target: fakeTarget() })
171
+
172
+ nav.onLine = false
173
+ expect(net.refresh()).toBe(false)
174
+ expect(net.online).toBe(false)
175
+
176
+ nav.onLine = true
177
+ expect(net.online).toBe(false) // pas de sondage automatique
178
+ expect(net.refresh()).toBe(true)
179
+ expect(net.online).toBe(true)
180
+ })
181
+
182
+ it("destroy() détache les écouteurs et vide les abonnements", () => {
183
+ const target = fakeTarget()
184
+ const net = createNetwork({ navigator: { onLine: true }, target })
185
+ let calls = 0
186
+ net.on("offline", () => calls++)
187
+
188
+ expect(target.count("online")).toBe(1)
189
+ expect(target.count("offline")).toBe(1)
190
+
191
+ net.destroy()
192
+ target.emit("offline")
193
+
194
+ expect(target.count("online")).toBe(0)
195
+ expect(target.count("offline")).toBe(0)
196
+ expect(net.online).toBe(true)
197
+ expect(calls).toBe(0)
198
+ })
199
+ })
package/lib/network.ts ADDED
@@ -0,0 +1,147 @@
1
+ // $q.network — état de la connexion réseau (API Quasar-style, SSR-safe).
2
+ //
3
+ // Source unique : `navigator.onLine` + événements `online` / `offline` de `window`.
4
+ // L'objet exposé se lit **sans `.value`** (`$q.network.online`), donc directement dans
5
+ // un template, et reste **réactif** (ses getters lisent une ref interne).
6
+ //
7
+ // Le serveur n'a pas d'état réseau : au SSR `online` vaut `true` (pas de bannière
8
+ // « hors ligne » fantôme). Côté client, l'état réel de `navigator.onLine` est lu au
9
+ // chargement du module, avant hydratation — deux rendus ne diffèrent que si l'appareil
10
+ // est réellement hors ligne (comportement de `useOnline` de VueUse).
11
+ //
12
+ // La logique pure (`resolveOnline`, `onlineAfterEvent`) et la fabrique `createNetwork`
13
+ // (cible d'événements injectable) sont testables sans DOM.
14
+ import { reactive, ref } from "vue"
15
+
16
+ /** Sous-ensemble de `navigator` utilisé (injectable pour les tests). */
17
+ export interface NetworkNavigator {
18
+ onLine?: boolean
19
+ }
20
+
21
+ /** Cible des événements `online` / `offline` (`window` en production, objet factice en test). */
22
+ export interface NetworkEventTarget {
23
+ addEventListener: (type: string, listener: (event: { type: string }) => void) => void
24
+ removeEventListener?: (type: string, listener: (event: { type: string }) => void) => void
25
+ }
26
+
27
+ export interface QNetwork {
28
+ /** Connecté au réseau — booléen réactif, se lit **sans `.value`** */
29
+ readonly online: boolean
30
+ /** Inversé de `online` (`v-if="$q.network.offline"`) */
31
+ readonly offline: boolean
32
+ /** Re-lit `navigator.onLine` (retour au premier plan…) et met à jour l'état */
33
+ refresh: () => boolean
34
+ /**
35
+ * Abonnement aux passages `"online"` / `"offline"` (transitions réelles seulement).
36
+ * Renvoie la fonction de désabonnement.
37
+ */
38
+ on: (type: NetworkEvent, cb: () => void) => () => void
39
+ /** Détache les écouteurs (instance de test / usage hors singleton) */
40
+ destroy: () => void
41
+ }
42
+
43
+ /** Événements réseau exposés par `$q.network.on()`. */
44
+ export type NetworkEvent = "online" | "offline"
45
+
46
+ export interface NetworkOptions {
47
+ /** Source de `navigator.onLine` (défaut : `navigator` global ; `null` au serveur) */
48
+ navigator?: NetworkNavigator | null
49
+ /** Cible des événements (défaut : `window` ; `null` au serveur) */
50
+ target?: NetworkEventTarget | null
51
+ /** Force la valeur initiale (sinon `resolveOnline(navigator)`) */
52
+ initial?: boolean
53
+ }
54
+
55
+ /**
56
+ * `true` par défaut : le serveur et les navigateurs sans `navigator.onLine` sont
57
+ * considérés **en ligne** (évite une bannière « hors ligne » au chargement SSR).
58
+ */
59
+ export function resolveOnline(nav?: NetworkNavigator | null): boolean {
60
+ return typeof nav?.onLine === "boolean" ? nav.onLine : true
61
+ }
62
+
63
+ /** État après un événement : `online` → true, `offline` → false, autre → inchangé. */
64
+ export function onlineAfterEvent(type: string, current: boolean): boolean {
65
+ if (type === "online") return true
66
+ if (type === "offline") return false
67
+ return current
68
+ }
69
+
70
+ /**
71
+ * Crée un objet réseau réactif. Sans `target`, aucun écouteur n'est posé (SSR) ;
72
+ * sans `navigator`, l'état initial vaut `true`.
73
+ */
74
+ export function createNetwork(options: NetworkOptions = {}): QNetwork {
75
+ const nav = "navigator" in options
76
+ ? (options.navigator ?? null)
77
+ : typeof navigator !== "undefined"
78
+ ? navigator
79
+ : null
80
+ const target = "target" in options
81
+ ? (options.target ?? null)
82
+ : typeof window !== "undefined"
83
+ ? window
84
+ : null
85
+
86
+ const online = ref(options.initial ?? resolveOnline(nav))
87
+ const listeners: Record<"online" | "offline", Set<() => void>> = {
88
+ online: new Set(),
89
+ offline: new Set(),
90
+ }
91
+
92
+ const setOnline = (next: boolean) => {
93
+ if (online.value === next) return
94
+ online.value = next
95
+ for (const cb of [...listeners[next ? "online" : "offline"]]) {
96
+ try {
97
+ cb()
98
+ }
99
+ catch {
100
+ // Un abonné fautif ne doit pas empêcher les autres d'être notifiés.
101
+ }
102
+ }
103
+ }
104
+
105
+ const handleEvent = (event: { type: string }) => {
106
+ setOnline(onlineAfterEvent(event.type, online.value))
107
+ }
108
+
109
+ const refresh = () => {
110
+ const next = resolveOnline(nav)
111
+ setOnline(next)
112
+ return next
113
+ }
114
+
115
+ const on = (type: NetworkEvent, cb: () => void) => {
116
+ // Garde runtime : un type inconnu (JS non typé) renvoie un désabonnement inerte.
117
+ if (type !== "online" && type !== "offline") return () => {}
118
+ listeners[type].add(cb)
119
+ return () => {
120
+ listeners[type].delete(cb)
121
+ }
122
+ }
123
+
124
+ target?.addEventListener("online", handleEvent)
125
+ target?.addEventListener("offline", handleEvent)
126
+
127
+ return reactive({
128
+ /** @see QNetwork.online */
129
+ get online() {
130
+ return online.value
131
+ },
132
+ get offline() {
133
+ return !online.value
134
+ },
135
+ refresh,
136
+ on,
137
+ destroy: () => {
138
+ target?.removeEventListener?.("online", handleEvent)
139
+ target?.removeEventListener?.("offline", handleEvent)
140
+ listeners.online.clear()
141
+ listeners.offline.clear()
142
+ },
143
+ })
144
+ }
145
+
146
+ /** Singleton exposé sous `$q.network` (et importable directement depuis `@dnax/ui`). */
147
+ export const network: QNetwork = createNetwork()
package/lib/q.ts CHANGED
@@ -15,6 +15,8 @@ import { platform, qBreakpoints } from "./platform"
15
15
  import { screen } from "./screen"
16
16
  import { loading } from "./loading"
17
17
  import { localStorage, sessionStorage } from "./storage"
18
+ import { network } from "./network"
19
+ import { wakeLock } from "./wakeLock"
18
20
 
19
21
  export interface DialogOptions {
20
22
  /** Composant de dialogue (SFC importé) ou nom de composant global */
@@ -324,7 +326,8 @@ function notify(opts: NotifyOptions): NotifyController {
324
326
  }
325
327
 
326
328
  // API publique : $q.dialog / $q.bottomSheet / $q.notify / $q.imagePreview / $q.platform / $q.breakpoints / $q.screen / $q.loading
327
- // / $q.localStorage / $q.sessionStorage (stockage web typé)
329
+ // / $q.localStorage / $q.sessionStorage (stockage web typé) / $q.network (connexion en ligne)
330
+ // / $q.wakeLock (écran allumé, Screen Wake Lock API)
328
331
  // Conventions Quasar : .open() pour les piles (dialog, bottomSheet, imagePreview),
329
332
  // .show() pour notify et loading ; méthodes directes pour le stockage.
330
333
  const notifyPlugin = notify as typeof notify & { show: typeof notify }
@@ -347,6 +350,8 @@ export const $q = {
347
350
  loading,
348
351
  localStorage,
349
352
  sessionStorage,
353
+ network,
354
+ wakeLock,
350
355
  }
351
356
 
352
357
  /** Accès programmatique aux plugins ($q.dialog, $q.notify, $q.loading…) */
@@ -101,6 +101,17 @@ describe("classes de style (composant ↔ CSS)", () => {
101
101
  ".q-bottom-sheet__header-text--with-description .q-bottom-sheet__title",
102
102
  ],
103
103
  ["components/QCollapse.vue", "q-collapse--guide", ".q-collapse--guide .q-collapse__inner"],
104
+ ["components/QTabs.vue", "q-tabs--arrows", ".q-tabs--arrows .q-tabs__content"],
105
+ [
106
+ "components/QTabs.vue",
107
+ "q-tabs--scroll-start",
108
+ ".q-tabs--arrows.q-tabs--scroll-start .q-tabs__content",
109
+ ],
110
+ [
111
+ "components/QTabs.vue",
112
+ "q-tabs--scroll-end",
113
+ ".q-tabs--arrows.q-tabs--scroll-end .q-tabs__content",
114
+ ],
104
115
  ]
105
116
 
106
117
  for (const [file, className, selector] of CONTRACTS) {
@@ -128,6 +139,27 @@ describe("classes de style (composant ↔ CSS)", () => {
128
139
  })
129
140
  })
130
141
 
142
+ describe("fondu de défilement des tabs (thème-agnostique)", () => {
143
+ const block = blockAfter(css, ".q-tabs--arrows .q-tabs__content")
144
+
145
+ it("le fondu est un masque alpha (`#fff`), sans couleur de thème", () => {
146
+ // `#fff` est opaque en mode `alpha` ET `luminance` (ancien WebKit).
147
+ expect({ defined: block !== undefined }).toEqual({ defined: true })
148
+ expect({ hasMask: block!.includes("mask-image") }).toEqual({ hasMask: true })
149
+ expect({ opaque: block!.includes("#fff") }).toEqual({ opaque: true })
150
+ // Aucune couleur de surface : le fondu ne peut pas se désaligner d'un thème à l'autre.
151
+ expect({ themeColor: block!.includes("var(--card") }).toEqual({ themeColor: false })
152
+ expect({ hardcodedBlack: block!.includes("rgb(0 0 0") }).toEqual({ hardcodedBlack: false })
153
+ })
154
+
155
+ it("le fondu suit l'axe des tabs (horizontal → to right, vertical → to bottom)", () => {
156
+ const vertical = blockAfter(css, ".q-tabs--vertical.q-tabs--arrows .q-tabs__content")
157
+ expect({ defined: vertical !== undefined }).toEqual({ defined: true })
158
+ expect({ right: block!.includes("to right") }).toEqual({ right: true })
159
+ expect({ bottom: vertical!.includes("to bottom") }).toEqual({ bottom: true })
160
+ })
161
+ })
162
+
131
163
  describe("mode sombre des contrôles", () => {
132
164
  it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
133
165
  // Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
@@ -0,0 +1,35 @@
1
+ // Tests unitaires des flèches de défilement de QTabs (`lib/tabs.ts`).
2
+ import { describe, expect, it } from "bun:test"
3
+ import { tabArrowState, tabArrowStep } from "./tabs"
4
+
5
+ describe("tabArrowState", () => {
6
+ it("pas de débordement → aucune flèche", () => {
7
+ expect(tabArrowState(0, 400, 400)).toEqual({ canStart: false, canEnd: false })
8
+ })
9
+
10
+ it("début → seule la flèche de fin", () => {
11
+ expect(tabArrowState(0, 400, 900)).toEqual({ canStart: false, canEnd: true })
12
+ })
13
+
14
+ it("milieu → les deux flèches", () => {
15
+ expect(tabArrowState(200, 400, 900)).toEqual({ canStart: true, canEnd: true })
16
+ })
17
+
18
+ it("fin → seule la flèche de début", () => {
19
+ expect(tabArrowState(500, 400, 900)).toEqual({ canStart: true, canEnd: false })
20
+ })
21
+
22
+ it("tolère l'arrondi des sous-pixels (1 px)", () => {
23
+ // Collé au début malgré un `scrollLeft` de 1 (arrondi)
24
+ expect(tabArrowState(1, 400, 900)).toEqual({ canStart: false, canEnd: true })
25
+ // Collé à la fin malgré un `scrollLeft` à 1 px du max
26
+ expect(tabArrowState(499, 400, 900)).toEqual({ canStart: true, canEnd: false })
27
+ })
28
+ })
29
+
30
+ describe("tabArrowStep", () => {
31
+ it("avance d'environ 60 % de la zone visible, avec un plancher de 80 px", () => {
32
+ expect(tabArrowStep(400)).toBe(240)
33
+ expect(tabArrowStep(100)).toBe(80) // 60 < 80 → plancher
34
+ })
35
+ })
package/lib/tabs.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Flèches de défilement de QTabs (`show-arrows`, comportement Quasar) : décide si les boutons
2
+ // de début / fin doivent apparaître, selon la position de défilement et les mesures.
3
+ //
4
+ // Logique **pure**, testable hors navigateur. Les navigateurs arrondissent `scrollLeft` /
5
+ // `scrollTop` (sous-pixels, zoom) : on tolère 1 px pour ne pas faire clignoter une flèche.
6
+
7
+ const EPSILON = 1
8
+
9
+ export interface QTabArrowState {
10
+ /** On peut défiler vers le **début** (gauche / haut) */
11
+ canStart: boolean
12
+ /** On peut défiler vers la **fin** (droite / bas) */
13
+ canEnd: boolean
14
+ }
15
+
16
+ /**
17
+ * État des flèches pour un axe donné.
18
+ *
19
+ * @param position `scrollLeft` / `scrollTop`
20
+ * @param clientSize `clientWidth` / `clientHeight` (partie visible)
21
+ * @param scrollSize `scrollWidth` / `scrollHeight` (contenu total)
22
+ */
23
+ export function tabArrowState(
24
+ position: number,
25
+ clientSize: number,
26
+ scrollSize: number,
27
+ ): QTabArrowState {
28
+ const overflow = scrollSize - clientSize
29
+ return {
30
+ canStart: position > EPSILON,
31
+ canEnd: overflow > EPSILON && position < overflow - EPSILON,
32
+ }
33
+ }
34
+
35
+ /** Déplacement d'un clic sur une flèche : ~60 % de la zone visible (au moins 80 px). */
36
+ export function tabArrowStep(clientSize: number): number {
37
+ return Math.max(80, Math.round(clientSize * 0.6))
38
+ }