@pablozaiden/webapp 1.2.0 → 1.3.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 +19 -0
- package/docs/ui-guidelines.md +1 -0
- package/package.json +1 -1
- package/src/web/WebAppRoot.tsx +5 -1
- package/src/web/app-shell.tsx +31 -17
- package/src/web/components/index.tsx +382 -19
- package/src/web/index.ts +2 -0
- package/src/web/motion.tsx +487 -0
- package/src/web/routing.ts +36 -5
- package/src/web/settings/resource-state.tsx +12 -7
- package/src/web/sidebar-tree.tsx +15 -4
- package/src/web/styles.css +349 -3
- package/src/web/toast.tsx +67 -20
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { Children, cloneElement, Fragment, isValidElement, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export const MOTION_FAST_MS = 350;
|
|
4
|
+
export const MOTION_NORMAL_MS = 350;
|
|
5
|
+
|
|
6
|
+
export type MotionPresenceState = "enter" | "idle" | "exit";
|
|
7
|
+
|
|
8
|
+
function prefersReducedMotion(): boolean {
|
|
9
|
+
return typeof window !== "undefined"
|
|
10
|
+
&& typeof window.matchMedia === "function"
|
|
11
|
+
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useReducedMotion(): boolean {
|
|
15
|
+
const [reducedMotion, setReducedMotion] = useState(prefersReducedMotion);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
23
|
+
const update = () => setReducedMotion(mediaQuery.matches);
|
|
24
|
+
update();
|
|
25
|
+
if (mediaQuery.addEventListener) {
|
|
26
|
+
mediaQuery.addEventListener("change", update);
|
|
27
|
+
} else {
|
|
28
|
+
mediaQuery.addListener?.(update);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return () => {
|
|
32
|
+
if (mediaQuery.removeEventListener) {
|
|
33
|
+
mediaQuery.removeEventListener("change", update);
|
|
34
|
+
} else {
|
|
35
|
+
mediaQuery.removeListener?.(update);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
return reducedMotion;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function usePresence(
|
|
44
|
+
present: boolean,
|
|
45
|
+
{ duration = MOTION_FAST_MS }: { duration?: number } = {},
|
|
46
|
+
): {
|
|
47
|
+
mounted: boolean;
|
|
48
|
+
state: MotionPresenceState;
|
|
49
|
+
reducedMotion: boolean;
|
|
50
|
+
} {
|
|
51
|
+
const reducedMotion = useReducedMotion();
|
|
52
|
+
const [mounted, setMounted] = useState(present);
|
|
53
|
+
const [state, setState] = useState<MotionPresenceState>(present ? "enter" : "idle");
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (present) {
|
|
57
|
+
setMounted(true);
|
|
58
|
+
setState(reducedMotion ? "idle" : "enter");
|
|
59
|
+
|
|
60
|
+
if (reducedMotion) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const frameId = window.requestAnimationFrame(() => setState("idle"));
|
|
65
|
+
return () => window.cancelAnimationFrame(frameId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!mounted) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (reducedMotion || duration <= 0) {
|
|
73
|
+
setState("idle");
|
|
74
|
+
setMounted(false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setState("exit");
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
setState("idle");
|
|
81
|
+
setMounted(false);
|
|
82
|
+
}, duration);
|
|
83
|
+
|
|
84
|
+
return () => clearTimeout(timer);
|
|
85
|
+
}, [duration, mounted, present, reducedMotion]);
|
|
86
|
+
|
|
87
|
+
return { mounted, state, reducedMotion };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function Presence({
|
|
91
|
+
present,
|
|
92
|
+
duration = MOTION_FAST_MS,
|
|
93
|
+
children,
|
|
94
|
+
}: {
|
|
95
|
+
present: boolean;
|
|
96
|
+
duration?: number;
|
|
97
|
+
children: (state: MotionPresenceState) => ReactNode;
|
|
98
|
+
}) {
|
|
99
|
+
const presence = usePresence(present, { duration });
|
|
100
|
+
if (!presence.mounted) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return children(presence.state);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function Collapsible({
|
|
107
|
+
open,
|
|
108
|
+
id,
|
|
109
|
+
className = "",
|
|
110
|
+
duration = MOTION_NORMAL_MS,
|
|
111
|
+
children,
|
|
112
|
+
}: {
|
|
113
|
+
open: boolean;
|
|
114
|
+
id?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
duration?: number;
|
|
117
|
+
children: ReactNode;
|
|
118
|
+
}) {
|
|
119
|
+
const collapsibleRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (open) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const activeElement = document.activeElement;
|
|
126
|
+
if (activeElement instanceof HTMLElement && collapsibleRef.current?.contains(activeElement)) {
|
|
127
|
+
activeElement.blur();
|
|
128
|
+
}
|
|
129
|
+
}, [open]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<Presence present={open} duration={duration}>
|
|
133
|
+
{(state) => (
|
|
134
|
+
<div
|
|
135
|
+
ref={collapsibleRef}
|
|
136
|
+
id={id}
|
|
137
|
+
className={`wapp-collapsible wapp-collapsible-${state} ${className}`.trim()}
|
|
138
|
+
data-open={open ? "true" : "false"}
|
|
139
|
+
aria-hidden={open ? undefined : true}
|
|
140
|
+
>
|
|
141
|
+
<div className="wapp-collapsible-inner">{children}</div>
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</Presence>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type AsyncStateStatus = "loading" | "refreshing" | "empty" | "error" | "ready";
|
|
149
|
+
|
|
150
|
+
export function AsyncState({
|
|
151
|
+
status,
|
|
152
|
+
loading,
|
|
153
|
+
empty,
|
|
154
|
+
error,
|
|
155
|
+
refreshing,
|
|
156
|
+
children,
|
|
157
|
+
className = "",
|
|
158
|
+
}: {
|
|
159
|
+
status: AsyncStateStatus;
|
|
160
|
+
loading?: ReactNode;
|
|
161
|
+
empty?: ReactNode;
|
|
162
|
+
error?: ReactNode;
|
|
163
|
+
refreshing?: ReactNode;
|
|
164
|
+
children?: ReactNode;
|
|
165
|
+
className?: string;
|
|
166
|
+
}) {
|
|
167
|
+
const content = status === "loading"
|
|
168
|
+
? loading
|
|
169
|
+
: status === "empty"
|
|
170
|
+
? empty
|
|
171
|
+
: status === "error"
|
|
172
|
+
? error
|
|
173
|
+
: children;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
key={status === "refreshing" ? "ready" : status}
|
|
178
|
+
className={`wapp-async-state wapp-async-state-${status} ${className}`.trim()}
|
|
179
|
+
aria-busy={status === "loading" || status === "refreshing" ? true : undefined}
|
|
180
|
+
>
|
|
181
|
+
{content}
|
|
182
|
+
{status === "refreshing" && refreshing ? (
|
|
183
|
+
<div className="wapp-async-state-refreshing" role="status">
|
|
184
|
+
{refreshing}
|
|
185
|
+
</div>
|
|
186
|
+
) : null}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface StreamingTextProps {
|
|
192
|
+
content: string;
|
|
193
|
+
active?: boolean;
|
|
194
|
+
as?: "div" | "span";
|
|
195
|
+
className?: string;
|
|
196
|
+
chunkClassName?: string;
|
|
197
|
+
duration?: number;
|
|
198
|
+
maxPendingChars?: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Displays append-only text with one sequentially fading chunk at a time.
|
|
203
|
+
* Initial content is rendered immediately; only post-mount appends are queued.
|
|
204
|
+
*/
|
|
205
|
+
export function StreamingText({
|
|
206
|
+
content,
|
|
207
|
+
active = true,
|
|
208
|
+
as = "span",
|
|
209
|
+
className = "",
|
|
210
|
+
chunkClassName = "",
|
|
211
|
+
duration = MOTION_FAST_MS,
|
|
212
|
+
maxPendingChars = 16_384,
|
|
213
|
+
}: StreamingTextProps) {
|
|
214
|
+
const reducedMotion = useReducedMotion();
|
|
215
|
+
const previousContentRef = useRef<string | null>(null);
|
|
216
|
+
const activeChunkRef = useRef<string | null>(null);
|
|
217
|
+
const pendingContentRef = useRef("");
|
|
218
|
+
const [committedContent, setCommittedContent] = useState(content);
|
|
219
|
+
const [activeChunk, setActiveChunk] = useState<string | null>(null);
|
|
220
|
+
const [pendingContent, setPendingContent] = useState("");
|
|
221
|
+
|
|
222
|
+
const resetToContent = useCallback((nextContent: string) => {
|
|
223
|
+
previousContentRef.current = nextContent;
|
|
224
|
+
activeChunkRef.current = null;
|
|
225
|
+
pendingContentRef.current = "";
|
|
226
|
+
setCommittedContent(nextContent);
|
|
227
|
+
setActiveChunk(null);
|
|
228
|
+
setPendingContent("");
|
|
229
|
+
}, []);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (!active || reducedMotion) {
|
|
233
|
+
resetToContent(content);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const previousContent = previousContentRef.current;
|
|
238
|
+
previousContentRef.current = content;
|
|
239
|
+
if (previousContent === null) {
|
|
240
|
+
resetToContent(content);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (content === previousContent || !content.startsWith(previousContent)) {
|
|
244
|
+
if (content !== previousContent) {
|
|
245
|
+
resetToContent(content);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const delta = content.slice(previousContent.length);
|
|
251
|
+
if (!delta) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const nextPendingLength = pendingContentRef.current.length + delta.length;
|
|
256
|
+
if (nextPendingLength > Math.max(1, maxPendingChars)) {
|
|
257
|
+
resetToContent(content);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (activeChunkRef.current !== null || pendingContentRef.current.length > 0) {
|
|
262
|
+
pendingContentRef.current += delta;
|
|
263
|
+
setPendingContent(pendingContentRef.current);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
activeChunkRef.current = delta;
|
|
268
|
+
setActiveChunk(delta);
|
|
269
|
+
}, [active, content, maxPendingChars, reducedMotion, resetToContent]);
|
|
270
|
+
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (activeChunk === null) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (duration <= 0 || reducedMotion) {
|
|
277
|
+
const completedChunk = activeChunkRef.current;
|
|
278
|
+
if (completedChunk !== null) {
|
|
279
|
+
activeChunkRef.current = null;
|
|
280
|
+
setCommittedContent((current) => current + completedChunk);
|
|
281
|
+
setActiveChunk(null);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const timer = setTimeout(() => {
|
|
287
|
+
const completedChunk = activeChunkRef.current;
|
|
288
|
+
if (completedChunk === null) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
activeChunkRef.current = null;
|
|
292
|
+
setCommittedContent((current) => current + completedChunk);
|
|
293
|
+
setActiveChunk(null);
|
|
294
|
+
}, duration);
|
|
295
|
+
|
|
296
|
+
return () => clearTimeout(timer);
|
|
297
|
+
}, [activeChunk, duration, reducedMotion]);
|
|
298
|
+
|
|
299
|
+
useEffect(() => {
|
|
300
|
+
if (activeChunk !== null || !pendingContent) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const nextChunk = pendingContentRef.current;
|
|
305
|
+
if (!nextChunk) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
pendingContentRef.current = "";
|
|
309
|
+
setPendingContent("");
|
|
310
|
+
activeChunkRef.current = nextChunk;
|
|
311
|
+
setActiveChunk(nextChunk);
|
|
312
|
+
}, [activeChunk, pendingContent]);
|
|
313
|
+
|
|
314
|
+
const Element = as;
|
|
315
|
+
if (!active || reducedMotion) {
|
|
316
|
+
return <Element className={className}>{content}</Element>;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<Element className={className} data-wapp-streaming-text="active">
|
|
321
|
+
{committedContent}
|
|
322
|
+
{activeChunk ? (
|
|
323
|
+
<span className={`wapp-streaming-text-chunk wapp-motion-enter ${chunkClassName}`.trim()}>
|
|
324
|
+
{activeChunk}
|
|
325
|
+
</span>
|
|
326
|
+
) : null}
|
|
327
|
+
</Element>
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Animates keyed children entering and leaving a collection. Callers should
|
|
333
|
+
* provide stable keys so a refresh does not turn an existing item into a new
|
|
334
|
+
* animation entry.
|
|
335
|
+
*/
|
|
336
|
+
export function AnimatedList({
|
|
337
|
+
children,
|
|
338
|
+
className = "",
|
|
339
|
+
duration = MOTION_FAST_MS,
|
|
340
|
+
layout = "normal",
|
|
341
|
+
}: {
|
|
342
|
+
children: ReactNode;
|
|
343
|
+
className?: string;
|
|
344
|
+
duration?: number;
|
|
345
|
+
layout?: "normal" | "contents";
|
|
346
|
+
}) {
|
|
347
|
+
const childMap = new Map<string, ReactNode>();
|
|
348
|
+
Children.toArray(children).forEach((child, index) => {
|
|
349
|
+
const key = isValidElement(child) && child.key !== null ? String(child.key) : `index-${index}`;
|
|
350
|
+
childMap.set(key, child);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const childrenRef = useRef<Map<string, ReactNode> | null>(null);
|
|
354
|
+
const exitingChildrenRef = useRef(new Map<string, ReactNode>());
|
|
355
|
+
if (childrenRef.current === null) {
|
|
356
|
+
childrenRef.current = childMap;
|
|
357
|
+
} else {
|
|
358
|
+
for (const [key, child] of childrenRef.current) {
|
|
359
|
+
if (!childMap.has(key)) {
|
|
360
|
+
exitingChildrenRef.current.set(key, child);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
childrenRef.current = childMap;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const [entries, setEntries] = useState<AnimatedListEntryState[]>(() => (
|
|
367
|
+
Array.from(childMap.keys(), (key) => ({ key, present: true, animate: false }))
|
|
368
|
+
));
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
setEntries((current) => {
|
|
372
|
+
const currentByKey = new Map(current.map((entry) => [entry.key, entry]));
|
|
373
|
+
const next: AnimatedListEntryState[] = [];
|
|
374
|
+
|
|
375
|
+
for (const key of childMap.keys()) {
|
|
376
|
+
const existing = currentByKey.get(key);
|
|
377
|
+
next.push(existing?.present
|
|
378
|
+
? existing
|
|
379
|
+
: existing
|
|
380
|
+
? { ...existing, present: true, animate: true }
|
|
381
|
+
: { key, present: true, animate: true });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
for (const entry of current) {
|
|
385
|
+
if (!childMap.has(entry.key)) {
|
|
386
|
+
next.push(entry.present ? { ...entry, present: false, animate: true } : entry);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (
|
|
391
|
+
next.length === current.length
|
|
392
|
+
&& next.every((entry, index) => {
|
|
393
|
+
const previous = current[index];
|
|
394
|
+
return previous?.key === entry.key
|
|
395
|
+
&& previous.present === entry.present
|
|
396
|
+
&& previous.animate === entry.animate;
|
|
397
|
+
})
|
|
398
|
+
) {
|
|
399
|
+
return current;
|
|
400
|
+
}
|
|
401
|
+
return next;
|
|
402
|
+
});
|
|
403
|
+
}, [childMap]);
|
|
404
|
+
|
|
405
|
+
const removeExited = useCallback((key: string) => {
|
|
406
|
+
exitingChildrenRef.current.delete(key);
|
|
407
|
+
setEntries((current) => current.filter((entry) => entry.key !== key || entry.present));
|
|
408
|
+
}, []);
|
|
409
|
+
|
|
410
|
+
return (
|
|
411
|
+
<div className={`wapp-animated-list ${layout === "contents" ? "wapp-animated-list-contents" : ""} ${className}`.trim()}>
|
|
412
|
+
{entries.map((entry) => {
|
|
413
|
+
const child = childMap.get(entry.key) ?? exitingChildrenRef.current.get(entry.key);
|
|
414
|
+
if (child === undefined) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
return (
|
|
418
|
+
<AnimatedListEntry
|
|
419
|
+
key={entry.key}
|
|
420
|
+
entryKey={entry.key}
|
|
421
|
+
child={child}
|
|
422
|
+
present={entry.present}
|
|
423
|
+
animate={entry.animate}
|
|
424
|
+
duration={duration}
|
|
425
|
+
onExitComplete={removeExited}
|
|
426
|
+
/>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
interface AnimatedListEntryState {
|
|
434
|
+
key: string;
|
|
435
|
+
present: boolean;
|
|
436
|
+
animate: boolean;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
type MotionChildProps = {
|
|
440
|
+
className?: string;
|
|
441
|
+
["data-wapp-motion"]?: string;
|
|
442
|
+
["aria-hidden"]?: boolean;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
function AnimatedListEntry({
|
|
446
|
+
entryKey,
|
|
447
|
+
child,
|
|
448
|
+
present,
|
|
449
|
+
animate,
|
|
450
|
+
duration,
|
|
451
|
+
onExitComplete,
|
|
452
|
+
}: {
|
|
453
|
+
entryKey: string;
|
|
454
|
+
child: ReactNode;
|
|
455
|
+
present: boolean;
|
|
456
|
+
animate: boolean;
|
|
457
|
+
duration: number;
|
|
458
|
+
onExitComplete: (key: string) => void;
|
|
459
|
+
}) {
|
|
460
|
+
const presence = usePresence(present, { duration });
|
|
461
|
+
|
|
462
|
+
useEffect(() => {
|
|
463
|
+
if (animate && !present && !presence.mounted) {
|
|
464
|
+
onExitComplete(entryKey);
|
|
465
|
+
}
|
|
466
|
+
}, [animate, entryKey, onExitComplete, presence.mounted, present]);
|
|
467
|
+
|
|
468
|
+
if (!presence.mounted) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const state = animate ? presence.state : "idle";
|
|
473
|
+
if (isValidElement<MotionChildProps>(child) && child.type !== Fragment) {
|
|
474
|
+
const className = [child.props.className, `wapp-motion-${state}`].filter(Boolean).join(" ");
|
|
475
|
+
return cloneElement(child, {
|
|
476
|
+
className,
|
|
477
|
+
"data-wapp-motion": state,
|
|
478
|
+
"aria-hidden": state === "exit" ? true : child.props["aria-hidden"],
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return (
|
|
483
|
+
<span className={`wapp-animated-list-item wapp-motion-${state}`} data-wapp-motion={state} aria-hidden={state === "exit" ? true : undefined}>
|
|
484
|
+
{child}
|
|
485
|
+
</span>
|
|
486
|
+
);
|
|
487
|
+
}
|
package/src/web/routing.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { flushSync } from "react-dom";
|
|
2
3
|
import type { WebAppRoute } from "./sidebar/types";
|
|
3
4
|
|
|
4
5
|
export function routeToHash(route: WebAppRoute): string {
|
|
@@ -35,6 +36,32 @@ export function replaceWebAppRoute(route: WebAppRoute): boolean {
|
|
|
35
36
|
return replaceHashRoute(routeToHash(route));
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
export function supportsViewTransitions(): boolean {
|
|
40
|
+
return typeof document !== "undefined"
|
|
41
|
+
&& typeof document.startViewTransition === "function";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function prefersReducedMotion(): boolean {
|
|
45
|
+
return typeof window !== "undefined"
|
|
46
|
+
&& typeof window.matchMedia === "function"
|
|
47
|
+
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function updateRoute(
|
|
51
|
+
setRoute: (route: WebAppRoute) => void,
|
|
52
|
+
route: WebAppRoute,
|
|
53
|
+
): void {
|
|
54
|
+
const startViewTransition = typeof document === "undefined" ? undefined : document.startViewTransition;
|
|
55
|
+
if (!startViewTransition || prefersReducedMotion()) {
|
|
56
|
+
setRoute(route);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
document.startViewTransition(() => {
|
|
61
|
+
flushSync(() => setRoute(route));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
|
|
39
66
|
const hash = window.location.hash.replace(/^#\/?/, "");
|
|
40
67
|
if (!hash) {
|
|
@@ -47,15 +74,19 @@ function parseRoute(defaultRoute: WebAppRoute): WebAppRoute {
|
|
|
47
74
|
|
|
48
75
|
export function useRoute(defaultRoute: WebAppRoute) {
|
|
49
76
|
const [route, setRoute] = useState(() => parseRoute(defaultRoute));
|
|
77
|
+
const commitRoute = useCallback((nextRoute: WebAppRoute) => {
|
|
78
|
+
updateRoute(setRoute, nextRoute);
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
50
81
|
useEffect(() => {
|
|
51
|
-
const listener = () =>
|
|
82
|
+
const listener = () => commitRoute(parseRoute(defaultRoute));
|
|
52
83
|
window.addEventListener("hashchange", listener);
|
|
53
84
|
return () => window.removeEventListener("hashchange", listener);
|
|
54
|
-
}, [defaultRoute]);
|
|
85
|
+
}, [commitRoute, defaultRoute]);
|
|
86
|
+
|
|
55
87
|
const navigate = useCallback((next: WebAppRoute) => {
|
|
56
|
-
|
|
57
|
-
setRoute(next);
|
|
58
|
-
}
|
|
88
|
+
replaceWebAppRoute(next);
|
|
59
89
|
}, []);
|
|
90
|
+
|
|
60
91
|
return { route, navigate };
|
|
61
92
|
}
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { Button, ErrorState, LoadingState } from "../components";
|
|
3
|
+
import { AsyncState } from "../motion";
|
|
3
4
|
|
|
4
5
|
export function ResourceState({ loading, error, hasData, refresh }: { loading: boolean; error?: Error; hasData: boolean; refresh: () => Promise<void> }): ReactNode {
|
|
5
6
|
if (!hasData && loading) {
|
|
6
|
-
return <LoadingState />;
|
|
7
|
+
return <AsyncState status="loading" loading={<LoadingState />} children={null} />;
|
|
7
8
|
}
|
|
8
9
|
if (!error) {
|
|
9
10
|
return null;
|
|
10
11
|
}
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
return <AsyncState
|
|
13
|
+
status="error"
|
|
14
|
+
error={(
|
|
15
|
+
<ErrorState
|
|
16
|
+
description={error.message}
|
|
17
|
+
action={<Button type="button" loading={loading} onClick={() => void refresh()}>Retry</Button>}
|
|
18
|
+
/>
|
|
19
|
+
)}
|
|
20
|
+
children={null}
|
|
21
|
+
/>;
|
|
17
22
|
}
|
package/src/web/sidebar-tree.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Badge, ContextMenu, type ContextMenuPosition } from "./components";
|
|
3
|
+
import { AnimatedList, Collapsible } from "./motion";
|
|
3
4
|
import type { SidebarCollapsedState } from "./sidebar-state";
|
|
4
5
|
import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
5
6
|
|
|
@@ -29,7 +30,8 @@ export function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed
|
|
|
29
30
|
const [contextMenu, setContextMenu] = useState<{ position: ContextMenuPosition; items: ActionMenuItem[]; title: string } | null>(null);
|
|
30
31
|
return (
|
|
31
32
|
<>
|
|
32
|
-
{
|
|
33
|
+
<AnimatedList className={`wapp-sidebar-tree-level wapp-sidebar-tree-level-${level}`}>
|
|
34
|
+
{nodes.map((node) => {
|
|
33
35
|
const hasChildren = Boolean(node.children?.length);
|
|
34
36
|
const storedIsCollapsed = collapsed[node.id] ?? node.defaultCollapsed ?? false;
|
|
35
37
|
const isCollapsed = searchActive && hasChildren ? false : storedIsCollapsed;
|
|
@@ -52,7 +54,11 @@ export function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed
|
|
|
52
54
|
)}
|
|
53
55
|
{node.action ? <button type="button" className="wapp-sidebar-action" title={node.action.title} aria-label={node.action.title} onClick={node.action.onAction ?? (() => node.action?.route && navigate(node.action.route))}>{node.action.label ?? "New"}</button> : null}
|
|
54
56
|
</div>
|
|
55
|
-
{
|
|
57
|
+
{hasChildren ? (
|
|
58
|
+
<Collapsible open={!isCollapsed} className="wapp-sidebar-collapsible">
|
|
59
|
+
<SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" />
|
|
60
|
+
</Collapsible>
|
|
61
|
+
) : null}
|
|
56
62
|
{!isCollapsed && !hasChildren && level === 0 ? <div className="wapp-sidebar-empty">No items.</div> : null}
|
|
57
63
|
</section>
|
|
58
64
|
);
|
|
@@ -79,10 +85,15 @@ export function SidebarTree({ nodes, route, navigate, collapsed, toggleCollapsed
|
|
|
79
85
|
</span>
|
|
80
86
|
{node.badge ? <Badge variant={node.badgeVariant} className="wapp-sidebar-badge" title={node.badge} aria-label={node.badge}> </Badge> : null}
|
|
81
87
|
</button>
|
|
82
|
-
{
|
|
88
|
+
{node.children ? (
|
|
89
|
+
<Collapsible open={!isCollapsed} className="wapp-sidebar-children">
|
|
90
|
+
<SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" />
|
|
91
|
+
</Collapsible>
|
|
92
|
+
) : null}
|
|
83
93
|
</div>
|
|
84
94
|
);
|
|
85
|
-
|
|
95
|
+
})}
|
|
96
|
+
</AnimatedList>
|
|
86
97
|
<ContextMenu items={contextMenu?.items ?? []} position={contextMenu?.position ?? null} ariaLabel={contextMenu ? `Actions for ${contextMenu.title}` : "Actions"} onClose={() => setContextMenu(null)} />
|
|
87
98
|
</>
|
|
88
99
|
);
|