@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.
- package/components/QDatePicker.vue +12 -5
- package/components/QTabs.vue +79 -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 +32 -0
- package/lib/tabs.test.ts +35 -0
- package/lib/tabs.ts +38 -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 +143 -1
|
@@ -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…) */
|
package/lib/styleTokens.test.ts
CHANGED
|
@@ -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
|
package/lib/tabs.test.ts
ADDED
|
@@ -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
|
+
}
|