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

0% Statements 0/106
100% Branches 1/1
100% Functions 1/1
0% Lines 0/106

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
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170

     /**
     * Wrapper component for events in the EventStream that provides:
     * - Hover action row (Copy + Select) in normal mode
     * - Checkbox + full-row click target in selection mode
     *
     * Presentational component decoupled from useGrackle(). Performs clipboard
     * side effects via navigator.clipboard when the user clicks Copy.
     */

    import {
      useState,
      useCallback,
      useEffect,
      useRef,
      type JSX,
      type ReactNode,
      type MouseEvent,
    } from "react";
    import { Clipboard, Check, CheckSquare } from "lucide-react";
    import { ICON_SM } from "../../utils/iconSize.js";
    import styles from "./EventHoverRow.module.scss";

    /** Props for the EventHoverRow component. */
    export interface EventHoverRowProps {
      /** Text to copy when the hover Copy button is clicked. */
      copyText: string;
      /** Whether this event has copyable content (shows hover actions). */
      isContentBearing: boolean;
      /** Whether multi-select mode is active. */
      isSelecting: boolean;
      /** Whether this event is currently selected in multi-select mode. */
      isSelected: boolean;
      /** Accessible label for the selection checkbox (e.g. "Select message from assistant at 2:34 PM"). */
      checkboxLabel?: string;
      /** Called when the Select button in the hover row is clicked (enters selection mode). */
      onSelect: () => void;
      /** Called when the row is clicked in selection mode. Receives the shiftKey state. */
      onToggle: (shiftKey: boolean) => void;
      /** Called after a successful single-event copy from the hover row. */
      onCopied?: () => void;
      /** The event content to wrap. */
      children: ReactNode;
    }

    /**
     * Wraps an event in the EventStream with hover actions (normal mode) or
     * selection affordances (multi-select mode).
     */
    export function EventHoverRow({
      copyText,
      isContentBearing,
      isSelecting,
      isSelected,
      checkboxLabel,
      onSelect,
      onToggle,
      onCopied,
      children,
    }: EventHoverRowProps): JSX.Element {
      const [copied, setCopied] = useState(false);
      const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

      // Clear the "copied" feedback timer on unmount
      useEffect(() => {
        return () => {
          if (copiedTimerRef.current !== undefined) {
            clearTimeout(copiedTimerRef.current);
          }
        };
      }, []);

      const handleCopy = useCallback(async () => {
        try {
          await navigator.clipboard.writeText(copyText);
          setCopied(true);
          onCopied?.();
          if (copiedTimerRef.current !== undefined) {
            clearTimeout(copiedTimerRef.current);
          }
          copiedTimerRef.current = setTimeout(() => {
            setCopied(false);
          }, 2000);
        } catch {
          // Clipboard write failed silently
        }
      }, [copyText, onCopied]);

      const handleRowClick = useCallback(
        (e: MouseEvent<HTMLDivElement>) => {
          if (!isSelecting) {
            return;
          }
          // Don't intercept if the user clicked inside an interactive element
          const target = e.target as HTMLElement;
          if (target.closest("a, button, input, textarea, select, [role=button]")) {
            return;
          }
          e.preventDefault();
          onToggle(e.shiftKey);
        },
        [isSelecting, onToggle],
      );

      // Non-content-bearing events: render plain, no interactivity
      if (!isContentBearing) {
        return <div className={styles.row}>{children}</div>;
      }

      // Selection mode: checkbox + clickable row
      if (isSelecting) {
        return (
          <div
            className={`${styles.row} ${styles.selectingRow} ${isSelected ? styles.selected : ""}`}
            onClick={handleRowClick}
            data-testid="event-selectable-row"
          >
            <div className={styles.checkboxArea}>
              <input
                type="checkbox"
                checked={isSelected}
                onChange={(e) => {
                  onToggle((e.nativeEvent as globalThis.MouseEvent).shiftKey);
                }}
                onClick={(e) => {
                  e.stopPropagation();
                }}
                className={styles.checkbox}
                aria-label={checkboxLabel ?? "Select this event"}
                data-testid="event-select-checkbox"
              />
            </div>
            <div className={styles.contentArea}>{children}</div>
          </div>
        );
      }

      // Normal mode: hover action row
      return (
        <div className={styles.row} data-testid="event-hover-row">
          <div className={styles.hoverActions} data-testid="event-hover-actions">
            <button
              type="button"
              className={styles.hoverButton}
              onClick={() => {
                handleCopy().catch(() => {});
              }}
              aria-label="Copy event content"
              data-testid="event-hover-copy"
            >
              {copied ? (
                <Check size={ICON_SM} aria-hidden="true" />
              ) : (
                <Clipboard size={ICON_SM} aria-hidden="true" />
              )}
            </button>
            <button
              type="button"
              className={styles.hoverButton}
              onClick={onSelect}
              aria-label="Select this event"
              data-testid="event-hover-select"
            >
              <CheckSquare size={ICON_SM} aria-hidden="true" />
            </button>
          </div>
          {children}
        </div>
      );
    }