refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal, { ModalFooter } from "./Modal";
|
||||
import { Button } from "./Button";
|
||||
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
|
||||
|
||||
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
|
||||
|
||||
export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<{
|
||||
opts: ChoiceOptions;
|
||||
resolve: (id: string | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
const ask = useCallback<ChoiceFn>(
|
||||
(opts) => new Promise<string | null>((resolve) => setState({ opts, resolve })),
|
||||
[],
|
||||
);
|
||||
|
||||
function close(id: string | null) {
|
||||
state?.resolve(id);
|
||||
setState(null);
|
||||
}
|
||||
|
||||
const choices = state?.opts.choices ?? [];
|
||||
// Focus Cancel when any offered action is destructive: the confirm button used to take
|
||||
// autoFocus unconditionally, so a stray Enter on an open dialog executed "Delete user" or
|
||||
// "Clear audit log" without the user ever looking at it.
|
||||
const anyDanger = choices.some((c) => c.danger);
|
||||
const multi = choices.length > 1;
|
||||
|
||||
return (
|
||||
<ChoiceContext.Provider value={ask}>
|
||||
{children}
|
||||
{state && (
|
||||
<Modal
|
||||
initialFocus="action"
|
||||
title={state.opts.title ?? t("common.confirmTitle")}
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
>
|
||||
<div className="text-sm text-muted leading-relaxed">{state.opts.message}</div>
|
||||
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||
<ModalFooter
|
||||
className={multi ? "flex flex-col gap-2" : "flex flex-wrap justify-end gap-2"}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
autoFocus={anyDanger}
|
||||
onClick={() => close(null)}
|
||||
fullWidth={multi}
|
||||
className={multi ? "order-last" : ""}
|
||||
>
|
||||
{state.opts.cancelLabel ?? t("common.cancel")}
|
||||
</Button>
|
||||
{choices.map((c, i) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
variant={c.danger ? "danger" : "primary"}
|
||||
autoFocus={!anyDanger && i === choices.length - 1}
|
||||
onClick={() => close(c.id)}
|
||||
fullWidth={multi}
|
||||
>
|
||||
{c.label || t("common.confirm")}
|
||||
</Button>
|
||||
))}
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
)}
|
||||
</ChoiceContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
|
||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
|
||||
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
|
||||
// `children` defaults to nothing for the rails that need none.
|
||||
export default function FilterRail({
|
||||
kind,
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
actions,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
layout,
|
||||
setLayout,
|
||||
items,
|
||||
collapsible = true,
|
||||
iconExpanded,
|
||||
onTitleClick,
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
kind: PanelId;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
count?: number;
|
||||
actions?: ReactNode;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
items: PanelItem[];
|
||||
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
|
||||
collapsible?: boolean;
|
||||
iconExpanded?: ReactNode;
|
||||
onTitleClick?: () => void;
|
||||
titleActionLabel?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [editing, setEditing] = useState(false);
|
||||
return (
|
||||
<SidePanel
|
||||
title={title}
|
||||
icon={icon}
|
||||
count={count}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout(kind))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={actions}
|
||||
iconExpanded={iconExpanded}
|
||||
onTitleClick={onTitleClick}
|
||||
titleActionLabel={titleActionLabel}
|
||||
>
|
||||
<PanelGroups
|
||||
items={items}
|
||||
layout={layout}
|
||||
setLayout={setLayout}
|
||||
editing={editing}
|
||||
collapsible={collapsible}
|
||||
/>
|
||||
{children}
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { MenuCheck } from "./MenuCheck";
|
||||
import { LANGUAGES, type LangCode } from "../../i18n";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { Pressable } from "./Pressable";
|
||||
import { RAIL_GLYPH, RAIL_ROW } from "./NavRail";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
//
|
||||
// Two variants, now one implementation on the popover platform: "header" opens below the pill
|
||||
// (used on the landing page and the setup wizard) and "rail" is an icon-only button in the left
|
||||
// nav's bottom cluster whose menu opens to the RIGHT, bottom-aligned. Both used to hand-roll their
|
||||
// own anchoring, and the header one also opened on hover — dropped on purpose: the platform focuses
|
||||
// the first item when a menu opens, so a hover-open would yank focus off whatever the user was doing.
|
||||
export default function LanguageSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
align = "right",
|
||||
variant = "header",
|
||||
}: {
|
||||
value: LangCode;
|
||||
onChange: (code: LangCode) => void;
|
||||
align?: "left" | "right";
|
||||
variant?: "header" | "rail";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const currentIdx = Math.max(
|
||||
0,
|
||||
LANGUAGES.findIndex((l) => l.code === value),
|
||||
);
|
||||
const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0];
|
||||
const rail = variant === "rail";
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: rail ? "right-end" : align === "right" ? "bottom-end" : "bottom-start",
|
||||
offset: rail ? 8 : 4,
|
||||
// Single choice: opening lands on the language already in use.
|
||||
selectedIndex: currentIdx,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
layer
|
||||
className={
|
||||
rail
|
||||
? `${RAIL_ROW} h-9 text-muted hover:text-fg`
|
||||
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition touch-target"
|
||||
}
|
||||
>
|
||||
{rail ? (
|
||||
/* A rail row (F32), with the current-language badge (HU/EN) on the glyph's corner so the
|
||||
active language reads at a glance. The ring matches the page to detach it from the icon;
|
||||
its type is the rail count badge's (text-3xs), the smallest step on the scale (it was an
|
||||
8px one-off). */
|
||||
<span className="relative">
|
||||
<Globe className={RAIL_GLYPH} />
|
||||
<span className="absolute -bottom-1.5 -right-2.5 text-3xs font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
|
||||
{current.code.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<Globe className="w-4 h-4" />
|
||||
)}
|
||||
{rail ? null : <span className="text-xs font-semibold uppercase">{current.code}</span>}
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-40 animate-[popIn_0.16s_ease]`}>
|
||||
{LANGUAGES.map((l) => (
|
||||
<PopoverItem
|
||||
key={l.code}
|
||||
role="menuitemradio"
|
||||
checked={l.code === value}
|
||||
onSelect={() => {
|
||||
onChange(l.code);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={l.label}
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
|
||||
>
|
||||
<MenuCheck kind="one" on={l.code === value} />
|
||||
<span>{l.label}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ describe("NavRailAction", () => {
|
||||
});
|
||||
|
||||
it("the nav rail holds no toolbar icon button", () => {
|
||||
const src = readFileSync(resolve(__dirname, "../NavSidebar.tsx"), "utf8");
|
||||
const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8");
|
||||
expect(src).not.toMatch(/<IconButton[\s>]/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,12 +18,12 @@ import { readSources } from "../../lib/rawSources.testkit";
|
||||
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
||||
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
||||
// (found by a later review).
|
||||
const sources = readSources(__dirname, ["../**/*.tsx", "./*.tsx"]);
|
||||
const sources = readSources(__dirname, ["../../**/*.tsx", "!../../design-lab/**"]);
|
||||
|
||||
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
||||
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
||||
// there — App renders Welcome instead of the shell).
|
||||
const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]);
|
||||
const EXEMPT = new Set(["../../modules/plex/PlexBrowse.tsx", "../../shell/Welcome.tsx"]);
|
||||
|
||||
/** Every class string in the file (quoted or templated), including multi-line ones. */
|
||||
const classStrings = (src: string): string[] =>
|
||||
@@ -67,9 +67,9 @@ describe("full-screen overlays host their own toasts", () => {
|
||||
expect(paths).toEqual(
|
||||
expect.arrayContaining([
|
||||
"./Modal.tsx",
|
||||
"../PlayerModal.tsx",
|
||||
"../PlexPlayer.tsx",
|
||||
"../OnboardingWizard.tsx",
|
||||
"../../features/player/PlayerModal.tsx",
|
||||
"../../modules/plex/PlexPlayer.tsx",
|
||||
"../../features/wizard/OnboardingWizard.tsx",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
import { type LucideIcon } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { MenuCheck } from "./MenuCheck";
|
||||
import { SelectTrigger } from "./SelectTrigger";
|
||||
import { SURFACE } from "./surfaces";
|
||||
|
||||
export interface ViewOption<T extends string> {
|
||||
id: T;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a
|
||||
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
|
||||
// density modes; the managers' layouts) without re-deriving the menu behaviour.
|
||||
//
|
||||
// This was the house's textbook menu, with its own hand-rolled roving-tabindex/arrow/Escape engine.
|
||||
// It now rides the popover platform like every other menu, which supplies the same model
|
||||
// (arrows/Home/End rove, Escape closes and hands focus back to the trigger, Tab out closes) plus the
|
||||
// two things the hand-rolled version lacked: typeahead, and anchoring that flips and shifts instead
|
||||
// of being absolutely positioned inside whatever ancestor happens to clip it.
|
||||
export default function ViewSwitcher<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
label,
|
||||
labelClassName = "hidden",
|
||||
}: {
|
||||
value: T;
|
||||
options: readonly ViewOption<T>[];
|
||||
onChange: (v: T) => void;
|
||||
label: string;
|
||||
/** Where the current mode's NAME is visible beside the icon — a Tailwind display class, e.g.
|
||||
* `"hidden xl:inline"`. The caller sets it because only it knows how crowded its own toolbar is:
|
||||
* the feed's is packed (show chips, content chips, source, count, sort) and can't spare the room
|
||||
* until xl, while an emptier one could show it always. Defaults to icon-only. */
|
||||
labelClassName?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
|
||||
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
|
||||
const currentIdx = Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.id === value),
|
||||
);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
placement: "bottom-end",
|
||||
// Opening a "pick one of these modes" menu should land on the mode that is already picked.
|
||||
selectedIndex: currentIdx,
|
||||
});
|
||||
|
||||
const { setReference } = pop.refs;
|
||||
|
||||
const current = options[currentIdx];
|
||||
if (!current) return null;
|
||||
const CurrentIcon = current.icon;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectTrigger
|
||||
context="toolbar"
|
||||
open={open}
|
||||
icon={<CurrentIcon className="w-4 h-4 shrink-0" />}
|
||||
ref={setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The aria-label carries the mode's name at every size — the visible text below is a
|
||||
// nicety for wide windows, not the accessible name.
|
||||
aria-label={`${label}: ${current.label}`}
|
||||
title={`${label}: ${current.label}`}
|
||||
>
|
||||
<span className={clsx("whitespace-nowrap", labelClassName)}>{current.label}</span>
|
||||
</SelectTrigger>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className={`${SURFACE.menu} min-w-44 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{/* The modes are one exclusive choice, so the radio items need a group to scope their
|
||||
checked-ness to — otherwise they read as independent checkable items. */}
|
||||
<div role="group">
|
||||
{options.map((o) => {
|
||||
const Icon = o.icon;
|
||||
const on = o.id === value;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.id}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={o.label}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition ${
|
||||
on ? "text-accent" : "text-fg"
|
||||
}`}
|
||||
>
|
||||
<MenuCheck kind="one" on={on} />
|
||||
<Icon className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createContext, useCallback, useContext, type ReactNode } from "react";
|
||||
|
||||
// App-styled, promise-based replacement for window.confirm. Wrap the tree in
|
||||
// <ConfirmProvider> once, then anywhere: const confirm = useConfirm();
|
||||
// if (await confirm({ message: "…", danger: true })) { … }
|
||||
//
|
||||
// For a decision with more than two outcomes use `useChoice()` instead — never chain two
|
||||
// confirms, because the second dialog's Cancel/Escape/backdrop is then indistinguishable from
|
||||
// a deliberate answer (the playlist delete used to read a dismissal as "delete here only" and
|
||||
// went ahead and deleted).
|
||||
//
|
||||
// The context + hooks live here, apart from the dialog in ConfirmProvider, so that file exports only
|
||||
// components (Fast Refresh).
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface Choice {
|
||||
id: string;
|
||||
label: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface ChoiceOptions {
|
||||
title?: string;
|
||||
message: ReactNode;
|
||||
cancelLabel?: string;
|
||||
/** Rendered left→right after Cancel. Resolves to the clicked choice's `id`. */
|
||||
choices: Choice[];
|
||||
}
|
||||
|
||||
export type ChoiceFn = (opts: ChoiceOptions) => Promise<string | null>;
|
||||
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
|
||||
|
||||
export const ChoiceContext = createContext<ChoiceFn>(() => Promise.resolve(null));
|
||||
|
||||
/** Two outcomes: resolves true on confirm, false on cancel/Escape/backdrop. */
|
||||
export function useConfirm(): ConfirmFn {
|
||||
const ask = useContext(ChoiceContext);
|
||||
return useCallback(
|
||||
async (opts: ConfirmOptions) => {
|
||||
const picked = await ask({
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
cancelLabel: opts.cancelLabel,
|
||||
choices: [{ id: "confirm", label: opts.confirmLabel ?? "", danger: opts.danger }],
|
||||
});
|
||||
return picked === "confirm";
|
||||
},
|
||||
[ask],
|
||||
);
|
||||
}
|
||||
|
||||
/** Three or more outcomes. Resolves to the chosen id, or **null** when the user cancels,
|
||||
* presses Escape or clicks the backdrop — dismissal is never one of the actions. */
|
||||
export function useChoice(): ChoiceFn {
|
||||
return useContext(ChoiceContext);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Link2 } from "lucide-react";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
import { type ActionMenuItem } from "./ActionMenu";
|
||||
|
||||
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
||||
// (DESIGN.md C1, "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a
|
||||
// finger cannot select is copied from here. One builder, so every object says it the same way and
|
||||
// the clipboard write always runs inside the gesture (`inGesture`, which Safari insists on).
|
||||
//
|
||||
// A feature file, not `ui/`: it speaks the app's notifications.
|
||||
|
||||
type Named = "title" | "name";
|
||||
|
||||
export function useCopyItems() {
|
||||
const { t } = useTranslation();
|
||||
const copy = (text: string, done: string) => {
|
||||
void copyText(text).then((ok) =>
|
||||
notify(
|
||||
ok
|
||||
? { level: "success", message: done }
|
||||
: { level: "error", message: t("common.copy.failed") },
|
||||
),
|
||||
);
|
||||
};
|
||||
/** The rows for one object. `text` is its title (a video, a show) or its name (a channel, a
|
||||
* playlist); a row whose value is missing is left out rather than copying an empty string.
|
||||
* `linkLabel` names the link row where "Copy link" would be ambiguous (a library file's menu also
|
||||
* copies its share link). */
|
||||
return (
|
||||
text: string | null | undefined,
|
||||
link: string | null | undefined,
|
||||
named: Named = "title",
|
||||
linkLabel?: string,
|
||||
): ActionMenuItem[] => [
|
||||
...(text
|
||||
? [
|
||||
{
|
||||
id: "copy-text",
|
||||
label: t(named === "title" ? "common.copy.title" : "common.copy.name"),
|
||||
icon: <Copy className="w-4 h-4" />,
|
||||
inGesture: true,
|
||||
onSelect: () => copy(text, t("common.copy.done")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(link
|
||||
? [
|
||||
{
|
||||
id: "copy-link",
|
||||
label: linkLabel ?? t("common.copy.link"),
|
||||
icon: <Link2 className="w-4 h-4" />,
|
||||
inGesture: true,
|
||||
onSelect: () => copy(link, t("common.copy.linkDone")),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
@@ -6,9 +6,9 @@ import { describe, expect, it } from "vitest";
|
||||
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
|
||||
describe("every view that filters rows by column", () => {
|
||||
it("renders the hidden-filter cue", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab"))
|
||||
.map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const)
|
||||
.filter(([, src]) => /filterRows\(/.test(src));
|
||||
expect(sites.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
@@ -7,9 +7,9 @@ import { readSources } from "../../lib/rawSources.testkit";
|
||||
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
|
||||
// that decision is taken again.
|
||||
const files = readSources(__dirname, [
|
||||
"../**/*.tsx",
|
||||
"!../**/*.test.*",
|
||||
"!../design-lab/**",
|
||||
"../../**/*.tsx",
|
||||
"!../../**/*.test.*",
|
||||
"!../../design-lab/**",
|
||||
"!./**",
|
||||
]) as Record<string, string>;
|
||||
|
||||
@@ -17,14 +17,14 @@ describe("the pages with a background menu", () => {
|
||||
it("are the five the owner chose", () => {
|
||||
const users = Object.entries(files)
|
||||
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
|
||||
.map(([path]) => path.replace(/^\.\.\//, ""))
|
||||
.map(([path]) => path.replace(/^(\.\.\/)+/, ""))
|
||||
.sort();
|
||||
expect(users).toEqual([
|
||||
"Channels.tsx",
|
||||
"Feed.tsx",
|
||||
"NotificationsPanel.tsx",
|
||||
"Playlists.tsx",
|
||||
"PlexBrowse.tsx",
|
||||
"modules/channels/Channels.tsx",
|
||||
"modules/feed/Feed.tsx",
|
||||
"modules/notifications/NotificationsPanel.tsx",
|
||||
"modules/playlists/Playlists.tsx",
|
||||
"modules/plex/PlexBrowse.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,66 +10,74 @@ import { readSources } from "../../lib/rawSources.testkit";
|
||||
// someone decides.
|
||||
|
||||
const files = readSources(__dirname, [
|
||||
"../**/*.tsx",
|
||||
"!../**/*.test.*",
|
||||
"!../design-lab/**",
|
||||
"../../**/*.tsx",
|
||||
"!../../**/*.test.*",
|
||||
"!../../design-lab/**",
|
||||
]) as Record<string, string>;
|
||||
|
||||
/** file → [how many plain Pressables, why they are not titles]. */
|
||||
const PLAIN: Record<string, [number, string]> = {
|
||||
"ApiTokens.tsx": [
|
||||
"modules/settings/ApiTokens.tsx": [
|
||||
1,
|
||||
"a token kind's tile in the new-token dialog (an option: its text is its label)",
|
||||
],
|
||||
"DownloadTray.tsx": [
|
||||
"features/downloads/DownloadTray.tsx": [
|
||||
2,
|
||||
"the folded pill (its summary is its label); a failure's reason, which opens its details",
|
||||
],
|
||||
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
|
||||
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
||||
"Home.tsx": [
|
||||
"modules/files/FileCard.tsx": [
|
||||
1,
|
||||
"a card's picture (named by aria-label; its title is the caption)",
|
||||
],
|
||||
"modules/files/FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
||||
"modules/home/Home.tsx": [
|
||||
2,
|
||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||
],
|
||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
||||
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||
"PlexSearchBox.tsx": [1, "a listbox option"],
|
||||
"plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
|
||||
"Scheduler.tsx": [1, "the interval editor trigger"],
|
||||
"SettingsPanel.tsx": [1, "a colour swatch"],
|
||||
"SyncStatus.tsx": [1, "the status pill trigger"],
|
||||
"TagManager.tsx": [1, "a suggestion in a picker"],
|
||||
"UploadDialog.tsx": [1, "the drop zone"],
|
||||
"VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
"playlistColumns.tsx": [1, "the thumbnail"],
|
||||
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||
"plexViews.tsx": [1, "the rating filter (a number)"],
|
||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
||||
"ui/BackToTop.tsx": [1, "a control"],
|
||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
||||
"ui/NavRail.tsx": [
|
||||
"components/ui/LanguageSwitcher.tsx": [1, "an option row"],
|
||||
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
||||
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||
"features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||
"modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||
"modules/plex/PlexSearchBox.tsx": [1, "a listbox option"],
|
||||
"features/plexCards/plexCards.tsx": [
|
||||
1,
|
||||
"a card's picture (named by aria-label; its title is the caption)",
|
||||
],
|
||||
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
|
||||
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
|
||||
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
|
||||
"modules/channels/TagManager.tsx": [1, "a suggestion in a picker"],
|
||||
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
|
||||
"features/player/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
|
||||
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
|
||||
"components/ui/ActionMenu.tsx": [1, "a menu row"],
|
||||
"components/ui/BackToTop.tsx": [1, "a control"],
|
||||
"components/ui/CommandPalette.tsx": [1, "a command row"],
|
||||
"components/ui/NavRail.tsx": [
|
||||
2,
|
||||
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
|
||||
],
|
||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
||||
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||
"components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||
"components/ui/SidePanel.tsx": [2, "panel controls"],
|
||||
"components/ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||
};
|
||||
|
||||
function scan(): { plain: Record<string, number>; selectable: number } {
|
||||
const plain: Record<string, number> = {};
|
||||
let selectable = 0;
|
||||
for (const [path, code] of Object.entries(files)) {
|
||||
// Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/.
|
||||
const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, "");
|
||||
// Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/.
|
||||
const rel = path.startsWith("./")
|
||||
? `components/ui/${path.slice(2)}`
|
||||
: path.replace(/^(\.\.\/)+/, "");
|
||||
for (const m of code.matchAll(/<Pressable\b/g)) {
|
||||
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
|
||||
else plain[rel] = (plain[rel] ?? 0) + 1;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A Copy button beside its value: "Copied" for a moment and a notice a screen reader hears, or a
|
||||
// warning when the clipboard refused — the same at every such button.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
|
||||
const h = vi.hoisted(() => ({ copyText: vi.fn(async (_t: string) => true), notify: vi.fn() }));
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
|
||||
|
||||
import { useCopyFeedback } from "./useCopyFeedback";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
|
||||
describe("useCopyFeedback", () => {
|
||||
it("a copy that worked reads Copied for a moment and is said out loud", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(h.copyText).toHaveBeenCalledWith("sfl_x");
|
||||
expect(result.current.copied).toBe(true);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "success", message: "common.copy.done" });
|
||||
act(() => void vi.advanceTimersByTime(2000));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it("a copy again within the moment reads Copied for a whole moment again", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
act(() => void vi.advanceTimersByTime(1500));
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
act(() => void vi.advanceTimersByTime(1000)); // past the first copy's moment
|
||||
expect(result.current.copied).toBe(true);
|
||||
act(() => void vi.advanceTimersByTime(1000));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
|
||||
h.copyText.mockResolvedValueOnce(false);
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
expect(result.current.copied).toBe(false);
|
||||
expect(h.notify).toHaveBeenCalledWith({ level: "warning", message: "common.copy.failed" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { notify } from "../../lib/notifications";
|
||||
|
||||
// How long a Copy button reads "Copied" after it worked.
|
||||
const COPIED_MS = 2000;
|
||||
|
||||
/** The feedback of a Copy button that sits beside the value it copies (a token, a share link, a
|
||||
* set-password link), the same at every such button: it reads "Copied" for a moment, and a notice
|
||||
* says it too — the notices are a live region, so a screen reader hears what a swapped label would
|
||||
* not tell it. A copy that failed is a warning, not an error: the value is on screen and
|
||||
* selectable, so it is a convenience that failed, not the action.
|
||||
*
|
||||
* A feature file, not `ui/`: it speaks the app's notifications (as `copyItems` does). */
|
||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
const copy = async (value: string) => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
notify({ level: "success", message: t("common.copy.done") });
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
return;
|
||||
}
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
};
|
||||
return { copied, copy };
|
||||
}
|
||||
Reference in New Issue
Block a user