Skip to the content

Capsid menu

app/components/admin/media-palette.tsx

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

Functions

The functions of media-palette.tsx
FunctionLinesComplexityCoveredCRAPMutation
optionId15 to 151No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
paletteKey28 to 4511No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
MediaPalette47 to 23619No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:64>64 to 762No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onInput69 to 731No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:75>75 to 751No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:79>79 to 894No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
copy92 to 1061No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:94>94 to 991No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:98>98 to 981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:100>100 to 1051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:104>104 to 1041No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:108>108 to 1561No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onKey109 to 15311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:136>136 to 1372No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:155>155 to 1551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:206>206 to 2293No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:218>218 to 2181No 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/toast.tsx, app/components/admin/use-media-search.ts, app/lib/clipboard.ts, app/lib/media/byte-size.mjs. Imported by app/components/admin/media-search.tsx.

Source

import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { Panel } from "capsomer/react/panel";

import { toast } from "~/components/admin/toast";
import { useMediaSearch } from "~/components/admin/use-media-search";
import { copyText } from "~/lib/clipboard";
import { byteSize } from "~/lib/media/byte-size.mjs";

// Attaches to the existing search input by id rather than replacing it, so the no-script page is unchanged.
// The input becomes an APG combobox over a listbox, the pattern the editor's link palette uses.

const LIST_ID = "media-palette-list";
// By position, not key: two keys can sanitise to the same id, and activedescendant would then point at the wrong row.
const optionId = (index: number) => `media-palette-opt-${index}`;

type PaletteKey =
  | { do: "focus"; select: boolean }
  | { do: "close" }
  | { do: "move"; by: 1 | -1 }
  | { do: "choose"; open: boolean };

/**
 * The key map. Only Cmd+K is global: a bare "/" was a single-key shortcut on the whole page, which
 * WCAG 2.1.4 forbids without a way to turn it off. The rest applies in the search box alone, and the
 * list keys only while a list is showing.
 */
function paletteKey(
  event: KeyboardEvent,
  inSearchBox: boolean,
  listing: boolean,
): PaletteKey | null {
  if ((event.key === "k" || event.key === "K") && (event.metaKey || event.ctrlKey)) {
    return { do: "focus", select: true };
  }
  if (!inSearchBox) return null;

  if (event.key === "Escape") return { do: "close" };
  if (!listing) return null;

  if (event.key === "ArrowDown") return { do: "move", by: 1 };
  if (event.key === "ArrowUp") return { do: "move", by: -1 };
  if (event.key === "Enter") return { do: "choose", open: event.shiftKey };
  return null;
}

