Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8% fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
61 lines
2.8 KiB
TypeScript
61 lines
2.8 KiB
TypeScript
import clsx from "clsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Layers } from "lucide-react";
|
|
import { formatNumber } from "../../lib/format";
|
|
|
|
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
|
|
// the pill shows "1M+" and the tooltip carries the precise number.
|
|
const OVERFLOW_AT = 1_000_000;
|
|
|
|
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
|
|
// and tonal like a `Badge` (the same opaque `bg-tint`: the toolbar is glass over scrolling thumbnails)
|
|
// — no edge, because it does nothing (edged = pressable, flat = just
|
|
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
|
|
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
|
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
|
// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
|
|
// so any surface can reuse it for whatever it counts. `stale`: the count is the previous filter's,
|
|
// kept while the new one loads (keepPreviousData) — dimmed, and "updating" for a screen reader (which
|
|
// does not voice aria-busy on plain text), so a broad search's slow count ("a": ~3 s) never reads as
|
|
// the new result. `failed`: the count could not be read — a dash that says so, not the icon-only box
|
|
// of a first load.
|
|
export default function ItemCount({
|
|
count,
|
|
stale = false,
|
|
failed = false,
|
|
}: {
|
|
count?: number;
|
|
stale?: boolean;
|
|
failed?: boolean;
|
|
}) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
// `count` undefined and not `failed`: a first, un-cached load — keep the fixed box (nothing shifts),
|
|
// just show the icon until the number arrives.
|
|
const label = failed
|
|
? "—"
|
|
: count == null
|
|
? ""
|
|
: t("common.itemCount", {
|
|
count,
|
|
formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count, lang),
|
|
});
|
|
return (
|
|
<span
|
|
data-testid="item-count"
|
|
title={
|
|
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
|
|
}
|
|
aria-busy={stale || undefined}
|
|
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-tint px-3 text-sm whitespace-nowrap text-muted"
|
|
>
|
|
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
|
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
|
{label}
|
|
</span>
|
|
{failed && <span className="sr-only">{t("common.countFailed")}</span>}
|
|
{stale && !failed && <span className="sr-only">{t("common.countUpdating")}</span>}
|
|
</span>
|
|
);
|
|
}
|