}
{plexResult && (
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index e2b9d838..dd3c18eb 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -50,6 +50,7 @@ import FilterSelect from "./ui/FilterSelect";
import ItemCount from "./ui/ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -516,7 +517,7 @@ export default function Feed({
/>
{ytQuery.hasNextPage && (
)}
>
diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx
index 592edd1a..32fe0501 100644
--- a/frontend/src/components/PersonalViewsModal.tsx
+++ b/frontend/src/components/PersonalViewsModal.tsx
@@ -24,6 +24,7 @@ import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
@@ -227,7 +228,7 @@ export default function PersonalViewsModal() {
onBlur={() => void onRename()}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
- void onRename()}
disabled={!nameChanged}
title={t("views.rename")}
@@ -235,7 +236,7 @@ export default function PersonalViewsModal() {
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-30"
>
-
+
@@ -283,7 +284,7 @@ export default function PersonalViewsModal() {
>
{t("views.apply")}
- {
const saved = await saveCurrent(selected.id);
// Re-seed the draft to what we just stored (the live filters) so the chip row
@@ -298,7 +299,7 @@ export default function PersonalViewsModal() {
>
{t("views.saveCurrent")}
-
+
void toggleDefault(selected)}
title={t("views.default")}
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 9535ed30..72d9e8c0 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -61,6 +61,7 @@ import {
useYtVolume,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -539,7 +540,7 @@ export default function PlayerModal({
where there's no room beside the card. */}
))}
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
index 5ec60c1b..ecf0ca8c 100644
--- a/frontend/src/components/PlexCollectionView.tsx
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -13,7 +13,7 @@ import { usePref } from "../lib/usePref";
import SortControl from "./ui/SortControl";
import { type SortState } from "../lib/columnSort";
import { useConfirm } from "./confirmContext";
-import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
+import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi";
import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu";
@@ -21,6 +21,7 @@ import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState";
import NavGrid from "./ui/NavGrid";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
@@ -411,7 +412,7 @@ export default function PlexCollectionView({
menuProps={pinMenu.triggerProps}
/>
{canEdit && (
- doRemove(c)}
disabled={busy}
title={t("plex.collection.removeItem")}
@@ -419,7 +420,7 @@ export default function PlexCollectionView({
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
>
-
+
)}
))}
@@ -489,14 +490,14 @@ function AddItemPanel({
renderAction={(c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
- add(c)}
disabled={inColl}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
{inColl ? : }
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
-
+
);
}}
/>
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index e9141100..0614e766 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -6,7 +6,7 @@ import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } f
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
-import { DetailCustomizeMenu, Filterable, PrefToggle } from "../lib/plexDetailUi";
+import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { usePref } from "../lib/usePref";
import i18n from "../i18n";
@@ -15,6 +15,7 @@ import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { navItemAttrs } from "../lib/listNav";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -736,7 +737,7 @@ export default function PlexInfo({
{col.items.map((m, i) => (
- onPlayItem?.(m.id)}
@@ -778,7 +779,7 @@ export default function PlexInfo({
{m.title}
{m.year ?
{m.year}
: null}
-
+
))}
diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx
index bdd63f76..e8fee825 100644
--- a/frontend/src/components/PlexPlaylistAdd.tsx
+++ b/frontend/src/components/PlexPlaylistAdd.tsx
@@ -8,6 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
// set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist.
@@ -145,7 +146,7 @@ export default function PlexPlaylistAdd({
const all = n >= size;
const partial = n > 0 && n < size;
return (
- toggle(p)}
disabled={busy}
@@ -174,7 +175,7 @@ export default function PlexPlaylistAdd({
{t("plex.playlistAdd.count", { count: p.item_count })}
)}
-
+
);
})
)}
diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx
index a8a4bb4c..9c9f95ce 100644
--- a/frontend/src/components/PlexPlaylistView.tsx
+++ b/frontend/src/components/PlexPlaylistView.tsx
@@ -39,6 +39,7 @@ import { useConfirm } from "./confirmContext";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -180,14 +181,14 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
{ep.title}
- ctx.onRemove([ep.id])}
disabled={ctx.busy}
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
>
-
+
);
}
@@ -223,14 +224,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
- ctx.onRemove([item.id])}
disabled={ctx.busy}
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
aria-label="remove"
>
-
+
);
}
@@ -285,14 +286,14 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{t("plex.playlist.episodes", { count: block.items.length })}
- ctx.onRemove(block.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeShow")}
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
>
-
+
{open && (
@@ -323,14 +324,14 @@ function ShowGroup({ block, ctx }: { block: Extract; ct
{seasonLabel(s)} · {s.items.length}
- ctx.onRemove(s.items.map((i) => i.id))}
disabled={ctx.busy}
title={t("plex.playlist.removeSeason")}
className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30"
>
-
+
{sOpen && (
{t("scheduler.minutes")}
-
-
+
setEditing(false)}
className="text-muted hover:text-fg"
@@ -232,14 +233,14 @@ function JobRow({
)}
-
-
+
);
@@ -464,17 +465,17 @@ export default function Scheduler() {
- runAllMut.mutate()}
disabled={runAllMut.isPending || data.paused}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{t("scheduler.runAll")}
-
+
- {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
@@ -489,17 +490,17 @@ export default function Scheduler() {
>
{t("scheduler.purgeDiscovery")}
-
+
- pauseResume.mutate(data.paused)}
disabled={pauseResume.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{data.paused ? : }
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
-
+
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx
index 11dc408c..423ee1a4 100644
--- a/frontend/src/components/SearchBar.tsx
+++ b/frontend/src/components/SearchBar.tsx
@@ -8,6 +8,7 @@ import {
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../lib/keymap";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// The floating search pill in the top header. One component for every module that searches
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
@@ -100,7 +101,7 @@ export default function SearchBar({
)}
{onGo && (
-
{goLabel ?? t("header.go")}
-
+
)}
{below}
diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx
index 7632e1fb..e7377292 100644
--- a/frontend/src/components/SetupWizard.tsx
+++ b/frontend/src/components/SetupWizard.tsx
@@ -6,6 +6,7 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button";
import { inputCls } from "./ui/form";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time
// setup token comes from the URL the app printed to the container logs (?token=…); every step
@@ -279,15 +280,15 @@ export default function SetupWizard() {
)}
)}
@@ -229,15 +230,15 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
)}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
- savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()}
className="px-2 py-1 rounded-md text-xs font-semibold btn-primary disabled:opacity-40 transition"
>
{t("downloads.share.savePassword")}
-
+
{link.has_password && (
- {
if (
await confirm({
@@ -253,7 +254,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
className="px-2 py-1 rounded-md text-xs text-muted hover:text-danger transition"
>
{t("downloads.share.removePassword")}
-
+
)}
)}
diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx
index 2a23cbb8..e34bd929 100644
--- a/frontend/src/components/Stats.tsx
+++ b/frontend/src/components/Stats.tsx
@@ -16,6 +16,7 @@ import Tabs from "./ui/Tabs";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
import { notifyActionError } from "../lib/actionErrors";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
@@ -175,24 +176,24 @@ function Overview({ me }: { me: Me }) {
{!me.is_demo && (
- syncSubs.mutate()}
disabled={syncSubs.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
{t("stats.sync.syncSubscriptions")}
-
+
- deepAll.mutate()}
disabled={deepAll.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition mt-2"
>
{t("stats.sync.backfillEverything")}
-
+
)}
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx
index 8e21437f..e1062f62 100644
--- a/frontend/src/components/SyncStatus.tsx
+++ b/frontend/src/components/SyncStatus.tsx
@@ -8,6 +8,7 @@ import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Per-user status (not the global catalog): shows the number of videos available to *this*
// user, how many of *their* channels are still being fetched, and how many lack full
@@ -97,14 +98,14 @@ export default function SyncStatus({
);
const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && (
- toggle.mutate()}
disabled={toggle.isPending}
title={data.paused ? t("header.sync.resume") : t("header.sync.pause")}
className="p-1 rounded-md hover:bg-card hover:text-fg transition"
>
{data.paused ? : }
-
+
);
// Chip: the headline count rides in the header; the state, the history link and pause live in a
diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx
index c46951aa..c6670db4 100644
--- a/frontend/src/components/UploadDialog.tsx
+++ b/frontend/src/components/UploadDialog.tsx
@@ -12,6 +12,7 @@ import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav";
import { formatBytes } from "../lib/format";
import { inputCls } from "./ui/form";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
@@ -75,7 +76,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
{t("downloads.upload.hint")}
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
- {
@@ -117,7 +118,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
>
)}
-
+
seconds.
function tc(s: number): string {
@@ -617,7 +618,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{t("editor.output.label")}:
{(["separate", "join"] as const).map((m) => (
- setOutput(m)}
disabled={m === "join" && kept.length < 2}
@@ -629,7 +630,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
)}
>
{t(`editor.output.${m}`)}
-
+
))}
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx
index ce3c34c5..fb273019 100644
--- a/frontend/src/components/Welcome.tsx
+++ b/frontend/src/components/Welcome.tsx
@@ -35,6 +35,7 @@ import { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry";
import { MODE_URL, type Mode } from "./welcomeMode";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const PASSWORD_MIN = 10;
@@ -682,13 +683,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
{error}
)}
-
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
-
+
{/* A confirmation mail can be slow, filtered, or — before E9.5 S6 — never sent at all for a
@@ -772,13 +773,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
placeholder={t("welcome.auth.demoEmail")}
className={inputCls}
/>
-
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
-
+
{demoError &&
{demoError}
}
diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx
index e5618d23..13c0d799 100644
--- a/frontend/src/components/channelCells.tsx
+++ b/frontend/src/components/channelCells.tsx
@@ -4,6 +4,7 @@ import type { ManagedChannel } from "../lib/api";
import { formatNumber } from "../lib/format";
import Tooltip from "./ui/Tooltip";
import { isFullySynced } from "./channelSync";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// The Channels-manager cells that are components in their own right (they read i18n), split out of
// channelColumns so that file holds only the column factory and its predicates (Fast Refresh).
@@ -66,7 +67,7 @@ export function ActionsCell({
return (
diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx
similarity index 97%
rename from frontend/src/lib/plexDetailUi.tsx
rename to frontend/src/components/plexDetailUi.tsx
index 367ae626..f9edb035 100644
--- a/frontend/src/lib/plexDetailUi.tsx
+++ b/frontend/src/components/plexDetailUi.tsx
@@ -8,7 +8,8 @@ import {
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
-import { useDismiss } from "./useDismiss";
+import { useDismiss } from "../lib/useDismiss";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
@@ -181,7 +182,7 @@ export function ActionBtn({
disabled?: boolean;
}) {
return (
- {label}
{sub && {sub}}
-
+
);
}
@@ -223,7 +224,7 @@ export function DetailHero({
return (
{customizeMenu}
-
)}
-
+
{children}
);
diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx
index e764c52e..c1ff744b 100644
--- a/frontend/src/components/plexPlayerControls.tsx
+++ b/frontend/src/components/plexPlayerControls.tsx
@@ -1,5 +1,6 @@
import { type ReactNode } from "react";
import { RotateCcw } from "lucide-react";
+import { FocusSafeButton } from "./ui/FocusSafeButton";
// ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native
// muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double
@@ -139,14 +140,14 @@ export function Ctrl({
align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2";
return (
-
{children}
-
+
diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx
index f18792b6..c3ccd0dd 100644
--- a/frontend/src/components/plexViews.tsx
+++ b/frontend/src/components/plexViews.tsx
@@ -16,7 +16,7 @@ import {
DetailHero,
Filterable,
WatchedToggle,
-} from "../lib/plexDetailUi";
+} from "./plexDetailUi";
import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu";
@@ -31,7 +31,7 @@ import NavGrid from "./ui/NavGrid";
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
// which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared
-// detail-page UI from lib/plexDetailUi.
+// detail-page UI from plexDetailUi.
// Lazy: the rich movie info panel loads only when an info page is opened.
const PlexInfo = lazy(() => import("./PlexInfo"));
diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx
index e8e93683..b2e06dd1 100644
--- a/frontend/src/components/ui/Button.dom.test.tsx
+++ b/frontend/src/components/ui/Button.dom.test.tsx
@@ -5,7 +5,7 @@
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
// stops an action button from submitting a surrounding form.
import { afterEach, describe, expect, it, vi } from "vitest";
-import { render, screen, cleanup, fireEvent } from "@testing-library/react";
+import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
afterEach(cleanup);
@@ -153,6 +153,54 @@ describe("Button primitive (S5)", () => {
// so weakening the union turns the whole typecheck lane red.
//
// Rendered, not just declared, so eslint sees them used and the file stays one test suite.
+describe("Button · disabled while it holds the focus", () => {
+ // A focused button that turns natively disabled loses the focus to in Chrome (measured, E11
+ // S2) — and ~60 call sites disable the very button that was just pressed while its mutation runs.
+ // jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is
+ // unavailable by aria-disabled + a click guard, never by the native attribute.
+ it("keeps the native attribute off while focused, and blocks activation", () => {
+ const onClick = vi.fn();
+ const { rerender } = render(Save);
+ const btn = screen.getByRole("button", { name: "Save" });
+ act(() => btn.focus());
+ rerender(
+
+ Save
+ ,
+ );
+ expect(btn.hasAttribute("disabled")).toBe(false);
+ expect(btn.getAttribute("aria-disabled")).toBe("true");
+ fireEvent.click(btn);
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it("also holds when the window has no focus and no focus event ever fired", () => {
+ // Chrome, with another app in front: `.focus()` moves activeElement and fires nothing. The
+ // button asks the document at render time, so it does not rely on the event (measured).
+ const { rerender } = render( {}}>Next);
+ const btn = screen.getByRole("button", { name: "Next" });
+ const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn);
+ rerender(
+ {}} disabled>
+ Next
+ ,
+ );
+ expect(btn.hasAttribute("disabled")).toBe(false);
+ expect(btn.getAttribute("aria-disabled")).toBe("true");
+ active.mockRestore();
+ });
+
+ it("is natively disabled again once the focus is elsewhere", () => {
+ render(
+ {}} disabled>
+ Save
+ ,
+ );
+ const btn = screen.getByRole("button", { name: "Save" });
+ expect(btn.hasAttribute("disabled")).toBe(true);
+ });
+});
+
describe("Button accessible-name contract (type level)", () => {
it("rejects a text-less button with no name, and accepts every named spelling", () => {
render(
diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx
index dd759d0b..6dd5b9d4 100644
--- a/frontend/src/components/ui/Button.tsx
+++ b/frontend/src/components/ui/Button.tsx
@@ -2,6 +2,7 @@ import { forwardRef } from "react";
import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react";
import clsx from "clsx";
import { Loader2 } from "lucide-react";
+import { FocusSafeButton } from "./FocusSafeButton";
// The ONE action-button primitive. Before this there were 385 raw s styling themselves with
// ad-hoc utility strings over the CSS paint-recipes (.btn-primary/.btn-danger, index.css) + the btnCls
@@ -142,10 +143,14 @@ const ButtonImpl = forwardRef(function Button
disabled,
type,
onClick,
+ onFocus,
+ onBlur,
...rest
},
ref,
) {
+ // Unavailable while busy (`loading`) or disabled — without dropping a focused button's focus:
+ // see FocusSafeButton, which owns that rule for every button in the app.
// A link must not inherit the action-button padding/radius, so its size default differs. Resolved
// here rather than by asking every call site to pass `size="none"` alongside `variant="link"` —
// a pairing everyone would eventually forget, which is how a "link" grows a button's box.
@@ -157,26 +162,14 @@ const ButtonImpl = forwardRef(function Button
const touchTarget = variant !== "link";
const leading = loading ? : icon;
return (
- ~60 ms after the reroll click on Home and nothing
- // handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
- // out of the page on every press. `aria-disabled` keeps the button focusable and announced as
- // unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
- // says, and its preventDefault also cancels a form's implicit submission (which is a click on
- // the default button).
disabled={disabled}
- aria-disabled={loading || undefined}
- aria-busy={loading || undefined}
- onClick={(e) => {
- if (loading) {
- e.preventDefault();
- return;
- }
- onClick?.(e);
- }}
+ busy={loading}
+ onClick={onClick}
+ onFocus={onFocus}
+ onBlur={onBlur}
aria-label={label}
title={label}
className={clsx(
@@ -191,7 +184,7 @@ const ButtonImpl = forwardRef(function Button
>
{leading}
{!iconOnly && children}
-
+
);
});
diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx
new file mode 100644
index 00000000..5e83d44a
--- /dev/null
+++ b/frontend/src/components/ui/FocusSafeButton.tsx
@@ -0,0 +1,66 @@
+import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react";
+
+// A `` whose `disabled` never throws the focus out of the page.
+//
+// A focused button that turns natively disabled loses the focus — Chrome moves it to
+// (measured, E11 S2) — and the app disables the very button that was just pressed on ~60 sites (a
+// mutation pending, a form gone clean after its save), so a keyboard user was dropped out of the page
+// on every submit. While THIS button holds the focus, `disabled` (and `busy`) is expressed as
+// `aria-disabled` plus a click guard instead: still focusable, announced as unavailable, and not
+// activatable — its preventDefault also cancels a form's implicit submission, which is a click on the
+// default button. The native attribute applies again once the focus is elsewhere (at the blur; in
+// a window without focus, which fires no blur, at the next render).
+//
+// Unstyled on purpose: `ui/Button` is built on it, and a hand-rolled `` swaps its tag for
+// this one keeping its classes. The dimmed look of the soft state is one rule in index.css
+// (`[data-focus-safe][aria-disabled="true"]`), since a caller's `disabled:*` utilities match only the
+// native attribute.
+export const FocusSafeButton = forwardRef<
+ HTMLButtonElement,
+ ButtonHTMLAttributes & {
+ /** Working: unavailable and announced as busy, whether focused or not. */
+ busy?: boolean;
+ }
+>(function FocusSafeButton({ disabled, busy = false, onClick, onFocus, onBlur, ...rest }, ref) {
+ const [focusEvent, setFocusEvent] = useState(false);
+ const node = useRef(null);
+ const setNode = useCallback(
+ (el: HTMLButtonElement | null) => {
+ node.current = el;
+ if (typeof ref === "function") ref(el);
+ else if (ref) ref.current = el;
+ },
+ [ref],
+ );
+ // Focused by the event OR by the document's own answer at render time: when the window itself is
+ // not focused (another app in front) `.focus()` moves `activeElement` but fires no focus event
+ // (measured in Chrome), and the button disabled in that state lost the focus all the same.
+ const focused =
+ focusEvent || (!!node.current && node.current.ownerDocument.activeElement === node.current);
+ const soft = busy || (!!disabled && focused);
+ return (
+ {
+ if (soft) {
+ e.preventDefault();
+ return;
+ }
+ onClick?.(e);
+ }}
+ onFocus={(e) => {
+ setFocusEvent(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setFocusEvent(false);
+ onBlur?.(e);
+ }}
+ />
+ );
+});
diff --git a/frontend/src/components/ui/Pager.tsx b/frontend/src/components/ui/Pager.tsx
index 702e45e4..5bb0382f 100644
--- a/frontend/src/components/ui/Pager.tsx
+++ b/frontend/src/components/ui/Pager.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { hasPagerContent, pageCount } from "./pagerContent";
+import { FocusSafeButton } from "./FocusSafeButton";
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
@@ -58,14 +59,14 @@ export default function Pager({
)}
{total > 0 && (
diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx
index 38cc48f6..58b0bcaf 100644
--- a/frontend/src/components/ui/UndoToolbar.tsx
+++ b/frontend/src/components/ui/UndoToolbar.tsx
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redo2, Undo2 } from "lucide-react";
import { dispatch, useKeyScope } from "../../lib/keymap";
+import { FocusSafeButton } from "./FocusSafeButton";
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
@@ -47,7 +48,7 @@ export default function UndoToolbar({
"p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition";
return (
-
-
-
+
-
+
);
}
diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx
index 43b416df..f2a8c6fc 100644
--- a/frontend/src/components/ui/form.tsx
+++ b/frontend/src/components/ui/form.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import Tooltip from "./Tooltip";
+import { FocusSafeButton } from "./FocusSafeButton";
// Shared form/settings primitives, factored out of the many panels that each re-declared
// them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged.
@@ -29,8 +30,10 @@ export function Switch({
label?: string;
disabled?: boolean;
}) {
+ // FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a
+ // natively disabled switch that holds the focus throws it to .
return (
-
-
+
);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 2e8f1cd2..631d6a15 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -88,6 +88,14 @@
outline: 2px solid var(--accent);
outline-offset: 2px;
}
+
+ /* ui/FocusSafeButton's soft-disabled state (a focused button its caller disabled, or a busy one):
+ `aria-disabled` instead of the native attribute, so a caller's `disabled:*` utilities do not see
+ it. One look for all of them, in base so a component's own aria-disabled style wins. */
+ [data-focus-safe][aria-disabled="true"] {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
}
:root {
diff --git a/frontend/src/lib/focusSafe.contract.test.ts b/frontend/src/lib/focusSafe.contract.test.ts
new file mode 100644
index 00000000..7deab408
--- /dev/null
+++ b/frontend/src/lib/focusSafe.contract.test.ts
@@ -0,0 +1,35 @@
+import { fileURLToPath } from "node:url";
+
+import { ESLint } from "eslint";
+import { describe, expect, it } from "vitest";
+
+// `local/focus-safe-disabled` (eslint.config.js): a raw `` throws a focused keyboard
+// user to the moment it turns disabled. ~60 were swept onto ui/FocusSafeButton; this pins that
+// the rule keeping a new one out actually fires.
+const RULE = "local/focus-safe-disabled";
+const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
+
+async function hits(source: string, filePath = "src/components/__probe.tsx"): Promise {
+ const eslint = new ESLint({ cwd: FRONTEND });
+ const [result] = await eslint.lintText(source, { filePath });
+ return (result?.messages ?? []).filter((m) => m.ruleId === RULE).length;
+}
+
+describe("a raw button that can be disabled", () => {
+ it("is refused, statically or not", async () => {
+ expect(
+ await hits("export const A = ({ p }: { p: boolean }) => x;\n"),
+ ).toBe(1);
+ expect(await hits("export const B = () => x;\n")).toBe(1);
+ });
+
+ it("leaves a raw button with no disabled, and the primitive, alone", async () => {
+ expect(await hits("export const C = () => {}}>x;\n")).toBe(0);
+ expect(
+ await hits(
+ "export const D = (p: { d: boolean }) => x;\n",
+ "src/components/ui/FocusSafeButton.tsx",
+ ),
+ ).toBe(0);
+ });
+});