@creezio/interactive-demo 0.22.0
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/README.md +138 -0
- package/dist/content.d.ts +49 -0
- package/dist/content.d.ts.map +1 -0
- package/dist/content.js +329 -0
- package/dist/content.js.map +1 -0
- package/dist/contributions.d.ts +48 -0
- package/dist/contributions.d.ts.map +1 -0
- package/dist/contributions.js +87 -0
- package/dist/contributions.js.map +1 -0
- package/dist/generic.d.ts +32 -0
- package/dist/generic.d.ts.map +1 -0
- package/dist/generic.js +116 -0
- package/dist/generic.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +132 -0
- package/dist/types.js.map +1 -0
- package/dist-cjs/content.js +335 -0
- package/dist-cjs/content.js.map +1 -0
- package/dist-cjs/contributions.js +90 -0
- package/dist-cjs/contributions.js.map +1 -0
- package/dist-cjs/generic.js +120 -0
- package/dist-cjs/generic.js.map +1 -0
- package/dist-cjs/index.js +27 -0
- package/dist-cjs/index.js.map +1 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/types.js +136 -0
- package/dist-cjs/types.js.map +1 -0
- package/package.json +67 -0
- package/ui/demo-player.tsx +563 -0
- package/ui/demo-root.tsx +312 -0
- package/ui/dom.ts +280 -0
- package/ui/fake-cursor.ts +157 -0
- package/ui/index.ts +25 -0
- package/ui/interactive-demo.css +423 -0
package/ui/demo-root.tsx
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Racine de la démo interactive — à monter une fois dans le chrome de la
|
|
5
|
+
* marque (layout / BrandChrome). Charge les scénarios depuis le mount
|
|
6
|
+
* `/api/v1/modules/interactive-demo`, lance automatiquement le scénario
|
|
7
|
+
* `autoStart` à la première visite (après setup/onboarding), affiche le
|
|
8
|
+
* lanceur flottant « Visite guidée » et écoute l'événement
|
|
9
|
+
* `creezio-interactive-demo` pour un déclenchement programmatique.
|
|
10
|
+
*
|
|
11
|
+
* « Déjà vu » : localStorage (immédiat) + preferences serveur (par
|
|
12
|
+
* utilisateur, si `userKey` fourni) — best effort, jamais bloquant.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { DemoScenario } from "@creezio/interactive-demo";
|
|
17
|
+
import { scenarioMatchesRole } from "@creezio/interactive-demo";
|
|
18
|
+
import { registerSidebarActionsProvider } from "@creezio/shell-ui/ui";
|
|
19
|
+
import { DemoPlayer } from "./demo-player";
|
|
20
|
+
|
|
21
|
+
/** Icône du lanceur (play) — inline : lucide-react est un peer optionnel. */
|
|
22
|
+
function LauncherIcon({ className }: { className?: string }) {
|
|
23
|
+
return (
|
|
24
|
+
<svg
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
fill="none"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
strokeWidth="2"
|
|
29
|
+
strokeLinecap="round"
|
|
30
|
+
strokeLinejoin="round"
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
className={className}
|
|
33
|
+
>
|
|
34
|
+
<path d="M5 3l14 9-14 9V3z" />
|
|
35
|
+
</svg>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const INTERACTIVE_DEMO_EVENT = "creezio-interactive-demo";
|
|
40
|
+
|
|
41
|
+
/** Déclenche la démo depuis n'importe où (bouton marque, page d'aide…). */
|
|
42
|
+
export function startInteractiveDemo(scenarioId?: string) {
|
|
43
|
+
if (typeof window === "undefined") return;
|
|
44
|
+
window.dispatchEvent(
|
|
45
|
+
new CustomEvent(INTERACTIVE_DEMO_EVENT, { detail: { scenarioId } }),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type InteractiveDemoRootProps = {
|
|
50
|
+
/** Base du mount api-kernel (défaut `/api/v1/modules/interactive-demo`). */
|
|
51
|
+
apiBase?: string;
|
|
52
|
+
/** Navigation SPA (ex. `router.push` Next) — fortement recommandé. */
|
|
53
|
+
navigate?: (href: string) => void;
|
|
54
|
+
/** Clé utilisateur pour la persistance serveur du « déjà vu ». */
|
|
55
|
+
userKey?: string | null;
|
|
56
|
+
/**
|
|
57
|
+
* Rôle de l'utilisateur courant : le lanceur et l'autoStart ne proposent
|
|
58
|
+
* que les scénarios sans `roles` ou dont `roles` inclut ce rôle.
|
|
59
|
+
* `null`/`undefined` = pas de filtrage (comportement historique). Un
|
|
60
|
+
* lancement explicite (`startInteractiveDemo(id)`) ignore ce filtre.
|
|
61
|
+
*/
|
|
62
|
+
role?: string | null;
|
|
63
|
+
/** Lancement auto du scénario `autoStart` à la première visite (défaut true). */
|
|
64
|
+
autoStart?: boolean;
|
|
65
|
+
/** Affiche le bouton flottant « Visite guidée » (défaut true). */
|
|
66
|
+
showLauncher?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Placement du lanceur : "floating" = bouton flottant historique (défaut,
|
|
69
|
+
* rétrocompatible) ; "sidebar" = entrée d'action dans la sidebar kit
|
|
70
|
+
* (registre @creezio/shell-ui — visible uniquement dans le workspace
|
|
71
|
+
* authentifié, jamais sur /login) ; "none" = aucun lanceur (déclenchement
|
|
72
|
+
* uniquement via `startInteractiveDemo()`).
|
|
73
|
+
*/
|
|
74
|
+
launcher?: "floating" | "sidebar" | "none";
|
|
75
|
+
/** Libellé du lanceur. */
|
|
76
|
+
launcherLabel?: string;
|
|
77
|
+
/** Scénarios injectés (tests / offline) — court-circuite le fetch. */
|
|
78
|
+
scenarios?: DemoScenario[];
|
|
79
|
+
/** Badge du faux curseur (défaut « Démo »). */
|
|
80
|
+
cursorLabel?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function seenStorageKey(id: string) {
|
|
84
|
+
return `creezio-demo-seen:${id}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasSeenLocally(id: string): boolean {
|
|
88
|
+
try {
|
|
89
|
+
return window.localStorage.getItem(seenStorageKey(id)) != null;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function markSeenLocally(id: string) {
|
|
96
|
+
try {
|
|
97
|
+
window.localStorage.setItem(seenStorageKey(id), new Date().toISOString());
|
|
98
|
+
} catch {
|
|
99
|
+
/* stockage indisponible — le serveur garde la trace */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function InteractiveDemoRoot({
|
|
104
|
+
apiBase = "/api/v1/modules/interactive-demo",
|
|
105
|
+
navigate,
|
|
106
|
+
userKey,
|
|
107
|
+
role,
|
|
108
|
+
autoStart = true,
|
|
109
|
+
showLauncher = true,
|
|
110
|
+
launcher = "floating",
|
|
111
|
+
launcherLabel = "Visite guidée",
|
|
112
|
+
scenarios: injected,
|
|
113
|
+
cursorLabel,
|
|
114
|
+
}: InteractiveDemoRootProps) {
|
|
115
|
+
const [scenarios, setScenarios] = useState<DemoScenario[]>(injected ?? []);
|
|
116
|
+
const [active, setActive] = useState<DemoScenario | null>(null);
|
|
117
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
118
|
+
const autoStartedRef = useRef(false);
|
|
119
|
+
const serverSeenRef = useRef<Set<string>>(new Set());
|
|
120
|
+
|
|
121
|
+
const enabled = scenarios.filter((s) => s.enabled !== false && s.steps.length > 0);
|
|
122
|
+
/* Lanceur + autoStart : scénarios visibles pour le rôle courant. */
|
|
123
|
+
const visible = enabled.filter((s) => scenarioMatchesRole(s, role));
|
|
124
|
+
|
|
125
|
+
/* Chargement des scénarios + « déjà vu » serveur. */
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (injected) {
|
|
128
|
+
setScenarios(injected);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
let disposed = false;
|
|
132
|
+
(async () => {
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(`${apiBase}/scenarios`);
|
|
135
|
+
if (!res.ok) return;
|
|
136
|
+
const data = (await res.json()) as { scenarios?: DemoScenario[] };
|
|
137
|
+
if (!disposed && Array.isArray(data.scenarios)) {
|
|
138
|
+
setScenarios(data.scenarios);
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
/* mount absent — pas de démo */
|
|
142
|
+
}
|
|
143
|
+
if (disposed || !userKey) return;
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(
|
|
146
|
+
`${apiBase}/preferences?user=${encodeURIComponent(userKey)}`,
|
|
147
|
+
);
|
|
148
|
+
if (!res.ok) return;
|
|
149
|
+
const data = (await res.json()) as { answers?: Record<string, unknown> };
|
|
150
|
+
if (!disposed && data.answers) {
|
|
151
|
+
for (const key of Object.keys(data.answers)) {
|
|
152
|
+
if (key.startsWith("seen:")) {
|
|
153
|
+
serverSeenRef.current.add(key.slice("seen:".length));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
/* préférences indisponibles — localStorage suffit */
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
return () => {
|
|
162
|
+
disposed = true;
|
|
163
|
+
};
|
|
164
|
+
}, [apiBase, injected, userKey]);
|
|
165
|
+
|
|
166
|
+
const markSeen = useCallback(
|
|
167
|
+
(id: string) => {
|
|
168
|
+
markSeenLocally(id);
|
|
169
|
+
serverSeenRef.current.add(id);
|
|
170
|
+
if (!userKey) return;
|
|
171
|
+
fetch(`${apiBase}/preferences`, {
|
|
172
|
+
method: "PUT",
|
|
173
|
+
headers: { "Content-Type": "application/json" },
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
user: userKey,
|
|
176
|
+
answers: { [`seen:${id}`]: new Date().toISOString() },
|
|
177
|
+
}),
|
|
178
|
+
keepalive: true,
|
|
179
|
+
}).catch(() => {
|
|
180
|
+
/* best effort */
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
[apiBase, userKey],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const start = useCallback(
|
|
187
|
+
(scenarioId?: string) => {
|
|
188
|
+
setMenuOpen(false);
|
|
189
|
+
const list = scenarios.filter((s) => s.enabled !== false && s.steps.length > 0);
|
|
190
|
+
// Id explicite (startInteractiveDemo) : lancement forcé, sans filtre rôle.
|
|
191
|
+
if (scenarioId) {
|
|
192
|
+
const scenario = list.find((s) => s.id === scenarioId);
|
|
193
|
+
if (scenario) setActive(scenario);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const candidates = list.filter((s) => scenarioMatchesRole(s, role));
|
|
197
|
+
const scenario = candidates.find((s) => s.autoStart) ?? candidates[0];
|
|
198
|
+
if (scenario) setActive(scenario);
|
|
199
|
+
},
|
|
200
|
+
[scenarios, role],
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
/* Déclenchement programmatique. */
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const onEvent = (e: Event) => {
|
|
206
|
+
const detail = (e as CustomEvent<{ scenarioId?: string }>).detail;
|
|
207
|
+
start(detail?.scenarioId);
|
|
208
|
+
};
|
|
209
|
+
window.addEventListener(INTERACTIVE_DEMO_EVENT, onEvent);
|
|
210
|
+
return () => window.removeEventListener(INTERACTIVE_DEMO_EVENT, onEvent);
|
|
211
|
+
}, [start]);
|
|
212
|
+
|
|
213
|
+
/* Lancement auto à la première visite. */
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (!autoStart || autoStartedRef.current || active) return;
|
|
216
|
+
const candidate = visible.find((s) => s.autoStart);
|
|
217
|
+
if (!candidate) return;
|
|
218
|
+
if (hasSeenLocally(candidate.id) || serverSeenRef.current.has(candidate.id)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
autoStartedRef.current = true;
|
|
222
|
+
const timer = setTimeout(() => setActive(candidate), 1400);
|
|
223
|
+
return () => clearTimeout(timer);
|
|
224
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
225
|
+
}, [autoStart, active, scenarios, role]);
|
|
226
|
+
|
|
227
|
+
/* Lanceur « sidebar » : entrée d'action dans la sidebar kit dès qu'un
|
|
228
|
+
scénario est proposable pour le rôle courant. L'entrée reste visible
|
|
229
|
+
même pendant une lecture (cliquer relance proprement via start()). Le
|
|
230
|
+
provider est relu à chaque (ré)enregistrement — garder la closure pure. */
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (launcher !== "sidebar" || visible.length === 0) return;
|
|
233
|
+
return registerSidebarActionsProvider(() => [
|
|
234
|
+
{
|
|
235
|
+
id: "interactive-demo",
|
|
236
|
+
label: launcherLabel,
|
|
237
|
+
icon: LauncherIcon,
|
|
238
|
+
onSelect: () => start(),
|
|
239
|
+
},
|
|
240
|
+
]);
|
|
241
|
+
}, [launcher, launcherLabel, visible.length, start]);
|
|
242
|
+
|
|
243
|
+
const stop = useCallback(
|
|
244
|
+
(finished: boolean) => {
|
|
245
|
+
if (active) markSeen(active.id);
|
|
246
|
+
void finished;
|
|
247
|
+
setActive(null);
|
|
248
|
+
},
|
|
249
|
+
[active, markSeen],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
if (active) {
|
|
253
|
+
return (
|
|
254
|
+
<DemoPlayer
|
|
255
|
+
scenario={active}
|
|
256
|
+
navigate={navigate}
|
|
257
|
+
cursorLabel={cursorLabel}
|
|
258
|
+
onFinish={() => stop(true)}
|
|
259
|
+
onExit={() => stop(false)}
|
|
260
|
+
/>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (launcher !== "floating" || !showLauncher || visible.length === 0) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<div data-creezio-demo-ui="1">
|
|
270
|
+
{menuOpen && visible.length > 1 ? (
|
|
271
|
+
<div className="creezio-demo-launcher-menu">
|
|
272
|
+
{visible.map((s) => (
|
|
273
|
+
<button
|
|
274
|
+
key={s.id}
|
|
275
|
+
type="button"
|
|
276
|
+
className="creezio-demo-launcher-item"
|
|
277
|
+
onClick={() => start(s.id)}
|
|
278
|
+
>
|
|
279
|
+
<p className="creezio-demo-launcher-item-title">{s.title}</p>
|
|
280
|
+
{s.description ? (
|
|
281
|
+
<p className="creezio-demo-launcher-item-desc">{s.description}</p>
|
|
282
|
+
) : null}
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
285
|
+
</div>
|
|
286
|
+
) : null}
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className="creezio-demo-launcher"
|
|
290
|
+
onClick={() =>
|
|
291
|
+
visible.length > 1 ? setMenuOpen((v) => !v) : start(visible[0]!.id)
|
|
292
|
+
}
|
|
293
|
+
aria-label={launcherLabel}
|
|
294
|
+
>
|
|
295
|
+
<svg
|
|
296
|
+
width="15"
|
|
297
|
+
height="15"
|
|
298
|
+
viewBox="0 0 24 24"
|
|
299
|
+
fill="none"
|
|
300
|
+
stroke="currentColor"
|
|
301
|
+
strokeWidth="2"
|
|
302
|
+
strokeLinecap="round"
|
|
303
|
+
strokeLinejoin="round"
|
|
304
|
+
aria-hidden="true"
|
|
305
|
+
>
|
|
306
|
+
<path d="M5 3l14 9-14 9V3z" />
|
|
307
|
+
</svg>
|
|
308
|
+
{launcherLabel}
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
}
|
package/ui/dom.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilitaires DOM du lecteur de démo : résolution des cibles (`DemoTarget` :
|
|
3
|
+
* sélecteur CSS, `data-aid` shell-ui, libellé d'élément interactif),
|
|
4
|
+
* événements synthétiques (clic pointeur complet, frappe caractère par
|
|
5
|
+
* caractère compatible React) et déplacement du faux curseur.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DemoTarget } from "@creezio/interactive-demo";
|
|
9
|
+
import { resolveAidAttr } from "@creezio/shell-ui/ui";
|
|
10
|
+
import { getDemoCursor } from "./fake-cursor";
|
|
11
|
+
|
|
12
|
+
export const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
13
|
+
|
|
14
|
+
function interactiveSelector(): string {
|
|
15
|
+
return [
|
|
16
|
+
"a[href]",
|
|
17
|
+
"button",
|
|
18
|
+
'[role="button"]',
|
|
19
|
+
'[role="option"]',
|
|
20
|
+
'[role="menuitem"]',
|
|
21
|
+
'[role="tab"]',
|
|
22
|
+
'[role="combobox"]',
|
|
23
|
+
'input:not([type="hidden"])',
|
|
24
|
+
"select",
|
|
25
|
+
"textarea",
|
|
26
|
+
`[${resolveAidAttr()}]`,
|
|
27
|
+
].join(", ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isVisible(el: Element): boolean {
|
|
31
|
+
if (el.closest("[data-creezio-demo-ui]")) return false;
|
|
32
|
+
if (el.closest('[aria-hidden="true"]')) return false;
|
|
33
|
+
const he = el as HTMLElement;
|
|
34
|
+
if (he.hidden) return false;
|
|
35
|
+
if ((he as HTMLButtonElement).disabled) return false;
|
|
36
|
+
const rect = el.getBoundingClientRect();
|
|
37
|
+
if (rect.width < 4 || rect.height < 4) return false;
|
|
38
|
+
const margin = 400;
|
|
39
|
+
if (
|
|
40
|
+
rect.bottom < -margin ||
|
|
41
|
+
rect.top > window.innerHeight + margin ||
|
|
42
|
+
rect.right < -margin ||
|
|
43
|
+
rect.left > window.innerWidth + margin
|
|
44
|
+
) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const style = window.getComputedStyle(el);
|
|
48
|
+
if (style.visibility === "hidden" || style.display === "none") return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalize(s: string): string {
|
|
53
|
+
return s
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.normalize("NFD")
|
|
56
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function labelFor(el: Element): string {
|
|
62
|
+
const he = el as HTMLElement;
|
|
63
|
+
const aria = he.getAttribute("aria-label");
|
|
64
|
+
if (aria?.trim()) return aria.trim();
|
|
65
|
+
const title = he.getAttribute("title");
|
|
66
|
+
if (title?.trim()) return title.trim();
|
|
67
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
68
|
+
const ph = el.placeholder?.trim();
|
|
69
|
+
if (ph) return ph;
|
|
70
|
+
if (el.name) return el.name;
|
|
71
|
+
}
|
|
72
|
+
const text = (he.innerText || he.textContent || "").replace(/\s+/g, " ").trim();
|
|
73
|
+
return text.slice(0, 90);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function scope(within?: string): ParentNode {
|
|
77
|
+
if (within) {
|
|
78
|
+
const root = document.querySelector(within);
|
|
79
|
+
if (root) return root;
|
|
80
|
+
}
|
|
81
|
+
return document;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function findByText(text: string, within?: string): Element | null {
|
|
85
|
+
const needle = normalize(text);
|
|
86
|
+
if (!needle) return null;
|
|
87
|
+
let partial: Element | null = null;
|
|
88
|
+
for (const el of Array.from(scope(within).querySelectorAll(interactiveSelector()))) {
|
|
89
|
+
if (!isVisible(el)) continue;
|
|
90
|
+
const label = normalize(labelFor(el));
|
|
91
|
+
if (!label) continue;
|
|
92
|
+
if (label === needle) return el;
|
|
93
|
+
if (!partial && (label.includes(needle) || needle.includes(label))) partial = el;
|
|
94
|
+
}
|
|
95
|
+
return partial;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function findByAid(aid: string, within?: string): Element | null {
|
|
99
|
+
const attr = resolveAidAttr();
|
|
100
|
+
const needle = normalize(aid);
|
|
101
|
+
for (const el of Array.from(scope(within).querySelectorAll(`[${attr}]`))) {
|
|
102
|
+
if (normalize(el.getAttribute(attr) || "") === needle && isVisible(el)) return el;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function findBySelector(selector: string, within?: string): Element | null {
|
|
108
|
+
try {
|
|
109
|
+
for (const el of Array.from(scope(within).querySelectorAll(selector))) {
|
|
110
|
+
if (isVisible(el)) return el;
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
/* sélecteur invalide → autres stratégies */
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Résolution ponctuelle d'une cible (null si introuvable/invisible). */
|
|
119
|
+
export function resolveDemoTarget(target: DemoTarget): Element | null {
|
|
120
|
+
if (typeof target === "string") {
|
|
121
|
+
return (
|
|
122
|
+
findBySelector(target) || findByAid(target) || findByText(target)
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const { selector, aid, text, within } = target;
|
|
126
|
+
if (selector) {
|
|
127
|
+
const el = findBySelector(selector, within);
|
|
128
|
+
if (el) return el;
|
|
129
|
+
}
|
|
130
|
+
if (aid) {
|
|
131
|
+
const el = findByAid(aid, within);
|
|
132
|
+
if (el) return el;
|
|
133
|
+
}
|
|
134
|
+
if (text) {
|
|
135
|
+
const el = findByText(text, within);
|
|
136
|
+
if (el) return el;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Attend la cible (polling 150 ms) — les pages App Router chargent en
|
|
143
|
+
* asynchrone. Renvoie null au timeout, jamais de throw.
|
|
144
|
+
*/
|
|
145
|
+
export async function waitForDemoTarget(
|
|
146
|
+
target: DemoTarget,
|
|
147
|
+
timeoutMs = 6000,
|
|
148
|
+
isCancelled?: () => boolean,
|
|
149
|
+
): Promise<Element | null> {
|
|
150
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
151
|
+
for (;;) {
|
|
152
|
+
if (isCancelled?.()) return null;
|
|
153
|
+
const el = resolveDemoTarget(target);
|
|
154
|
+
if (el) return el;
|
|
155
|
+
if (Date.now() >= deadline) return null;
|
|
156
|
+
await sleep(150);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ── Événements synthétiques (mêmes séquences que la souris réelle) ── */
|
|
161
|
+
|
|
162
|
+
function pointerOpts(x: number, y: number): PointerEventInit & MouseEventInit {
|
|
163
|
+
return {
|
|
164
|
+
bubbles: true,
|
|
165
|
+
cancelable: true,
|
|
166
|
+
composed: true,
|
|
167
|
+
view: window,
|
|
168
|
+
clientX: x,
|
|
169
|
+
clientY: y,
|
|
170
|
+
button: 0,
|
|
171
|
+
buttons: 1,
|
|
172
|
+
pointerId: 9002,
|
|
173
|
+
pointerType: "mouse",
|
|
174
|
+
isPrimary: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function synthClick(el: Element, x: number, y: number) {
|
|
179
|
+
const opts = pointerOpts(x, y);
|
|
180
|
+
el.dispatchEvent(new PointerEvent("pointerover", opts));
|
|
181
|
+
el.dispatchEvent(new PointerEvent("pointerenter", { ...opts, bubbles: false }));
|
|
182
|
+
el.dispatchEvent(new PointerEvent("pointermove", opts));
|
|
183
|
+
el.dispatchEvent(new PointerEvent("pointerdown", opts));
|
|
184
|
+
el.dispatchEvent(new MouseEvent("mousedown", opts));
|
|
185
|
+
(el as HTMLElement).focus?.({ preventScroll: true });
|
|
186
|
+
el.dispatchEvent(new PointerEvent("pointerup", { ...opts, buttons: 0 }));
|
|
187
|
+
el.dispatchEvent(new MouseEvent("mouseup", { ...opts, buttons: 0 }));
|
|
188
|
+
el.dispatchEvent(new MouseEvent("click", { ...opts, buttons: 0, detail: 1 }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Pose la valeur via le setter natif (déclenche l'onChange React). */
|
|
192
|
+
export function setNativeValue(
|
|
193
|
+
el: HTMLInputElement | HTMLTextAreaElement,
|
|
194
|
+
value: string,
|
|
195
|
+
) {
|
|
196
|
+
const proto =
|
|
197
|
+
el instanceof HTMLTextAreaElement
|
|
198
|
+
? window.HTMLTextAreaElement.prototype
|
|
199
|
+
: window.HTMLInputElement.prototype;
|
|
200
|
+
const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
|
|
201
|
+
if (setter) setter.call(el, value);
|
|
202
|
+
else el.value = value;
|
|
203
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Frappe progressive avec touches simulées (vitesse plafonnée). */
|
|
207
|
+
export async function typeInto(
|
|
208
|
+
el: HTMLInputElement | HTMLTextAreaElement,
|
|
209
|
+
text: string,
|
|
210
|
+
isCancelled?: () => boolean,
|
|
211
|
+
) {
|
|
212
|
+
setNativeValue(el, "");
|
|
213
|
+
const perChar = Math.max(28, Math.min(75, Math.floor(2200 / Math.max(text.length, 1))));
|
|
214
|
+
let acc = "";
|
|
215
|
+
for (const ch of text) {
|
|
216
|
+
if (isCancelled?.()) return;
|
|
217
|
+
const keyOpts = { bubbles: true, cancelable: true, key: ch };
|
|
218
|
+
el.dispatchEvent(new KeyboardEvent("keydown", keyOpts));
|
|
219
|
+
acc += ch;
|
|
220
|
+
setNativeValue(el, acc);
|
|
221
|
+
el.dispatchEvent(new KeyboardEvent("keyup", keyOpts));
|
|
222
|
+
await sleep(perChar);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function submitField(el: HTMLInputElement | HTMLTextAreaElement) {
|
|
227
|
+
const keyOpts = { bubbles: true, cancelable: true, key: "Enter", code: "Enter" };
|
|
228
|
+
el.dispatchEvent(new KeyboardEvent("keydown", keyOpts));
|
|
229
|
+
el.dispatchEvent(new KeyboardEvent("keyup", keyOpts));
|
|
230
|
+
el.form?.requestSubmit();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Conteneur défilant principal (layouts à scroll interne inclus). */
|
|
234
|
+
export function findScrollableRoot(): Element {
|
|
235
|
+
const doc = document.scrollingElement;
|
|
236
|
+
if (doc && doc.scrollHeight > doc.clientHeight + 50) return doc;
|
|
237
|
+
let best: Element | null = null;
|
|
238
|
+
let bestArea = 0;
|
|
239
|
+
for (const el of Array.from(
|
|
240
|
+
document.querySelectorAll("main, [data-scroll-root], div"),
|
|
241
|
+
)) {
|
|
242
|
+
if (el.closest("[data-creezio-demo-ui]")) continue;
|
|
243
|
+
const he = el as HTMLElement;
|
|
244
|
+
if (he.scrollHeight <= he.clientHeight + 50) continue;
|
|
245
|
+
const style = window.getComputedStyle(he);
|
|
246
|
+
if (!/(auto|scroll)/.test(style.overflowY)) continue;
|
|
247
|
+
const rect = he.getBoundingClientRect();
|
|
248
|
+
const area = rect.width * rect.height;
|
|
249
|
+
if (area > bestArea) {
|
|
250
|
+
bestArea = area;
|
|
251
|
+
best = el;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return best || doc || document.documentElement;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Scroll la cible au centre si besoin puis amène le curseur dessus. */
|
|
258
|
+
export async function moveCursorToElement(
|
|
259
|
+
el: Element,
|
|
260
|
+
): Promise<{ x: number; y: number }> {
|
|
261
|
+
const cursor = getDemoCursor();
|
|
262
|
+
cursor.show();
|
|
263
|
+
|
|
264
|
+
let rect = el.getBoundingClientRect();
|
|
265
|
+
const outOfView =
|
|
266
|
+
rect.top < 60 ||
|
|
267
|
+
rect.bottom > window.innerHeight - 20 ||
|
|
268
|
+
rect.left < 0 ||
|
|
269
|
+
rect.right > window.innerWidth;
|
|
270
|
+
if (outOfView) {
|
|
271
|
+
el.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
|
|
272
|
+
await sleep(450);
|
|
273
|
+
rect = el.getBoundingClientRect();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const x = rect.left + Math.min(rect.width / 2, 180);
|
|
277
|
+
const y = rect.top + rect.height / 2;
|
|
278
|
+
await cursor.moveTo(x, y);
|
|
279
|
+
return { x, y };
|
|
280
|
+
}
|