feat(settings): replace single-spinner loading with skeleton rows on Thresholds and Sources

Replaces the centred `<Loader2 /> Loading` line in
`<ThresholdsEditorSection>` and `<SourcesSection>` with a skeleton
list whose row count matches the loaded UI (14 rows each — one per
`METRIC_ORDER` entry and one per `SOURCE_PRIORITY_METRIC_KEYS` entry
respectively). The skeleton rows reuse the live UI's container
classes (`border-border space-y-3 rounded-lg border p-4` on
Thresholds; `border-border bg-background/30 space-y-2 rounded-md
border p-3` on Sources) so the post-fetch swap lands in place. The
`<Skeleton>` primitive's `motion-reduce:animate-none` covers the
reduced-motion preference. Combined with the `min-h` shell reserve
from the previous commit, this removes the page-height jump that
landed under the sticky sidebar when these two sections finished
loading.
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:31:42 +02:00
1 parent e060ab33a2
commit 9c8660a4e0
2 files changed
+70 -14

No files matched your search

+32 -4
View File
@@ -40,6 +40,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -314,10 +315,7 @@ export function SourcesSection() {
</p>
{isLoading || !priority ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
<SourcesSkeletonList />
) : (
<div className="space-y-3">
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => {
@@ -564,3 +562,33 @@ export function SourcesSection() {
</section>
);
}
/**
* Skeleton placeholder rendered while `/api/auth/me/source-priority`
* is in flight. Reserves one row per `SOURCE_PRIORITY_METRIC_KEYS`
* entry at roughly the loaded height so the page does not jump when
* the fetched ladder list swaps in. The pulsing animation honours
* `prefers-reduced-motion` via Tailwind's `motion-reduce:animate-none`.
*/
function SourcesSkeletonList() {
return (
<div
className="space-y-3"
data-testid="sources-skeleton"
aria-hidden="true"
>
{SOURCE_PRIORITY_METRIC_KEYS.map((metric) => (
<div
key={metric}
className="border-border bg-background/30 space-y-2 rounded-md border p-3"
>
<Skeleton className="h-4 w-40" />
<div className="space-y-1">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
</div>
))}
</div>
);
}
@@ -3,15 +3,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
SlidersHorizontal,
RotateCcw,
Loader2,
AlertTriangle,
} from "lucide-react";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -147,10 +143,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
</div>
{isLoading || !data ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
{t("common.loading")}
</div>
<ThresholdsSkeletonList />
) : (
<div className="space-y-3">
{METRIC_ORDER.map((metric) => (
@@ -170,6 +163,41 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
);
}
/**
* Skeleton placeholder rendered while `/api/user/thresholds` is in
* flight. Reserves one row per `METRIC_ORDER` entry at roughly the
* loaded height so the page does not jump when the fetched list
* swaps in. The pulsing animation honours `prefers-reduced-motion`
* via Tailwind's `motion-reduce:animate-none`.
*/
function ThresholdsSkeletonList() {
return (
<div
className="space-y-3"
data-testid="thresholds-skeleton"
aria-hidden="true"
>
{METRIC_ORDER.map((metric) => (
<div
key={metric}
className="border-border space-y-3 rounded-lg border p-4"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
<div className="flex shrink-0 items-center gap-2">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-5 w-9 rounded-full" />
</div>
</div>
</div>
))}
</div>
);
}
interface MetricRowProps {
metric: ThresholdMetric;
effective: EffectiveRange | undefined;