export function MediaPalette({
  inputId = "media-q",
}: {
  inputId?: string;
}) {
  const [query, setQuery] = useState("");
  const [cursor, setCursor] = useState(0);
  const { results, hasMore, searchError } = useMediaSearch(query, setCursor);
  const navigate = useNavigate();
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState("");
  const [copyFailed, setCopyFailed] = useState(false);
  const listing = open && results.length > 0 && !searchError;
  const activeIndex = listing ? Math.min(cursor, results.length - 1) : -1;

  const inputRef = useRef<HTMLInputElement | null>(null);

  useEffect(() => {
    inputRef.current = document.getElementById(inputId) as HTMLInputElement | null;
    const input = inputRef.current;
    if (!input) return;

    const onInput = () => {
      setQuery(input.value);
      setCursor(0);
      setOpen(input.value.trim().length > 0);
    };
    input.addEventListener("input", onInput);
    return () => input.removeEventListener("input", onInput);
  }, [inputId]);

  // The combobox state lives on the page's own input, so it is written there rather than rendered.
  useEffect(() => {
    const input = inputRef.current;
    if (!input) return;
    input.setAttribute("role", "combobox");
    input.setAttribute("aria-autocomplete", "list");
    input.setAttribute("aria-expanded", String(listing));
    if (listing) input.setAttribute("aria-controls", LIST_ID);
    else input.removeAttribute("aria-controls");
    if (activeIndex >= 0) input.setAttribute("aria-activedescendant", optionId(activeIndex));
    else input.removeAttribute("aria-activedescendant");
  }, [listing, activeIndex]);

  // Announced by the page's toast region as well as shown in the count: the count is not a live region.
  const copy = (value: string) => {
    copyText(value)
      .then(() => {
        setCopyFailed(false);
        setCopied(value);
        toast(`Copied ${value}`);
        window.setTimeout(() => setCopied(""), 1600);
      })
      .catch(() => {
        setCopied("");
        setCopyFailed(true);
        toast(`Copy failed. The address is ${value}`);
        window.setTimeout(() => setCopyFailed(false), 2400);
      });
  };

  useEffect(() => {
    const onKey = async (event: KeyboardEvent) => {
      const key = paletteKey(
        event,
        event.target === inputRef.current,
        open && results.length > 0,
      );
      if (!key) return;

      if (key.do === "focus") {
        event.preventDefault();
        inputRef.current?.focus();
        if (key.select) inputRef.current?.select();
        return;
      }
      // APG: Escape closes the list and, pressed again, clears the field. Focus stays in the box.
      if (key.do === "close") {
        event.preventDefault();
        if (open) {
          setOpen(false);
          return;
        }
        if (inputRef.current) inputRef.current.value = "";
        setQuery("");
        return;
      }
      if (key.do === "move") {
        event.preventDefault();
        setCursor((c) =>
          key.by > 0 ? Math.min(results.length - 1, c + 1) : Math.max(0, c - 1),
        );
        return;
      }
      // preventDefault: otherwise the form submits and navigates, throwing away the copy.
      event.preventDefault();
      const hit = results[Math.min(cursor, results.length - 1)];
      if (!hit) return;
      if (key.open) {
        // Router navigation: assigning window.location rebuilds the whole document just to show a panel.
        await navigate(`/admin/media?key=${encodeURIComponent(hit.key)}`, {
          preventScrollReset: true,
        });
      } else {
        copy(hit.url);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open, results, cursor, navigate]);

  const count = `${results.length}${hasMore ? "+" : ""} match${results.length === 1 && !hasMore ? "" : "es"}`;
  // In the document before the list opens, so the first count is announced as well as later ones.
  const announcement = !open || !query.trim()
    ? ""
    : searchError
      ? "Search failed"
      : results.length
        ? count
        : "No matches";
  const status = (
    <p className="cap-sr-only" role="status">
      {announcement}
    </p>
  );

  const showing = open && (results.length > 0 || query.trim().length > 0);

  // One return, so the region keeps its place whether or not the list shows and is never remounted.
  return (
    <>
      {status}
      {showing ? (
        <Panel
          title="Matching files"
          src={
            <span aria-hidden="true">
              {copied ? "copied" : copyFailed ? "copy failed" : results.length ? count : ""}
            </span>
          }
          flush
          footer={
            /* The count says "6+" when the cap was hit, so six never reads as the whole answer. */
            <p className="cap-muted">
              <kbd>up down</kbd> move · <kbd>enter</kbd> copies the address · <kbd>shift enter</kbd>{" "}
              opens details
            </p>
          }
        >
          {searchError ? (
            <p className="cap-panel-pad">
              Search failed: {searchError}. Press Enter to search the full page.
            </p>
          ) : results.length === 0 ? (
            <p className="cap-panel-pad">
              Nothing matches &ldquo;{query.trim()}&rdquo;. Searched paths, names, alt text and tags.
            </p>
          ) : (
            <ul className="cap-listbox" id={LIST_ID} role="listbox" aria-label="Matching files">
              {results.map((r, i) => (
                <li key={r.key} role="presentation">
                  {/* A link, not a button: clicking a row opens it, while Enter copies. Out of the tab
                      order: the box keeps focus and points at the option. */}
                  <a
                    href={`/admin/media?key=${encodeURIComponent(r.key)}`}
                    id={optionId(i)}
                    role="option"
                    aria-selected={i === Math.min(cursor, results.length - 1)}
                    tabIndex={-1}
                    className="cap-option"
                    data-active={i === Math.min(cursor, results.length - 1) ? "" : undefined}
                    onMouseEnter={() => setCursor(i)}
                  >
                    <span className="cap-option-label">
                      {r.name} <span className="cap-muted">{r.dir}</span>
                    </span>
                    <span className="cap-option-hint">
                      {r.viewable ? "" : `${r.key.split(".").pop()?.toUpperCase()} · `}
                      {byteSize(r.size)}
                    </span>
                  </a>
                </li>
              ))}
            </ul>
          )}
        </Panel>
      ) : null}
    </>
  );
}