refactoring-backend #3
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
| `<SkeletonTable>` | Table/grid loading | Pass `rowCount` and `cellCount` to match real layout. |
|
||||
| `<SkeletonTableRow>` | Individual table rows | Renders a single animated skeleton row. |
|
||||
| `<SkeletonChart>` | Chart/graph loading | Pass `barCount` and `height` to match container dimensions. |
|
||||
| `<SkeletonStat>` | Stat badge loading | Shows label + value stacked. Pass `showLabel={false}` to hide label. |
|
||||
| `<SkeletonFormField>` | Form input loading | Shows label + input placeholder. Pass `inputHeight` for custom sizing. |
|
||||
| `<PageLoadingSkeleton>` | 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 <SkeletonTable rowCount={10} cellCount={6} />;
|
||||
}
|
||||
|
||||
// Loading chart
|
||||
if (chartLoading) {
|
||||
return <PageLoadingSkeleton type="chart" itemCount={12} chartHeight={220} />;
|
||||
}
|
||||
|
||||
// Loading multiple stats
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)" }}>
|
||||
{[1, 2, 3].map(i => <SkeletonStat key={i} />)}
|
||||
</div>
|
||||
|
||||
// Loading form
|
||||
<div style={{ gap: "16px", display: "flex", flexDirection: "column" }}>
|
||||
<SkeletonFormField />
|
||||
<SkeletonFormField />
|
||||
<SkeletonFormField showLabel={false} />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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 `<table>` elements or a third-party data grid |
|
||||
| Use semantic colour slots (`errorText`, `bodyBackground`) | Use descriptive palette slots (`red`, `neutralLight`) directly |
|
||||
|
||||
@@ -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
|
||||
<FluentProvider theme={theme}>
|
||||
|
||||
@@ -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 (
|
||||
<div className={styles.centred} style={placeholderStyle}>
|
||||
<Spinner label="Loading chart data…" />
|
||||
</div>
|
||||
<SkeletonChart barCount={12} height={minHeight} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 <PageLoadingSkeleton type="table" itemCount={10} cellCount={6} />;
|
||||
* if (loading) return <PageLoadingSkeleton type="chart" itemCount={12} chartHeight={220} />;
|
||||
* ```
|
||||
*/
|
||||
export function PageLoadingSkeleton({
|
||||
type = "table",
|
||||
itemCount,
|
||||
cellCount = 6,
|
||||
chartHeight = 200,
|
||||
}: PageLoadingSkeletonProps): React.JSX.Element {
|
||||
if (type === "chart") {
|
||||
return (
|
||||
<SkeletonChart
|
||||
barCount={itemCount ?? 7}
|
||||
height={chartHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SkeletonTable
|
||||
rowCount={itemCount ?? 5}
|
||||
cellCount={cellCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
78
frontend/src/components/skeletons/SkeletonChart.tsx
Normal file
78
frontend/src/components/skeletons/SkeletonChart.tsx
Normal file
@@ -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
|
||||
* <SkeletonChart barCount={12} height={220} />
|
||||
* ```
|
||||
*/
|
||||
export function SkeletonChart({ barCount = 7, height = 200 }: SkeletonChartProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const heightPx = String(height);
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonChart} style={{ height: `${heightPx}px` }} role="presentation">
|
||||
{Array.from({ length: barCount }).map((_, index: number) => {
|
||||
const randomHeight = 30 + Math.random() * 70;
|
||||
return (
|
||||
<div
|
||||
key={`bar-${String(index)}`}
|
||||
className={styles.bar}
|
||||
style={{ height: `${String(randomHeight)}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/skeletons/SkeletonFormField.tsx
Normal file
78
frontend/src/components/skeletons/SkeletonFormField.tsx
Normal file
@@ -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
|
||||
* <div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
* <SkeletonFormField />
|
||||
* <SkeletonFormField />
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function SkeletonFormField({ showLabel = true, inputHeight = 32 }: SkeletonFormFieldProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonField} role="presentation">
|
||||
{showLabel && <div className={styles.label} aria-hidden="true" />}
|
||||
<div
|
||||
className={styles.input}
|
||||
style={{ height: `${String(inputHeight)}px` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/skeletons/SkeletonStat.tsx
Normal file
74
frontend/src/components/skeletons/SkeletonStat.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* SkeletonStat component.
|
||||
*
|
||||
* A placeholder for stat cards and badges showing a pulsing skeleton that
|
||||
* matches the dimensions of actual stat content. Used for loading states
|
||||
* on dashboard status indicators.
|
||||
*/
|
||||
|
||||
import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
skeletonStat: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: tokens.spacingVerticalS,
|
||||
},
|
||||
label: {
|
||||
height: "12px",
|
||||
width: "60%",
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
},
|
||||
value: {
|
||||
height: "24px",
|
||||
width: "100%",
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkeletonStatProps {
|
||||
/** Whether to show the label line. Defaults to true. */
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a skeleton stat card with animated pulsing placeholder.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "16px" }}>
|
||||
* <SkeletonStat />
|
||||
* <SkeletonStat />
|
||||
* <SkeletonStat />
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function SkeletonStat({ showLabel = true }: SkeletonStatProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonStat} role="presentation">
|
||||
{showLabel && <div className={styles.label} aria-hidden="true" />}
|
||||
<div className={styles.value} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/skeletons/SkeletonTable.tsx
Normal file
58
frontend/src/components/skeletons/SkeletonTable.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* SkeletonTable component.
|
||||
*
|
||||
* A placeholder for DataGrid tables showing multiple skeleton rows with
|
||||
* animated pulsing. Used to display table structure during data load.
|
||||
*/
|
||||
|
||||
import {
|
||||
makeStyles,
|
||||
} from "@fluentui/react-components";
|
||||
import { SkeletonTableRow } from "./SkeletonTableRow";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
skeletonTable: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkeletonTableProps {
|
||||
/** Number of rows to render. Defaults to 5. */
|
||||
rowCount?: number;
|
||||
/** Number of cells per row. Defaults to 6. */
|
||||
cellCount?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a skeleton table with multiple animated rows.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <SkeletonTable rowCount={10} cellCount={5} />
|
||||
* ```
|
||||
*/
|
||||
export function SkeletonTable({ rowCount = 5, cellCount = 6 }: SkeletonTableProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonTable} role="presentation">
|
||||
{Array.from({ length: rowCount }).map((_, index: number) => (
|
||||
<SkeletonTableRow key={`row-${String(index)}`} cellCount={cellCount} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/skeletons/SkeletonTableRow.tsx
Normal file
76
frontend/src/components/skeletons/SkeletonTableRow.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* SkeletonTableRow component.
|
||||
*
|
||||
* A placeholder row for tables that mimics the layout and height of a real
|
||||
* DataGrid row. Used to show loading state while data is being fetched.
|
||||
* Prevents content shift by matching the actual row dimensions.
|
||||
*/
|
||||
|
||||
import {
|
||||
makeStyles,
|
||||
tokens,
|
||||
} from "@fluentui/react-components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const useStyles = makeStyles({
|
||||
skeletonRow: {
|
||||
display: "flex",
|
||||
padding: `${tokens.spacingVerticalS} 0`,
|
||||
gap: tokens.spacingHorizontalM,
|
||||
borderBottomWidth: "1px",
|
||||
borderBottomStyle: "solid",
|
||||
borderBottomColor: tokens.colorNeutralStroke2,
|
||||
},
|
||||
skeletonCell: {
|
||||
flex: "1 1 0",
|
||||
minWidth: "80px",
|
||||
backgroundColor: tokens.colorNeutralBackground1Hover,
|
||||
borderRadius: tokens.borderRadiusSmall,
|
||||
height: "16px",
|
||||
animation: "skeleton-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SkeletonTableRowProps {
|
||||
/** Number of cells to render. Defaults to 5. */
|
||||
cellCount?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Render a skeleton row that matches table dimensions.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <div>
|
||||
* {Array.from({ length: 5 }).map((_, i) => (
|
||||
* <SkeletonTableRow key={i} cellCount={6} />
|
||||
* ))}
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
export function SkeletonTableRow({ cellCount = 5 }: SkeletonTableRowProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonRow} role="presentation">
|
||||
{Array.from({ length: cellCount }).map((_, index: number) => (
|
||||
<div
|
||||
key={`cell-${String(index)}`}
|
||||
className={styles.skeletonCell}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Tests for SkeletonChart component.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkeletonChart } from "../SkeletonChart";
|
||||
|
||||
describe("SkeletonChart", () => {
|
||||
it("renders with default bar count", () => {
|
||||
const { container } = render(<SkeletonChart />);
|
||||
const bars = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(bars).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("renders with custom bar count", () => {
|
||||
const { container } = render(<SkeletonChart barCount={12} />);
|
||||
const bars = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(bars).toHaveLength(12);
|
||||
});
|
||||
|
||||
it("applies custom height", () => {
|
||||
const { container } = render(<SkeletonChart height={300} />);
|
||||
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
expect(wrapper).toHaveStyle("height: 300px");
|
||||
});
|
||||
|
||||
it("marks bars as aria-hidden", () => {
|
||||
const { container } = render(<SkeletonChart barCount={5} />);
|
||||
const bars = container.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(bars).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("has proper accessibility attributes", () => {
|
||||
const { container } = render(<SkeletonChart />);
|
||||
const chart = container.querySelector('[role="presentation"]');
|
||||
expect(chart).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Tests for SkeletonFormField component.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkeletonFormField } from "../SkeletonFormField";
|
||||
|
||||
describe("SkeletonFormField", () => {
|
||||
it("renders label by default", () => {
|
||||
const { container } = render(<SkeletonFormField />);
|
||||
const elements = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides label when showLabel is false", () => {
|
||||
const { container } = render(<SkeletonFormField showLabel={false} />);
|
||||
const elements = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies custom input height", () => {
|
||||
const { container } = render(<SkeletonFormField inputHeight={48} />);
|
||||
const divs = container.querySelectorAll('[role="presentation"] > div');
|
||||
const input = divs[divs.length - 1] as HTMLElement;
|
||||
expect(input).toHaveStyle("height: 48px");
|
||||
});
|
||||
|
||||
it("marks elements as aria-hidden", () => {
|
||||
const { container } = render(<SkeletonFormField />);
|
||||
const hidden = container.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(hidden.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has proper accessibility attributes", () => {
|
||||
const { container } = render(<SkeletonFormField />);
|
||||
const field = container.querySelector('[role="presentation"]');
|
||||
expect(field).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tests for SkeletonStat component.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkeletonStat } from "../SkeletonStat";
|
||||
|
||||
describe("SkeletonStat", () => {
|
||||
it("renders label by default", () => {
|
||||
const { container } = render(<SkeletonStat />);
|
||||
const elements = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(elements).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("hides label when showLabel is false", () => {
|
||||
const { container } = render(<SkeletonStat showLabel={false} />);
|
||||
const elements = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(elements).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks elements as aria-hidden", () => {
|
||||
const { container } = render(<SkeletonStat />);
|
||||
const hidden = container.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(hidden.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("has proper accessibility attributes", () => {
|
||||
const { container } = render(<SkeletonStat />);
|
||||
const stat = container.querySelector('[role="presentation"]');
|
||||
expect(stat).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Tests for SkeletonTable component.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkeletonTable } from "../SkeletonTable";
|
||||
|
||||
describe("SkeletonTable", () => {
|
||||
it("renders with default row and cell count", () => {
|
||||
const { container } = render(<SkeletonTable />);
|
||||
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
const rows = wrapper?.children ?? [];
|
||||
expect(rows).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders with custom row count", () => {
|
||||
const { container } = render(<SkeletonTable rowCount={10} />);
|
||||
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
const rows = wrapper?.children ?? [];
|
||||
expect(rows).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("renders with custom cell count per row", () => {
|
||||
const { container } = render(<SkeletonTable rowCount={2} cellCount={8} />);
|
||||
const wrapper = container.querySelector('[role="presentation"]') as HTMLElement;
|
||||
const rows = Array.from(wrapper?.children ?? []);
|
||||
let totalCells = 0;
|
||||
rows.forEach((row) => {
|
||||
totalCells += row.children.length;
|
||||
});
|
||||
expect(totalCells).toBe(16); // 2 rows × 8 cells
|
||||
});
|
||||
|
||||
it("has proper structure", () => {
|
||||
const { container } = render(<SkeletonTable rowCount={1} cellCount={3} />);
|
||||
const table = container.querySelector('div[role="presentation"]');
|
||||
expect(table).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tests for SkeletonTableRow component.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SkeletonTableRow } from "../SkeletonTableRow";
|
||||
|
||||
describe("SkeletonTableRow", () => {
|
||||
it("renders with default cell count", () => {
|
||||
const { container } = render(<SkeletonTableRow />);
|
||||
const cells = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(cells).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders with custom cell count", () => {
|
||||
const { container } = render(<SkeletonTableRow cellCount={8} />);
|
||||
const cells = container.querySelectorAll('[role="presentation"] > div');
|
||||
expect(cells).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("marks cells as aria-hidden", () => {
|
||||
const { container } = render(<SkeletonTableRow cellCount={3} />);
|
||||
const cells = container.querySelectorAll('[aria-hidden="true"]');
|
||||
expect(cells).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("has proper accessibility attributes", () => {
|
||||
const { container } = render(<SkeletonTableRow />);
|
||||
const row = container.querySelector('[role="presentation"]');
|
||||
expect(row).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
11
frontend/src/components/skeletons/index.ts
Normal file
11
frontend/src/components/skeletons/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Skeleton components index.
|
||||
*
|
||||
* Re-exports all skeleton loading placeholders for convenient importing.
|
||||
*/
|
||||
|
||||
export { SkeletonChart } from "./SkeletonChart";
|
||||
export { SkeletonFormField } from "./SkeletonFormField";
|
||||
export { SkeletonStat } from "./SkeletonStat";
|
||||
export { SkeletonTable } from "./SkeletonTable";
|
||||
export { SkeletonTableRow } from "./SkeletonTableRow";
|
||||
43
frontend/src/utils/skeletonStyles.ts
Normal file
43
frontend/src/utils/skeletonStyles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Global skeleton animation styles.
|
||||
*
|
||||
* This module injects CSS for skeleton animations into the document.
|
||||
* It must be imported once at app initialization.
|
||||
*/
|
||||
|
||||
const SKELETON_STYLES = `
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Inject skeleton animation styles into the document head.
|
||||
* Call this once during app initialization.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In main.tsx or App.tsx
|
||||
* injectSkeletonStyles();
|
||||
* ```
|
||||
*/
|
||||
export function injectSkeletonStyles(): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Avoid injecting twice
|
||||
if (document.getElementById("skeleton-styles")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "skeleton-styles";
|
||||
style.textContent = SKELETON_STYLES;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
Reference in New Issue
Block a user