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:
@@ -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}"],
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user