@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.
@@ -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 <div className={`wapp-data-list wapp-data-list-${variant}`}>{children ?? empty ?? null}</div>;
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 (!isOpen) {
709
+ if (!presence.mounted) {
566
710
  return null;
567
711
  }
568
712
 
569
713
  return createPortal(
570
- <div className="wapp-modal-layer">
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
- if (!open) return null;
857
+ const presence = usePresence(open);
858
+ if (!presence.mounted) return null;
712
859
  return createPortal(
713
- <div className="wapp-dialog-backdrop" role="presentation">
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
- {open ? createPortal(
903
- <div ref={menuRef} className="wapp-action-menu" role="menu" aria-label={ariaLabel} style={style}>
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 (!position) {
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, position));
1296
+ setMenuStyle(boundedMenuStyle(menuRef.current, renderPosition));
935
1297
  };
936
1298
 
937
- setMenuStyle(hiddenMenuStyle(position));
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, position]);
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 (!position || !menuStyle) return null;
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="wapp-action-menu"
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";