All files / src/components/display SplitButton.tsx

5.74% Statements 5/87
100% Branches 0/0
0% Functions 0/1
5.74% Lines 5/87

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 1321x 1x   1x 1x                                                               1x                                                                                                                                                                                              
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 {
      if (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 => {
    if (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>
  );
}