diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 49ccb65b..18534306 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -61,7 +61,30 @@ const jsxCommentText = { }; }, }; -const localRules = { rules: { "jsx-comment-text": jsxCommentText } }; +// A raw ` + )} - + - + {link && ( diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 1a9a5ee3..78c7d3d6 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -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") } > - + )} {/* An entry with an account behind it stays read-only: deleting it would revoke that @@ -759,14 +760,14 @@ function AdminInvites() { ) : ( - + )} @@ -876,7 +877,7 @@ function AdminDemo() {
- +
diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 13a79555..b6573058 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -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") } > - + ), }, diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index e87e02d6..ac52f5f1 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -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({ )} {!me.is_demo && ( - + )} {/* 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 && ( - + )} {!me.is_demo && !ch?.blocked && diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 87a69fd4..4536ef33 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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" /> - + ); diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx index 431dce7c..fba20a40 100644 --- a/frontend/src/components/ConfigPanel.tsx +++ b/frontend/src/components/ConfigPanel.tsx @@ -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 && {t("config.emailOff")}} - + )} @@ -220,14 +221,14 @@ export default function ConfigPanel() { {activeGroup === "plex" && (
- + {dirty &&

{t("config.plexTestDirty")}

} {plexResult && ( diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index e2b9d838..dd3c18eb 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -50,6 +50,7 @@ import FilterSelect from "./ui/FilterSelect"; import ItemCount from "./ui/ItemCount"; import ViewSwitcher from "./ViewSwitcher"; import VirtualFeed from "./VirtualFeed"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. const PlayerModal = lazy(() => import("./PlayerModal")); @@ -516,7 +517,7 @@ export default function Feed({ /> {ytQuery.hasNextPage && (
- +
)} diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index 592edd1a..32fe0501 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -24,6 +24,7 @@ import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; import UndoToolbar from "./ui/UndoToolbar"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name @@ -227,7 +228,7 @@ export default function PersonalViewsModal() { onBlur={() => void onRename()} className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30" /> - +
@@ -283,7 +284,7 @@ export default function PersonalViewsModal() { > {t("views.apply")} - + + )}
{hasQueue && ( - + )}
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 0b246166..894a3d9e 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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")} )} - + {editable && canWrite && ( - + )} {linked && detail.dirty && ( - +
); }) @@ -242,13 +243,13 @@ export default function PlexCollectionEditor({ {t("plex.collEditor.count", { count: c.child_count ?? 0 })} - + ))} diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index 5ec60c1b..ecf0ca8c 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -13,7 +13,7 @@ import { usePref } from "../lib/usePref"; import SortControl from "./ui/SortControl"; import { type SortState } from "../lib/columnSort"; import { useConfirm } from "./confirmContext"; -import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi"; +import { BackBtn, ActionBtn, DetailHero } from "./plexDetailUi"; import { PlexPosterCard } from "./plexCards"; import { PlexPinMenu } from "./ComfortPinMenu"; import { useContextMenu } from "./ui/useContextMenu"; @@ -21,6 +21,7 @@ import PlexLibraryPicker from "./PlexLibraryPicker"; import { toggleWatchState } from "../lib/plexWatch"; import { LoadingState, StateMessage } from "./ui/QueryState"; import NavGrid from "./ui/NavGrid"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the // ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external, @@ -411,7 +412,7 @@ export default function PlexCollectionView({ menuProps={pinMenu.triggerProps} /> {canEdit && ( - + )} ))} @@ -489,14 +490,14 @@ function AddItemPanel({ renderAction={(c) => { const inColl = existing.has(c.id) || added.has(c.id); return ( - + ); }} /> diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index e9141100..0614e766 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -6,7 +6,7 @@ import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } f import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; -import { DetailCustomizeMenu, Filterable, PrefToggle } from "../lib/plexDetailUi"; +import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { usePref } from "../lib/usePref"; import i18n from "../i18n"; @@ -15,6 +15,7 @@ import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; import { PLAYABLE_TINT, playableClass } from "./plexPlayable"; import { navItemAttrs } from "../lib/listNav"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -736,7 +737,7 @@ export default function PlexInfo({ {col.items.map((m, i) => ( - + ))} diff --git a/frontend/src/components/PlexPlaylistAdd.tsx b/frontend/src/components/PlexPlaylistAdd.tsx index bdd63f76..e8fee825 100644 --- a/frontend/src/components/PlexPlaylistAdd.tsx +++ b/frontend/src/components/PlexPlaylistAdd.tsx @@ -8,6 +8,7 @@ import { notifyActionError } from "../lib/actionErrors"; import { useScrollFade } from "../lib/useScrollFade"; import { Button } from "./ui/Button"; import Modal from "./ui/Modal"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a // set of episode rating_keys). Groups get a tri-state (none / some / all) per playlist. @@ -145,7 +146,7 @@ export default function PlexPlaylistAdd({ const all = n >= size; const partial = n > 0 && n < size; return ( - + ); }) )} diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index a8a4bb4c..9c9f95ce 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -39,6 +39,7 @@ import { useConfirm } from "./confirmContext"; import { Button } from "./ui/Button"; import { LoadingState, StateMessage } from "./ui/QueryState"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the // same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat @@ -180,14 +181,14 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
{ep.title}
- + ); } @@ -223,14 +224,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { - + ); } @@ -285,14 +286,14 @@ function ShowGroup({ block, ctx }: { block: Extract; ct {t("plex.playlist.episodes", { count: block.items.length })} - + {open && ( @@ -323,14 +324,14 @@ function ShowGroup({ block, ctx }: { block: Extract; ct {seasonLabel(s)} · {s.items.length} - + {sOpen && (
{t("scheduler.minutes")} - +
- + ); @@ -464,17 +465,17 @@ export default function Scheduler() {
- + - + - +
diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index 11dc408c..423ee1a4 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -8,6 +8,7 @@ import { import { useTranslation } from "react-i18next"; import { Search, X } from "lucide-react"; import { useSearchFocusKey } from "../lib/keymap"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // The floating search pill in the top header. One component for every module that searches // (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a @@ -100,7 +101,7 @@ export default function SearchBar({ )} {onGo && ( - + )} {below} diff --git a/frontend/src/components/SetupWizard.tsx b/frontend/src/components/SetupWizard.tsx index 7632e1fb..e7377292 100644 --- a/frontend/src/components/SetupWizard.tsx +++ b/frontend/src/components/SetupWizard.tsx @@ -6,6 +6,7 @@ import { setLanguage, type LangCode } from "../i18n"; import LanguageSwitcher from "./LanguageSwitcher"; import { Button } from "./ui/Button"; import { inputCls } from "./ui/form"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // First-run install wizard. Shown by App.tsx while the instance is unconfigured. The one-time // setup token comes from the URL the app printed to the container logs (?token=…); every step @@ -279,15 +280,15 @@ export default function SetupWizard() { )}
- - +
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index b6a456b5..3fa4fe5a 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -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 && (
{filtered.map((u) => ( - + ))}
)} @@ -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" /> - + {link.has_password && ( - + )} )} diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index 2a23cbb8..e34bd929 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -16,6 +16,7 @@ import Tabs from "./ui/Tabs"; import { Section, SettingRow } from "./ui/form"; import { useMe } from "../lib/useMe"; import { notifyActionError } from "../lib/actionErrors"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; const RANGES = [7, 30, 90] as const; type StatsTab = "overview" | "system"; @@ -175,24 +176,24 @@ function Overview({ me }: { me: Me }) { {!me.is_demo && (
- + - +
)} diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx index 8e21437f..e1062f62 100644 --- a/frontend/src/components/SyncStatus.tsx +++ b/frontend/src/components/SyncStatus.tsx @@ -8,6 +8,7 @@ import { notifyActionError } from "../lib/actionErrors"; import { formatViews } from "../lib/format"; import { PopoverSurface } from "./ui/AnchoredPopover"; import { useAnchoredPopover } from "./ui/anchoredPopoverHooks"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // Per-user status (not the global catalog): shows the number of videos available to *this* // user, how many of *their* channels are still being fetched, and how many lack full @@ -97,14 +98,14 @@ export default function SyncStatus({ ); const pauseBtn = isAdmin && (data.paused || syncing > 0 || notFull > 0) && ( - + ); // Chip: the headline count rides in the header; the state, the history link and pause live in a diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index c46951aa..c6670db4 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -12,6 +12,7 @@ import { notify } from "../lib/notifications"; import { navigateTo } from "../lib/nav"; import { formatBytes } from "../lib/format"; import { inputCls } from "./ui/form"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // Local-file ingest (E7 S5c · D5-A). A general upload — ANY file type, no format list: media // (video/audio) joins the library with the full function set, a generic file is download-only. The @@ -75,7 +76,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {

{t("downloads.upload.hint")}

{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */} - + seconds. function tc(s: number): string { @@ -617,7 +618,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{t("editor.output.label")}: {(["separate", "join"] as const).map((m) => ( - + ))}
diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index ce3c34c5..fb273019 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -35,6 +35,7 @@ import { inputCls } from "./ui/form"; import { entryHash, entrySearch } from "../lib/entryUrl"; import type { VerifyEntry } from "../lib/useVerifyEntry"; import { MODE_URL, type Mode } from "./welcomeMode"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const PASSWORD_MIN = 10; @@ -682,13 +683,13 @@ function AuthCard({ verify }: { verify: VerifyEntry }) { {error}

)} - + {/* 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} /> - +
{demoError &&

{demoError}

} diff --git a/frontend/src/components/channelCells.tsx b/frontend/src/components/channelCells.tsx index e5618d23..13c0d799 100644 --- a/frontend/src/components/channelCells.tsx +++ b/frontend/src/components/channelCells.tsx @@ -4,6 +4,7 @@ import type { ManagedChannel } from "../lib/api"; import { formatNumber } from "../lib/format"; import Tooltip from "./ui/Tooltip"; import { isFullySynced } from "./channelSync"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // The Channels-manager cells that are components in their own right (they read i18n), split out of // channelColumns so that file holds only the column factory and its predicates (Fast Refresh). @@ -66,7 +67,7 @@ export function ActionsCell({ return (
- + + {index + 1} / {queueLength} - + {/* Persistent playback settings — cycle on click, saved to your account. */} + )} />
diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx similarity index 97% rename from frontend/src/lib/plexDetailUi.tsx rename to frontend/src/components/plexDetailUi.tsx index 367ae626..f9edb035 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -8,7 +8,8 @@ import { SlidersHorizontal, type LucideIcon, } from "lucide-react"; -import { useDismiss } from "./useDismiss"; +import { useDismiss } from "../lib/useDismiss"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages, // so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two @@ -181,7 +182,7 @@ export function ActionBtn({ disabled?: boolean; }) { return ( - + ); } @@ -223,7 +224,7 @@ export function DetailHero({ return (
{customizeMenu}
-
)} - +
{children}
); diff --git a/frontend/src/components/plexPlayerControls.tsx b/frontend/src/components/plexPlayerControls.tsx index e764c52e..c1ff744b 100644 --- a/frontend/src/components/plexPlayerControls.tsx +++ b/frontend/src/components/plexPlayerControls.tsx @@ -1,5 +1,6 @@ import { type ReactNode } from "react"; import { RotateCcw } from "lucide-react"; +import { FocusSafeButton } from "./ui/FocusSafeButton"; // ±10s skip glyph: an outline circular arrow with a "10" in the centre — the YouTube/Plex-native // muscle-memory shape. Deliberately distinct from the episode-level SkipBack/SkipForward (double @@ -139,14 +140,14 @@ export function Ctrl({ align === "start" ? "left-0" : align === "end" ? "right-0" : "left-1/2 -translate-x-1/2"; return (
- + diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index f18792b6..c3ccd0dd 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -16,7 +16,7 @@ import { DetailHero, Filterable, WatchedToggle, -} from "../lib/plexDetailUi"; +} from "./plexDetailUi"; import { epLabel, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexPinMenu } from "./ComfortPinMenu"; @@ -31,7 +31,7 @@ import NavGrid from "./ui/NavGrid"; // The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with // shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state, // which is what made this a pure code-motion split. Cards/strips come from plexCards, the shared -// detail-page UI from lib/plexDetailUi. +// detail-page UI from plexDetailUi. // Lazy: the rich movie info panel loads only when an info page is opened. const PlexInfo = lazy(() => import("./PlexInfo")); diff --git a/frontend/src/components/ui/Button.dom.test.tsx b/frontend/src/components/ui/Button.dom.test.tsx index e8e93683..b2e06dd1 100644 --- a/frontend/src/components/ui/Button.dom.test.tsx +++ b/frontend/src/components/ui/Button.dom.test.tsx @@ -5,7 +5,7 @@ // dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that // stops an action button from submitting a surrounding form. import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { act, render, screen, cleanup, fireEvent } from "@testing-library/react"; import { Button } from "./Button"; afterEach(cleanup); @@ -153,6 +153,54 @@ describe("Button primitive (S5)", () => { // so weakening the union turns the whole typecheck lane red. // // Rendered, not just declared, so eslint sees them used and the file stays one test suite. +describe("Button · disabled while it holds the focus", () => { + // A focused button that turns natively disabled loses the focus to in Chrome (measured, E11 + // S2) — and ~60 call sites disable the very button that was just pressed while its mutation runs. + // jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is + // unavailable by aria-disabled + a click guard, never by the native attribute. + it("keeps the native attribute off while focused, and blocks activation", () => { + const onClick = vi.fn(); + const { rerender } = render(); + const btn = screen.getByRole("button", { name: "Save" }); + act(() => btn.focus()); + rerender( + , + ); + 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(); + const btn = screen.getByRole("button", { name: "Next" }); + const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn); + rerender( + , + ); + 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( + , + ); + const btn = screen.getByRole("button", { name: "Save" }); + expect(btn.hasAttribute("disabled")).toBe(true); + }); +}); + describe("Button accessible-name contract (type level)", () => { it("rejects a text-less button with no name, and accepts every named spelling", () => { render( diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index dd759d0b..6dd5b9d4 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -2,6 +2,7 @@ import { forwardRef } from "react"; import type { ButtonHTMLAttributes, ReactNode, RefAttributes } from "react"; import clsx from "clsx"; import { Loader2 } from "lucide-react"; +import { FocusSafeButton } from "./FocusSafeButton"; // The ONE action-button primitive. Before this there were 385 raw + ); }); diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx new file mode 100644 index 00000000..5e83d44a --- /dev/null +++ b/frontend/src/components/ui/FocusSafeButton.tsx @@ -0,0 +1,66 @@ +import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react"; + +// A ` + {t("datatable.pager.pageLabel")} {t("datatable.pager.ofTotal", { total: totalPages })} - +
)} {total > 0 && ( diff --git a/frontend/src/components/ui/UndoToolbar.tsx b/frontend/src/components/ui/UndoToolbar.tsx index 38cc48f6..58b0bcaf 100644 --- a/frontend/src/components/ui/UndoToolbar.tsx +++ b/frontend/src/components/ui/UndoToolbar.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Redo2, Undo2 } from "lucide-react"; import { dispatch, useKeyScope } from "../../lib/keymap"; +import { FocusSafeButton } from "./FocusSafeButton"; // Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z // (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any @@ -47,7 +48,7 @@ export default function UndoToolbar({ "p-1.5 rounded-lg border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"; return (
- - +
); } diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx index 43b416df..f2a8c6fc 100644 --- a/frontend/src/components/ui/form.tsx +++ b/frontend/src/components/ui/form.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import Tooltip from "./Tooltip"; +import { FocusSafeButton } from "./FocusSafeButton"; // Shared form/settings primitives, factored out of the many panels that each re-declared // them (Settings, Config, Stats, admin pages, Setup wizard). Visual contract unchanged. @@ -29,8 +30,10 @@ export function Switch({ label?: string; disabled?: boolean; }) { + // FocusSafeButton: a switch is disabled by its caller while the change it made is saving, and a + // natively disabled switch that holds the focus throws it to . return ( - + ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 2e8f1cd2..631d6a15 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -88,6 +88,14 @@ outline: 2px solid var(--accent); outline-offset: 2px; } + + /* ui/FocusSafeButton's soft-disabled state (a focused button its caller disabled, or a busy one): + `aria-disabled` instead of the native attribute, so a caller's `disabled:*` utilities do not see + it. One look for all of them, in base so a component's own aria-disabled style wins. */ + [data-focus-safe][aria-disabled="true"] { + opacity: 0.5; + cursor: not-allowed; + } } :root { diff --git a/frontend/src/lib/focusSafe.contract.test.ts b/frontend/src/lib/focusSafe.contract.test.ts new file mode 100644 index 00000000..7deab408 --- /dev/null +++ b/frontend/src/lib/focusSafe.contract.test.ts @@ -0,0 +1,35 @@ +import { fileURLToPath } from "node:url"; + +import { ESLint } from "eslint"; +import { describe, expect, it } from "vitest"; + +// `local/focus-safe-disabled` (eslint.config.js): a raw `;\n"), + ).toBe(1); + expect(await hits("export const B = () => ;\n")).toBe(1); + }); + + it("leaves a raw button with no disabled, and the primitive, alone", async () => { + expect(await hits("export const C = () => ;\n")).toBe(0); + expect( + await hits( + "export const D = (p: { d: boolean }) => ;\n", + "src/components/ui/FocusSafeButton.tsx", + ), + ).toBe(0); + }); +});