File size: 3,099 Bytes
9b906ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import React, { ReactNode, useEffect, useRef } from "react";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import {
  MODAL_MAX_WIDTH_VIEWPORT,
  type ModalWidth,
  modalWidthClassName,
} from "#/components/shared/modals/modal-body";
import { cn } from "#/utils/utils";

interface ApiKeyModalBaseProps {
  isOpen: boolean;
  title: string;
  width?: ModalWidth;
  children: ReactNode;
  footer: ReactNode;
  /** Called when the modal should close (e.g., Escape key or backdrop click) */
  onClose?: () => void;
  /** Ref to an element that should receive initial focus when modal opens */
  initialFocusRef?: React.RefObject<HTMLElement | null>;
}

export function ApiKeyModalBase({
  isOpen,
  title,
  width = "md",
  children,
  footer,
  onClose,
  initialFocusRef,
}: ApiKeyModalBaseProps) {
  const modalRef = useRef<HTMLDivElement>(null);

  // Focus management: set initial focus and trap focus within modal
  useEffect(() => {
    if (!isOpen) return undefined;

    // Set initial focus
    const focusTarget = initialFocusRef?.current ?? modalRef.current;
    if (focusTarget) {
      // Small delay to ensure modal is fully rendered
      const timeoutId = setTimeout(() => {
        focusTarget.focus();
      }, 0);
      return () => clearTimeout(timeoutId);
    }
    return undefined;
  }, [isOpen, initialFocusRef]);

  // Focus trap: keep focus within modal
  useEffect(() => {
    if (!isOpen || !modalRef.current) return undefined;

    const modal = modalRef.current;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key !== "Tab") return;

      const focusableElements = modal.querySelectorAll<HTMLElement>(
        'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
      );

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];

      if (e.shiftKey && document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      } else if (!e.shiftKey && document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <ModalBackdrop onClose={onClose}>
      <div
        ref={modalRef}
        tabIndex={-1}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className={cn(
          "bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-[var(--oh-border)]",
          modalWidthClassName(width),
          MODAL_MAX_WIDTH_VIEWPORT,
        )}
      >
        <BaseModalTitle id="modal-title" title={title} />
        {children}
        <div className="w-full flex justify-end gap-2 mt-2">{footer}</div>
      </div>
    </ModalBackdrop>
  );
}