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>
  );
}