Skip to the content

Capsid menu

app/components/admin/copy-button.tsx

133 lines and 14 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of copy-button.tsx
FunctionLinesComplexityCoveredCRAPMutation
CopyButton8 to 684No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:32>32 to 511No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:35>35 to 421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:39>39 to 411No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:43>43 to 501No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:47>47 to 491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
CopyTextButton76 to 1386No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:92>92 to 981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:94>94 to 971No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
settle100 to 1083No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:107>107 to 1071No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:117>117 to 1251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:122>122 to 1221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:123>123 to 1231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/admin/live-notice.tsx, app/components/admin/toast.tsx, app/lib/clipboard.ts. Imported by app/components/admin/media-inspector-sections.tsx, app/components/admin/media-inspector.tsx, app/components/admin/media-tile.tsx, app/components/admin/preview-links.tsx, app/components/admin/settings-drawer.tsx.

Source

import { useEffect, useRef, useState } from "react";

import { LiveNotice } from "~/components/admin/live-notice";
import { toast } from "~/components/admin/toast";
import { copyText } from "~/lib/clipboard";

// An icon, not the word: the word cost a third of the row and pushed the filename into truncation.
export function CopyButton({
  value,
  label,
  name,
  showLabel,
  tabIndex,
}: {
  value: string;
  label: string;
  name?: string;
  showLabel?: boolean;
  /** -1 on a grid tile that is not the grid's tab stop. */
  tabIndex?: number;
}) {
  return (
    <button
      type="button"
      className="cap-btn"
      data-variant="quiet"
      data-size="sm"
      data-icon-only={showLabel ? undefined : ""}
      data-cap-copy=""
      title={value}
      tabIndex={tabIndex}
      onClick={(event) => {
        const button = event.currentTarget;
        copyText(value)
          .then(() => {
            button.dataset.copied = "yes";
            // Announced too: the data attribute drives a ::after that assistive technology cannot see.
            toast(`Copied ${value}`);
            window.setTimeout(() => {
              delete button.dataset.copied;
            }, 1500);
          })
          .catch(() => {
            button.dataset.copied = "no";
            toast(`Copy failed. The address is ${value}`);
            // Cleared like success, or "failed" stays on the button after a later copy works.
            window.setTimeout(() => {
              delete button.dataset.copied;
            }, 2400);
          });
      }}
    >
      <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
        <path
          d="M5.5 5.5h7v8h-7zM3.5 10.5h-1v-8h7v1"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      {showLabel ? <span>{label}</span> : null}
      {/* Always named: the visible label is absent on a tile and only a fragment elsewhere. */}
      <span className="cap-sr-only">{name ?? `Copy the address for ${label}`}</span>
    </button>
  );
}

/**
 * A text button whose own label reports the outcome, for rows where a toast has nowhere to show.
 * The label is only what a sighted author sees; a screen reader hears the outcome from LiveNotice's
 * regions, which are in the document before any copy, and both reset so a stale "Copied" never
 * describes a later press.
 */
export function CopyTextButton({
  value,
  label,
  name,
  subject,
}: {
  value: string;
  label: string;
  /** Read after the label and never shown, so a row of identical "Copy" buttons each says which. */
  name?: string;
  /** What was copied, for the announcement: "the link starting abc123". */
  subject: string;
}) {
  const [copied, setCopied] = useState<"" | "copied" | "failed">("");
  const timer = useRef(0);
  const mounted = useRef(false);
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      window.clearTimeout(timer.current);
    };
  }, []);

  const settle = (outcome: "copied" | "failed") => {
    // A copy that settles after the row is gone (a revoke, a closed drawer) must not start a timer
    // the unmount cleanup already ran past.
    if (!mounted.current) return;
    setCopied(outcome);
    window.clearTimeout(timer.current);
    // A failure stays longer: it asks the author to do something.
    timer.current = window.setTimeout(() => setCopied(""), outcome === "copied" ? 2000 : 5000);
  };

  return (
    <>
      <button
        type="button"
        className="cap-btn"
        data-size="sm"
        data-variant="quiet"
        onClick={() => {
          // Emptied first, so a second copy of the same thing is announced again.
          window.clearTimeout(timer.current);
          setCopied("");
          copyText(value).then(
            () => settle("copied"),
            () => settle("failed"),
          );
        }}
      >
        {copied === "copied" ? "Copied" : copied === "failed" ? "Copy failed" : label}
        {name ? <span className="cap-sr-only"> {name}</span> : null}
      </button>
      <div className="cap-sr-only">
        <LiveNotice
          status={copied === "copied" ? `Copied ${subject}.` : null}
          alert={copied === "failed" ? `Could not copy ${subject}. The browser blocked the clipboard.` : null}
        />
      </div>
    </>
  );
}