fix(ui): no button drops the keyboard focus when its own action disables it

A focused button that turns natively disabled loses the focus to <body> in
Chrome. The app disables the very button that was pressed on ~60 sites — a
mutation pending, a pager at its last page, undo with nothing left, a player
at the end of its queue, a form gone clean — so a keyboard user was thrown
out of the page on every such press.

One primitive instead of a fix per site: ui/FocusSafeButton renders a
focused-and-disabled (or busy) button as aria-disabled with a click guard,
and applies the native attribute again once the focus is elsewhere. It asks
the document at render time as well as listening for focus events, because
a window without focus fires none (measured). ui/Button and ui/form's Switch
are built on it; 57 hand-rolled buttons swapped their tag for it keeping
their classes, and three hand-copies of the rule (the channel page's Block
and Hide, ChannelDiscovery's Subscribe) now pass `busy`. The soft state's
dim look is one rule in index.css.

`local/focus-safe-disabled` refuses a new raw <button disabled>, pinned by
focusSafe.contract.test.ts. lib/plexDetailUi moved to components/: it is
components, and lib may not import the primitive.

Measured on localdev in Chrome: the audit log's Next button keeps the focus
on reaching the last page, reads aria-disabled, and the guarded click does
nothing.
This commit is contained in:
2026-09-19 08:07:25 +02:00
parent bc138e3b37
commit 7a47223a58
39 changed files with 368 additions and 159 deletions
+31 -1
View File
@@ -61,7 +61,30 @@ const jsxCommentText = {
};
},
};
const localRules = { rules: { "jsx-comment-text": jsxCommentText } };
// A raw `<button disabled>` drops a focused keyboard user to <body> the moment its caller disables it
// (a mutation pending, a list at its end, a form gone clean) — measured in Chrome, and ~60 buttons in
// this app are disabled by their own click. `ui/FocusSafeButton` (and `ui/Button` on top of it)
// keeps the focus; this is what stops a new raw one from reopening the hole.
const focusSafeDisabled = {
meta: {
type: "problem",
messages: {
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") return;
if (node.attributes.some((a) => a.type === "JSXAttribute" && a.name.name === "disabled"))
context.report({ node, messageId: "raw" });
},
};
},
};
const localRules = {
rules: { "jsx-comment-text": jsxCommentText, "focus-safe-disabled": focusSafeDisabled },
};
// A px-frozen BOX utility (the no-restricted-syntax block below): an esquery regex, as a string.
const BOX_PX =
@@ -89,6 +112,7 @@ export default tseslint.config(
rules: {
...reactHooks.configs.recommended.rules,
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
@@ -205,6 +229,12 @@ export default tseslint.config(
files: [owner],
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
})),
{
// The two that ARE the raw button: the primitive itself, and the popover's menu row, whose
// native `disabled` is what floating-ui's roving focus reads to skip it (see ActionMenu).
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
rules: { "local/focus-safe-disabled": "off" },
},
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
files: ["src/**/*.test.{ts,tsx}"],
+7 -6
View File
@@ -13,6 +13,7 @@ import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
@@ -195,17 +196,17 @@ export default function AccountPanel({
</div>
<div className="space-y-2">
{a.can_activate && (
<button
<FocusSafeButton
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</button>
</FocusSafeButton>
)}
<button
<FocusSafeButton
onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
@@ -222,9 +223,9 @@ export default function AccountPanel({
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</button>
</FocusSafeButton>
<button
<FocusSafeButton
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
@@ -233,7 +234,7 @@ export default function AccountPanel({
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</button>
</FocusSafeButton>
</div>
{link && (
+7 -6
View File
@@ -42,6 +42,7 @@ import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -728,7 +729,7 @@ function AdminInvites() {
: t("settings.invites.inviteHint")
}
>
<button
<FocusSafeButton
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
@@ -739,7 +740,7 @@ function AdminInvites() {
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</button>
</FocusSafeButton>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
@@ -759,14 +760,14 @@ function AdminInvites() {
</button>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<button
<FocusSafeButton
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</FocusSafeButton>
</Tooltip>
)}
</div>
@@ -876,7 +877,7 @@ function AdminDemo() {
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<button
<FocusSafeButton
onClick={async () => {
if (
await confirm({
@@ -893,7 +894,7 @@ function AdminDemo() {
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</button>
</FocusSafeButton>
</Tooltip>
</div>
</Section>
+6 -7
View File
@@ -23,6 +23,7 @@ import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -107,18 +108,16 @@ export default function ChannelDiscovery({
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<button
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
onClick={() => !subscribe.isPending && onSubscribe(c)}
<FocusSafeButton
// Busy blocks the click, not the focus (FocusSafeButton).
onClick={() => onSubscribe(c)}
disabled={!canWrite}
aria-disabled={subscribe.isPending || undefined}
aria-busy={subscribe.isPending || undefined}
busy={subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</button>
</FocusSafeButton>
</Tooltip>
),
},
+11 -13
View File
@@ -37,6 +37,7 @@ import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -480,13 +481,11 @@ export default function ChannelPage({
</Button>
)}
{!me.is_demo && (
<button
// Busy blocks the click, not the focus: a focused button that turns natively
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
// Nor does a busy toggle light up on hover, as if a click would do something.
onClick={() => !block.isPending && block.mutate()}
aria-disabled={block.isPending || undefined}
aria-busy={block.isPending || undefined}
<FocusSafeButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
onClick={() => block.mutate()}
busy={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
@@ -496,16 +495,15 @@ export default function ChannelPage({
}`}
>
<Ban className="w-4 h-4" />
</button>
</FocusSafeButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<button
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
aria-disabled={toggleHidden.isPending || undefined}
aria-busy={toggleHidden.isPending || undefined}
<FocusSafeButton
onClick={() => toggleHidden.mutate()}
busy={toggleHidden.isPending}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
@@ -515,7 +513,7 @@ export default function ChannelPage({
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</FocusSafeButton>
)}
{!me.is_demo &&
!ch?.blocked &&
+3 -2
View File
@@ -7,6 +7,7 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -267,7 +268,7 @@ function TagEditorMenu({
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
<FocusSafeButton
type="button"
onClick={submit}
disabled={!draft.trim()}
@@ -276,7 +277,7 @@ function TagEditorMenu({
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
>
<Plus className="w-3.5 h-3.5" />
</button>
</FocusSafeButton>
</div>
</div>
);
+5 -4
View File
@@ -17,6 +17,7 @@ import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -205,14 +206,14 @@ export default function ConfigPanel() {
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<button
<FocusSafeButton
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</button>
</FocusSafeButton>
</Tooltip>
</div>
)}
@@ -220,14 +221,14 @@ export default function ConfigPanel() {
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<button
<FocusSafeButton
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</button>
</FocusSafeButton>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{plexResult && (
+3 -2
View File
@@ -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 && (
<div className="text-center pt-4">
<button
<FocusSafeButton
onClick={() => ytQuery.fetchNextPage()}
disabled={ytQuery.isFetchingNextPage}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
@@ -526,7 +527,7 @@ export default function Feed({
: zeroQuota
? t("feed.yt.loadMoreFree")
: t("feed.yt.loadMore")}
</button>
</FocusSafeButton>
</div>
)}
</>
@@ -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"
/>
<button
<FocusSafeButton
onClick={() => 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"
>
<Check className="h-4 w-4" />
</button>
</FocusSafeButton>
</div>
<div>
<div className="mb-1.5 flex items-center justify-between gap-2">
@@ -283,7 +284,7 @@ export default function PersonalViewsModal() {
>
{t("views.apply")}
</Button>
<button
<FocusSafeButton
onClick={async () => {
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() {
>
<Save className="h-3.5 w-3.5" />
{t("views.saveCurrent")}
</button>
</FocusSafeButton>
<button
onClick={() => void toggleDefault(selected)}
title={t("views.default")}
+5 -4
View File
@@ -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. */}
<div className="relative flex w-full max-w-4xl max-h-full">
{hasQueue && (
<button
<FocusSafeButton
onClick={(e) => {
e.stopPropagation();
goPrev();
@@ -550,7 +551,7 @@ export default function PlayerModal({
className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronLeft className="h-8 w-8" />
</button>
</FocusSafeButton>
)}
<div
ref={cardRef}
@@ -970,7 +971,7 @@ export default function PlayerModal({
</div>
</div>
{hasQueue && (
<button
<FocusSafeButton
onClick={(e) => {
e.stopPropagation();
goNext();
@@ -981,7 +982,7 @@ export default function PlayerModal({
className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronRight className="h-8 w-8" />
</button>
</FocusSafeButton>
)}
</div>
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
+5 -4
View File
@@ -68,6 +68,7 @@ import { ColumnHead } from "./ui/tableHeader";
import { alignClass } from "./ui/tableAlign";
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
@@ -936,15 +937,15 @@ export default function Playlists() {
{t("playlists.unsynced")}
</span>
)}
<button
<FocusSafeButton
onClick={() => shown.length && openPlayer(shown[0]!.id)}
disabled={!shown.length}
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg btn-primary disabled:opacity-40 transition"
>
<Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
</button>
</FocusSafeButton>
{editable && canWrite && (
<button
<FocusSafeButton
onClick={pushToYoutube}
disabled={pushing}
title={
@@ -966,7 +967,7 @@ export default function Playlists() {
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
</FocusSafeButton>
)}
{linked && detail.dirty && (
<Button
@@ -9,6 +9,7 @@ import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// Admin-only "manage this movie's collections" dialog (Collections P2). Lists the library's EDITABLE
// collections with an in/out toggle (add/remove the movie, written back to Plex), plus a field to
@@ -190,7 +191,7 @@ export default function PlexCollectionEditor({
key={c.id}
className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
>
<button
<FocusSafeButton
onClick={() => toggle(c.id)}
disabled={!!busy}
className={`inline-flex min-w-0 flex-1 items-center gap-2 text-left disabled:opacity-50 ${
@@ -208,14 +209,14 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
</button>
<button
</FocusSafeButton>
<FocusSafeButton
onClick={() => removeCollection(c.id, c.title)}
disabled={!!busy}
className="shrink-0 rounded-sm px-1.5 py-0.5 text-xs text-muted hover:text-danger disabled:opacity-50"
>
{t("plex.collEditor.delete")}
</button>
</FocusSafeButton>
</div>
);
})
@@ -242,13 +243,13 @@ export default function PlexCollectionEditor({
<span className="shrink-0 text-xs text-muted">
{t("plex.collEditor.count", { count: c.child_count ?? 0 })}
</span>
<button
<FocusSafeButton
onClick={() => markEditable(c.id)}
disabled={!!busy}
className="glass-card glass-hover shrink-0 rounded-sm px-2 py-0.5 text-xs disabled:opacity-50"
>
{t("plex.collEditor.makeEditable")}
</button>
</FocusSafeButton>
</div>
))}
</div>
@@ -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 && (
<button
<FocusSafeButton
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</button>
</FocusSafeButton>
)}
</div>
))}
@@ -489,14 +490,14 @@ function AddItemPanel({
renderAction={(c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
<button
<FocusSafeButton
onClick={() => 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 ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
</button>
</FocusSafeButton>
);
}}
/>
+4 -3
View File
@@ -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({
</div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<button
<FocusSafeButton
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
@@ -778,7 +779,7 @@ export default function PlexInfo({
{m.title}
</div>
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
</button>
</FocusSafeButton>
))}
</Carousel>
</div>
+3 -2
View File
@@ -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 (
<button
<FocusSafeButton
key={p.id}
onClick={() => toggle(p)}
disabled={busy}
@@ -174,7 +175,7 @@ export default function PlexPlaylistAdd({
{t("plex.playlistAdd.count", { count: p.item_count })}
</span>
)}
</button>
</FocusSafeButton>
);
})
)}
+9 -8
View File
@@ -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
</div>
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
</button>
<button
<FocusSafeButton
onClick={() => 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"
>
<X className="h-4 w-4" />
</button>
</FocusSafeButton>
</div>
);
}
@@ -223,14 +224,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
<Play className="h-4 w-4" fill="currentColor" />
</div>
</button>
<button
<FocusSafeButton
onClick={() => 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"
>
<X className="h-4 w-4" />
</button>
</FocusSafeButton>
</div>
);
}
@@ -285,14 +286,14 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
{t("plex.playlist.episodes", { count: block.items.length })}
</div>
</button>
<button
<FocusSafeButton
onClick={() => 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"
>
<X className="h-4 w-4" />
</button>
</FocusSafeButton>
</div>
{open && (
@@ -323,14 +324,14 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
{seasonLabel(s)} · {s.items.length}
</span>
<button
<FocusSafeButton
onClick={() => 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"
>
<X className="h-3.5 w-3.5" />
</button>
</FocusSafeButton>
</div>
{sOpen && (
<div
+11 -10
View File
@@ -26,6 +26,7 @@ import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
const POLL_MS = 4000;
// While any job is running, poll faster so short scheduled runs aren't missed between ticks.
@@ -177,14 +178,14 @@ function JobRow({
className="w-16 bg-surface border border-border rounded-sm px-1.5 py-0.5 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<span className="text-muted text-xs">{t("scheduler.minutes")}</span>
<button
<FocusSafeButton
onClick={save}
disabled={saving}
className="text-success hover:opacity-80 disabled:opacity-50"
aria-label={t("common.save")}
>
<Check className="w-3.5 h-3.5" />
</button>
</FocusSafeButton>
<button
onClick={() => setEditing(false)}
className="text-muted hover:text-fg"
@@ -232,14 +233,14 @@ function JobRow({
)}
</div>
<Tooltip hint={t("scheduler.runNow")}>
<button
<FocusSafeButton
onClick={onRun}
disabled={job.running || runDisabled}
aria-label={t("scheduler.runNow")}
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition disabled:opacity-30 disabled:hover:text-muted disabled:hover:bg-transparent"
>
<Play className="w-4 h-4" />
</button>
</FocusSafeButton>
</Tooltip>
</div>
);
@@ -464,17 +465,17 @@ export default function Scheduler() {
</div>
<div className="flex-1" />
<Tooltip hint={data.paused ? t("scheduler.runAllPausedHint") : t("scheduler.runAllHint")}>
<button
<FocusSafeButton
onClick={() => 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"
>
<Play className="w-4 h-4" />
{t("scheduler.runAll")}
</button>
</FocusSafeButton>
</Tooltip>
<Tooltip hint={t("scheduler.purgeDiscoveryHint")}>
<button
<FocusSafeButton
onClick={async () => {
const ok = await confirm({
title: t("scheduler.purgeConfirmTitle"),
@@ -489,17 +490,17 @@ export default function Scheduler() {
>
<Trash2 className="w-4 h-4" />
{t("scheduler.purgeDiscovery")}
</button>
</FocusSafeButton>
</Tooltip>
<Tooltip hint={t("scheduler.pauseHint")}>
<button
<FocusSafeButton
onClick={() => 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 ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
{data.paused ? t("scheduler.resume") : t("scheduler.pause")}
</button>
</FocusSafeButton>
</Tooltip>
</div>
+3 -2
View File
@@ -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({
</button>
)}
{onGo && (
<button
<FocusSafeButton
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
@@ -110,7 +111,7 @@ export default function SearchBar({
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
</FocusSafeButton>
)}
</div>
{below}
+5 -4
View File
@@ -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() {
)}
<div className="flex items-center justify-between mt-6">
<button
<FocusSafeButton
onClick={() => go(-1)}
disabled={stepIdx === 0 || busy}
className="glass-card glass-hover inline-flex items-center gap-1 px-3 py-2 rounded-xl text-sm disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("setup.nav.back")}
</button>
<button
</FocusSafeButton>
<FocusSafeButton
onClick={next}
disabled={busy}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl text-sm btn-primary font-medium shadow-md shadow-accent/20 disabled:opacity-50 transition"
@@ -299,7 +300,7 @@ export default function SetupWizard() {
) : null}
{step === "finish" ? t("setup.nav.finish") : t("setup.nav.next")}
{step !== "finish" && !busy && <ChevronRight className="w-4 h-4" />}
</button>
</FocusSafeButton>
</div>
</div>
</div>
+7 -6
View File
@@ -13,6 +13,7 @@ import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { FocusSafeButton } from "./ui/FocusSafeButton";
const EXPIRY_DAYS = [null, 1, 7, 30] as const;
// --- share with a registered user (ACL) -----------------------------------------------------
@@ -70,7 +71,7 @@ function UserShare({ job }: { job: DownloadJob }) {
{open && filtered.length > 0 && (
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu overflow-hidden">
{filtered.map((u) => (
<button
<FocusSafeButton
key={u.id}
onClick={() => share.mutate(u.email)}
disabled={share.isPending}
@@ -78,7 +79,7 @@ function UserShare({ job }: { job: DownloadJob }) {
>
<span className="truncate">{u.display_name || u.email}</span>
{u.display_name && <span className="text-xs text-muted truncate">{u.email}</span>}
</button>
</FocusSafeButton>
))}
</div>
)}
@@ -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"
/>
<button
<FocusSafeButton
onClick={() => 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")}
</button>
</FocusSafeButton>
{link.has_password && (
<button
<FocusSafeButton
onClick={async () => {
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")}
</button>
</FocusSafeButton>
)}
</div>
)}
+5 -4
View File
@@ -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 && (
<Section title={t("stats.sync.actions")}>
<Tooltip hint={t("stats.sync.syncSubscriptionsHint")}>
<button
<FocusSafeButton
onClick={() => 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"
>
<RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
{t("stats.sync.syncSubscriptions")}
</button>
</FocusSafeButton>
</Tooltip>
<Tooltip hint={t("stats.sync.backfillEverythingHint")}>
<button
<FocusSafeButton
onClick={() => 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"
>
<History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
{t("stats.sync.backfillEverything")}
</button>
</FocusSafeButton>
</Tooltip>
</Section>
)}
+3 -2
View File
@@ -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) && (
<button
<FocusSafeButton
onClick={() => 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 ? <Play className="w-3.5 h-3.5" /> : <Pause className="w-3.5 h-3.5" />}
</button>
</FocusSafeButton>
);
// Chip: the headline count rides in the header; the state, the history link and pause live in a
+3 -2
View File
@@ -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 }) {
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<button
<FocusSafeButton
type="button"
onClick={pick}
onDragEnter={(e) => {
@@ -117,7 +118,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
</span>
</>
)}
</button>
</FocusSafeButton>
<input
ref={inputRef}
type="file"
+3 -2
View File
@@ -12,6 +12,7 @@ import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button";
import { failureReason, notifyActionError } from "../lib/actionErrors";
import { markReported } from "../lib/api/core";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// mm:ss.d timecode <-> seconds.
function tc(s: number): string {
@@ -617,7 +618,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => (
<button
<FocusSafeButton
key={m}
onClick={() => 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}`)}
</button>
</FocusSafeButton>
))}
<div className="flex-1" />
<span className="text-xs text-muted">
+5 -4
View File
@@ -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}
</p>
)}
<button
<FocusSafeButton
type="submit"
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold btn-primary disabled:opacity-50 transition"
>
{busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])}
</button>
</FocusSafeButton>
</form>
{/* 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}
/>
<button
<FocusSafeButton
type="submit"
disabled={demoBusy}
className="shrink-0 px-3 py-2.5 rounded-xl text-sm font-semibold bg-card border border-border hover:border-accent disabled:opacity-50 transition"
>
{demoBusy ? t("welcome.auth.working") : t("welcome.auth.demoEnter")}
</button>
</FocusSafeButton>
</div>
{demoError && <p className="text-xs text-danger">{demoError}</p>}
</form>
+3 -2
View File
@@ -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 (
<div className="inline-flex items-center gap-2">
<Tooltip hint={backfillHint}>
<button
<FocusSafeButton
onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
disabled={!enabled}
className={`shrink-0 transition ${
@@ -79,7 +80,7 @@ export function ActionsCell({
aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
>
<History className="w-4 h-4" />
</button>
</FocusSafeButton>
</Tooltip>
<Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
<button
@@ -1,6 +1,7 @@
import { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
import type { AutoMode, LoopMode } from "../lib/playerModes";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
@@ -51,7 +52,7 @@ export function PlayerTransportControls({
const toggleLabel = docked ? "hidden md:inline" : "";
return (
<>
<button
<FocusSafeButton
onClick={onPrev}
disabled={index === 0}
title={`${t("player.previous")} · Shift+←`}
@@ -59,11 +60,11 @@ export function PlayerTransportControls({
>
<SkipBack className="h-4 w-4" />
<span className={navLabel}>{t("player.previous")}</span>
</button>
</FocusSafeButton>
<span className={counter}>
{index + 1} / {queueLength}
</span>
<button
<FocusSafeButton
onClick={onNext}
disabled={index === queueLength - 1}
title={`${t("player.next")} · Shift+→`}
@@ -71,7 +72,7 @@ export function PlayerTransportControls({
>
<span className={navLabel}>{t("player.next")}</span>
<SkipForward className="h-4 w-4" />
</button>
</FocusSafeButton>
<span className={divider} />
{/* Persistent playback settings — cycle on click, saved to your account. */}
<button
+3 -2
View File
@@ -12,6 +12,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker";
import NavGrid from "./ui/NavGrid";
import { FocusSafeButton } from "./ui/FocusSafeButton";
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime).
@@ -145,14 +146,14 @@ function NewCollectionPanel({
term={term}
enabled={nameOk}
renderAction={(c) => (
<button
<FocusSafeButton
onClick={() => create(c)}
disabled={busy}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
{t("plex.collection.createWith")}
</button>
</FocusSafeButton>
)}
/>
</div>
@@ -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 (
<button
<FocusSafeButton
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
@@ -193,7 +194,7 @@ export function ActionBtn({
<span>{label}</span>
{sub && <span className="text-2xs font-normal opacity-70">{sub}</span>}
</span>
</button>
</FocusSafeButton>
);
}
@@ -223,7 +224,7 @@ export function DetailHero({
return (
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
<button
<FocusSafeButton
onClick={onPosterClick}
disabled={posterDisabled}
className={`group/poster relative ${posterSizeClass} shrink-0 self-start`}
@@ -239,7 +240,7 @@ export function DetailHero({
<Play className="w-10 h-10 text-white" fill="currentColor" />
</div>
)}
</button>
</FocusSafeButton>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
@@ -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 (
<div className="relative flex group/ctrl">
<button
<FocusSafeButton
onClick={onClick}
disabled={disabled}
aria-label={label}
className="p-1 hover:text-accent disabled:opacity-30"
>
{children}
</button>
</FocusSafeButton>
<span
className={`pointer-events-none absolute bottom-full ${pos} mb-2 whitespace-nowrap rounded-sm bg-black/90 px-2 py-1 text-2xs text-white opacity-0 transition group-hover/ctrl:opacity-100`}
>
+2 -2
View File
@@ -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"));
+49 -1
View File
@@ -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 <body> 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(<Button onClick={onClick}>Save</Button>);
const btn = screen.getByRole("button", { name: "Save" });
act(() => btn.focus());
rerender(
<Button onClick={onClick} disabled>
Save
</Button>,
);
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(<Button onClick={() => {}}>Next</Button>);
const btn = screen.getByRole("button", { name: "Next" });
const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn);
rerender(
<Button onClick={() => {}} disabled>
Next
</Button>,
);
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(
<Button onClick={() => {}} disabled>
Save
</Button>,
);
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(
+11 -18
View File
@@ -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 <button>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<HTMLButtonElement, ButtonOwnProps>(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<HTMLButtonElement, ButtonOwnProps>(function Button
const touchTarget = variant !== "link";
const leading = loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon;
return (
<button
<FocusSafeButton
ref={ref}
type={type ?? "button"}
// `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
// loses the focus: Chrome moved it to <body> ~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<HTMLButtonElement, ButtonOwnProps>(function Button
>
{leading}
{!iconOnly && children}
</button>
</FocusSafeButton>
);
});
@@ -0,0 +1,66 @@
import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react";
// A `<button>` whose `disabled` never throws the focus out of the page.
//
// A focused button that turns natively disabled loses the focus — Chrome moves it to <body>
// (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 `<button>` 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<HTMLButtonElement> & {
/** 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<HTMLButtonElement | null>(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 (
<button
ref={setNode}
{...rest}
data-focus-safe=""
disabled={!!disabled && !focused}
aria-disabled={soft || undefined}
aria-busy={busy || undefined}
onClick={(e) => {
if (soft) {
e.preventDefault();
return;
}
onClick?.(e);
}}
onFocus={(e) => {
setFocusEvent(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocusEvent(false);
onBlur?.(e);
}}
/>
);
});
+5 -4
View File
@@ -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({
<div className="flex items-center gap-3 flex-wrap">
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
<FocusSafeButton
onClick={() => goToPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
<ChevronLeft className="w-4 h-4" />
{t("datatable.pager.prev")}
</button>
</FocusSafeButton>
<span className="text-muted flex items-center gap-1.5">
{t("datatable.pager.pageLabel")}
<input
@@ -81,14 +82,14 @@ export default function Pager({
/>
{t("datatable.pager.ofTotal", { total: totalPages })}
</span>
<button
<FocusSafeButton
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
>
{t("datatable.pager.next")}
<ChevronRight className="w-4 h-4" />
</button>
</FocusSafeButton>
</div>
)}
{total > 0 && (
+5 -4
View File
@@ -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 (
<div className="inline-flex items-center gap-1">
<button
<FocusSafeButton
onClick={onUndo}
disabled={!canUndo}
title={t("common.undo")}
@@ -55,8 +56,8 @@ export default function UndoToolbar({
className={btn}
>
<Undo2 className="w-4 h-4" />
</button>
<button
</FocusSafeButton>
<FocusSafeButton
onClick={onRedo}
disabled={!canRedo}
title={t("common.redo")}
@@ -64,7 +65,7 @@ export default function UndoToolbar({
className={btn}
>
<Redo2 className="w-4 h-4" />
</button>
</FocusSafeButton>
</div>
);
}
+5 -2
View File
@@ -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 <body>.
return (
<button
<FocusSafeButton
type="button"
role="switch"
aria-checked={checked}
@@ -44,7 +47,7 @@ export function Switch({
checked ? "left-4.5" : "left-0.5"
}`}
/>
</button>
</FocusSafeButton>
);
}
+8
View File
@@ -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 {
@@ -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 `<button disabled>` throws a focused keyboard
// user to <body> 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<number> {
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 }) => <button disabled={p}>x</button>;\n"),
).toBe(1);
expect(await hits("export const B = () => <button disabled>x</button>;\n")).toBe(1);
});
it("leaves a raw button with no disabled, and the primitive, alone", async () => {
expect(await hits("export const C = () => <button onClick={() => {}}>x</button>;\n")).toBe(0);
expect(
await hits(
"export const D = (p: { d: boolean }) => <button disabled={p.d}>x</button>;\n",
"src/components/ui/FocusSafeButton.tsx",
),
).toBe(0);
});
});