diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 55247634..5005b81e 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -232,6 +232,7 @@ function UsersRoles({ me }: { me: Me }) { className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`} > setPanelFor(u)} title={t("users.account.open")} diff --git a/frontend/src/components/ChannelLink.tsx b/frontend/src/components/ChannelLink.tsx index 1162027d..3e0d1ca5 100644 --- a/frontend/src/components/ChannelLink.tsx +++ b/frontend/src/components/ChannelLink.tsx @@ -30,6 +30,7 @@ export default function ChannelLink({ {onView ? ( toggleMinimize(chat.partnerId)} className="flex items-center gap-2 min-w-0 flex-1" title={chat.minimized ? t("messages.expand") : t("messages.minimize")} diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 657de2b4..cbf41baa 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -283,6 +283,7 @@ function ShelfTile({ : t("home.shelf.itemCount", { count: count ?? 0 }); return ( card.next && onPlay(card)} {...menuProps?.(card)} {...(navIndex !== undefined && navItemAttrs(navIndex))} diff --git a/frontend/src/components/LogLine.tsx b/frontend/src/components/LogLine.tsx index 179daa39..8a150e14 100644 --- a/frontend/src/components/LogLine.tsx +++ b/frontend/src/components/LogLine.tsx @@ -41,6 +41,7 @@ export default function LogLine({ {...navItemAttrs(navIndex, `${event} ${msg}`)} > onOpen(c)} className="flex items-center gap-3 p-2.5 min-w-0 flex-1" @@ -255,6 +256,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
{users.map((u) => ( onPick(u)} diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index b7a2eeff..1556265c 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -64,6 +64,7 @@ function ViewRow({ > diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx index ef604e63..5810eab2 100644 --- a/frontend/src/components/PlaylistsRail.tsx +++ b/frontend/src/components/PlaylistsRail.tsx @@ -329,6 +329,7 @@ export default function PlaylistsRail({ // A sibling of the row button, not inside it — a button cannot hold a button.
{ if (pl.id === selectedId) selectedRef.current = el; // Back from the rename field by Enter/Escape — see refocusRowRef. diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 1731923b..a0da74e8 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -583,6 +583,7 @@ export default function PlexBrowse() { > {/* The photo + name open the actor's filmography page; the ✕ removes the filter. */} onOpenPerson(p.name)} title={t("plex.info.viewPerson", { name: p.name })} className="group/person flex min-w-0 items-center gap-2.5 rounded-lg" diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index b2586b2f..1092ace0 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -298,6 +298,7 @@ function CrewPanel({
{onOpenPerson ? ( onOpenPerson(c.name)} title={t("plex.info.viewPerson", { name: c.name })} className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" @@ -739,6 +740,7 @@ export default function PlexInfo({ ); return clickable ? ( {col.items.map((m, i) => ( onPlayItem?.(m.id)} diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index cf125471..828a0bfc 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -190,6 +190,7 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean {ep.episode_number} ctx.onPlay(ep.id)} data-nav-open className="group/thumb flex min-w-0 flex-1 items-center gap-2.5" @@ -242,6 +243,7 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) { {...listeners} /> ctx.onPlay(item.id)} data-nav-open className="group/thumb flex min-w-0 flex-1 items-center gap-3" @@ -327,6 +329,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) { )} ctx.toggleShow(block.showKey)} data-nav-open className="min-w-0 flex-1" diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx index a2298e5c..673a7199 100644 --- a/frontend/src/components/PosterTile.tsx +++ b/frontend/src/components/PosterTile.tsx @@ -28,6 +28,7 @@ export default function PosterTile({ const band = hueTileGradient(hueOf(title)); return ( setOpen((o) => !o)} aria-expanded={open} className="w-full flex items-center gap-3" diff --git a/frontend/src/components/descriptionLinks.tsx b/frontend/src/components/descriptionLinks.tsx index 4ed6c032..afcb51e5 100644 --- a/frontend/src/components/descriptionLinks.tsx +++ b/frontend/src/components/descriptionLinks.tsx @@ -80,6 +80,7 @@ export function renderDescription( const sameVideo = yt.id === opts.currentId; out.push( sameVideo ? opts.onSeek(yt.start ?? 0) : opts.onLoadVideo(yt.id, yt.start) @@ -135,6 +136,7 @@ export function renderDescription( const ts = m[3]!; out.push( opts.onSeek(tsToSeconds(ts))} className="text-accent hover:underline" diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 67e01875..e62aa90c 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -74,6 +74,7 @@ export function playlistColumns({ onPlay(v)} // The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it. data-nav-open="" diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index ac38efea..88b48e48 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -146,3 +146,34 @@ describe("Plex cards in a keyboard-walkable list", () => { expect(container.querySelector("[data-nav-index]")).toBeNull(); }); }); + +describe("a Plex card's title stays selectable", () => { + it("opens from its caption, but not on the click that ends selecting the title", () => { + const onOpen = vi.fn(); + render(); + const title = screen.getByText("M"); + const range = document.createRange(); + range.selectNodeContents(title); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + fireEvent.click(title); + expect(onOpen).not.toHaveBeenCalled(); + window.getSelection()!.removeAllRanges(); + fireEvent.click(title); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); + +describe("a Plex card's caption and a drag", () => { + it("does not open on a press dragged past the slop", () => { + const onOpen = vi.fn(); + render(); + const title = screen.getByText("M"); + fireEvent.mouseDown(title, { clientX: 10, clientY: 10 }); + fireEvent.click(title, { detail: 1, clientX: 60, clientY: 10 }); + expect(onOpen).not.toHaveBeenCalled(); + fireEvent.mouseDown(title, { clientX: 10, clientY: 10 }); + fireEvent.click(title, { detail: 1, clientX: 11, clientY: 10 }); + expect(onOpen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index fbca598c..147b05f6 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -1,10 +1,11 @@ import { useTranslation } from "react-i18next"; import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react"; -import { type CSSProperties, type ReactNode } from "react"; +import { type CSSProperties, type ReactNode, useRef } from "react"; import { formatRuntime } from "../lib/format"; import { Carousel } from "./ui/Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import { navItemAttrs } from "../lib/listNav"; +import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { PLAYABLE_TINT, playableClass } from "./plexPlayable"; import { plexAddLabelKey } from "./usePlexCardActions"; import { IconButton } from "./ui/IconButton"; @@ -97,9 +98,22 @@ function Caption({ children: ReactNode; }) { const { t } = useTranslation(); + const pressedAt = useRef<{ x: number; y: number } | null>(null); return (
{ + if (!endsTextSelection(e.currentTarget) && !movedPastSlop(pressedAt.current, e)) + onClick(); + }) + } + onMouseDown={(e) => { + pressedAt.current = { x: e.clientX, y: e.clientY }; + }} className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`} style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }} > @@ -443,6 +457,7 @@ export function CastStrip({ ); return clickable ? ( {children}; return ( } > - {se.title} diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 83317399..962735cb 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -36,6 +36,8 @@ interface ButtonOwnProps extends Omit, " * `media` variant. */ round?: boolean; fullWidth?: boolean; + /** Its text is a title or a name (a link back to a show): it stays selectable (FocusSafeButton). */ + selectable?: boolean; } // A button of this primitive always renders text, or it is an `IconButton`. ` + , + ); + selectText(screen.getByText("elsewhere")); + screen.getByRole("button").click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("is off unless asked: a plain button neither selects nor guards", () => { + const onClick = vi.fn(); + render(Save); + const btn = screen.getByRole("button"); + expect(btn.classList.contains("select-text")).toBe(false); + selectText(btn); + btn.click(); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/ui/FocusSafeButton.tsx b/frontend/src/components/ui/FocusSafeButton.tsx index 33ce42c4..b3d1a53a 100644 --- a/frontend/src/components/ui/FocusSafeButton.tsx +++ b/frontend/src/components/ui/FocusSafeButton.tsx @@ -1,4 +1,6 @@ import { forwardRef, useCallback, useRef, useState, type ButtonHTMLAttributes } from "react"; +import clsx from "clsx"; +import { endsTextSelection, movedPastSlop } from "../../lib/textSelection"; // A `