# All files / src/components/display SplitButton.tsx

90% Statements 27/30
62.5% Branches 15/24
91.66% Functions 11/12
88.88% Lines 24/27

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    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
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132

    25x
    25x

    25x
    25x
    20x

    5x

    2x

    5x
    5x

    25x
    25x
    20x

    5x
    5x

    25x
    25x

    25x
    4x

    4x

    25x

    5x

    10x

    3x
    3x

     import { useState, useRef, useEffect, type JSX } from "react";
    import { ChevronDown } from "lucide-react";
    import { type SplitButtonBuiltinProps } from "@grackle-ai/common";
    import { ICON_MD } from "../../utils/iconSize.js";
    import styles from "./SplitButton.module.scss";

    /** A single option in the split button dropdown. */
    export interface SplitButtonOption {
      /** Display label. */
      label: string;
      /** Short description shown below the label. */
      description?: string;
      /** Callback fired when this option is selected. */
      onClick: () => void;
    }

    /**
     * Props for the {@link SplitButton} component. The data props (label, variant,
     * size) are inferred from the built-in's zod schema; `options` is the richer
     * component type (its entries carry an `onClick` callback) and the main action's
     * `onClick` are added on top.
     */
    export interface SplitButtonProps extends Omit<SplitButtonBuiltinProps, "options"> {
      /** Callback for the main action (clicking the label area). */
      onClick: () => void;
      /** Menu options shown when the chevron is clicked. */
      options: SplitButtonOption[];
      /** data-testid for the root element. */
      "data-testid"?: string;
    }

    /**
     * Compound split button with a main action and a chevron dropdown for
     * additional options. The main area fires the default action; the chevron
     * opens a dropdown menu with all available options.
     */
    export function SplitButton({
      label,
      onClick,
      options,
      variant = "primary",
      size = "md",
      "data-testid": testId,
    }: SplitButtonProps): JSX.Element {
      const [open, setOpen] = useState(false);
      const containerRef = useRef<HTMLDivElement>(null);

      // Close on click outside
      useEffect(() => {
        if (!open) {
          return;
        }
        function handleClickOutside(e: MouseEvent): void {
          if (
            containerRef.current &&
            e.target instanceof Node &&
            !containerRef.current.contains(e.target)
          ) {
            setOpen(false);
          }
        }
        document.addEventListener("mousedown", handleClickOutside);
        return () => document.removeEventListener("mousedown", handleClickOutside);
      }, [open]);

      // Close on Escape
      useEffect(() => {
        if (!open) {
          return;
        }
        function handleKeyDown(e: KeyboardEvent): void {
          Iif (e.key === "Escape") {
            setOpen(false);
          }
        }
        document.addEventListener("keydown", handleKeyDown);
        return () => document.removeEventListener("keydown", handleKeyDown);
      }, [open]);

      const variantClass = styles[variant] || "";
      const sizeClass = styles[size] || "";

      const handleMainClick = (): void => {
        Iif (open) {
          setOpen(false);
        }
        onClick();
      };

      return (
        <div ref={containerRef} className={styles.container} data-testid={testId}>
          <button
            type="button"
            className={`${styles.mainButton} ${variantClass} ${sizeClass}`}
            onClick={handleMainClick}
            data-testid={testId ? `${testId}-main` : undefined}
          >
            {label}
          </button>
          <button
            type="button"
            className={`${styles.chevronButton} ${variantClass} ${sizeClass}`}
            onClick={() => setOpen((prev) => !prev)}
            aria-label={`More options for ${label}`}
            aria-haspopup="menu"
            aria-expanded={open}
            data-testid={testId ? `${testId}-chevron` : undefined}
          >
            <ChevronDown size={ICON_MD} aria-hidden="true" />
          </button>
          {open && (
            <div className={styles.dropdown} data-testid={testId ? `${testId}-menu` : undefined}>
              {options.map((opt, idx) => (
                <button
                  key={idx}
                  type="button"
                  className={styles.option}
                  onClick={() => {
                    opt.onClick();
                    setOpen(false);
                  }}
                >
                  <span className={styles.optionLabel}>{opt.label}</span>
                  {opt.description && <span className={styles.optionDesc}>{opt.description}</span>}
                </button>
              ))}
            </div>
          )}
        </div>
      );
    }