@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/components/QDatePicker.vue +12 -5
- package/components/QTabs.vue +11 -2
- package/components/internal/QDateCalendar.vue +25 -23
- package/index.ts +16 -0
- package/lib/dateI18n.test.ts +91 -0
- package/lib/dateI18n.ts +85 -0
- package/lib/network.test.ts +199 -0
- package/lib/network.ts +147 -0
- package/lib/q.ts +6 -1
- package/lib/styleTokens.test.ts +31 -0
- package/lib/wakeLock.test.ts +219 -0
- package/lib/wakeLock.ts +202 -0
- package/package.json +1 -1
- package/runtime.ts +7 -0
- package/styles/main.css +98 -16
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…) */
|
package/lib/styleTokens.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/lib/wakeLock.ts
ADDED
|
@@ -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
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"
|