From 760d54cf270c25ebdfaf50009e84df74c9681410 Mon Sep 17 00:00:00 2001 From: CrazyFreak <44674613+OffCrazyFreak@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:46:18 +0200 Subject: [PATCH 01/17] feat(a11y): Add skeleton kit and async section primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Add components/custom/skeleton/ primitives: SkeletonRegion, RepeatSkeleton, CountSkeleton, TextSkeleton, SectionHeaderSkeleton, TableSkeleton, ChartSkeleton and PageShellSkeleton - Add AsyncSection, which fixes the state order as pending, error, empty, data - Add ErrorState and toUserMessage, one entry point for turning either error shape into Croatian copy - Add useDataPending, plus the remembered row-count store and its hooks - Disable animate-pulse under prefers-reduced-motion The building blocks for the loading sweep, added first so later batches only compose them. AsyncSection makes the state order structural because several pages checked error or emptiness before loading had finished, which is what painted "Greška" and "(0)" on a cold cache. Notes: - animate-pulse was the one animation in the app that no reduced-motion rule covered, so skeletons would have kept pulsing for readers who asked for less. - CountSkeleton renders a span rather than the shared Skeleton div, because a heading only permits phrasing content. --- frontend/src/app/globals.css | 5 ++ .../custom/common/async-section.tsx | 47 +++++++++++++ .../components/custom/common/error-state.tsx | 56 ++++++++++++++++ .../custom/skeleton/chart-skeleton.tsx | 33 +++++++++ .../custom/skeleton/count-skeleton.tsx | 31 +++++++++ .../custom/skeleton/page-shell-skeleton.tsx | 22 ++++++ .../custom/skeleton/repeat-skeleton.tsx | 24 +++++++ .../skeleton/section-header-skeleton.tsx | 48 +++++++++++++ .../custom/skeleton/skeleton-region.tsx | 36 ++++++++++ .../custom/skeleton/table-skeleton.tsx | 34 ++++++++++ .../custom/skeleton/text-skeleton.tsx | 39 +++++++++++ .../src/hooks/use-remembered-row-count.ts | 38 +++++++++++ frontend/src/lib/api/error-message.ts | 34 ++++++++++ frontend/src/lib/query/use-data-pending.ts | 25 +++++++ frontend/src/lib/skeleton/row-count-store.ts | 67 +++++++++++++++++++ 15 files changed, 539 insertions(+) create mode 100644 frontend/src/components/custom/common/async-section.tsx create mode 100644 frontend/src/components/custom/common/error-state.tsx create mode 100644 frontend/src/components/custom/skeleton/chart-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/count-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/page-shell-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/repeat-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/section-header-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/skeleton-region.tsx create mode 100644 frontend/src/components/custom/skeleton/table-skeleton.tsx create mode 100644 frontend/src/components/custom/skeleton/text-skeleton.tsx create mode 100644 frontend/src/hooks/use-remembered-row-count.ts create mode 100644 frontend/src/lib/api/error-message.ts create mode 100644 frontend/src/lib/query/use-data-pending.ts create mode 100644 frontend/src/lib/skeleton/row-count-store.ts diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index aec7eb67..f2733f8e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -568,6 +568,11 @@ } @media (prefers-reduced-motion: reduce) { + /* Skeletons use Tailwind's animate-pulse, which no rule above covers. They + stay visible, just static, which is what reduced motion asks for. */ + [data-slot="skeleton"] { + animation: none; + } .spinner_9y7u { animation: none; } diff --git a/frontend/src/components/custom/common/async-section.tsx b/frontend/src/components/custom/common/async-section.tsx new file mode 100644 index 00000000..352f5b6a --- /dev/null +++ b/frontend/src/components/custom/common/async-section.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; + +import ErrorState from "@/components/custom/common/error-state"; + +interface IAsyncSectionProps { + /** From useAuthedQuery's `pending`, or useDataPending. Never a query's isLoading. */ + pending: boolean; + error?: unknown; + isEmpty?: boolean; + /** Shown while pending. Should mirror `children` closely enough that nothing shifts. */ + skeleton: ReactNode; + /** Defaults to the shared ErrorState. Override for a retry or a way back. */ + errorState?: ReactNode; + /** Shown when the fetch succeeded with nothing in it. Falls through to children if unset. */ + empty?: ReactNode; + children: ReactNode; +} + +/** + * Picks which of the four states a data-backed section renders, in a fixed + * order: pending, then error, then empty, then the data. + * + * The order being structural is the point. Every page used to hand-write this + * ternary chain, and several checked error or emptiness before loading had + * finished, so a cold cache painted "Greška" or "(0)" for a frame. There is no + * way to express that mistake through this component. + * + * Auth is deliberately absent: a missing session replaces the whole page rather + * than one section, so callers return LoginRequired early off `requiresAuth`. + */ +export default function AsyncSection({ + pending, + error, + isEmpty = false, + skeleton, + errorState, + empty, + children, +}: IAsyncSectionProps) { + if (pending) return <>{skeleton}; + + if (error) return <>{errorState ?? }; + + if (isEmpty && empty) return <>{empty}; + + return <>{children}; +} diff --git a/frontend/src/components/custom/common/error-state.tsx b/frontend/src/components/custom/common/error-state.tsx new file mode 100644 index 00000000..a6099df4 --- /dev/null +++ b/frontend/src/components/custom/common/error-state.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ReactNode } from "react"; +import { TriangleAlert } from "lucide-react"; + +import { toUserMessage } from "@/lib/api/error-message"; +import { cn } from "@/lib/utils"; + +interface IErrorStateProps { + /** The thrown value. Turned into copy by toUserMessage, never shown raw. */ + error?: unknown; + title?: string; + /** Shown when the error carries no message of its own. */ + fallbackMessage?: string; + icon?: ReactNode; + /** Retry, or a way back. Rendered under the message. */ + action?: ReactNode; + className?: string; +} + +/** + * The failed branch of every async section, so a failure reads the same + * everywhere instead of each page inventing its own red text. + */ +export default function ErrorState({ + error, + title = "Nešto je pošlo po zlu", + fallbackMessage = "Podatke trenutačno nije moguće učitati. Pokušaj ponovno.", + icon, + action, + className, +}: IErrorStateProps) { + return ( +
+ {icon ?? ( +
+ ); +} diff --git a/frontend/src/components/custom/skeleton/chart-skeleton.tsx b/frontend/src/components/custom/skeleton/chart-skeleton.tsx new file mode 100644 index 00000000..61860a36 --- /dev/null +++ b/frontend/src/components/custom/skeleton/chart-skeleton.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; + +interface IChartSkeletonProps { + /** Match the real chart's height so the card does not resize under the reader. */ + className?: string; +} + +/** + * A price chart's footprint: y-axis labels, plot area, x-axis labels. Bars, not + * a fake plot, since inventing a shape would read as real data for a moment. + */ +export default function ChartSkeleton({ className }: IChartSkeletonProps) { + return ( +
+
+ {Array.from({ length: 5 }, (_, index) => ( +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/custom/skeleton/count-skeleton.tsx b/frontend/src/components/custom/skeleton/count-skeleton.tsx new file mode 100644 index 00000000..83d18322 --- /dev/null +++ b/frontend/src/components/custom/skeleton/count-skeleton.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/lib/utils"; + +interface ICountSkeletonProps { + className?: string; +} + +/** + * Stands in for a "(N)" inside a heading, so a count never paints as 0 first. + * + * Sized in em against the heading's own font and one line box tall, so the real + * number swaps in without nudging the words either side of it. + * + * A span, not the shared Skeleton component, because that renders a div and a + * heading only permits phrasing content. It carries the same `data-slot`, so the + * reduced-motion rule in globals.css still switches its animation off. + * + * Hidden from assistive tech on purpose: the heading reads correctly without a + * number, whereas announcing a placeholder count would announce something false. + */ +export default function CountSkeleton({ className }: ICountSkeletonProps) { + return ( +