refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
17 changed files with 741 additions and 29 deletions
Showing only changes of commit ca23858946 - Show all commits

View File

@@ -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)

View File

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

View File

@@ -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}>

View File

@@ -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} />
);
}

View File

@@ -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}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View 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";

View 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);
}