diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7d02ad02..90fa1955 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -191,7 +191,8 @@ Also in force: 3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add height, never width, so each module has a short rail label that fits, and the full name stays in the tooltip. No wide padding around the icons. -- **Loading (B8):** a first load of a grid, list or table shows its skeleton (a `Skeleton` primitive), so +- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" | +"poster" | "list" | "table"`, built from the `Skeleton` primitive), so nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears before ~300ms. [Nielsen #1] - **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the diff --git a/frontend/src/components/AdminLogs.tsx b/frontend/src/components/AdminLogs.tsx index a8ea913e..623d98cb 100644 --- a/frontend/src/components/AdminLogs.tsx +++ b/frontend/src/components/AdminLogs.tsx @@ -369,7 +369,7 @@ export default function AdminLogs() { {t("common.loadError")} ) : logs.isLoading ? ( - + ) : rows.length === 0 ? (

{t("adminLogs.empty")} diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx index 40853cd2..a475bff4 100644 --- a/frontend/src/components/AdminUsers.tsx +++ b/frontend/src/components/AdminUsers.tsx @@ -224,7 +224,7 @@ function UsersRoles({ me }: { me: Me }) { {t("common.loadError")} ) : q.isLoading && !q.data ? ( - + ) : rows.length === 0 ? (

{t("users.roles.empty")}

) : ( @@ -395,7 +395,7 @@ function PendingAccess() { {t("common.loadError")} ) : rows.isLoading && !rows.data ? ( - + ) : (
{list.map((u) => ( @@ -664,7 +664,7 @@ function AdminInvites() { {t("common.loadError")} ) : invites.isLoading && !invites.data ? ( - + ) : pending.length === 0 ? (

{t("settings.invites.noPending")}

) : ( diff --git a/frontend/src/components/AuditLog.tsx b/frontend/src/components/AuditLog.tsx index 9aafe88d..36a5316b 100644 --- a/frontend/src/components/AuditLog.tsx +++ b/frontend/src/components/AuditLog.tsx @@ -62,7 +62,7 @@ export default function AuditLog() { {t("common.loadError")} ) : q.isLoading ? ( - + ) : rows.length === 0 ? (

{t("audit.empty")}

) : ( diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index 2db1ff5d..f69b8ac7 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -228,7 +228,7 @@ export default function ChannelDiscovery({ {t("common.loadError")} ) : query.isLoading ? ( - + ) : layout === "table" ? ( ) : channelsQuery.isLoading ? ( - + ) : layout === "table" ? ( ) : jobsQ.isLoading ? ( - + ) : (
{t("downloads.empty.queue")} @@ -356,7 +356,7 @@ export default function DownloadCenter() { {t("common.loadError")} ) : jobsQ.isLoading ? ( - + ) : (
{t("downloads.empty.done")} @@ -411,7 +411,7 @@ export default function DownloadCenter() { {t("common.loadError")} ) : sharedQ.isLoading ? ( - + ) : (
{t("downloads.empty.shared")} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index f47acd10..c1e94f26 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -563,7 +563,7 @@ export default function Feed({ ); } - if (query.isLoading) return ; + if (query.isLoading) return ; if (query.isError && !query.data) return ( query.refetch()}> diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 3c11edd6..e41701d0 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -699,7 +699,7 @@ export default function Home() { if (surpriseRequest !== null && takeSurprise()) reroll("surprise"); }, [surpriseRequest, reroll]); - if (q.isPending) return ; + if (q.isPending) return ; // `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query // flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation // on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx index 83c50941..822ac33a 100644 --- a/frontend/src/components/Messages.tsx +++ b/frontend/src/components/Messages.tsx @@ -145,7 +145,7 @@ function ConversationList({ {t("common.loadError")} ) : q.isLoading && !q.data ? ( - + ) : items.length === 0 ? (
{t("messages.empty")}
) : ( @@ -247,7 +247,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac {t("common.loadError")} ) : q.isLoading ? ( - + ) : users.length === 0 ? (
{t("messages.noPeople")} diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 8b8fdfa3..b418ff4b 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -181,7 +181,7 @@ export default function NotificationsPanel() { {t("common.loadError")} ) : q.isLoading && !q.data && clientItems.length === 0 ? ( - + ) : !hasAny ? (
diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 929e88a5..60d4313c 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -844,7 +844,7 @@ export default function Playlists() { if (!detail) { return ( <> - + {player} ); diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index e8b57564..a4a289bd 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -261,7 +261,7 @@ export default function PlexCollectionView({ {t("common.loadError")} ) : q.isLoading || !d ? ( - + ) : ( <> ; + return ; } const d = q.data; diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index e5ed2233..aa480f8b 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -39,7 +39,7 @@ export default function PlexLibraryPicker({ if (!enabled) return null; if (debounced.length === 0) return <>{emptyHint}; - if (q.isLoading) return ; + if (q.isLoading) return ; if (q.isError) return

{t("common.loadError")}

; if (results.length === 0) return

{t("plex.collection.addNoResults")}

; diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index baf9687b..383da8f8 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -592,7 +592,7 @@ export default function PlexPlaylistView({ {t("common.loadError")} ) : q.isLoading ? ( - + ) : items.length === 0 ? (

{t("plex.playlist.empty")}

) : ( diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx index d9ced901..f2642ae7 100644 --- a/frontend/src/components/plexBrowseLists.tsx +++ b/frontend/src/components/plexBrowseLists.tsx @@ -49,7 +49,7 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void } ); } - if (q.isLoading) return ; + if (q.isLoading) return ; if (playlists.length === 0) return

{t("plex.playlist.none")}

; @@ -212,7 +212,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: {t("common.loadError")} ) : q.isLoading ? ( - + ) : collections.length === 0 ? (

{t("plex.filter.collectionEmpty")}

) : ( diff --git a/frontend/src/components/ui/QueryState.dom.test.tsx b/frontend/src/components/ui/QueryState.dom.test.tsx index 0b18581c..5e4183ca 100644 --- a/frontend/src/components/ui/QueryState.dom.test.tsx +++ b/frontend/src/components/ui/QueryState.dom.test.tsx @@ -75,3 +75,15 @@ describe("LoadingState reveal", () => { expect(revealDelay(container)).toBe(150); }); }); + +describe("LoadingState shape (B8)", () => { + it("shows the content's skeleton on the same clock, the label for a screen reader only", () => { + now = 90_000; + const { container } = render(); + const status = container.querySelector("[role=status]")!; + expect(revealDelay(container)).toBe(300); + expect(status.querySelector(".animate-spin")).toBeNull(); + expect(status.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + expect(status.querySelector(".sr-only")?.textContent).toBe("Loading playlists"); + }); +}); diff --git a/frontend/src/components/ui/QueryState.tsx b/frontend/src/components/ui/QueryState.tsx index d93334bc..8752b723 100644 --- a/frontend/src/components/ui/QueryState.tsx +++ b/frontend/src/components/ui/QueryState.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { AlertTriangle, Loader2 } from "lucide-react"; import { Button } from "./Button"; +import { SkeletonShapes, type SkeletonShape } from "./Skeleton"; // A loading state fades in only after a delay: most loads here answer in tens of milliseconds, and a // spinner shown for two frames before the content replaces it reads as a flicker, not as feedback. A @@ -22,15 +23,19 @@ function revealWait(since: number | undefined, delayMs: number): number { } // Centered loading state: a spinning accent ring + an optional label. The single shared loading -// visual for a page/section's first load, so all of them read the same (and can be swapped for -// skeletons later in one place). `role=status`/`aria-live` announces it to screen readers. +// visual for a page/section's first load, so all of them read the same. A grid, a list or a table +// passes its `shape` instead (B8): the skeleton of what is coming, on the same reveal clock, with the +// label kept for a screen reader only. `role=status`/`aria-live` announces it either way. export function LoadingState({ label, className = "", since, delayMs = LOADING_REVEAL_MS, + shape, }: { label?: ReactNode; + /** The content's shape: its skeleton replaces the spinner (B8). */ + shape?: SkeletonShape; className?: string; /** When the wait began (a `performance.now()` time), if earlier than this mount — the boot screen * passes 0, navigation start. Only used when this state starts a new clock. */ @@ -46,10 +51,18 @@ export function LoadingState({ reveal.lastGone = performance.now(); }; }, []); + const style = wait > 0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined; + if (shape) + return ( +
+ {label ?? t("common.loading")} + +
+ ); return (
0 ? { animation: `fadeIn 0.2s ease ${Math.round(wait)}ms both` } : undefined} + style={style} role="status" aria-live="polite" > diff --git a/frontend/src/components/ui/Skeleton.tsx b/frontend/src/components/ui/Skeleton.tsx new file mode 100644 index 00000000..a6a06ab3 --- /dev/null +++ b/frontend/src/components/ui/Skeleton.tsx @@ -0,0 +1,71 @@ +import clsx from "clsx"; +import { SURFACE } from "./surfaces"; + +// B8 (docs/DESIGN.md): a first load of a grid, list or table shows the SHAPE of what is coming, so +// nothing jumps when the data lands. One block primitive (a tonal fill, the Badge's 8% of the text +// colour, pulsing unless the user asked for less motion) and the four page shapes built from it. The +// shapes are shown through `LoadingState shape=…`, which owns the 300 ms reveal and the status role. + +export function Skeleton({ className }: { className?: string }) { + return ( +