All files / src/features/sorting DecisionControls.tsx

100% Statements 11/11
100% Branches 10/10
100% Functions 6/6
100% Lines 9/9

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 668x                                                 102x           40x 40x       42x     102x             402x 402x           42x                        
"use client";
 
import { useState } from "react";
import { Button } from "@/components/ui";
 
// Generic over the decision shape D so a caller's narrowed decision type reaches onDecision.
interface DecisionControlsProps<D extends Record<string, unknown> = Record<string, unknown>> {
  /** Records the answer. Return false (or a promise of it) when it did not hold, so "saved" is never said too early. */
  onDecision: (decision: D) => void | boolean | Promise<void | boolean>;
  decisions: Array<{ label: string; decision: D }>;
  /**
   * A decision label already standing for this item (undo-as-revisit, F19): that tier renders
   * as currently selected (`aria-pressed` + a visible ring); the caller renders the
   * "You placed this with … — change it?" line.
   */
  priorAnswer?: string;
}
 
export function DecisionControls<D extends Record<string, unknown> = Record<string, unknown>>({
  onDecision,
  decisions,
  priorAnswer,
}: DecisionControlsProps<D>) {
  // Mounted (empty) on first render so the region exists BEFORE its text ever
  // changes — a live region that appears with its content is not announced.
  const [announcement, setAnnouncement] = useState("");
 
  // The item advances silently under a screen-reader user otherwise (SC 4.1.3).
  // Triage runs Love, Love, Love — identical text does NOT re-fire a live region,
  // so nudge the string (a trailing space is not spoken).
  function announce(label: string) {
    const line = `${label} — saved.`;
    setAnnouncement((prev) => (prev === line ? `${line} ` : line));
  }
 
  async function submitDecision(label: string, decision: D) {
    if ((await onDecision(decision)) !== false) announce(label);
  }
 
  return (
    <div className="space-y-3">
      <p role="status" aria-live="polite" className="sr-only">
        {announcement}
      </p>
      <div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
        {decisions.map((item) => {
          const selected = priorAnswer !== undefined && item.label === priorAnswer;
          return (
            <Button
              key={item.label}
              type="button"
              variant="primary"
              size="lg"
              onClick={() => submitDecision(item.label, item.decision)}
              aria-pressed={selected || undefined}
              className={`flex-1 min-w-24${selected ? " ring-2 ring-offset-2 ring-offset-ground ring-primary" : ""}`}
            >
              {item.label}
            </Button>
          );
        })}
      </div>
    </div>
  );
}