@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.
- package/README.md +19 -0
- package/docs/getting-started.md +27 -34
- package/docs/server.md +54 -3
- package/docs/settings.md +6 -5
- package/docs/ui-guidelines.md +1 -0
- package/package.json +2 -1
- package/src/contracts/index.ts +25 -1
- package/src/server/create-web-app-server.ts +2 -1
- package/src/server/index.ts +11 -0
- package/src/server/logger.ts +107 -42
- package/src/server/request-schemas.ts +2 -1
- package/src/server/runtime-config.ts +8 -6
- package/src/web/WebAppRoot.tsx +13 -1
- package/src/web/app-shell.tsx +31 -17
- package/src/web/components/index.tsx +382 -19
- package/src/web/index.ts +3 -0
- package/src/web/logger.ts +44 -0
- package/src/web/motion.tsx +487 -0
- package/src/web/routing.ts +36 -5
- package/src/web/settings/account-section.tsx +3 -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
- package/src/web/webapp-config.tsx +2 -2
package/src/web/app-shell.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import { ActionMenu, IconButton } from "./components";
|
|
3
|
+
import { Presence } from "./motion";
|
|
3
4
|
import { SidebarTree } from "./sidebar-tree";
|
|
4
5
|
import type { SidebarCollapsedState } from "./sidebar-state";
|
|
5
6
|
import type { ActionMenuItem, SidebarAction, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
@@ -61,6 +62,8 @@ export interface AppShellProps {
|
|
|
61
62
|
headerActionLabel: string;
|
|
62
63
|
primaryHeaderActions?: ReactNode;
|
|
63
64
|
headerActions: ActionMenuItem[];
|
|
65
|
+
routeKey: string;
|
|
66
|
+
nativeRouteTransitions?: boolean;
|
|
64
67
|
view: ReactNode;
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -88,6 +91,8 @@ export function AppShell({
|
|
|
88
91
|
headerActionLabel,
|
|
89
92
|
primaryHeaderActions,
|
|
90
93
|
headerActions,
|
|
94
|
+
routeKey,
|
|
95
|
+
nativeRouteTransitions = false,
|
|
91
96
|
view,
|
|
92
97
|
}: AppShellProps) {
|
|
93
98
|
useEffect(() => {
|
|
@@ -129,20 +134,25 @@ export function AppShell({
|
|
|
129
134
|
};
|
|
130
135
|
|
|
131
136
|
return (
|
|
132
|
-
<main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""}
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
137
|
+
<main className={`wapp-shell ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${sidebarOpen ? "sidebar-open" : ""} ${nativeRouteTransitions ? "wapp-native-route-transitions" : ""}`.trim()}>
|
|
138
|
+
<Presence present={sidebarOpen}>
|
|
139
|
+
{(state) => (
|
|
140
|
+
<div
|
|
141
|
+
className={`wapp-mobile-backdrop wapp-motion-${state}`}
|
|
142
|
+
role="button"
|
|
143
|
+
tabIndex={0}
|
|
144
|
+
aria-label="Close sidebar"
|
|
145
|
+
aria-hidden={state === "exit" ? true : undefined}
|
|
146
|
+
onClick={closeSidebar}
|
|
147
|
+
onKeyDown={(event) => {
|
|
148
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
closeSidebar();
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</Presence>
|
|
146
156
|
<aside id="wapp-sidebar" className="wapp-sidebar">
|
|
147
157
|
<div className="wapp-sidebar-header">
|
|
148
158
|
<button type="button" className="wapp-brand" onClick={() => navigateFromSidebarHeader(homeRoute)}>{appName}</button>
|
|
@@ -182,16 +192,20 @@ export function AppShell({
|
|
|
182
192
|
<header className="wapp-main-header">
|
|
183
193
|
<div className="wapp-main-header-title">
|
|
184
194
|
{sidebarCollapsed ? <IconButton className="wapp-sidebar-top-button" aria-label={sidebarToggleLabel} title={sidebarToggleLabel} aria-expanded={!sidebarCollapsed} aria-controls="wapp-sidebar" onClick={toggleSidebarCollapsed}><Icon name="sidebar" /></IconButton> : <IconButton className="wapp-mobile-only wapp-sidebar-top-button" aria-label="Show sidebar" title="Show sidebar" aria-expanded={sidebarOpen} aria-controls="wapp-sidebar" onClick={() => setSidebarOpen(true)}><Icon name="sidebar" /></IconButton>}
|
|
185
|
-
<h1>{headerTitle}</h1>
|
|
195
|
+
<h1 key={routeKey} className="wapp-route-fade">{headerTitle}</h1>
|
|
186
196
|
</div>
|
|
187
197
|
{primaryHeaderActions || headerActions.length ? (
|
|
188
|
-
<div className="wapp-main-header-actions">
|
|
198
|
+
<div key={routeKey} className="wapp-main-header-actions wapp-route-fade">
|
|
189
199
|
{primaryHeaderActions}
|
|
190
200
|
{headerActions.length ? <ActionMenu items={headerActions} ariaLabel={`Actions for ${headerActionLabel}`} /> : null}
|
|
191
201
|
</div>
|
|
192
202
|
) : null}
|
|
193
203
|
</header>
|
|
194
|
-
<div className="wapp-main-content">
|
|
204
|
+
<div className="wapp-main-content">
|
|
205
|
+
<div key={routeKey} className="wapp-route-view">
|
|
206
|
+
{view}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
195
209
|
</section>
|
|
196
210
|
</main>
|
|
197
211
|
);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type HTMLAttributes, type InputHTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type HTMLAttributes, type InputHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import type { ActionMenuItem, BadgeVariant } from "../sidebar/types";
|
|
4
|
+
import { AnimatedList, MOTION_FAST_MS, usePresence } from "../motion";
|
|
4
5
|
|
|
5
6
|
export type ButtonVariant = "default" | "primary" | "danger" | "ghost";
|
|
6
7
|
export type ButtonSize = "xs" | "sm" | "md" | "lg";
|
|
@@ -167,7 +168,7 @@ export function DataList({
|
|
|
167
168
|
empty?: ReactNode;
|
|
168
169
|
variant?: DataListVariant;
|
|
169
170
|
}) {
|
|
170
|
-
return <
|
|
171
|
+
return <AnimatedList className={`wapp-data-list wapp-data-list-${variant}`}>{children ?? empty ?? null}</AnimatedList>;
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
export function DataListRow({
|
|
@@ -276,6 +277,148 @@ export function SegmentedControl<T extends string>({ value, options, onChange, l
|
|
|
276
277
|
);
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
export interface TabOption {
|
|
281
|
+
id: string;
|
|
282
|
+
label: ReactNode;
|
|
283
|
+
disabled?: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function Tabs({
|
|
287
|
+
tabs,
|
|
288
|
+
value,
|
|
289
|
+
onChange,
|
|
290
|
+
ariaLabel = "Tabs",
|
|
291
|
+
panelIdPrefix = "wapp-tab-panel",
|
|
292
|
+
className = "",
|
|
293
|
+
}: {
|
|
294
|
+
tabs: TabOption[];
|
|
295
|
+
value: string;
|
|
296
|
+
onChange: (id: string) => void;
|
|
297
|
+
ariaLabel?: string;
|
|
298
|
+
panelIdPrefix?: string;
|
|
299
|
+
className?: string;
|
|
300
|
+
}) {
|
|
301
|
+
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
|
302
|
+
|
|
303
|
+
const handleKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
|
|
304
|
+
const enabledTabs = tabs.filter((tab) => !tab.disabled);
|
|
305
|
+
const currentIndex = enabledTabs.findIndex((tab) => tab.id === tabId);
|
|
306
|
+
if (currentIndex < 0 || enabledTabs.length < 2) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let nextIndex = currentIndex;
|
|
311
|
+
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
312
|
+
nextIndex = (currentIndex + 1) % enabledTabs.length;
|
|
313
|
+
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
314
|
+
nextIndex = (currentIndex - 1 + enabledTabs.length) % enabledTabs.length;
|
|
315
|
+
} else if (event.key === "Home") {
|
|
316
|
+
nextIndex = 0;
|
|
317
|
+
} else if (event.key === "End") {
|
|
318
|
+
nextIndex = enabledTabs.length - 1;
|
|
319
|
+
} else {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
const nextTab = enabledTabs[nextIndex];
|
|
325
|
+
if (nextTab) {
|
|
326
|
+
onChange(nextTab.id);
|
|
327
|
+
const nextElement = tabRefs.current.get(nextTab.id);
|
|
328
|
+
if (nextElement) {
|
|
329
|
+
nextElement.focus();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div className={`wapp-tabs ${className}`.trim()} role="tablist" aria-label={ariaLabel}>
|
|
336
|
+
{tabs.map((tab) => (
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
key={tab.id}
|
|
340
|
+
className={`wapp-tab ${value === tab.id ? "active" : ""}`}
|
|
341
|
+
role="tab"
|
|
342
|
+
aria-selected={value === tab.id}
|
|
343
|
+
aria-controls={`${panelIdPrefix}-${tab.id}`}
|
|
344
|
+
tabIndex={value === tab.id ? 0 : -1}
|
|
345
|
+
disabled={tab.disabled}
|
|
346
|
+
ref={(element) => {
|
|
347
|
+
if (element) {
|
|
348
|
+
tabRefs.current.set(tab.id, element);
|
|
349
|
+
} else {
|
|
350
|
+
tabRefs.current.delete(tab.id);
|
|
351
|
+
}
|
|
352
|
+
}}
|
|
353
|
+
onClick={() => onChange(tab.id)}
|
|
354
|
+
onKeyDown={(event) => handleKeyDown(event, tab.id)}
|
|
355
|
+
>
|
|
356
|
+
{tab.label}
|
|
357
|
+
</button>
|
|
358
|
+
))}
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function TabPanels({ children, className = "" }: { children: ReactNode; className?: string }) {
|
|
364
|
+
return <div className={`wapp-tab-panels ${className}`.trim()}>{children}</div>;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function TabPanel({
|
|
368
|
+
id,
|
|
369
|
+
active,
|
|
370
|
+
labelledBy,
|
|
371
|
+
children,
|
|
372
|
+
keepMounted = false,
|
|
373
|
+
duration = MOTION_FAST_MS,
|
|
374
|
+
className = "",
|
|
375
|
+
}: {
|
|
376
|
+
id: string;
|
|
377
|
+
active: boolean;
|
|
378
|
+
labelledBy?: string;
|
|
379
|
+
children: ReactNode;
|
|
380
|
+
keepMounted?: boolean;
|
|
381
|
+
duration?: number;
|
|
382
|
+
className?: string;
|
|
383
|
+
}) {
|
|
384
|
+
const presence = usePresence(active, { duration });
|
|
385
|
+
const [keepMountedHidden, setKeepMountedHidden] = useState(!active);
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!keepMounted || active) {
|
|
389
|
+
if (active) {
|
|
390
|
+
setKeepMountedHidden(false);
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (presence.reducedMotion || duration <= 0) {
|
|
396
|
+
setKeepMountedHidden(true);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const timer = setTimeout(() => setKeepMountedHidden(true), duration);
|
|
401
|
+
return () => clearTimeout(timer);
|
|
402
|
+
}, [active, duration, keepMounted, presence.reducedMotion]);
|
|
403
|
+
|
|
404
|
+
if (!keepMounted && !presence.mounted) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return (
|
|
409
|
+
<div
|
|
410
|
+
id={id}
|
|
411
|
+
className={`wapp-tab-panel wapp-motion-${presence.state} ${className}`.trim()}
|
|
412
|
+
role="tabpanel"
|
|
413
|
+
aria-labelledby={labelledBy}
|
|
414
|
+
aria-hidden={active ? undefined : true}
|
|
415
|
+
hidden={keepMounted && keepMountedHidden && !active}
|
|
416
|
+
>
|
|
417
|
+
{children}
|
|
418
|
+
</div>
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
279
422
|
export function FormSection({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
|
280
423
|
return (
|
|
281
424
|
<section className="wapp-form-section">
|
|
@@ -501,6 +644,7 @@ export function Modal({
|
|
|
501
644
|
closeOnOverlayClick = true,
|
|
502
645
|
className = "",
|
|
503
646
|
}: ModalProps) {
|
|
647
|
+
const presence = usePresence(isOpen);
|
|
504
648
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
505
649
|
const previousFocusRef = useRef<Element | null>(null);
|
|
506
650
|
const titleId = useId();
|
|
@@ -509,7 +653,7 @@ export function Modal({
|
|
|
509
653
|
|
|
510
654
|
useDialogKeyboardShortcuts({
|
|
511
655
|
dialogRef: modalRef,
|
|
512
|
-
enabled: isOpen,
|
|
656
|
+
enabled: isOpen && presence.mounted,
|
|
513
657
|
onCancel: () => onCloseRef.current(),
|
|
514
658
|
});
|
|
515
659
|
|
|
@@ -560,14 +704,14 @@ export function Modal({
|
|
|
560
704
|
previousFocusRef.current.focus();
|
|
561
705
|
}
|
|
562
706
|
};
|
|
563
|
-
}, [handleFocusTrap, isOpen]);
|
|
707
|
+
}, [handleFocusTrap, isOpen, presence.mounted]);
|
|
564
708
|
|
|
565
|
-
if (!
|
|
709
|
+
if (!presence.mounted) {
|
|
566
710
|
return null;
|
|
567
711
|
}
|
|
568
712
|
|
|
569
713
|
return createPortal(
|
|
570
|
-
<div className=
|
|
714
|
+
<div className={`wapp-modal-layer wapp-motion-${presence.state}`} aria-hidden={isOpen ? undefined : true}>
|
|
571
715
|
<div
|
|
572
716
|
className="wapp-modal-overlay"
|
|
573
717
|
onClick={closeOnOverlayClick ? onClose : undefined}
|
|
@@ -576,7 +720,7 @@ export function Modal({
|
|
|
576
720
|
<div
|
|
577
721
|
ref={modalRef}
|
|
578
722
|
tabIndex={-1}
|
|
579
|
-
className={`wapp-modal wapp-modal-${size} ${className}`}
|
|
723
|
+
className={`wapp-modal wapp-modal-${size} wapp-motion-${presence.state} ${className}`}
|
|
580
724
|
role="dialog"
|
|
581
725
|
aria-modal="true"
|
|
582
726
|
aria-labelledby={titleId}
|
|
@@ -660,6 +804,7 @@ export function Dialog({
|
|
|
660
804
|
children,
|
|
661
805
|
actions,
|
|
662
806
|
onClose,
|
|
807
|
+
keyboardShortcutsEnabled = true,
|
|
663
808
|
className = "",
|
|
664
809
|
}: {
|
|
665
810
|
title: string;
|
|
@@ -667,10 +812,11 @@ export function Dialog({
|
|
|
667
812
|
children?: ReactNode;
|
|
668
813
|
actions?: ReactNode;
|
|
669
814
|
onClose?: () => void;
|
|
815
|
+
keyboardShortcutsEnabled?: boolean;
|
|
670
816
|
className?: string;
|
|
671
817
|
}) {
|
|
672
818
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
673
|
-
useDialogKeyboardShortcuts({ dialogRef, onCancel: onClose });
|
|
819
|
+
useDialogKeyboardShortcuts({ dialogRef, enabled: keyboardShortcutsEnabled, onCancel: onClose });
|
|
674
820
|
|
|
675
821
|
return (
|
|
676
822
|
<div ref={dialogRef} className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
|
|
@@ -708,12 +854,15 @@ export function ConfirmDialog({
|
|
|
708
854
|
onCancel: () => void;
|
|
709
855
|
onConfirm: () => void;
|
|
710
856
|
}) {
|
|
711
|
-
|
|
857
|
+
const presence = usePresence(open);
|
|
858
|
+
if (!presence.mounted) return null;
|
|
712
859
|
return createPortal(
|
|
713
|
-
<div className=
|
|
860
|
+
<div className={`wapp-dialog-backdrop wapp-motion-${presence.state}`} role="presentation" aria-hidden={open ? undefined : true}>
|
|
714
861
|
<Dialog
|
|
715
862
|
title={title}
|
|
716
863
|
onClose={onCancel}
|
|
864
|
+
keyboardShortcutsEnabled={open && presence.mounted}
|
|
865
|
+
className={`wapp-motion-${presence.state}`}
|
|
717
866
|
actions={(
|
|
718
867
|
<>
|
|
719
868
|
<Button type="button" variant="ghost" onClick={onCancel}>Cancel</Button>
|
|
@@ -762,6 +911,210 @@ function getViewportBounds(): ViewportBounds {
|
|
|
762
911
|
};
|
|
763
912
|
}
|
|
764
913
|
|
|
914
|
+
export type FloatingPanelPlacement = "top-start" | "top-end" | "bottom-start" | "bottom-end";
|
|
915
|
+
|
|
916
|
+
export interface FloatingPanelProps {
|
|
917
|
+
open: boolean;
|
|
918
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
919
|
+
onClose: () => void;
|
|
920
|
+
children: ReactNode;
|
|
921
|
+
ariaLabel?: string;
|
|
922
|
+
role?: "dialog" | "group" | "menu" | "region";
|
|
923
|
+
id?: string;
|
|
924
|
+
placement?: FloatingPanelPlacement;
|
|
925
|
+
offset?: number;
|
|
926
|
+
className?: string;
|
|
927
|
+
style?: CSSProperties;
|
|
928
|
+
focusSelector?: string;
|
|
929
|
+
restoreFocusOnClose?: boolean;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function floatingPanelStyle(
|
|
933
|
+
panel: HTMLDivElement,
|
|
934
|
+
anchor: HTMLElement,
|
|
935
|
+
placement: FloatingPanelPlacement,
|
|
936
|
+
offset: number,
|
|
937
|
+
): CSSProperties {
|
|
938
|
+
const margin = 8;
|
|
939
|
+
const viewport = getViewportBounds();
|
|
940
|
+
const panelRect = panel.getBoundingClientRect();
|
|
941
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
942
|
+
const panelWidth = panelRect.width;
|
|
943
|
+
const panelHeight = panelRect.height;
|
|
944
|
+
const maxHeight = Math.max(80, viewport.height - margin * 2);
|
|
945
|
+
const canPlaceTop = anchorRect.top - panelHeight - offset >= viewport.top + margin;
|
|
946
|
+
const canPlaceBottom = anchorRect.bottom + panelHeight + offset <= viewport.bottom - margin;
|
|
947
|
+
const prefersTop = placement.startsWith("top");
|
|
948
|
+
const placeTop = prefersTop ? (canPlaceTop || !canPlaceBottom) : (!canPlaceBottom && canPlaceTop);
|
|
949
|
+
const preferredLeft = placement.endsWith("start")
|
|
950
|
+
? anchorRect.left
|
|
951
|
+
: anchorRect.right - panelWidth;
|
|
952
|
+
const left = Math.max(viewport.left + margin, Math.min(preferredLeft, viewport.right - panelWidth - margin));
|
|
953
|
+
const preferredTop = placeTop
|
|
954
|
+
? anchorRect.top - panelHeight - offset
|
|
955
|
+
: anchorRect.bottom + offset;
|
|
956
|
+
const top = Math.max(viewport.top + margin, Math.min(preferredTop, viewport.bottom - panelHeight - margin));
|
|
957
|
+
|
|
958
|
+
return {
|
|
959
|
+
position: "fixed",
|
|
960
|
+
left,
|
|
961
|
+
top,
|
|
962
|
+
maxHeight,
|
|
963
|
+
overflowY: "auto",
|
|
964
|
+
visibility: "visible",
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function FloatingPanel({
|
|
969
|
+
open,
|
|
970
|
+
anchorRef,
|
|
971
|
+
onClose,
|
|
972
|
+
children,
|
|
973
|
+
ariaLabel = "Floating panel",
|
|
974
|
+
role = "dialog",
|
|
975
|
+
id,
|
|
976
|
+
placement = "bottom-start",
|
|
977
|
+
offset = 8,
|
|
978
|
+
className = "",
|
|
979
|
+
style,
|
|
980
|
+
focusSelector,
|
|
981
|
+
restoreFocusOnClose = false,
|
|
982
|
+
}: FloatingPanelProps) {
|
|
983
|
+
const presence = usePresence(open);
|
|
984
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
985
|
+
const positionFrameRef = useRef<number | null>(null);
|
|
986
|
+
const [panelStyle, setPanelStyle] = useState<CSSProperties | null>(null);
|
|
987
|
+
const wasOpenRef = useRef(open);
|
|
988
|
+
|
|
989
|
+
const updatePosition = useCallback(() => {
|
|
990
|
+
const panel = panelRef.current;
|
|
991
|
+
const anchor = anchorRef.current;
|
|
992
|
+
if (!panel || !anchor) {
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
setPanelStyle(floatingPanelStyle(panel, anchor, placement, offset));
|
|
996
|
+
}, [anchorRef, offset, placement]);
|
|
997
|
+
|
|
998
|
+
const schedulePositionUpdate = useCallback(() => {
|
|
999
|
+
if (positionFrameRef.current !== null) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
positionFrameRef.current = window.requestAnimationFrame(() => {
|
|
1003
|
+
positionFrameRef.current = null;
|
|
1004
|
+
updatePosition();
|
|
1005
|
+
});
|
|
1006
|
+
}, [updatePosition]);
|
|
1007
|
+
|
|
1008
|
+
useLayoutEffect(() => {
|
|
1009
|
+
if (!open || !presence.mounted) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
updatePosition();
|
|
1014
|
+
schedulePositionUpdate();
|
|
1015
|
+
return () => {
|
|
1016
|
+
if (positionFrameRef.current !== null) {
|
|
1017
|
+
window.cancelAnimationFrame(positionFrameRef.current);
|
|
1018
|
+
positionFrameRef.current = null;
|
|
1019
|
+
}
|
|
1020
|
+
};
|
|
1021
|
+
}, [open, presence.mounted, schedulePositionUpdate, updatePosition]);
|
|
1022
|
+
|
|
1023
|
+
useEffect(() => {
|
|
1024
|
+
if (!presence.mounted) {
|
|
1025
|
+
setPanelStyle(null);
|
|
1026
|
+
}
|
|
1027
|
+
}, [presence.mounted]);
|
|
1028
|
+
|
|
1029
|
+
useEffect(() => {
|
|
1030
|
+
if (!open) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
1035
|
+
if (event.key === "Escape") {
|
|
1036
|
+
event.preventDefault();
|
|
1037
|
+
onClose();
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
1041
|
+
const panel = panelRef.current;
|
|
1042
|
+
const anchor = anchorRef.current;
|
|
1043
|
+
const path = event.composedPath();
|
|
1044
|
+
if ((panel && path.includes(panel)) || (anchor && path.includes(anchor))) {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
onClose();
|
|
1048
|
+
};
|
|
1049
|
+
const handleViewportChange = () => schedulePositionUpdate();
|
|
1050
|
+
|
|
1051
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
1052
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
1053
|
+
window.addEventListener("resize", handleViewportChange);
|
|
1054
|
+
window.addEventListener("scroll", handleViewportChange, true);
|
|
1055
|
+
window.visualViewport?.addEventListener("resize", handleViewportChange);
|
|
1056
|
+
return () => {
|
|
1057
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
1058
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
1059
|
+
window.removeEventListener("resize", handleViewportChange);
|
|
1060
|
+
window.removeEventListener("scroll", handleViewportChange, true);
|
|
1061
|
+
window.visualViewport?.removeEventListener("resize", handleViewportChange);
|
|
1062
|
+
};
|
|
1063
|
+
}, [anchorRef, onClose, open, schedulePositionUpdate]);
|
|
1064
|
+
|
|
1065
|
+
useEffect(() => {
|
|
1066
|
+
if (open) {
|
|
1067
|
+
wasOpenRef.current = true;
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (!wasOpenRef.current) {
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
wasOpenRef.current = false;
|
|
1074
|
+
if (!restoreFocusOnClose) {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const frameId = window.requestAnimationFrame(() => anchorRef.current?.focus());
|
|
1078
|
+
return () => window.cancelAnimationFrame(frameId);
|
|
1079
|
+
}, [anchorRef, open, restoreFocusOnClose]);
|
|
1080
|
+
|
|
1081
|
+
useEffect(() => {
|
|
1082
|
+
if (!open || !presence.mounted || !focusSelector) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const frameId = window.requestAnimationFrame(() => {
|
|
1086
|
+
panelRef.current?.querySelector<HTMLElement>(focusSelector)?.focus();
|
|
1087
|
+
});
|
|
1088
|
+
return () => window.cancelAnimationFrame(frameId);
|
|
1089
|
+
}, [focusSelector, open, presence.mounted]);
|
|
1090
|
+
|
|
1091
|
+
if (!presence.mounted) {
|
|
1092
|
+
return null;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const resolvedPanelStyle = panelStyle ?? {
|
|
1096
|
+
position: "fixed" as const,
|
|
1097
|
+
top: -9999,
|
|
1098
|
+
left: -9999,
|
|
1099
|
+
visibility: "hidden" as const,
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
return createPortal(
|
|
1103
|
+
<div
|
|
1104
|
+
ref={panelRef}
|
|
1105
|
+
id={id}
|
|
1106
|
+
className={`wapp-floating-panel wapp-motion-${presence.state} ${className}`.trim()}
|
|
1107
|
+
role={role}
|
|
1108
|
+
aria-label={ariaLabel}
|
|
1109
|
+
aria-hidden={open ? undefined : true}
|
|
1110
|
+
style={{ ...resolvedPanelStyle, ...style }}
|
|
1111
|
+
>
|
|
1112
|
+
{children}
|
|
1113
|
+
</div>,
|
|
1114
|
+
document.body,
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
765
1118
|
function MenuIcon() {
|
|
766
1119
|
return (
|
|
767
1120
|
<svg aria-hidden="true" viewBox="0 0 24 24" className="wapp-svg">
|
|
@@ -841,6 +1194,7 @@ export function ActionMenu({
|
|
|
841
1194
|
triggerSize?: "default" | "compact";
|
|
842
1195
|
}) {
|
|
843
1196
|
const [open, setOpen] = useState(false);
|
|
1197
|
+
const presence = usePresence(open);
|
|
844
1198
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
845
1199
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
846
1200
|
const [style, setStyle] = useState<CSSProperties>({ position: "fixed", top: -9999, left: -9999 });
|
|
@@ -873,7 +1227,7 @@ export function ActionMenu({
|
|
|
873
1227
|
y: triggerRect.bottom + 4,
|
|
874
1228
|
}),
|
|
875
1229
|
});
|
|
876
|
-
}, [open]);
|
|
1230
|
+
}, [open, presence.mounted]);
|
|
877
1231
|
|
|
878
1232
|
function handleItemClick(item: ActionMenuItem) {
|
|
879
1233
|
if (item.disabled) return;
|
|
@@ -899,8 +1253,8 @@ export function ActionMenu({
|
|
|
899
1253
|
>
|
|
900
1254
|
{trigger ?? <MenuIcon />}
|
|
901
1255
|
</button>
|
|
902
|
-
{
|
|
903
|
-
<div ref={menuRef} className=
|
|
1256
|
+
{presence.mounted ? createPortal(
|
|
1257
|
+
<div ref={menuRef} className={`wapp-action-menu wapp-motion-${presence.state}`} role="menu" aria-label={ariaLabel} aria-hidden={open ? undefined : true} style={style}>
|
|
904
1258
|
<ActionMenuItems items={items} onItemClick={handleItemClick} />
|
|
905
1259
|
</div>,
|
|
906
1260
|
document.body,
|
|
@@ -921,20 +1275,28 @@ export function ContextMenu({
|
|
|
921
1275
|
ariaLabel?: string;
|
|
922
1276
|
}) {
|
|
923
1277
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
1278
|
+
const presence = usePresence(Boolean(position));
|
|
1279
|
+
const [renderPosition, setRenderPosition] = useState(position);
|
|
924
1280
|
const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(position ? hiddenMenuStyle(position) : null);
|
|
925
1281
|
|
|
1282
|
+
useEffect(() => {
|
|
1283
|
+
if (position) {
|
|
1284
|
+
setRenderPosition(position);
|
|
1285
|
+
}
|
|
1286
|
+
}, [position]);
|
|
1287
|
+
|
|
926
1288
|
useLayoutEffect(() => {
|
|
927
|
-
if (!
|
|
1289
|
+
if (!renderPosition) {
|
|
928
1290
|
setMenuStyle(null);
|
|
929
1291
|
return;
|
|
930
1292
|
}
|
|
931
1293
|
|
|
932
1294
|
let frameId: number | null = null;
|
|
933
1295
|
const updatePosition = () => {
|
|
934
|
-
setMenuStyle(boundedMenuStyle(menuRef.current,
|
|
1296
|
+
setMenuStyle(boundedMenuStyle(menuRef.current, renderPosition));
|
|
935
1297
|
};
|
|
936
1298
|
|
|
937
|
-
setMenuStyle(hiddenMenuStyle(
|
|
1299
|
+
setMenuStyle(hiddenMenuStyle(renderPosition));
|
|
938
1300
|
updatePosition();
|
|
939
1301
|
frameId = window.requestAnimationFrame(updatePosition);
|
|
940
1302
|
|
|
@@ -943,7 +1305,7 @@ export function ContextMenu({
|
|
|
943
1305
|
window.cancelAnimationFrame(frameId);
|
|
944
1306
|
}
|
|
945
1307
|
};
|
|
946
|
-
}, [items,
|
|
1308
|
+
}, [items, renderPosition]);
|
|
947
1309
|
|
|
948
1310
|
useEffect(() => {
|
|
949
1311
|
if (!position) return;
|
|
@@ -974,7 +1336,7 @@ export function ContextMenu({
|
|
|
974
1336
|
};
|
|
975
1337
|
}, [onClose, position]);
|
|
976
1338
|
|
|
977
|
-
if (!
|
|
1339
|
+
if (!presence.mounted || !renderPosition || !menuStyle) return null;
|
|
978
1340
|
|
|
979
1341
|
function handleItemClick(item: ActionMenuItem) {
|
|
980
1342
|
if (item.disabled) return;
|
|
@@ -985,9 +1347,10 @@ export function ContextMenu({
|
|
|
985
1347
|
return createPortal(
|
|
986
1348
|
<div
|
|
987
1349
|
ref={menuRef}
|
|
988
|
-
className=
|
|
1350
|
+
className={`wapp-action-menu wapp-motion-${presence.state}`}
|
|
989
1351
|
role="menu"
|
|
990
1352
|
aria-label={ariaLabel}
|
|
1353
|
+
aria-hidden={position ? undefined : true}
|
|
991
1354
|
style={menuStyle}
|
|
992
1355
|
onContextMenu={(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
993
1356
|
event.preventDefault();
|
package/src/web/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from "./WebAppRoot";
|
|
2
2
|
export * from "./components";
|
|
3
3
|
export * from "./render";
|
|
4
|
+
export * from "./motion";
|
|
5
|
+
export { supportsViewTransitions } from "./routing";
|
|
4
6
|
export * from "./api-client";
|
|
5
7
|
export * from "./sidebar/types";
|
|
6
8
|
export * from "./realtime/useRealtime";
|
|
@@ -11,5 +13,6 @@ export { useTheme } from "./theme";
|
|
|
11
13
|
export type { ResolvedTheme, WebAppThemeState } from "./theme";
|
|
12
14
|
export { useLogLevel } from "./log-level";
|
|
13
15
|
export type { WebAppLogLevelState } from "./log-level";
|
|
16
|
+
export { createLogger, getLogLevel, log } from "./logger";
|
|
14
17
|
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
18
|
export type { ThemePreference } from "../contracts";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Logger, type ILogObj } from "tslog";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LOG_LEVEL,
|
|
4
|
+
LOG_LEVEL_NAMES,
|
|
5
|
+
LOG_LEVELS,
|
|
6
|
+
VALID_LOG_LEVELS,
|
|
7
|
+
type LogLevelName,
|
|
8
|
+
} from "../contracts";
|
|
9
|
+
|
|
10
|
+
const subLoggers = new Map<string, Logger<ILogObj>>();
|
|
11
|
+
|
|
12
|
+
function isLogLevelName(value: unknown): value is LogLevelName {
|
|
13
|
+
return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const log = new Logger<ILogObj>({
|
|
17
|
+
name: "webapp",
|
|
18
|
+
minLevel: LOG_LEVELS[DEFAULT_LOG_LEVEL],
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function createLogger(scope: string): Logger<ILogObj> {
|
|
22
|
+
const existing = subLoggers.get(scope);
|
|
23
|
+
if (existing) {
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
26
|
+
const logger = log.getSubLogger({ name: scope });
|
|
27
|
+
subLoggers.set(scope, logger);
|
|
28
|
+
return logger;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setLogLevel(level: LogLevelName): void {
|
|
32
|
+
if (!isLogLevelName(level)) {
|
|
33
|
+
throw new Error(`Invalid log level: ${String(level)}`);
|
|
34
|
+
}
|
|
35
|
+
const numericLevel = LOG_LEVELS[level];
|
|
36
|
+
log.settings.minLevel = numericLevel;
|
|
37
|
+
for (const subLogger of subLoggers.values()) {
|
|
38
|
+
subLogger.settings.minLevel = numericLevel;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getLogLevel(): LogLevelName {
|
|
43
|
+
return LOG_LEVEL_NAMES[log.settings.minLevel] ?? DEFAULT_LOG_LEVEL;
|
|
44
|
+
}
|