@pablozaiden/webapp 0.2.2 → 0.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,13 +1,21 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type InputHTMLAttributes, type MouseEvent as ReactMouseEvent, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from "react";
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";
2
2
  import { createPortal } from "react-dom";
3
3
  import type { ActionMenuItem, BadgeVariant } from "../sidebar/types";
4
4
 
5
5
  export function Button({
6
6
  variant = "default",
7
+ loading = false,
7
8
  className = "",
9
+ disabled,
10
+ children,
8
11
  ...props
9
- }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost" }) {
10
- return <button {...props} className={`wapp-button wapp-button-${variant} ${className}`} />;
12
+ }: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: "default" | "primary" | "danger" | "ghost"; loading?: boolean }) {
13
+ return (
14
+ <button {...props} disabled={disabled || loading} className={`wapp-button wapp-button-${variant} ${className}`}>
15
+ {loading ? <span className="wapp-button-spinner" aria-hidden="true" /> : null}
16
+ {children}
17
+ </button>
18
+ );
11
19
  }
12
20
 
13
21
  export function IconButton({
@@ -18,8 +26,8 @@ export function IconButton({
18
26
  return <button {...props} className={`wapp-icon-button ${active ? "active" : ""} ${className}`} />;
19
27
  }
20
28
 
21
- export function Badge({ variant = "default", children }: { variant?: BadgeVariant; children: ReactNode }) {
22
- return <span className={`wapp-badge wapp-badge-${variant}`}>{children}</span>;
29
+ export function Badge({ variant = "default", className = "", children, ...props }: HTMLAttributes<HTMLSpanElement> & { variant?: BadgeVariant; children: ReactNode }) {
30
+ return <span {...props} className={`wapp-badge wapp-badge-${variant} ${className}`}>{children}</span>;
23
31
  }
24
32
 
25
33
  export function Panel({ title, description, actions, children, className = "" }: { title?: string; description?: string; actions?: ReactNode; children?: ReactNode; className?: string }) {
@@ -233,6 +241,323 @@ export function CodeValue({ value, label, copyLabel = "Copy" }: { value: string;
233
241
  );
234
242
  }
235
243
 
244
+ function isTopmostDialog(dialog: HTMLElement): boolean {
245
+ const openDialogs = Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'][aria-modal='true']"));
246
+ return openDialogs[openDialogs.length - 1] === dialog;
247
+ }
248
+
249
+ function isNativeEnterTarget(target: EventTarget | null): boolean {
250
+ if (!(target instanceof HTMLElement)) {
251
+ return false;
252
+ }
253
+ if (target.isContentEditable) {
254
+ return true;
255
+ }
256
+ if (target instanceof HTMLTextAreaElement) {
257
+ return true;
258
+ }
259
+ if (target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || target instanceof HTMLSelectElement) {
260
+ return true;
261
+ }
262
+ if (target instanceof HTMLInputElement) {
263
+ return true;
264
+ }
265
+ return false;
266
+ }
267
+
268
+ function findDefaultDialogAction(dialog: HTMLElement): HTMLElement | null {
269
+ const explicit = dialog.querySelector<HTMLElement>("[data-dialog-default-action]:not(:disabled)");
270
+ if (explicit) {
271
+ return explicit;
272
+ }
273
+
274
+ const submit = dialog.querySelector<HTMLElement>("button[type='submit']:not(:disabled), input[type='submit']:not(:disabled)");
275
+ if (submit) {
276
+ return submit;
277
+ }
278
+
279
+ const actionContainers = Array.from(dialog.querySelectorAll<HTMLElement>("[data-dialog-actions], .wapp-dialog-actions"));
280
+ const actionContainer = actionContainers[actionContainers.length - 1];
281
+ if (!actionContainer) {
282
+ return null;
283
+ }
284
+
285
+ const buttons = Array.from(actionContainer.querySelectorAll<HTMLElement>("button:not(:disabled), [role='button']:not([aria-disabled='true'])"));
286
+ return buttons[buttons.length - 1] ?? null;
287
+ }
288
+
289
+ export function useDialogKeyboardShortcuts({
290
+ dialogRef,
291
+ enabled = true,
292
+ onCancel,
293
+ onAccept,
294
+ acceptDisabled = false,
295
+ }: {
296
+ dialogRef: RefObject<HTMLElement | null>;
297
+ enabled?: boolean;
298
+ onCancel?: () => void;
299
+ onAccept?: () => void;
300
+ acceptDisabled?: boolean;
301
+ }) {
302
+ const onCancelRef = useRef(onCancel);
303
+ const onAcceptRef = useRef(onAccept);
304
+ onCancelRef.current = onCancel;
305
+ onAcceptRef.current = onAccept;
306
+
307
+ useEffect(() => {
308
+ if (!enabled) return;
309
+
310
+ function handleKeyDown(event: KeyboardEvent) {
311
+ const dialog = dialogRef.current;
312
+ if (!dialog || !isTopmostDialog(dialog)) {
313
+ return;
314
+ }
315
+
316
+ if (event.key === "Escape") {
317
+ if (onCancelRef.current) {
318
+ event.preventDefault();
319
+ event.stopPropagation();
320
+ onCancelRef.current();
321
+ }
322
+ return;
323
+ }
324
+
325
+ if (event.key !== "Enter" || event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) {
326
+ return;
327
+ }
328
+
329
+ if (isNativeEnterTarget(event.target)) {
330
+ return;
331
+ }
332
+
333
+ event.preventDefault();
334
+ event.stopPropagation();
335
+ if (onAcceptRef.current) {
336
+ if (!acceptDisabled) {
337
+ onAcceptRef.current();
338
+ }
339
+ return;
340
+ }
341
+
342
+ if (!acceptDisabled) {
343
+ findDefaultDialogAction(dialog)?.click();
344
+ }
345
+ }
346
+
347
+ document.addEventListener("keydown", handleKeyDown);
348
+ return () => document.removeEventListener("keydown", handleKeyDown);
349
+ }, [acceptDisabled, dialogRef, enabled]);
350
+ }
351
+
352
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
353
+ const MODAL_SCROLL_LOCK_COUNT_KEY = "__webappModalScrollLockCount";
354
+ const MODAL_SCROLL_LOCK_OVERFLOW_KEY = "__webappModalScrollLockOverflow";
355
+
356
+ type ScrollLockedBody = HTMLElement & {
357
+ [MODAL_SCROLL_LOCK_COUNT_KEY]?: number;
358
+ [MODAL_SCROLL_LOCK_OVERFLOW_KEY]?: string;
359
+ };
360
+
361
+ function lockBodyScroll(): () => void {
362
+ const body = document.body as ScrollLockedBody;
363
+ const lockCount = body[MODAL_SCROLL_LOCK_COUNT_KEY] ?? 0;
364
+ if (lockCount === 0) {
365
+ body[MODAL_SCROLL_LOCK_OVERFLOW_KEY] = body.style.overflow;
366
+ body.style.overflow = "hidden";
367
+ }
368
+ body[MODAL_SCROLL_LOCK_COUNT_KEY] = lockCount + 1;
369
+
370
+ return () => {
371
+ const nextLockCount = Math.max((body[MODAL_SCROLL_LOCK_COUNT_KEY] ?? 1) - 1, 0);
372
+ if (nextLockCount > 0) {
373
+ body[MODAL_SCROLL_LOCK_COUNT_KEY] = nextLockCount;
374
+ return;
375
+ }
376
+
377
+ body.style.overflow = body[MODAL_SCROLL_LOCK_OVERFLOW_KEY] ?? "";
378
+ delete body[MODAL_SCROLL_LOCK_COUNT_KEY];
379
+ delete body[MODAL_SCROLL_LOCK_OVERFLOW_KEY];
380
+ };
381
+ }
382
+
383
+ export interface ModalProps {
384
+ isOpen: boolean;
385
+ onClose: () => void;
386
+ title: string;
387
+ description?: ReactNode;
388
+ children: ReactNode;
389
+ footer?: ReactNode;
390
+ size?: "sm" | "md" | "lg" | "xl";
391
+ showCloseButton?: boolean;
392
+ closeOnOverlayClick?: boolean;
393
+ className?: string;
394
+ }
395
+
396
+ export function Modal({
397
+ isOpen,
398
+ onClose,
399
+ title,
400
+ description,
401
+ children,
402
+ footer,
403
+ size = "md",
404
+ showCloseButton = true,
405
+ closeOnOverlayClick = true,
406
+ className = "",
407
+ }: ModalProps) {
408
+ const modalRef = useRef<HTMLDivElement>(null);
409
+ const previousFocusRef = useRef<Element | null>(null);
410
+ const titleId = useId();
411
+ const onCloseRef = useRef(onClose);
412
+ onCloseRef.current = onClose;
413
+
414
+ useDialogKeyboardShortcuts({
415
+ dialogRef: modalRef,
416
+ enabled: isOpen,
417
+ onCancel: () => onCloseRef.current(),
418
+ });
419
+
420
+ const handleFocusTrap = useCallback((event: KeyboardEvent) => {
421
+ if (event.key !== "Tab") {
422
+ return;
423
+ }
424
+
425
+ const currentModal = modalRef.current;
426
+ const openModals = Array.from(document.querySelectorAll<HTMLElement>("[role='dialog'][aria-modal='true']"));
427
+ const topmostModal = openModals[openModals.length - 1];
428
+ if (!currentModal || currentModal !== topmostModal) {
429
+ return;
430
+ }
431
+
432
+ const focusable = currentModal.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
433
+ if (focusable.length === 0) {
434
+ return;
435
+ }
436
+
437
+ const first = focusable[0]!;
438
+ const last = focusable[focusable.length - 1]!;
439
+ if (event.shiftKey && document.activeElement === first) {
440
+ event.preventDefault();
441
+ last.focus();
442
+ return;
443
+ }
444
+ if (!event.shiftKey && document.activeElement === last) {
445
+ event.preventDefault();
446
+ first.focus();
447
+ }
448
+ }, []);
449
+
450
+ useEffect(() => {
451
+ if (!isOpen) {
452
+ return;
453
+ }
454
+
455
+ previousFocusRef.current = document.activeElement;
456
+ document.addEventListener("keydown", handleFocusTrap);
457
+ const unlockBodyScroll = lockBodyScroll();
458
+ modalRef.current?.focus();
459
+
460
+ return () => {
461
+ document.removeEventListener("keydown", handleFocusTrap);
462
+ unlockBodyScroll();
463
+ if (previousFocusRef.current instanceof HTMLElement) {
464
+ previousFocusRef.current.focus();
465
+ }
466
+ };
467
+ }, [handleFocusTrap, isOpen]);
468
+
469
+ if (!isOpen) {
470
+ return null;
471
+ }
472
+
473
+ return createPortal(
474
+ <div className="wapp-modal-layer">
475
+ <div
476
+ className="wapp-modal-overlay"
477
+ onClick={closeOnOverlayClick ? onClose : undefined}
478
+ aria-hidden="true"
479
+ />
480
+ <div
481
+ ref={modalRef}
482
+ tabIndex={-1}
483
+ className={`wapp-modal wapp-modal-${size} ${className}`}
484
+ role="dialog"
485
+ aria-modal="true"
486
+ aria-labelledby={titleId}
487
+ >
488
+ <div className="wapp-modal-header">
489
+ <div className="wapp-modal-title-block">
490
+ <h2 id={titleId}>{title}</h2>
491
+ {description ? <p>{description}</p> : null}
492
+ </div>
493
+ {showCloseButton ? (
494
+ <button type="button" className="wapp-modal-close" aria-label="Close" onClick={onClose}>
495
+ ×
496
+ </button>
497
+ ) : null}
498
+ </div>
499
+ <div className="wapp-modal-body">
500
+ {children}
501
+ </div>
502
+ {footer ? (
503
+ <div className="wapp-modal-footer" data-dialog-actions>
504
+ {footer}
505
+ </div>
506
+ ) : null}
507
+ </div>
508
+ </div>,
509
+ document.body,
510
+ );
511
+ }
512
+
513
+ export interface ConfirmModalProps {
514
+ isOpen: boolean;
515
+ onClose: () => void;
516
+ onConfirm: () => void;
517
+ title: string;
518
+ message: string;
519
+ children?: ReactNode;
520
+ confirmLabel?: string;
521
+ cancelLabel?: string;
522
+ loading?: boolean;
523
+ variant?: "danger" | "primary";
524
+ }
525
+
526
+ export function ConfirmModal({
527
+ isOpen,
528
+ onClose,
529
+ onConfirm,
530
+ title,
531
+ message,
532
+ children,
533
+ confirmLabel = "Confirm",
534
+ cancelLabel = "Cancel",
535
+ loading = false,
536
+ variant = "danger",
537
+ }: ConfirmModalProps) {
538
+ return (
539
+ <Modal
540
+ isOpen={isOpen}
541
+ onClose={onClose}
542
+ title={title}
543
+ size="sm"
544
+ footer={(
545
+ <>
546
+ <Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
547
+ {cancelLabel}
548
+ </Button>
549
+ <Button type="button" variant={variant} onClick={onConfirm} loading={loading}>
550
+ {confirmLabel}
551
+ </Button>
552
+ </>
553
+ )}
554
+ >
555
+ <p>{message}</p>
556
+ {children}
557
+ </Modal>
558
+ );
559
+ }
560
+
236
561
  export function Dialog({
237
562
  title,
238
563
  description,
@@ -248,8 +573,11 @@ export function Dialog({
248
573
  onClose?: () => void;
249
574
  className?: string;
250
575
  }) {
576
+ const dialogRef = useRef<HTMLDivElement>(null);
577
+ useDialogKeyboardShortcuts({ dialogRef, onCancel: onClose });
578
+
251
579
  return (
252
- <div className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
580
+ <div ref={dialogRef} className={`wapp-dialog ${className}`} role="dialog" aria-modal="true" aria-label={title}>
253
581
  <div className="wapp-dialog-title">
254
582
  <div>
255
583
  <h2>{title}</h2>
@@ -260,7 +588,7 @@ export function Dialog({
260
588
  <div className="wapp-dialog-body">
261
589
  {children}
262
590
  </div>
263
- <div className="wapp-dialog-actions">
591
+ <div className="wapp-dialog-actions" data-dialog-actions>
264
592
  {actions}
265
593
  </div>
266
594
  </div>
@@ -284,17 +612,6 @@ export function ConfirmDialog({
284
612
  onCancel: () => void;
285
613
  onConfirm: () => void;
286
614
  }) {
287
- useEffect(() => {
288
- if (!open) return;
289
- function handleKeyDown(event: KeyboardEvent) {
290
- if (event.key === "Escape") {
291
- onCancel();
292
- }
293
- }
294
- document.addEventListener("keydown", handleKeyDown);
295
- return () => document.removeEventListener("keydown", handleKeyDown);
296
- }, [onCancel, open]);
297
-
298
615
  if (!open) return null;
299
616
  return createPortal(
300
617
  <div className="wapp-dialog-backdrop" role="presentation">
@@ -324,6 +641,31 @@ export interface ContextMenuPosition {
324
641
  y: number;
325
642
  }
326
643
 
644
+ interface ViewportBounds {
645
+ left: number;
646
+ top: number;
647
+ right: number;
648
+ bottom: number;
649
+ width: number;
650
+ height: number;
651
+ }
652
+
653
+ function getViewportBounds(): ViewportBounds {
654
+ const viewport = window.visualViewport;
655
+ const left = viewport?.offsetLeft ?? 0;
656
+ const top = viewport?.offsetTop ?? 0;
657
+ const width = viewport?.width ?? window.innerWidth;
658
+ const height = viewport?.height ?? window.innerHeight;
659
+ return {
660
+ left,
661
+ top,
662
+ width,
663
+ height,
664
+ right: left + width,
665
+ bottom: top + height,
666
+ };
667
+ }
668
+
327
669
  function MenuIcon() {
328
670
  return (
329
671
  <svg aria-hidden="true" viewBox="0 0 24 24" className="wapp-svg">
@@ -332,26 +674,52 @@ function MenuIcon() {
332
674
  );
333
675
  }
334
676
 
335
- function boundedMenuPosition(menu: HTMLDivElement | null, position: ContextMenuPosition): ContextMenuPosition {
336
- if (!menu) return position;
677
+ function boundedMenuStyle(menu: HTMLDivElement | null, position: ContextMenuPosition): CSSProperties {
337
678
  const margin = 8;
338
- const rect = menu.getBoundingClientRect();
679
+ const viewport = getViewportBounds();
680
+ const rect = menu?.getBoundingClientRect();
681
+ const width = rect?.width ?? 180;
682
+ const height = rect?.height ?? 0;
683
+ const maxHeight = Math.max(80, viewport.height - margin * 2);
684
+ const boundedHeight = Math.min(height || maxHeight, maxHeight);
685
+ const left = Math.max(viewport.left + margin, Math.min(position.x, viewport.right - width - margin));
686
+ const top = Math.max(viewport.top + margin, Math.min(position.y, viewport.bottom - boundedHeight - margin));
339
687
  return {
340
- x: Math.max(margin, Math.min(position.x, window.innerWidth - rect.width - margin)),
341
- y: Math.max(margin, Math.min(position.y, window.innerHeight - rect.height - margin)),
688
+ position: "fixed",
689
+ left,
690
+ top,
691
+ maxHeight,
692
+ overflowY: "auto",
342
693
  };
343
694
  }
344
695
 
696
+ function hiddenMenuStyle(position: ContextMenuPosition): CSSProperties {
697
+ return {
698
+ position: "fixed",
699
+ left: position.x,
700
+ top: position.y,
701
+ visibility: "hidden",
702
+ };
703
+ }
704
+
705
+ function isDestructiveActionMenuItem(item: ActionMenuItem): boolean {
706
+ return item.destructive === true || item.id?.toLowerCase().includes("delete") === true || item.label.toLowerCase().includes("delete");
707
+ }
708
+
345
709
  function ActionMenuItems({ items, onItemClick }: { items: ActionMenuItem[]; onItemClick: (item: ActionMenuItem) => void }) {
710
+ const orderedItems = [
711
+ ...items.filter((item) => !isDestructiveActionMenuItem(item)),
712
+ ...items.filter(isDestructiveActionMenuItem),
713
+ ];
346
714
  return (
347
715
  <div className="wapp-action-menu-items">
348
- {items.map((item, index) => (
716
+ {orderedItems.map((item, index) => (
349
717
  <button
350
718
  type="button"
351
719
  role="menuitem"
352
720
  key={item.id ?? `${item.label}:${index}`}
353
721
  disabled={item.disabled}
354
- className={`wapp-action-menu-item ${item.destructive ? "danger" : ""}`}
722
+ className={`wapp-action-menu-item ${isDestructiveActionMenuItem(item) ? "danger" : ""}`}
355
723
  onClick={() => onItemClick(item)}
356
724
  >
357
725
  {item.label}
@@ -366,11 +734,15 @@ export function ActionMenu({
366
734
  ariaLabel = "Actions",
367
735
  disabled = false,
368
736
  trigger,
737
+ triggerVariant = "default",
738
+ triggerSize = "default",
369
739
  }: {
370
740
  items: ActionMenuItem[];
371
741
  ariaLabel?: string;
372
742
  disabled?: boolean;
373
743
  trigger?: ReactNode;
744
+ triggerVariant?: "default" | "ghost";
745
+ triggerSize?: "default" | "compact";
374
746
  }) {
375
747
  const [open, setOpen] = useState(false);
376
748
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -399,12 +771,11 @@ export function ActionMenu({
399
771
  useLayoutEffect(() => {
400
772
  if (!open || !triggerRef.current || !menuRef.current) return;
401
773
  const triggerRect = triggerRef.current.getBoundingClientRect();
402
- const menuRect = menuRef.current.getBoundingClientRect();
403
- const margin = 8;
404
774
  setStyle({
405
- position: "fixed",
406
- top: Math.max(margin, Math.min(triggerRect.bottom + 4, window.innerHeight - menuRect.height - margin)),
407
- left: Math.max(margin, Math.min(triggerRect.right - menuRect.width, window.innerWidth - menuRect.width - margin)),
775
+ ...boundedMenuStyle(menuRef.current, {
776
+ x: triggerRect.right - menuRef.current.getBoundingClientRect().width,
777
+ y: triggerRect.bottom + 4,
778
+ }),
408
779
  });
409
780
  }, [open]);
410
781
 
@@ -419,7 +790,11 @@ export function ActionMenu({
419
790
  <button
420
791
  type="button"
421
792
  ref={triggerRef}
422
- className="wapp-action-menu-trigger"
793
+ className={[
794
+ "wapp-action-menu-trigger",
795
+ triggerVariant === "ghost" ? "wapp-action-menu-trigger-ghost" : "",
796
+ triggerSize === "compact" ? "wapp-action-menu-trigger-compact" : "",
797
+ ].filter(Boolean).join(" ")}
423
798
  aria-label={ariaLabel}
424
799
  aria-haspopup="menu"
425
800
  aria-expanded={open}
@@ -450,14 +825,33 @@ export function ContextMenu({
450
825
  ariaLabel?: string;
451
826
  }) {
452
827
  const menuRef = useRef<HTMLDivElement>(null);
453
- const [resolvedPosition, setResolvedPosition] = useState<ContextMenuPosition | null>(position);
828
+ const [menuStyle, setMenuStyle] = useState<CSSProperties | null>(position ? hiddenMenuStyle(position) : null);
454
829
 
455
830
  useLayoutEffect(() => {
456
- setResolvedPosition(position ? boundedMenuPosition(menuRef.current, position) : null);
457
- }, [position]);
831
+ if (!position) {
832
+ setMenuStyle(null);
833
+ return;
834
+ }
835
+
836
+ let frameId: number | null = null;
837
+ const updatePosition = () => {
838
+ setMenuStyle(boundedMenuStyle(menuRef.current, position));
839
+ };
840
+
841
+ setMenuStyle(hiddenMenuStyle(position));
842
+ updatePosition();
843
+ frameId = window.requestAnimationFrame(updatePosition);
844
+
845
+ return () => {
846
+ if (frameId !== null) {
847
+ window.cancelAnimationFrame(frameId);
848
+ }
849
+ };
850
+ }, [items, position]);
458
851
 
459
852
  useEffect(() => {
460
853
  if (!position) return;
854
+ const currentPosition = position;
461
855
  function handleKeyDown(event: KeyboardEvent) {
462
856
  if (event.key === "Escape") onClose();
463
857
  }
@@ -467,17 +861,24 @@ export function ContextMenu({
467
861
  function handleScroll() {
468
862
  onClose();
469
863
  }
864
+ function handleResize() {
865
+ setMenuStyle(boundedMenuStyle(menuRef.current, currentPosition));
866
+ }
470
867
  document.addEventListener("keydown", handleKeyDown);
471
868
  document.addEventListener("mousedown", handleMouseDown);
472
869
  window.addEventListener("scroll", handleScroll, true);
870
+ window.addEventListener("resize", handleResize);
871
+ window.visualViewport?.addEventListener("resize", handleResize);
473
872
  return () => {
474
873
  document.removeEventListener("keydown", handleKeyDown);
475
874
  document.removeEventListener("mousedown", handleMouseDown);
476
875
  window.removeEventListener("scroll", handleScroll, true);
876
+ window.removeEventListener("resize", handleResize);
877
+ window.visualViewport?.removeEventListener("resize", handleResize);
477
878
  };
478
879
  }, [onClose, position]);
479
880
 
480
- if (!position || !resolvedPosition) return null;
881
+ if (!position || !menuStyle) return null;
481
882
 
482
883
  function handleItemClick(item: ActionMenuItem) {
483
884
  if (item.disabled) return;
@@ -491,7 +892,7 @@ export function ContextMenu({
491
892
  className="wapp-action-menu"
492
893
  role="menu"
493
894
  aria-label={ariaLabel}
494
- style={{ position: "fixed", left: resolvedPosition.x, top: resolvedPosition.y }}
895
+ style={menuStyle}
495
896
  onContextMenu={(event: ReactMouseEvent<HTMLDivElement>) => {
496
897
  event.preventDefault();
497
898
  event.stopPropagation();
package/src/web/index.ts CHANGED
@@ -4,4 +4,5 @@ export * from "./render";
4
4
  export * from "./api-client";
5
5
  export * from "./sidebar/types";
6
6
  export * from "./realtime/useRealtime";
7
+ export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7
8
  export type { ThemePreference } from "../contracts";
@@ -1,6 +1,22 @@
1
1
  import type { ReactNode } from "react";
2
2
 
3
- export type BadgeVariant = "default" | "success" | "warning" | "error" | "info" | "disabled";
3
+ export type BadgeVariant =
4
+ | "default"
5
+ | "success"
6
+ | "warning"
7
+ | "error"
8
+ | "info"
9
+ | "disabled"
10
+ | "idle"
11
+ | "planning"
12
+ | "running"
13
+ | "completed"
14
+ | "stopped"
15
+ | "failed"
16
+ | "merged"
17
+ | "pushed"
18
+ | "deleted"
19
+ | "plan_ready";
4
20
 
5
21
  export interface WebAppRoute {
6
22
  view: string;