@aphrody/m3-reader 3.3.3
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 +12 -0
- package/dist/CollectionReader.d.ts +26 -0
- package/dist/CollectionReader.d.ts.map +1 -0
- package/dist/DatabookReader.d.ts +39 -0
- package/dist/DatabookReader.d.ts.map +1 -0
- package/dist/MangaReader.d.ts +36 -0
- package/dist/MangaReader.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1478 -0
- package/dist/index.js.map +14 -0
- package/dist/reader-lifecycle.test.d.ts +2 -0
- package/dist/reader-lifecycle.test.d.ts.map +1 -0
- package/dist/use-focus-trap.d.ts +9 -0
- package/dist/use-focus-trap.d.ts.map +1 -0
- package/dist/use-reader-core.d.ts +33 -0
- package/dist/use-reader-core.d.ts.map +1 -0
- package/dragon-ball.css +60 -0
- package/package.json +59 -0
- package/src/CollectionReader.tsx +82 -0
- package/src/DatabookReader.tsx +804 -0
- package/src/MangaReader.tsx +560 -0
- package/src/index.ts +7 -0
- package/src/reader-lifecycle.test.tsx +428 -0
- package/src/use-focus-trap.ts +129 -0
- package/src/use-reader-core.ts +138 -0
- package/styles.css +61 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
import { afterAll, afterEach, describe, expect, mock, test } from "bun:test";
|
|
3
|
+
import { act, useRef, type ReactNode } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { useFullscreen } from "./use-reader-core";
|
|
6
|
+
import { useFocusTrap } from "./use-focus-trap";
|
|
7
|
+
|
|
8
|
+
const environment = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
|
9
|
+
const previousAct = environment.IS_REACT_ACT_ENVIRONMENT;
|
|
10
|
+
environment.IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
const originalOwner = Object.getOwnPropertyDescriptor(document, "fullscreenElement");
|
|
12
|
+
const originalExit = Object.getOwnPropertyDescriptor(document, "exitFullscreen");
|
|
13
|
+
const originalOverflow = document.body.style.overflow;
|
|
14
|
+
const mounts: { root: Root; host: HTMLElement; active: boolean }[] = [];
|
|
15
|
+
const extraElements: HTMLElement[] = [];
|
|
16
|
+
let owner: Element | null = null;
|
|
17
|
+
|
|
18
|
+
async function mount(node: ReactNode) {
|
|
19
|
+
const host = document.createElement("div");
|
|
20
|
+
document.body.append(host);
|
|
21
|
+
const mounted = { root: createRoot(host), host, active: true };
|
|
22
|
+
mounts.push(mounted);
|
|
23
|
+
await act(() => mounted.root.render(node));
|
|
24
|
+
return mounted;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function unmount(mounted: (typeof mounts)[number]) {
|
|
28
|
+
await act(() => mounted.root.unmount());
|
|
29
|
+
mounted.active = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
// Each React root must finish its effect cleanup before the next root is removed.
|
|
34
|
+
for (const mounted of mounts.splice(0)) {
|
|
35
|
+
// eslint-disable-next-line no-await-in-loop
|
|
36
|
+
if (mounted.active) await unmount(mounted);
|
|
37
|
+
mounted.host.remove();
|
|
38
|
+
}
|
|
39
|
+
for (const element of extraElements.splice(0)) element.remove();
|
|
40
|
+
if (originalOwner) Object.defineProperty(document, "fullscreenElement", originalOwner);
|
|
41
|
+
else Reflect.deleteProperty(document, "fullscreenElement");
|
|
42
|
+
if (originalExit) Object.defineProperty(document, "exitFullscreen", originalExit);
|
|
43
|
+
else Reflect.deleteProperty(document, "exitFullscreen");
|
|
44
|
+
document.body.style.overflow = originalOverflow;
|
|
45
|
+
owner = null;
|
|
46
|
+
});
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
environment.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function platform() {
|
|
52
|
+
// Happy DOM has no native fullscreen. Simulate only this platform boundary;
|
|
53
|
+
// the hook, React roots, ownership, DOM events and effect cleanup remain real.
|
|
54
|
+
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => owner });
|
|
55
|
+
const exit = mock(async () => {
|
|
56
|
+
owner = null;
|
|
57
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
58
|
+
});
|
|
59
|
+
Object.defineProperty(document, "exitFullscreen", { configurable: true, value: exit });
|
|
60
|
+
return exit;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fullscreenReader(id = "reader", present = true) {
|
|
64
|
+
let latest!: ReturnType<typeof useFullscreen>;
|
|
65
|
+
let renders = 0;
|
|
66
|
+
function Reader() {
|
|
67
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
68
|
+
latest = useFullscreen(ref);
|
|
69
|
+
renders++;
|
|
70
|
+
return present ? <div ref={ref} data-reader={id} /> : null;
|
|
71
|
+
}
|
|
72
|
+
const mounted = await mount(<Reader />);
|
|
73
|
+
const element = mounted.host.querySelector<HTMLDivElement>("[data-reader]");
|
|
74
|
+
const request = mock(async () => {
|
|
75
|
+
owner = element;
|
|
76
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
77
|
+
});
|
|
78
|
+
if (element)
|
|
79
|
+
Object.defineProperty(element, "requestFullscreen", { configurable: true, value: request });
|
|
80
|
+
return {
|
|
81
|
+
mounted,
|
|
82
|
+
element,
|
|
83
|
+
request,
|
|
84
|
+
get latest() {
|
|
85
|
+
return latest;
|
|
86
|
+
},
|
|
87
|
+
get renders() {
|
|
88
|
+
return renders;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("reader fullscreen ownership and lifetime", () => {
|
|
94
|
+
test("another element's fullscreen is neither reported nor exited", async () => {
|
|
95
|
+
const exit = platform();
|
|
96
|
+
owner = document.createElement("video");
|
|
97
|
+
const reader = await fullscreenReader();
|
|
98
|
+
expect(reader.latest.isFullscreen).toBe(false);
|
|
99
|
+
await act(async () => {
|
|
100
|
+
expect(await reader.latest.toggleFullscreen()).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
expect(reader.request).toHaveBeenCalledTimes(1);
|
|
103
|
+
expect(exit).not.toHaveBeenCalled();
|
|
104
|
+
expect(reader.latest.isFullscreen).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("enter and exit affect only the owning reader, including external changes", async () => {
|
|
108
|
+
const exit = platform();
|
|
109
|
+
const first = await fullscreenReader("first");
|
|
110
|
+
const second = await fullscreenReader("second");
|
|
111
|
+
await act(async () => {
|
|
112
|
+
await first.latest.toggleFullscreen();
|
|
113
|
+
});
|
|
114
|
+
expect(first.latest.isFullscreen).toBe(true);
|
|
115
|
+
expect(second.latest.isFullscreen).toBe(false);
|
|
116
|
+
await act(async () => {
|
|
117
|
+
await first.latest.toggleFullscreen();
|
|
118
|
+
});
|
|
119
|
+
expect(exit).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(first.latest.isFullscreen).toBe(false);
|
|
121
|
+
await act(() => {
|
|
122
|
+
owner = second.element;
|
|
123
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
124
|
+
});
|
|
125
|
+
expect(first.latest.isFullscreen).toBe(false);
|
|
126
|
+
expect(second.latest.isFullscreen).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("recognizes an already-fullscreen container at mount", async () => {
|
|
130
|
+
platform();
|
|
131
|
+
Object.defineProperty(document, "fullscreenElement", {
|
|
132
|
+
configurable: true,
|
|
133
|
+
get: () => document.querySelector("[data-reader]"),
|
|
134
|
+
});
|
|
135
|
+
const reader = await fullscreenReader();
|
|
136
|
+
expect(reader.latest.isFullscreen).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test.each(["request", "exit"])(
|
|
140
|
+
"%s rejection is returned and exposed without an unhandled promise",
|
|
141
|
+
async (operation) => {
|
|
142
|
+
platform();
|
|
143
|
+
const reader = await fullscreenReader();
|
|
144
|
+
const denied = mock(async () => {
|
|
145
|
+
throw new Error("Permission denied");
|
|
146
|
+
});
|
|
147
|
+
if (operation === "request")
|
|
148
|
+
Object.defineProperty(reader.element!, "requestFullscreen", { value: denied });
|
|
149
|
+
else {
|
|
150
|
+
await act(() => {
|
|
151
|
+
owner = reader.element;
|
|
152
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
153
|
+
});
|
|
154
|
+
Object.defineProperty(document, "exitFullscreen", { configurable: true, value: denied });
|
|
155
|
+
}
|
|
156
|
+
await act(async () => {
|
|
157
|
+
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
expect(reader.latest.fullscreenError?.message).toBe("Permission denied");
|
|
160
|
+
expect(reader.latest.isFullscreen).toBe(operation === "exit");
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
test("unsupported APIs and synchronous native exceptions are handled", async () => {
|
|
165
|
+
platform();
|
|
166
|
+
const reader = await fullscreenReader();
|
|
167
|
+
Object.defineProperty(reader.element!, "requestFullscreen", {
|
|
168
|
+
configurable: true,
|
|
169
|
+
value: undefined,
|
|
170
|
+
});
|
|
171
|
+
await act(async () => {
|
|
172
|
+
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
expect(reader.latest.fullscreenError?.message).toContain("unavailable");
|
|
175
|
+
Object.defineProperty(reader.element!, "requestFullscreen", {
|
|
176
|
+
configurable: true,
|
|
177
|
+
value: () => {
|
|
178
|
+
throw "Native failure";
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
await act(async () => {
|
|
182
|
+
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
expect(reader.latest.fullscreenError?.message).toBe("Native failure");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("a successful newer operation ignores an older rejection", async () => {
|
|
188
|
+
platform();
|
|
189
|
+
const reader = await fullscreenReader();
|
|
190
|
+
let reject!: (reason: Error) => void;
|
|
191
|
+
Object.defineProperty(reader.element!, "requestFullscreen", {
|
|
192
|
+
configurable: true,
|
|
193
|
+
value: () =>
|
|
194
|
+
new Promise<void>((_resolve, rejectPromise) => {
|
|
195
|
+
reject = rejectPromise;
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
let pending!: Promise<boolean>;
|
|
199
|
+
await act(() => {
|
|
200
|
+
pending = reader.latest.toggleFullscreen();
|
|
201
|
+
});
|
|
202
|
+
Object.defineProperty(reader.element!, "requestFullscreen", {
|
|
203
|
+
configurable: true,
|
|
204
|
+
value: reader.request,
|
|
205
|
+
});
|
|
206
|
+
await act(async () => {
|
|
207
|
+
await reader.latest.toggleFullscreen();
|
|
208
|
+
});
|
|
209
|
+
await act(async () => {
|
|
210
|
+
reject(new Error("Old failure"));
|
|
211
|
+
expect(await pending).toBe(false);
|
|
212
|
+
});
|
|
213
|
+
expect(reader.latest.isFullscreen).toBe(true);
|
|
214
|
+
expect(reader.latest.fullscreenError).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("unmount removes listeners and ignores a late native failure", async () => {
|
|
218
|
+
platform();
|
|
219
|
+
const reader = await fullscreenReader();
|
|
220
|
+
let reject!: (reason: Error) => void;
|
|
221
|
+
Object.defineProperty(reader.element!, "requestFullscreen", {
|
|
222
|
+
configurable: true,
|
|
223
|
+
value: () =>
|
|
224
|
+
new Promise<void>((_resolve, rejectPromise) => {
|
|
225
|
+
reject = rejectPromise;
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
let pending!: Promise<boolean>;
|
|
229
|
+
await act(() => {
|
|
230
|
+
pending = reader.latest.toggleFullscreen();
|
|
231
|
+
});
|
|
232
|
+
await unmount(reader.mounted);
|
|
233
|
+
const renders = reader.renders;
|
|
234
|
+
await act(async () => {
|
|
235
|
+
reject(new Error("Late failure"));
|
|
236
|
+
expect(await pending).toBe(false);
|
|
237
|
+
owner = reader.element;
|
|
238
|
+
document.dispatchEvent(new Event("fullscreenchange"));
|
|
239
|
+
});
|
|
240
|
+
expect(reader.renders).toBe(renders);
|
|
241
|
+
expect(reader.latest.fullscreenError).toBeNull();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("an absent container is a harmless no-op", async () => {
|
|
245
|
+
platform();
|
|
246
|
+
const reader = await fullscreenReader("absent", false);
|
|
247
|
+
expect(await reader.latest.toggleFullscreen()).toBe(false);
|
|
248
|
+
expect(reader.latest.fullscreenError).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
function tab(shiftKey = false) {
|
|
253
|
+
const event = new KeyboardEvent("keydown", {
|
|
254
|
+
key: "Tab",
|
|
255
|
+
shiftKey,
|
|
256
|
+
cancelable: true,
|
|
257
|
+
bubbles: true,
|
|
258
|
+
});
|
|
259
|
+
document.dispatchEvent(event);
|
|
260
|
+
return event;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function Trap({
|
|
264
|
+
active = true,
|
|
265
|
+
onEscape,
|
|
266
|
+
children,
|
|
267
|
+
}: {
|
|
268
|
+
active?: boolean;
|
|
269
|
+
onEscape?: () => void;
|
|
270
|
+
children?: ReactNode;
|
|
271
|
+
}) {
|
|
272
|
+
const ref = useRef<HTMLElement>(null);
|
|
273
|
+
useFocusTrap(ref, active, onEscape);
|
|
274
|
+
return (
|
|
275
|
+
<section ref={ref} data-panel="" tabIndex={-1}>
|
|
276
|
+
{children}
|
|
277
|
+
</section>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const controls = (
|
|
282
|
+
<>
|
|
283
|
+
<button data-first="">First</button>
|
|
284
|
+
<button data-last="">Last</button>
|
|
285
|
+
</>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
describe("reader modal keyboard lifecycle", () => {
|
|
289
|
+
test("only the top modal handles keys and closing it restores the underlying modal", async () => {
|
|
290
|
+
const lowerEscape = mock(() => {});
|
|
291
|
+
const upperEscape = mock(() => {});
|
|
292
|
+
const lower = await mount(<Trap onEscape={lowerEscape}>{controls}</Trap>);
|
|
293
|
+
const lowerLast = lower.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
|
294
|
+
lowerLast.focus();
|
|
295
|
+
const upper = await mount(<Trap onEscape={upperEscape}>{controls}</Trap>);
|
|
296
|
+
tab(true);
|
|
297
|
+
expect(document.activeElement).toBe(upper.host.querySelector("[data-last]"));
|
|
298
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
299
|
+
expect(upperEscape).toHaveBeenCalledTimes(1);
|
|
300
|
+
expect(lowerEscape).not.toHaveBeenCalled();
|
|
301
|
+
await unmount(upper);
|
|
302
|
+
expect(document.activeElement).toBe(lowerLast);
|
|
303
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
304
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
305
|
+
expect(lowerEscape).toHaveBeenCalledTimes(1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("removing a lower modal preserves the upper scroll lock and original opener", async () => {
|
|
309
|
+
const opener = document.createElement("button");
|
|
310
|
+
document.body.append(opener);
|
|
311
|
+
extraElements.push(opener);
|
|
312
|
+
opener.focus();
|
|
313
|
+
document.body.style.overflow = "scroll";
|
|
314
|
+
const lower = await mount(<Trap>{controls}</Trap>);
|
|
315
|
+
const upper = await mount(<Trap>{controls}</Trap>);
|
|
316
|
+
const focused = document.activeElement;
|
|
317
|
+
await unmount(lower);
|
|
318
|
+
expect(document.activeElement).toBe(focused);
|
|
319
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
320
|
+
await unmount(upper);
|
|
321
|
+
expect(document.activeElement).toBe(opener);
|
|
322
|
+
expect(document.body.style.overflow).toBe("scroll");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("Tab and Shift+Tab from the initial panel go to the first/last control", async () => {
|
|
326
|
+
const mounted = await mount(<Trap>{controls}</Trap>);
|
|
327
|
+
const panel = mounted.host.querySelector<HTMLElement>("[data-panel]")!;
|
|
328
|
+
expect(document.activeElement).toBe(panel);
|
|
329
|
+
expect(tab(true).defaultPrevented).toBe(true);
|
|
330
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
|
331
|
+
panel.focus();
|
|
332
|
+
expect(tab().defaultPrevented).toBe(true);
|
|
333
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-first]"));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("wraps each boundary while preserving ordinary browser tab navigation", async () => {
|
|
337
|
+
const mounted = await mount(<Trap>{controls}</Trap>);
|
|
338
|
+
const first = mounted.host.querySelector<HTMLButtonElement>("[data-first]")!;
|
|
339
|
+
const last = mounted.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
|
340
|
+
first.focus();
|
|
341
|
+
expect(tab().defaultPrevented).toBe(false);
|
|
342
|
+
expect(tab(true).defaultPrevented).toBe(true);
|
|
343
|
+
expect(document.activeElement).toBe(last);
|
|
344
|
+
expect(tab().defaultPrevented).toBe(true);
|
|
345
|
+
expect(document.activeElement).toBe(first);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("skips hidden ancestors, inert regions, CSS-hidden, disabled and negative-tabindex controls", async () => {
|
|
349
|
+
const mounted = await mount(
|
|
350
|
+
<Trap>
|
|
351
|
+
<div hidden>
|
|
352
|
+
<button>Hidden ancestor</button>
|
|
353
|
+
</div>
|
|
354
|
+
<div style={{ display: "none" }}>
|
|
355
|
+
<button>Display none</button>
|
|
356
|
+
</div>
|
|
357
|
+
<div style={{ visibility: "hidden" }}>
|
|
358
|
+
<button>Visibility hidden</button>
|
|
359
|
+
</div>
|
|
360
|
+
<div inert>
|
|
361
|
+
<button>Inert</button>
|
|
362
|
+
</div>
|
|
363
|
+
<button disabled>Disabled</button>
|
|
364
|
+
<input type="hidden" />
|
|
365
|
+
<button tabIndex={-2}>Negative</button>
|
|
366
|
+
{controls}
|
|
367
|
+
</Trap>,
|
|
368
|
+
);
|
|
369
|
+
tab(true);
|
|
370
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
|
371
|
+
tab();
|
|
372
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-first]"));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("a modal with no tabbable children keeps focus on its panel", async () => {
|
|
376
|
+
const mounted = await mount(
|
|
377
|
+
<Trap>
|
|
378
|
+
<div hidden>
|
|
379
|
+
<button>Hidden</button>
|
|
380
|
+
</div>
|
|
381
|
+
</Trap>,
|
|
382
|
+
);
|
|
383
|
+
expect(tab().defaultPrevented).toBe(true);
|
|
384
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-panel]"));
|
|
385
|
+
expect(tab(true).defaultPrevented).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("focus lost outside the panel is recovered on the next Tab", async () => {
|
|
389
|
+
const outside = document.createElement("button");
|
|
390
|
+
document.body.append(outside);
|
|
391
|
+
extraElements.push(outside);
|
|
392
|
+
const mounted = await mount(<Trap>{controls}</Trap>);
|
|
393
|
+
outside.focus();
|
|
394
|
+
expect(tab(true).defaultPrevented).toBe(true);
|
|
395
|
+
expect(document.activeElement).toBe(mounted.host.querySelector("[data-last]"));
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("rerender keeps focus, uses the current Escape callback and restores opener/scroll on close", async () => {
|
|
399
|
+
const opener = document.createElement("button");
|
|
400
|
+
document.body.append(opener);
|
|
401
|
+
extraElements.push(opener);
|
|
402
|
+
opener.focus();
|
|
403
|
+
document.body.style.overflow = "scroll";
|
|
404
|
+
const original = mock(() => {});
|
|
405
|
+
const replacement = mock(() => {});
|
|
406
|
+
const mounted = await mount(<Trap onEscape={original}>{controls}</Trap>);
|
|
407
|
+
expect(document.body.style.overflow).toBe("hidden");
|
|
408
|
+
const last = mounted.host.querySelector<HTMLButtonElement>("[data-last]")!;
|
|
409
|
+
last.focus();
|
|
410
|
+
await act(() => mounted.root.render(<Trap onEscape={replacement}>{controls}</Trap>));
|
|
411
|
+
expect(document.activeElement).toBe(last);
|
|
412
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
413
|
+
expect(original).not.toHaveBeenCalled();
|
|
414
|
+
expect(replacement).toHaveBeenCalledTimes(1);
|
|
415
|
+
await act(() =>
|
|
416
|
+
mounted.root.render(
|
|
417
|
+
<Trap active={false} onEscape={replacement}>
|
|
418
|
+
{controls}
|
|
419
|
+
</Trap>,
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
expect(document.activeElement).toBe(opener);
|
|
423
|
+
expect(document.body.style.overflow).toBe("scroll");
|
|
424
|
+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
425
|
+
expect(replacement).toHaveBeenCalledTimes(1);
|
|
426
|
+
expect(tab().defaultPrevented).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Piège de focus pour une boîte de dialogue modale (WCAG 2.4.3 / 2.1.2).
|
|
6
|
+
*
|
|
7
|
+
* Keeps Tab inside the panel, closes on Escape, and restores focus on exit.
|
|
8
|
+
* A click on non-focusable text moves focus back into the panel on the next Tab.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, type RefObject } from "react";
|
|
11
|
+
|
|
12
|
+
const FOCUSABLE =
|
|
13
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
14
|
+
|
|
15
|
+
interface FocusLease {
|
|
16
|
+
panel: HTMLElement;
|
|
17
|
+
previousFocused: HTMLElement | null;
|
|
18
|
+
previousTrap?: FocusLease;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The document scroll lock is shared; only the last activated modal handles keys.
|
|
22
|
+
const activeTraps = new WeakMap<Document, { stack: FocusLease[]; previousOverflow: string }>();
|
|
23
|
+
|
|
24
|
+
/** Native tab stops, excluding controls hidden or disabled through their ancestors. */
|
|
25
|
+
function isTabbable(element: HTMLElement): boolean {
|
|
26
|
+
if (element.tabIndex < 0 || element.matches(":disabled, input[type='hidden']")) return false;
|
|
27
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
28
|
+
const view = element.ownerDocument.defaultView;
|
|
29
|
+
const visibility = view?.getComputedStyle(element).visibility;
|
|
30
|
+
if (visibility === "hidden" || visibility === "collapse") return false;
|
|
31
|
+
for (let ancestor: HTMLElement | null = element; ancestor; ancestor = ancestor.parentElement) {
|
|
32
|
+
if (view?.getComputedStyle(ancestor).display === "none") return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useFocusTrap(
|
|
38
|
+
panelRef: RefObject<HTMLElement | null>,
|
|
39
|
+
active: boolean,
|
|
40
|
+
onEscape?: () => void,
|
|
41
|
+
): void {
|
|
42
|
+
// `onEscape` est presque toujours une lambda écrite dans le JSX de l'appelant
|
|
43
|
+
// (`() => setLightbox(false)`), donc une NOUVELLE fonction à chaque rendu. En
|
|
44
|
+
// dépendance d'effet, elle rejouait tout le montage du piège à chaque rendu
|
|
45
|
+
// de la modale : le nettoyage rendait le focus au déclencheur, l'effet
|
|
46
|
+
// suivant le reprenait sur le panneau — l'utilisateur au clavier était donc
|
|
47
|
+
// ramené en tête du panneau dès qu'un état changeait, et `prevFocused`
|
|
48
|
+
// mémorisait le panneau au lieu du bouton d'ouverture, si bien que fermer ne
|
|
49
|
+
// rendait plus le focus au bon endroit. On garde la fonction dans une ref :
|
|
50
|
+
// l'effet ne dépend plus que de ce qui doit réellement le rejouer.
|
|
51
|
+
const onEscapeRef = useRef(onEscape);
|
|
52
|
+
onEscapeRef.current = onEscape;
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!active) return;
|
|
56
|
+
const panel = panelRef.current;
|
|
57
|
+
if (!panel) return;
|
|
58
|
+
const doc = panel.ownerDocument;
|
|
59
|
+
let state = activeTraps.get(doc);
|
|
60
|
+
if (!state) {
|
|
61
|
+
state = { stack: [], previousOverflow: doc.body.style.overflow };
|
|
62
|
+
activeTraps.set(doc, state);
|
|
63
|
+
}
|
|
64
|
+
const lease: FocusLease = {
|
|
65
|
+
panel,
|
|
66
|
+
previousFocused: doc.activeElement as HTMLElement | null,
|
|
67
|
+
previousTrap: state.stack.at(-1),
|
|
68
|
+
};
|
|
69
|
+
state.stack.push(lease);
|
|
70
|
+
// Le panneau doit porter `tabIndex={-1}` pour pouvoir recevoir le focus.
|
|
71
|
+
panelRef.current?.focus();
|
|
72
|
+
|
|
73
|
+
const onKey = (e: KeyboardEvent) => {
|
|
74
|
+
if (state.stack.at(-1) !== lease) return;
|
|
75
|
+
if (e.key === "Escape") {
|
|
76
|
+
onEscapeRef.current?.();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (e.key !== "Tab") return;
|
|
80
|
+
const currentPanel = panelRef.current;
|
|
81
|
+
if (!currentPanel) return;
|
|
82
|
+
const items = [...currentPanel.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(isTabbable);
|
|
83
|
+
if (items.length === 0) {
|
|
84
|
+
e.preventDefault();
|
|
85
|
+
currentPanel.focus();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const first = items[0]!;
|
|
89
|
+
const last = items[items.length - 1]!;
|
|
90
|
+
const current = doc.activeElement as HTMLElement | null;
|
|
91
|
+
if (!current || !items.includes(current)) {
|
|
92
|
+
e.preventDefault();
|
|
93
|
+
(e.shiftKey ? last : first).focus();
|
|
94
|
+
} else if (e.shiftKey && current === first) {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
last.focus();
|
|
97
|
+
} else if (!e.shiftKey && current === last) {
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
first.focus();
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
doc.addEventListener("keydown", onKey);
|
|
104
|
+
doc.body.style.overflow = "hidden";
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
doc.removeEventListener("keydown", onKey);
|
|
108
|
+
const wasTop = state.stack.at(-1) === lease;
|
|
109
|
+
state.stack.splice(state.stack.indexOf(lease), 1);
|
|
110
|
+
// Preserve the opener chain even when a lower modal is removed first.
|
|
111
|
+
for (const remaining of state.stack) {
|
|
112
|
+
if (remaining.previousTrap === lease) {
|
|
113
|
+
remaining.previousTrap = lease.previousTrap;
|
|
114
|
+
remaining.previousFocused = lease.previousFocused;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const top = state.stack.at(-1);
|
|
118
|
+
if (!top) {
|
|
119
|
+
doc.body.style.overflow = state.previousOverflow;
|
|
120
|
+
activeTraps.delete(doc);
|
|
121
|
+
}
|
|
122
|
+
if (wasTop) {
|
|
123
|
+
const previous = lease.previousFocused;
|
|
124
|
+
if (previous?.isConnected && (!top || top.panel.contains(previous))) previous.focus();
|
|
125
|
+
else top?.panel.focus();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}, [panelRef, active]);
|
|
129
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Behaviour shared by `MangaReader` and `DatabookReader`: reading mode, fullscreen, the Swiper
|
|
6
|
+
* handle and the scroll-position sync of the virtualized vertical mode.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type CSSProperties,
|
|
11
|
+
type RefObject,
|
|
12
|
+
useCallback,
|
|
13
|
+
useEffect,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from "react";
|
|
17
|
+
import type { Virtualizer } from "@tanstack/react-virtual";
|
|
18
|
+
import type { SwiperClass } from "swiper/react";
|
|
19
|
+
|
|
20
|
+
export type ViewMode = "paged" | "vertical";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Above this many pages the thumbnail strip (one DOM node per page, up to ~282 for a whole volume)
|
|
24
|
+
* is too heavy: a compact "go to page" control replaces it.
|
|
25
|
+
*/
|
|
26
|
+
export const SHOW_STRIP_MAX = 40;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Fullscreen state of this container. The toggle resolves false on native failure;
|
|
30
|
+
* `fullscreenError` exposes that failure without rejecting a button's event handler.
|
|
31
|
+
*/
|
|
32
|
+
export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
|
33
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
34
|
+
const [fullscreenError, setFullscreenError] = useState<Error | null>(null);
|
|
35
|
+
const mounted = useRef(false);
|
|
36
|
+
const operation = useRef(0);
|
|
37
|
+
|
|
38
|
+
const toggleFullscreen = useCallback(async (): Promise<boolean> => {
|
|
39
|
+
const el = containerRef.current;
|
|
40
|
+
if (!el) return false;
|
|
41
|
+
const doc = el.ownerDocument;
|
|
42
|
+
const current = ++operation.current;
|
|
43
|
+
if (mounted.current) setFullscreenError(null);
|
|
44
|
+
try {
|
|
45
|
+
if (doc.fullscreenElement === el) {
|
|
46
|
+
if (typeof doc.exitFullscreen !== "function")
|
|
47
|
+
throw new Error("Fullscreen exit is unavailable");
|
|
48
|
+
await doc.exitFullscreen();
|
|
49
|
+
} else {
|
|
50
|
+
// Request this container directly; never exit another component's fullscreen.
|
|
51
|
+
if (typeof el.requestFullscreen !== "function")
|
|
52
|
+
throw new Error("Fullscreen is unavailable");
|
|
53
|
+
await el.requestFullscreen();
|
|
54
|
+
}
|
|
55
|
+
if (mounted.current && current === operation.current) {
|
|
56
|
+
setIsFullscreen(
|
|
57
|
+
Boolean(containerRef.current && doc.fullscreenElement === containerRef.current),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
} catch (reason) {
|
|
62
|
+
if (mounted.current && current === operation.current) {
|
|
63
|
+
setFullscreenError(reason instanceof Error ? reason : new Error(String(reason)));
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}, [containerRef]);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const doc = containerRef.current?.ownerDocument;
|
|
71
|
+
if (!doc) return;
|
|
72
|
+
mounted.current = true;
|
|
73
|
+
const onChange = () =>
|
|
74
|
+
setIsFullscreen(
|
|
75
|
+
Boolean(containerRef.current && doc.fullscreenElement === containerRef.current),
|
|
76
|
+
);
|
|
77
|
+
onChange();
|
|
78
|
+
doc.addEventListener("fullscreenchange", onChange);
|
|
79
|
+
return () => {
|
|
80
|
+
mounted.current = false;
|
|
81
|
+
operation.current++;
|
|
82
|
+
doc.removeEventListener("fullscreenchange", onChange);
|
|
83
|
+
};
|
|
84
|
+
}, [containerRef]);
|
|
85
|
+
|
|
86
|
+
return { isFullscreen, fullscreenError, toggleFullscreen };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The Swiper instance and the two callbacks that keep `current` in step with it. */
|
|
90
|
+
export function useSwiperSync(setCurrent: (index: number) => void) {
|
|
91
|
+
const swiperRef = useRef<SwiperClass | null>(null);
|
|
92
|
+
|
|
93
|
+
const handleSwiper = useCallback(
|
|
94
|
+
(sw: SwiperClass) => {
|
|
95
|
+
swiperRef.current = sw;
|
|
96
|
+
setCurrent(sw.realIndex ?? sw.activeIndex ?? 0);
|
|
97
|
+
},
|
|
98
|
+
[setCurrent],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const handleSlideChange = useCallback(
|
|
102
|
+
(sw: SwiperClass) => setCurrent(sw.realIndex ?? sw.activeIndex ?? 0),
|
|
103
|
+
[setCurrent],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return { swiperRef, handleSwiper, handleSlideChange };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** In vertical mode, `current` follows the page at the middle of the scroll viewport. */
|
|
110
|
+
export function useVerticalScrollSync(
|
|
111
|
+
mode: ViewMode,
|
|
112
|
+
scrollParentRef: RefObject<HTMLElement | null>,
|
|
113
|
+
virtualizer: Virtualizer<HTMLDivElement, Element>,
|
|
114
|
+
setCurrent: (index: number) => void,
|
|
115
|
+
) {
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (mode !== "vertical") return;
|
|
118
|
+
const el = scrollParentRef.current;
|
|
119
|
+
if (!el) return;
|
|
120
|
+
const onScroll = () => {
|
|
121
|
+
const mid = el.scrollTop + el.clientHeight / 2;
|
|
122
|
+
const item = virtualizer.getVirtualItems().find((vi) => vi.start <= mid && vi.end >= mid);
|
|
123
|
+
if (item) setCurrent(item.index);
|
|
124
|
+
};
|
|
125
|
+
el.addEventListener("scroll", onScroll, { passive: true });
|
|
126
|
+
onScroll();
|
|
127
|
+
return () => el.removeEventListener("scroll", onScroll);
|
|
128
|
+
}, [mode, scrollParentRef, virtualizer, setCurrent]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Absolute placement of one virtual row. */
|
|
132
|
+
export const virtualRowStyle = (start: number): CSSProperties => ({
|
|
133
|
+
position: "absolute",
|
|
134
|
+
top: 0,
|
|
135
|
+
left: 0,
|
|
136
|
+
width: "100%",
|
|
137
|
+
transform: `translateY(${start}px)`,
|
|
138
|
+
});
|