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 &&