@pablozaiden/webapp 0.2.3 → 0.3.1

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