From ca23858946934351a9c92b0ab6650c652dd0a19f Mon Sep 17 00:00:00 2001 From: Lukas Date: Tue, 28 Apr 2026 09:40:10 +0200 Subject: [PATCH] Add skeleton loading components for progressive UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement standardized skeleton loading placeholders to reduce perceived loading time and prevent layout shift during data fetches. These components match actual content dimensions exactly, improving perceived responsiveness. New skeleton components in src/components/skeletons/: - SkeletonTable: Table/grid loading with customizable rows and cells - SkeletonTableRow: Individual animated skeleton row - SkeletonChart: Chart/graph loading with bars matching dimensions - SkeletonStat: Stat card loading with label and value - SkeletonFormField: Form input loading placeholder - PageLoadingSkeleton: Convenience wrapper for page-level loading states Implementation details: - All skeletons use global 'skeleton-pulse' animation (2s cycle) - Dimensions match real content to prevent layout shift on arrival - Marked with aria-hidden and role=presentation for accessibility - Theme-aware colors using Fluent UI tokens - Respects prefers-reduced-motion setting Updates: - ChartStateWrapper: Uses SkeletonChart instead of spinner - PageFeedback: Added PageLoadingSkeleton component - App.tsx: Injects skeleton styles at startup - Web-Design.md: Added § 8a with loading UX guidance and usage examples All components tested (22 tests, 100% passing) and linted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Docs/Tasks.md | 19 ----- Docs/Web-Design.md | 78 ++++++++++++++++++- frontend/src/App.tsx | 4 + frontend/src/components/ChartStateWrapper.tsx | 6 +- frontend/src/components/PageFeedback.tsx | 60 +++++++++++++- .../components/skeletons/SkeletonChart.tsx | 78 +++++++++++++++++++ .../skeletons/SkeletonFormField.tsx | 78 +++++++++++++++++++ .../src/components/skeletons/SkeletonStat.tsx | 74 ++++++++++++++++++ .../components/skeletons/SkeletonTable.tsx | 58 ++++++++++++++ .../components/skeletons/SkeletonTableRow.tsx | 76 ++++++++++++++++++ .../__tests__/SkeletonChart.test.tsx | 39 ++++++++++ .../__tests__/SkeletonFormField.test.tsx | 40 ++++++++++ .../skeletons/__tests__/SkeletonStat.test.tsx | 33 ++++++++ .../__tests__/SkeletonTable.test.tsx | 40 ++++++++++ .../__tests__/SkeletonTableRow.test.tsx | 33 ++++++++ frontend/src/components/skeletons/index.ts | 11 +++ frontend/src/utils/skeletonStyles.ts | 43 ++++++++++ 17 files changed, 741 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/skeletons/SkeletonChart.tsx create mode 100644 frontend/src/components/skeletons/SkeletonFormField.tsx create mode 100644 frontend/src/components/skeletons/SkeletonStat.tsx create mode 100644 frontend/src/components/skeletons/SkeletonTable.tsx create mode 100644 frontend/src/components/skeletons/SkeletonTableRow.tsx create mode 100644 frontend/src/components/skeletons/__tests__/SkeletonChart.test.tsx create mode 100644 frontend/src/components/skeletons/__tests__/SkeletonFormField.test.tsx create mode 100644 frontend/src/components/skeletons/__tests__/SkeletonStat.test.tsx create mode 100644 frontend/src/components/skeletons/__tests__/SkeletonTable.test.tsx create mode 100644 frontend/src/components/skeletons/__tests__/SkeletonTableRow.test.tsx create mode 100644 frontend/src/components/skeletons/index.ts create mode 100644 frontend/src/utils/skeletonStyles.ts diff --git a/Docs/Tasks.md b/Docs/Tasks.md index fd972ab..7b5265e 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,22 +1,3 @@ -## 19) Provider dependency chain is implicit -- Where found: - - [frontend/src/App.tsx](frontend/src/App.tsx) -- Why this is needed: - - Order-sensitive providers can fail silently during future refactors. -- Goal: - - Make provider dependency order explicit and tested. -- What to do: - - Document provider order rationale. - - Add provider composition tests. -- Possible traps and issues: - - Hidden assumptions in auth/timezone/theme initialization. -- Docs changes needed: - - Add provider order contract. -- Doc references: - - [Docs/Web-Development.md](Docs/Web-Development.md) - ---- - ## 20) Loading UX lacks progressive/skeleton states - Where found: - [frontend/src/pages](frontend/src/pages) diff --git a/Docs/Web-Design.md b/Docs/Web-Design.md index 7aae979..ea00499 100644 --- a/Docs/Web-Design.md +++ b/Docs/Web-Design.md @@ -235,12 +235,86 @@ Use Fluent UI React components as the building blocks. The following mapping sho | Success messages | `MessageBar` (success) | "IP 1.2.3.4 has been banned in jail sshd." | | Error messages | `MessageBar` (error) | "Failed to connect to fail2ban server." | | Warning messages | `MessageBar` (warning) | "Blocklist import encountered 12 invalid entries." | -| Loading states | `Shimmer` | Apply to `DetailsList` rows and stat cards while data loads. | +| Loading states | `SkeletonTable` / `SkeletonChart` (preferred) or `Shimmer` (legacy) | Show skeleton placeholders matching layout. Use `PageLoadingSkeleton` for page-level loading. | | Empty states | Custom illustration + text | "No bans recorded in the last 24 hours." Centre on the content area. | | Tooltips | `Tooltip` / `TooltipHost` | Full IP info on hover, full regex on truncated text, icon-only button labels. | --- +## 8a. Loading States & Skeleton Components + +Progressive loading states improve perceived responsiveness and reduce cognitive load during data fetches. + +### When to Use Skeleton Placeholders + +Use skeleton loading states instead of spinners for regions with a fixed, known layout: +- **Tables** — Show skeleton rows that match the actual column layout and row height +- **Charts** — Show skeleton bars matching the chart dimensions +- **Stat cards** — Show skeleton badges matching actual stat dimensions +- **Forms** — Show skeleton fields matching input dimensions + +Use full-page spinners only when: +- Loading time is expected to be under 1 second +- Content layout is unknown or highly variable +- Entire page structure is being loaded + +### Skeleton Components + +BanGUI provides pre-built skeleton components in `src/components/skeletons/`: + +| Component | Usage | Notes | +|---|---|---| +| `` | Table/grid loading | Pass `rowCount` and `cellCount` to match real layout. | +| `` | Individual table rows | Renders a single animated skeleton row. | +| `` | Chart/graph loading | Pass `barCount` and `height` to match container dimensions. | +| `` | Stat badge loading | Shows label + value stacked. Pass `showLabel={false}` to hide label. | +| `` | Form input loading | Shows label + input placeholder. Pass `inputHeight` for custom sizing. | +| `` | Page-level loading | Convenience wrapper. Pass `type="table"` or `type="chart"`. | + +### Skeleton Implementation Details + +- **Dimensions:** Skeletons must exactly match real content dimensions (height, width, spacing) to prevent layout shift when content arrives. +- **Animation:** All skeletons use a subtle 2-second pulse animation (`skeleton-pulse` keyframe). The animation respects `prefers-reduced-motion`. +- **Accessibility:** Skeleton elements are marked `aria-hidden="true"` and `role="presentation"` — they are not part of the accessible tree. +- **Theming:** Skeleton colour uses `colorNeutralBackground1Hover` token for automatic light/dark mode support. + +### Usage Examples + +```tsx +// Loading table data +if (loading) { + return ; +} + +// Loading chart +if (chartLoading) { + return ; +} + +// Loading multiple stats +
+ {[1, 2, 3].map(i => )} +
+ +// Loading form +
+ + + +
+``` + +### Avoiding Layout Shift + +**Critical:** Skeleton components must reserve the exact space that real content will occupy. Test by: +1. Load skeleton while data is fetching +2. Observe the skeleton render to full height/width +3. Observe real content arriving — no movement or repaint of surrounding elements + +If content shifts when it arrives, adjust skeleton dimensions or parent container constraints. + +--- + ## 9. Tables & Data Grids Tables are the primary UI element in BanGUI. They must be treated with extreme care. @@ -387,7 +461,7 @@ export const sideNavCollapsedWidth = 48; | Reference theme tokens for all colours | Hard-code hex values like `#ff0000` in components | | Follow the 4 px spacing grid | Use arbitrary pixel values (13 px, 7 px, 19 px) | | Provide a tooltip for every icon-only button | Leave icons unlabelled and inaccessible | -| Use `Shimmer` for loading states | Show a blank screen or a standalone spinner with no context | +| Use skeleton placeholders for loading states | Show a blank screen or a standalone spinner with no context | | Design for both light and dark themes | Default to white backgrounds assuming light mode only | | Use `DetailsList` for all tabular data | Use raw HTML `` elements or a third-party data grid | | Use semantic colour slots (`errorText`, `bodyBackground`) | Use descriptive palette slots (`red`, `neutralLight`) directly | diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbf1989..df52d3f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { ErrorBoundary } from "./components/ErrorBoundary"; import { PageErrorBoundary } from "./components/PageErrorBoundary"; import { NotificationContainer } from "./components/NotificationContainer"; import { MainLayout } from "./layouts/MainLayout"; +import { injectSkeletonStyles } from "./utils/skeletonStyles"; const SetupPage = lazy(() => import("./pages/SetupPage").then((m) => ({ default: m.SetupPage }))); const LoginPage = lazy(() => import("./pages/LoginPage").then((m) => ({ default: m.LoginPage }))); @@ -70,6 +71,9 @@ function AppContents(): React.JSX.Element { const { colorMode } = useThemeMode(); const theme = colorMode === "dark" ? darkTheme : lightTheme; + // Inject skeleton animation styles once at app startup + injectSkeletonStyles(); + return ( // 2. FluentProvider — supplies Fluent UI theme and tokens diff --git a/frontend/src/components/ChartStateWrapper.tsx b/frontend/src/components/ChartStateWrapper.tsx index 3c49ce3..f2187af 100644 --- a/frontend/src/components/ChartStateWrapper.tsx +++ b/frontend/src/components/ChartStateWrapper.tsx @@ -11,12 +11,12 @@ import { MessageBar, MessageBarActions, MessageBarBody, - Spinner, Text, makeStyles, tokens, } from "@fluentui/react-components"; import { ArrowClockwiseRegular } from "@fluentui/react-icons"; +import { SkeletonChart } from "./skeletons"; // --------------------------------------------------------------------------- // Types @@ -113,9 +113,7 @@ export function ChartStateWrapper({ if (isLoading) { return ( -
- -
+ ); } diff --git a/frontend/src/components/PageFeedback.tsx b/frontend/src/components/PageFeedback.tsx index 4c7152f..fda8d22 100644 --- a/frontend/src/components/PageFeedback.tsx +++ b/frontend/src/components/PageFeedback.tsx @@ -1,11 +1,12 @@ /** * Reusable page-level feedback components. * - * Three shared building blocks for consistent data-loading UI across all pages: + * Four shared building blocks for consistent data-loading UI across all pages: * - * - {@link PageLoading} — Centred `Spinner` for full-region loading states. - * - {@link PageError} — `MessageBar` with an error message and a retry button. - * - {@link PageEmpty} — Centred neutral message for zero-result states. + * - {@link PageLoading} — Centred `Spinner` for full-region loading states. + * - {@link PageLoadingSkeleton} — Progressive skeleton placeholder for table/form loading. + * - {@link PageError} — `MessageBar` with an error message and a retry button. + * - {@link PageEmpty} — Centred neutral message for zero-result states. */ import { @@ -20,6 +21,7 @@ import { tokens, } from "@fluentui/react-components"; import { ArrowClockwiseRegular } from "@fluentui/react-icons"; +import { SkeletonTable, SkeletonChart } from "./skeletons"; // --------------------------------------------------------------------------- // Styles @@ -137,3 +139,53 @@ export function PageEmpty({ message }: PageEmptyProps): React.JSX.Element { ); } + +// --------------------------------------------------------------------------- +// PageLoadingSkeleton +// --------------------------------------------------------------------------- + +export interface PageLoadingSkeletonProps { + /** Type of skeleton to display: "table" or "chart". */ + type?: "table" | "chart"; + /** Number of rows/bars to render. Defaults to 5 for tables, 7 for charts. */ + itemCount?: number; + /** Number of cells per row (table only). Defaults to 6. */ + cellCount?: number; + /** Chart height in pixels (chart only). Defaults to 200. */ + chartHeight?: number; +} + +/** + * Progressive loading skeleton for tables, charts, and other data regions. + * + * Shows a non-interactive placeholder that matches the layout of the actual + * content, providing better perceived responsiveness than a plain spinner. + * + * @example + * ```tsx + * if (loading) return ; + * if (loading) return ; + * ``` + */ +export function PageLoadingSkeleton({ + type = "table", + itemCount, + cellCount = 6, + chartHeight = 200, +}: PageLoadingSkeletonProps): React.JSX.Element { + if (type === "chart") { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/skeletons/SkeletonChart.tsx b/frontend/src/components/skeletons/SkeletonChart.tsx new file mode 100644 index 0000000..d38540f --- /dev/null +++ b/frontend/src/components/skeletons/SkeletonChart.tsx @@ -0,0 +1,78 @@ +/** + * SkeletonChart component. + * + * A placeholder for charts that shows a pulsing gradient bar matching the + * chart container dimensions. Used during initial data load to improve + * perceived responsiveness. + */ + +import { + makeStyles, + tokens, +} from "@fluentui/react-components"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + skeletonChart: { + width: "100%", + height: "100%", + display: "flex", + alignItems: "flex-end", + gap: tokens.spacingHorizontalS, + padding: tokens.spacingVerticalM, + boxSizing: "border-box", + }, + bar: { + flex: "1 1 0", + backgroundColor: tokens.colorNeutralBackground1Hover, + borderRadius: tokens.borderRadiusSmall, + animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + }, +}); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SkeletonChartProps { + /** Number of bars to render. Defaults to 7. */ + barCount?: number; + /** Height of the chart container in pixels. Defaults to 200. */ + height?: number; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Render a skeleton chart with animated pulsing bars. + * + * @example + * ```tsx + * + * ``` + */ +export function SkeletonChart({ barCount = 7, height = 200 }: SkeletonChartProps): React.JSX.Element { + const styles = useStyles(); + const heightPx = String(height); + + return ( +
+ {Array.from({ length: barCount }).map((_, index: number) => { + const randomHeight = 30 + Math.random() * 70; + return ( + + ); +} diff --git a/frontend/src/components/skeletons/SkeletonFormField.tsx b/frontend/src/components/skeletons/SkeletonFormField.tsx new file mode 100644 index 0000000..c9626d0 --- /dev/null +++ b/frontend/src/components/skeletons/SkeletonFormField.tsx @@ -0,0 +1,78 @@ +/** + * SkeletonFormField component. + * + * A placeholder for form inputs and text fields showing a pulsing skeleton that + * matches the height and width of actual input fields. Used during form data load. + */ + +import { + makeStyles, + tokens, +} from "@fluentui/react-components"; + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +const useStyles = makeStyles({ + skeletonField: { + display: "flex", + flexDirection: "column", + gap: tokens.spacingVerticalS, + }, + label: { + height: "14px", + width: "30%", + backgroundColor: tokens.colorNeutralBackground1Hover, + borderRadius: tokens.borderRadiusSmall, + animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + }, + input: { + height: "32px", + width: "100%", + backgroundColor: tokens.colorNeutralBackground1Hover, + borderRadius: tokens.borderRadiusSmall, + animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite", + }, +}); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SkeletonFormFieldProps { + /** Whether to show the label placeholder. Defaults to true. */ + showLabel?: boolean; + /** Height of the input in pixels. Defaults to 32 (standard input height). */ + inputHeight?: number; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * Render a skeleton form field with animated pulsing placeholder. + * + * @example + * ```tsx + *
+ * + * + *
+ * ``` + */ +export function SkeletonFormField({ showLabel = true, inputHeight = 32 }: SkeletonFormFieldProps): React.JSX.Element { + const styles = useStyles(); + + return ( +
+ {showLabel &&