All files / diff-viewer/src/components Gap.tsx

100% Statements 9/9
100% Branches 2/2
100% Functions 6/6
100% Lines 8/8

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                                                                                                                                                  87x 87x 156x   87x                 1x                   6x               1x               10x                        
/**
 * The collapsed-region expander (SPEC §3.2, §8).
 *
 * Everything it needs comes from the model: `hunk.hiddenBefore` /
 * `hiddenAfter` carry the real `DiffLine` objects (SPEC resolved decision
 * #11), so expansion works even when the consumer passed a pre-computed
 * `diff` and never handed us the original texts.
 *
 * Accessibility: the controls are real `<button>`s in natural focus order,
 * and their **accessible names are descriptive sentences** ("Expand 20 lines
 * above") even though the visible label is a compact glyph ("↑ 20"). A
 * screen reader reading the visible text would announce "up arrow twenty,
 * button"; under a label override that drops the glyph it would announce two
 * identically-named "20, button" controls. The two are therefore decoupled
 * (SPEC §8).
 */
 
import type { ReactNode } from "react";
 
import { cx, type RowClassNames, type RowStyles } from "./Row";
 
/** Which end of a gap a click revealed — drives what gets sliced off. */
export type ExpandDirection = "up" | "down" | "all";
 
/** Strings shown in a gap row; all overridable for i18n. */
export interface GapLabels {
  /** Visible text; `{count}` is substituted. */
  expandUp: string;
  /** Visible text; `{count}` is substituted. */
  expandDown: string;
  /** Visible text. */
  expandAll: string;
  /** Accessible name, given the number of lines the click will reveal. */
  ariaExpandUp: (count: number) => string;
  /** Accessible name, given the number of lines the click will reveal. */
  ariaExpandDown: (count: number) => string;
  /** Accessible name. */
  ariaExpandAll: string;
  /** The "⋯ N unchanged lines" summary. */
  hiddenLines: (count: number) => string;
}
 
/**
 * A single "⋯ N unchanged lines" row with its expand controls.
 *
 * **Precondition:** `hidden ≥ 1`. A gap with nothing left in it is not a
 * gap, and the caller already has to know the remaining count in order to
 * decide what to render — so the check lives there rather than being
 * duplicated (and left unreachable) here.
 */
export function Gap({
  hidden,
  ariaRowIndex,
  columnCount,
  styles,
  classNames,
  labels,
  expandStep,
  onExpand,
}: {
  /** How many lines are currently hidden here. */
  hidden: number;
  ariaRowIndex: number;
  /** Table columns to span. */
  columnCount: number;
  styles: RowStyles;
  classNames: RowClassNames & { gap?: string };
  labels: GapLabels;
  expandStep: number;
  onExpand: (direction: ExpandDirection) => void;
}): ReactNode {
  // One step would reveal everything — offer a single, honest control
  // instead of three that all do the same thing.
  const oneStepIsAll = hidden <= expandStep;
  const step = Math.min(expandStep, hidden);
  const withCount = (template: string) => template.replace("{count}", String(step));
 
  return (
    <tr className={cx(styles.row, classNames.row)} aria-rowindex={ariaRowIndex}>
      <td className={cx(styles.gapCell, classNames.gap)} colSpan={columnCount}>
        <div className={styles.gapInner}>
          <span className={styles.gapLabel}>{labels.hiddenLines(hidden)}</span>
          {oneStepIsAll ? (
            <button
              type="button"
              className={styles.gapButton}
              onClick={() => onExpand("all")}
              aria-label={labels.ariaExpandAll}
            >
              {labels.expandAll}
            </button>
          ) : (
            <>
              <button
                type="button"
                className={styles.gapButton}
                onClick={() => onExpand("up")}
                aria-label={labels.ariaExpandUp(step)}
              >
                {withCount(labels.expandUp)}
              </button>
              <button
                type="button"
                className={styles.gapButton}
                onClick={() => onExpand("down")}
                aria-label={labels.ariaExpandDown(step)}
              >
                {withCount(labels.expandDown)}
              </button>
              <button
                type="button"
                className={styles.gapButton}
                onClick={() => onExpand("all")}
                aria-label={labels.ariaExpandAll}
              >
                {labels.expandAll}
              </button>
            </>
          )}
        </div>
      </td>
    </tr>
  );
}