@pablozaiden/webapp 1.1.1 → 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.
@@ -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
+ }
@@ -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 = () => setRoute(parseRoute(defaultRoute));
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
- if (replaceWebAppRoute(next)) {
57
- setRoute(next);
58
- }
88
+ replaceWebAppRoute(next);
59
89
  }, []);
90
+
60
91
  return { route, navigate };
61
92
  }
@@ -1,14 +1,12 @@
1
1
  import { useState } from "react";
2
- import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
2
+ import { VALID_LOG_LEVELS, type LogLevelName, type WebAppConfigResponse } from "../../contracts";
3
3
  import { appJson } from "../api-client";
4
4
  import { Badge, Button, CheckboxField, ErrorState, FormSection, LoadingState, SelectField } from "../components";
5
5
  import { useLogLevel } from "../log-level";
6
6
  import { isThemePreference, useTheme } from "../theme";
7
7
 
8
- const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
9
-
10
8
  function isLogLevelName(value: string): value is LogLevelName {
11
- return LOG_LEVEL_NAMES.some((level) => level === value);
9
+ return VALID_LOG_LEVELS.some((level) => level === value);
12
10
  }
13
11
 
14
12
  export interface AccountSectionProps {
@@ -102,7 +100,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
102
100
  disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
103
101
  onChange={(event) => void updateLogLevel(event.currentTarget.value)}
104
102
  >
105
- {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
103
+ {VALID_LOG_LEVELS.map((level) => <option key={level} value={level}>{level}</option>)}
106
104
  </SelectField>
107
105
  ) : null}
108
106
  <CheckboxField
@@ -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
- <ErrorState
13
- description={error.message}
14
- action={<Button type="button" loading={loading} onClick={() => void refresh()}>Retry</Button>}
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
  }
@@ -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
- {nodes.map((node) => {
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
- {!isCollapsed && hasChildren ? <SidebarTree nodes={node.children ?? []} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="section" /> : null}
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
- {!isCollapsed && node.children ? <div className="wapp-sidebar-children"><SidebarTree nodes={node.children} route={route} navigate={navigate} collapsed={collapsed} toggleCollapsed={toggleCollapsed} searchActive={searchActive} level={level + 1} parentKind="item" /></div> : null}
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
  );