@dnax/ui 0.0.64 → 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.
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…) */
@@ -102,6 +102,16 @@ describe("classes de style (composant ↔ CSS)", () => {
102
102
  ],
103
103
  ["components/QCollapse.vue", "q-collapse--guide", ".q-collapse--guide .q-collapse__inner"],
104
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
+ ],
105
115
  ]
106
116
 
107
117
  for (const [file, className, selector] of CONTRACTS) {
@@ -129,6 +139,27 @@ describe("classes de style (composant ↔ CSS)", () => {
129
139
  })
130
140
  })
131
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
+
132
163
  describe("mode sombre des contrôles", () => {
133
164
  it("la bordure noire du checkbox / radio décoché est corrigée en sombre", () => {
134
165
  // Base claire : bordure codée en dur (le variant `--dark` du composant la corrige, mais
@@ -0,0 +1,219 @@
1
+ // $q.wakeLock — Screen Wake Lock : logique pure + fabrique testable hors navigateur.
2
+ import { describe, expect, it } from "bun:test"
3
+ import {
4
+ createWakeLock,
5
+ isDocumentVisible,
6
+ isWakeLockCapable,
7
+ shouldReacquire,
8
+ } from "./wakeLock"
9
+
10
+ class FakeSentinel {
11
+ released = false
12
+ releaseCalls = 0
13
+ private listeners = new Set<() => void>()
14
+
15
+ async release() {
16
+ this.releaseCalls++
17
+ this.released = true
18
+ for (const cb of [...this.listeners]) cb()
19
+ }
20
+
21
+ addEventListener(_type: "release", cb: () => void) {
22
+ this.listeners.add(cb)
23
+ }
24
+
25
+ removeEventListener(_type: "release", cb: () => void) {
26
+ this.listeners.delete(cb)
27
+ }
28
+
29
+ /** Simule le relâchement décidé par le navigateur (page masquée, veille…). */
30
+ autoRelease() {
31
+ this.released = true
32
+ for (const cb of [...this.listeners]) cb()
33
+ }
34
+ }
35
+
36
+ function fakeApi(options: { fail?: Error } = {}) {
37
+ const sentinels: FakeSentinel[] = []
38
+ return {
39
+ calls: 0,
40
+ sentinels,
41
+ async request(_type: "screen") {
42
+ this.calls++
43
+ if (options.fail) throw options.fail
44
+ const s = new FakeSentinel()
45
+ sentinels.push(s)
46
+ return s
47
+ },
48
+ }
49
+ }
50
+
51
+ function fakeVisibility(state = "visible") {
52
+ const listeners = new Set<() => void>()
53
+ return {
54
+ visibilityState: state as string,
55
+ addEventListener(_type: "visibilitychange", cb: () => void) {
56
+ listeners.add(cb)
57
+ },
58
+ removeEventListener(_type: "visibilitychange", cb: () => void) {
59
+ listeners.delete(cb)
60
+ },
61
+ set(next: string) {
62
+ this.visibilityState = next
63
+ for (const cb of [...listeners]) cb()
64
+ },
65
+ count() {
66
+ return listeners.size
67
+ },
68
+ }
69
+ }
70
+
71
+ function client(options: Partial<Parameters<typeof createWakeLock>[0]> = {}) {
72
+ return createWakeLock({
73
+ server: false,
74
+ deferCapability: false,
75
+ api: fakeApi(),
76
+ visibility: fakeVisibility(),
77
+ ...options,
78
+ })
79
+ }
80
+
81
+ describe("isWakeLockCapable", () => {
82
+ it("exige une API avec request()", () => {
83
+ expect(isWakeLockCapable(null)).toBe(false)
84
+ expect(isWakeLockCapable(undefined)).toBe(false)
85
+ expect(isWakeLockCapable({} as never)).toBe(false)
86
+ expect(isWakeLockCapable({ request: async () => new FakeSentinel() })).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe("isDocumentVisible", () => {
91
+ it("visible sauf `visibilityState: 'hidden'` (et sans document injecté)", () => {
92
+ expect(isDocumentVisible(null)).toBe(true)
93
+ expect(isDocumentVisible({ addEventListener() {} })).toBe(true)
94
+ expect(
95
+ isDocumentVisible({ visibilityState: "visible", addEventListener() {} }),
96
+ ).toBe(true)
97
+ expect(
98
+ isDocumentVisible({ visibilityState: "hidden", addEventListener() {} }),
99
+ ).toBe(false)
100
+ })
101
+ })
102
+
103
+ describe("shouldReacquire", () => {
104
+ it("uniquement si demandé, visible et rien de tenu", () => {
105
+ expect(shouldReacquire(true, true, false)).toBe(true)
106
+ expect(shouldReacquire(false, true, false)).toBe(false)
107
+ expect(shouldReacquire(true, false, false)).toBe(false)
108
+ expect(shouldReacquire(true, true, true)).toBe(false)
109
+ })
110
+ })
111
+
112
+ describe("createWakeLock", () => {
113
+ it("au serveur : méthodes sans effet, rien de capable ni d'actif", async () => {
114
+ const lock = createWakeLock({ server: true })
115
+ expect(lock.isCapable).toBe(false)
116
+ expect(lock.isActive).toBe(false)
117
+ await expect(lock.request()).resolves.toBeUndefined()
118
+ await expect(lock.release()).resolves.toBeUndefined()
119
+ })
120
+
121
+ it("sans API : `Not capable`", async () => {
122
+ const lock = client({ api: null })
123
+ expect(lock.isCapable).toBe(false)
124
+ await expect(lock.request()).rejects.toThrow("Not capable")
125
+ expect(lock.isActive).toBe(false)
126
+ })
127
+
128
+ it("request() acquiert le verrou, une seule fois (idempotent)", async () => {
129
+ const api = fakeApi()
130
+ const lock = client({ api })
131
+
132
+ await lock.request()
133
+ expect(lock.isActive).toBe(true)
134
+ expect(api.calls).toBe(1)
135
+
136
+ await lock.request()
137
+ expect(api.calls).toBe(1)
138
+ expect(lock.isActive).toBe(true)
139
+ })
140
+
141
+ it("release() rend la main au système", async () => {
142
+ const api = fakeApi()
143
+ const lock = client({ api })
144
+
145
+ await lock.request()
146
+ await lock.release()
147
+
148
+ expect(lock.isActive).toBe(false)
149
+ expect(api.sentinels[0]!.releaseCalls).toBe(1)
150
+
151
+ // Une nouvelle demande fonctionne.
152
+ await lock.request()
153
+ expect(api.calls).toBe(2)
154
+ expect(lock.isActive).toBe(true)
155
+ })
156
+
157
+ it("le navigateur relâche → isActive retombe, et le verrou est redemandé au retour", async () => {
158
+ const api = fakeApi()
159
+ const visibility = fakeVisibility("visible")
160
+ const lock = client({ api, visibility })
161
+
162
+ await lock.request()
163
+ expect(lock.isActive).toBe(true)
164
+
165
+ // Onglet masqué : le navigateur relâche de lui-même.
166
+ visibility.set("hidden")
167
+ api.sentinels[0]!.autoRelease()
168
+ expect(lock.isActive).toBe(false)
169
+ expect(api.calls).toBe(1)
170
+
171
+ // Retour de visibilité : la demande mémorisée est rejouée.
172
+ visibility.set("visible")
173
+ await Promise.resolve()
174
+ expect(api.calls).toBe(2)
175
+ expect(lock.isActive).toBe(true)
176
+ })
177
+
178
+ it("après release(), plus aucune redemande au changement de visibilité", async () => {
179
+ const api = fakeApi()
180
+ const visibility = fakeVisibility("visible")
181
+ const lock = client({ api, visibility })
182
+
183
+ await lock.request()
184
+ await lock.release()
185
+
186
+ visibility.set("hidden")
187
+ visibility.set("visible")
188
+ await Promise.resolve()
189
+
190
+ expect(api.calls).toBe(1)
191
+ expect(lock.isActive).toBe(false)
192
+ })
193
+
194
+ it("le refus du navigateur est propagé (échec doux côté appelant)", async () => {
195
+ const api = fakeApi({ fail: new Error("denied") })
196
+ const lock = client({ api })
197
+
198
+ await expect(lock.request()).rejects.toThrow("denied")
199
+ expect(lock.isActive).toBe(false)
200
+ })
201
+
202
+ it("destroy() détache l'écouteur de visibilité", async () => {
203
+ const visibility = fakeVisibility()
204
+ const lock = client({ visibility })
205
+
206
+ expect(visibility.count()).toBe(1)
207
+ lock.destroy()
208
+ expect(visibility.count()).toBe(0)
209
+ })
210
+
211
+ it("isCapable n'est révélé qu'après hydratation (report d'un tick)", async () => {
212
+ const api = fakeApi()
213
+ const lock = createWakeLock({ server: false, api, visibility: null })
214
+
215
+ expect(lock.isCapable).toBe(false)
216
+ await new Promise((resolve) => setTimeout(resolve, 1))
217
+ expect(lock.isCapable).toBe(true)
218
+ })
219
+ })
@@ -0,0 +1,202 @@
1
+ // $q.wakeLock — Screen Wake Lock façon Quasar (`AppWakeLockPlugin`).
2
+ // https://quasar.dev/quasar-plugins/app-wake-lock
3
+ //
4
+ // - `request()` garde l'écran allumé ; `release()` rend la main au délai système.
5
+ // - `isActive` ne vaut `true` que **tant que le verrou est réellement tenu** : le
6
+ // navigateur le relâche de lui-même quand la page est masquée (onglet, veille…).
7
+ // La demande est alors **mémorisée** et rejouée au retour de visibilité, jusqu'à
8
+ // `release()`.
9
+ // - `isCapable` vaut `false` au serveur **et au premier rendu client**, puis prend sa
10
+ // valeur réelle après hydratation (sinon le `v-if` d'un bouton « garder l'écran
11
+ // allumé » divergerait du HTML SSR).
12
+ // - Sans API (navigateur non supporté), les méthodes rejettent `Not capable` ; au
13
+ // serveur elles se résolvent sans effet.
14
+ //
15
+ // Logique pure (`isWakeLockCapable`, `isDocumentVisible`, `shouldReacquire`) et fabrique
16
+ // `createWakeLock` à dépendances injectables : testable sans DOM.
17
+ import { reactive, ref } from "vue"
18
+
19
+ /** Sentinel minimal du Screen Wake Lock API (injectable pour les tests). */
20
+ export interface WakeLockSentinelLike {
21
+ /** Passe à `true` quand le navigateur relâche le verrou de lui-même */
22
+ released?: boolean
23
+ release: () => Promise<void>
24
+ addEventListener?: (type: "release", listener: () => void) => void
25
+ removeEventListener?: (type: "release", listener: () => void) => void
26
+ }
27
+
28
+ /** Sous-ensemble de `navigator.wakeLock`. */
29
+ export interface WakeLockApiLike {
30
+ request: (type: "screen") => Promise<WakeLockSentinelLike>
31
+ }
32
+
33
+ /** Sous-ensemble de `document` utilisé pour suivre la visibilité. */
34
+ export interface WakeLockVisibilityLike {
35
+ visibilityState?: string
36
+ addEventListener: (type: "visibilitychange", listener: () => void) => void
37
+ removeEventListener?: (type: "visibilitychange", listener: () => void) => void
38
+ }
39
+
40
+ export interface QWakeLock {
41
+ /** Un verrou est réellement tenu (réactif, lecture sans `.value`) */
42
+ readonly isActive: boolean
43
+ /** Screen Wake Lock disponible — `false` au serveur et avant hydratation */
44
+ readonly isCapable: boolean
45
+ /** Demande le verrou (idempotent). Rejette si non supporté (`Not capable`). */
46
+ request: () => Promise<void>
47
+ /** Relâche le verrou et cesse les redemandes automatiques */
48
+ release: () => Promise<void>
49
+ /** Détache l'écouteur de visibilité (instance de test / usage hors singleton) */
50
+ destroy: () => void
51
+ }
52
+
53
+ export interface WakeLockOptions {
54
+ /** API Screen Wake Lock (défaut : `navigator.wakeLock` ; `null` si absente) */
55
+ api?: WakeLockApiLike | null
56
+ /** Document pour la visibilité (défaut : `document` ; `null` au serveur) */
57
+ visibility?: WakeLockVisibilityLike | null
58
+ /** Force le mode serveur : aucune API lue, méthodes sans effet */
59
+ server?: boolean
60
+ /**
61
+ * Diffère `isCapable` après l'hydratation (défaut : `true` côté client) — évite un
62
+ * décalage entre le HTML SSR (`isCapable = false`) et le premier rendu client.
63
+ */
64
+ deferCapability?: boolean
65
+ }
66
+
67
+ /** `true` seulement si l'API Screen Wake Lock est exposée par le navigateur. */
68
+ export function isWakeLockCapable(api?: WakeLockApiLike | null): boolean {
69
+ return !!api && typeof api.request === "function"
70
+ }
71
+
72
+ /** Une page est considérée visible sauf si le document dit explicitement `hidden`. */
73
+ export function isDocumentVisible(visibility?: WakeLockVisibilityLike | null): boolean {
74
+ return visibility?.visibilityState !== "hidden"
75
+ }
76
+
77
+ /** Faut-il redemander le verrou ? (demandé, page visible, et rien de tenu) */
78
+ export function shouldReacquire(wanted: boolean, visible: boolean, held: boolean): boolean {
79
+ return wanted && visible && !held
80
+ }
81
+
82
+ function notCapableError(): Error {
83
+ const message = "Not capable"
84
+ return typeof DOMException === "function"
85
+ ? new DOMException(message, "NotSupportedError")
86
+ : new Error(message)
87
+ }
88
+
89
+ /**
90
+ * Crée le gestionnaire de verrou. Sans `visibility`, aucune écoute n'est posée ;
91
+ * en mode serveur, `request()` / `release()` se résolvent sans effet.
92
+ */
93
+ export function createWakeLock(options: WakeLockOptions = {}): QWakeLock {
94
+ const server = options.server ?? typeof window === "undefined"
95
+ const api = "api" in options
96
+ ? (options.api ?? null)
97
+ : typeof navigator !== "undefined"
98
+ ? ((navigator as Navigator & { wakeLock?: WakeLockApiLike }).wakeLock ?? null)
99
+ : null
100
+ const visibility = "visibility" in options
101
+ ? (options.visibility ?? null)
102
+ : typeof document !== "undefined"
103
+ ? document
104
+ : null
105
+
106
+ const active = ref(false)
107
+ const capable = ref(false)
108
+
109
+ // `isCapable` n'est révélé qu'après le rendu SSR/hydratation (cf. en-tête).
110
+ if (!server) {
111
+ if (options.deferCapability === false) {
112
+ capable.value = isWakeLockCapable(api)
113
+ }
114
+ else {
115
+ setTimeout(() => {
116
+ capable.value = isWakeLockCapable(api)
117
+ }, 0)
118
+ }
119
+ }
120
+
121
+ /** L'utilisateur veut garder l'écran allumé (survit aux relâchements du navigateur). */
122
+ let wanted = false
123
+ let sentinel: WakeLockSentinelLike | null = null
124
+ let pending: Promise<void> | null = null
125
+
126
+ const bindRelease = (s: WakeLockSentinelLike) => {
127
+ s.addEventListener?.("release", () => {
128
+ if (sentinel !== s) return
129
+ sentinel = null
130
+ active.value = false
131
+ })
132
+ }
133
+
134
+ const request = async (): Promise<void> => {
135
+ if (server) return
136
+ if (!isWakeLockCapable(api)) throw notCapableError()
137
+
138
+ wanted = true
139
+ if (sentinel && sentinel.released !== true) {
140
+ active.value = true
141
+ return
142
+ }
143
+ if (pending) return pending
144
+
145
+ pending = (async () => {
146
+ const s = await api!.request("screen")
147
+ if (!wanted) {
148
+ // `release()` a été appelé pendant la demande : ne pas reprendre le verrou.
149
+ await s.release().catch(() => {})
150
+ return
151
+ }
152
+ sentinel = s
153
+ active.value = true
154
+ bindRelease(s)
155
+ })()
156
+
157
+ try {
158
+ await pending
159
+ }
160
+ finally {
161
+ pending = null
162
+ }
163
+ }
164
+
165
+ const release = async (): Promise<void> => {
166
+ wanted = false
167
+ const s = sentinel
168
+ sentinel = null
169
+ active.value = false
170
+ if (s) await s.release().catch(() => {})
171
+ }
172
+
173
+ const onVisibilityChange = () => {
174
+ if (sentinel?.released === true) {
175
+ sentinel = null
176
+ active.value = false
177
+ }
178
+ const held = sentinel !== null && sentinel.released !== true
179
+ if (!shouldReacquire(wanted, isDocumentVisible(visibility), held)) return
180
+ // Refus possible (batterie faible, iframe…) : la prochaine visibilité réessaiera.
181
+ void request().catch(() => {})
182
+ }
183
+
184
+ visibility?.addEventListener("visibilitychange", onVisibilityChange)
185
+
186
+ return reactive({
187
+ get isActive() {
188
+ return active.value
189
+ },
190
+ get isCapable() {
191
+ return capable.value
192
+ },
193
+ request,
194
+ release,
195
+ destroy: () => {
196
+ visibility?.removeEventListener?.("visibilitychange", onVisibilityChange)
197
+ },
198
+ })
199
+ }
200
+
201
+ /** Singleton exposé sous `$q.wakeLock` (et importable directement depuis `@dnax/ui`). */
202
+ export const wakeLock: QWakeLock = createWakeLock()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnax/ui",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "module": "index.ts",
5
5
  "description": "UI design System",
6
6
  "type": "module",
package/runtime.ts CHANGED
@@ -39,6 +39,13 @@ export {
39
39
  useDialogPluginComponent,
40
40
  useBottomSheetPluginComponent,
41
41
  } from "./lib/q"
42
+ // `$q.network` (état de connexion réactif) — accessible aussi par import direct,
43
+ // sans évaluer le barrel ni les composants.
44
+ export { network, createNetwork } from "./lib/network"
45
+ export type { QNetwork, NetworkOptions } from "./lib/network"
46
+ // `$q.wakeLock` (écran allumé) — Screen Wake Lock API.
47
+ export { wakeLock, createWakeLock } from "./lib/wakeLock"
48
+ export type { QWakeLock, WakeLockOptions } from "./lib/wakeLock"
42
49
 
43
50
  // — Providers de `$q` (montés au boot par l'app hôte) —
44
51
  export { default as QDialogProvider } from "./components/QDialogProvider.vue"