All files / src/components/display FilterDropdown.tsx

85.71% Statements 18/21
72.72% Branches 16/22
91.66% Functions 11/12
85% Lines 17/20

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 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159                                                                                                                              72x         7x                                             18x   18x   2x               18x 12x   18x 14x 14x       18x           18x 18x     18x           18x                           18x     72x                          
/**
 * FilterDropdown -- small absolutely-positioned multi-select menu for sidebar
 * filter/group/sort controls.
 *
 * Supports both flat option lists and grouped sections (e.g., filter by Status
 * AND Type in a single dropdown with labeled sections).
 *
 * @module
 */
 
import { useEffect, useRef, type JSX } from "react";
import { X } from "lucide-react";
import { ICON_SM } from "../../utils/iconSize.js";
import styles from "./FilterDropdown.module.scss";
 
/** A single selectable option in the filter menu. */
export interface FilterDropdownOption {
  /** Unique key identifying this option. */
  key: string;
  /** Human-readable label. */
  label: string;
}
 
/** A labeled group of options rendered with a section header. */
export interface FilterDropdownGroup {
  /** Section header label. */
  label: string;
  /** Options within this group. */
  options: FilterDropdownOption[];
}
 
/** Props for the {@link FilterDropdown} component. */
export interface FilterDropdownProps {
  /** Flat list of options (use this OR `groups`, not both). */
  options?: FilterDropdownOption[];
  /** Grouped sections with labeled headers. Takes precedence over `options`. */
  groups?: FilterDropdownGroup[];
  /** Currently selected option keys. */
  selected: ReadonlySet<string>;
  /** Toggle an option on/off. */
  onToggle: (key: string) => void;
  /** Clear all selections. */
  onClear: () => void;
  /** Close the dropdown. */
  onClose: () => void;
  /** Force the Clear button to show even when no options are selected. */
  showClear?: boolean;
  /** Optional data-testid for the menu root. */
  "data-testid"?: string;
}
 
/** Render a single option button. */
function OptionButton({
  opt,
  isSelected,
  onToggle,
  testId,
}: {
  opt: FilterDropdownOption;
  isSelected: boolean;
  onToggle: (key: string) => void;
  testId: string;
}): JSX.Element {
  return (
    <button
      key={opt.key}
      type="button"
      className={`${styles.option} ${isSelected ? styles.optionSelected : ""}`}
      onClick={() => onToggle(opt.key)}
      aria-pressed={isSelected}
      data-testid={`${testId}-option-${opt.key}`}
    >
      <span className={styles.check} aria-hidden="true">
        {isSelected ? "✓" : ""}
      </span>
      <span className={styles.optionLabel}>{opt.label}</span>
    </button>
  );
}
 
/** Absolutely-positioned multi-select filter menu. */
export function FilterDropdown({
  options,
  groups,
  selected,
  onToggle,
  onClear,
  onClose,
  showClear: showClearProp = false,
  "data-testid": testId = "filter-dropdown",
}: FilterDropdownProps): JSX.Element {
  const containerRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    function handleClickOutside(e: MouseEvent): void {
      Iif (
        containerRef.current &&
        e.target instanceof Node &&
        !containerRef.current.contains(e.target)
      ) {
        onClose();
      }
    }
    const id = requestAnimationFrame(() => {
      document.addEventListener("click", handleClickOutside);
    });
    return () => {
      cancelAnimationFrame(id);
      document.removeEventListener("click", handleClickOutside);
    };
  }, [onClose]);
 
  useEffect(() => {
    function handleKeyDown(e: KeyboardEvent): void {
      Iif (e.key === "Escape") {
        onClose();
      }
    }
    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [onClose]);
 
  const resolvedGroups: FilterDropdownGroup[] = groups
    ? groups
    : options
      ? [{ label: "", options }]
      : [];
 
  return (
    <div ref={containerRef} className={styles.dropdown} data-testid={testId}>
      {(selected.size > 0 || showClearProp) && (
        <button
          type="button"
          className={styles.clearButton}
          onClick={onClear}
          data-testid={`${testId}-clear`}
        >
          <X size={ICON_SM} aria-hidden="true" />
          Clear
        </button>
      )}
      {resolvedGroups.map((group) => (
        <div key={group.label || "__flat__"}>
          {group.label && <div className={styles.groupLabel}>{group.label}</div>}
          {group.options.map((opt) => (
            <OptionButton
              key={opt.key}
              opt={opt}
              isSelected={selected.has(opt.key)}
              onToggle={onToggle}
              testId={testId}
            />
          ))}
        </div>
      ))}
    </div>
  );
}