Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions docs/LOADING_STATES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Revora Loading States Hierarchy & Guidelines

Standardized progress and loading indicators across the Revora-Frontend platform.

---

## 1. Design Tokens & Motion

To maintain a premium, glassmorphic aesthetic while ensuring accessibility, all loading components utilize a single, high-contrast shimmer token.

### Tokens
- `--shimmer-gradient`: `linear-gradient(90deg, rgba(148, 163, 184, 0.06) 25%, rgba(148, 163, 184, 0.18) 50%, rgba(148, 163, 184, 0.06) 75%)`
- Animation: `shimmer 1.6s infinite linear`

### Motion Accessibility (WCAG 2.1 AA)
For users who prefer reduced motion (`prefers-reduced-motion: reduce`):
- **Shimmer animations**: Paused (`animation-play-state: paused`) and fallback to a static, low-contrast solid background (`rgba(148, 163, 184, 0.1)`).
- **Spinning loader animations**: Slowed down to `3s` duration to avoid rapid, distracting motion.

---

## 2. Loading Hierarchy & Latency Thresholds

| Loading State | Component | Latency Threshold | Best Use Cases | Accessibility Requirement |
| :--- | :--- | :--- | :--- | :--- |
| **Button Spinner** | `<LoadingSpinner>` | `< 1s` | In-context actions (e.g., submitting forms, primary CTAs). | `aria-hidden="true"` if label text is present. |
| **Inline Progress** | `<ProgressBar>` | `1s - 3s` | Background tasks, file uploads, multi-step actions. | `role="progressbar"`, `aria-valuenow`. |
| **Section Skeleton** | `<Skeleton>` | `1s - 3s` | Dashboard cards, lists, table row replacements. | `aria-hidden="true"` (purely decorative). |
| **Page-Level Loader** | `<PageLoader>` | `> 3s` | Full application boot, major router transitions. | `role="status"`, `aria-live="polite"`. |

---

## 3. Component Details & Examples

### A. Button Spinner (`LoadingSpinner`)
Renders a brand-consistent spinner using Lucide's `Loader2`. Included automatically inside the `<Button>` component when the `loading` prop is set.

```tsx
import { LoadingSpinner } from './components/LoadingSpinner';

// Standalone Spinner
<LoadingSpinner label="Loading user data..." />

// Inside Button (automatically handles aria-hidden based on loading text)
<Button loading={true}>Saving settings</Button>
```

### B. Inline Progress Bar (`ProgressBar`)
Supports both determinate (percentage-based) and indeterminate (shimmering) loading behavior.

```tsx
import { ProgressBar } from './components/ProgressBar';

// Indeterminate Progress
<ProgressBar label="Updating system" />

// Determinate Progress with label text
<ProgressBar value={45} label="Uploading file" showLabelText={true} />
```

### C. Section Skeleton (`Skeleton`)
Generates structural shape placeholders to minimize layout shift (CLS) during content fetching.

```tsx
import { Skeleton } from './components/Skeleton';

// Avatar placeholder
<Skeleton variant="avatar" />

// Titled text paragraph block (renders 3 lines of text)
<div className="card">
<Skeleton variant="title" />
<Skeleton variant="text" count={3} />
</div>
```

### D. Page-Level Loader (`PageLoader`)
A full-screen frosted glass overlay designed for initial loading states.

```tsx
import { PageLoader } from './components/PageLoader';

// Full Screen Page Loader
<PageLoader label="Booting Revora Console..." />
```
12 changes: 10 additions & 2 deletions src/components/AuthSubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { CheckCircle, Loader2 } from 'lucide-react';
import { CheckCircle } from 'lucide-react';
import { LoadingSpinner } from './LoadingSpinner';

export type SubmitButtonState = 'idle' | 'loading' | 'success';

Expand Down Expand Up @@ -32,9 +33,16 @@ export const AuthSubmitButton: React.FC<AuthSubmitButtonProps> = ({
aria-busy={isLoading}
data-state={state}
>
{isLoading && <Loader2 className="auth-submit-button__icon auth-submit-button__spinner" size={18} aria-hidden="true" />}
{isLoading && (
<LoadingSpinner
className="auth-submit-button__icon auth-submit-button__spinner"
size={18}
aria-hidden="true"
/>
)}
{isSuccess && <CheckCircle className="auth-submit-button__icon" size={18} aria-hidden="true" />}
<span>{label}</span>
</button>
);
};

6 changes: 4 additions & 2 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { forwardRef } from 'react';
import { Loader2, Check } from 'lucide-react';
import { Check } from 'lucide-react';
import { LoadingSpinner } from './LoadingSpinner';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
Expand Down Expand Up @@ -29,7 +30,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
>
{loading && (
<Loader2 className="btn-spinner" size={18} aria-hidden="true" />
<LoadingSpinner className="btn-spinner" size={18} aria-hidden="true" />
)}
{success && (
<Check className="btn-check-icon" size={18} aria-hidden="true" />
Expand All @@ -41,3 +42,4 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
);

Button.displayName = 'Button';

32 changes: 32 additions & 0 deletions src/components/LoadingSpinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import React from 'react';
import { Loader2 } from 'lucide-react';

export interface LoadingSpinnerProps extends Omit<React.ComponentPropsWithoutRef<typeof Loader2>, 'color'> {
label?: string;
size?: number;
}

export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
className = '',
size = 18,
label = 'Loading',
'aria-hidden': ariaHidden,
...props
}) => {
// If aria-hidden is true (boolean or string), we don't expose it to screen readers.
const isHidden = ariaHidden === true || ariaHidden === 'true';

return (
<Loader2
className={`animate-spin-loader ${className}`}
size={size}
role={isHidden ? undefined : 'img'}
aria-label={isHidden ? undefined : label}
aria-hidden={isHidden ? 'true' : undefined}
{...props}
/>
);
};


LoadingSpinner.displayName = 'LoadingSpinner';
23 changes: 23 additions & 0 deletions src/components/PageLoader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { LoadingSpinner } from './LoadingSpinner';

export interface PageLoaderProps {
label?: string; // visible text and accessible status label.
showText?: boolean; // whether to visually display the text label below the spinner.
className?: string;
}

export const PageLoader: React.FC<PageLoaderProps> = ({
label = 'Loading application...',
showText = true,
className = '',
}) => {
return (
<div className={`page-loader-overlay ${className}`} role="status" aria-live="polite">
<LoadingSpinner size={40} label={label} />
{showText && <span className="page-loader-text">{label}</span>}
</div>
);
};

PageLoader.displayName = 'PageLoader';
55 changes: 55 additions & 0 deletions src/components/ProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react';

export interface ProgressBarProps {
value?: number; // percentage from 0 to 100. If omitted, progress is indeterminate.
label?: string; // accessible name for the progress bar.
showLabelText?: boolean; // if true, displays visual label and percentage.
className?: string;
}

export const ProgressBar: React.FC<ProgressBarProps> = ({
value,
label = 'Loading progress',
showLabelText = false,
className = '',
}) => {
const isIndeterminate = value === undefined;
const clampedValue = value !== undefined ? Math.max(0, Math.min(100, value)) : undefined;

return (
<div className={`progress-bar-wrapper ${className}`}>
{showLabelText && !isIndeterminate && (
<div
className="progress-bar-label-text"
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 'var(--spacing-2xs)',
fontSize: 'var(--font-size-xs)',
color: 'var(--text-muted)',
fontWeight: 'var(--font-weight-medium)'
}}
>
<span>{label}</span>
<span aria-hidden="true">{Math.round(clampedValue!)}%</span>
</div>
)}
<div
className="progress-bar-container"
role="progressbar"
aria-label={label}
aria-valuenow={clampedValue}
aria-valuemin={0}
aria-valuemax={100}
>
{isIndeterminate ? (
<div className="progress-bar-indeterminate" />
) : (
<div className="progress-bar-fill" style={{ width: `${clampedValue}%` }} />
)}
</div>
</div>
);
};

ProgressBar.displayName = 'ProgressBar';
70 changes: 70 additions & 0 deletions src/components/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react';

export interface SkeletonProps {
variant?: 'text' | 'title' | 'avatar' | 'rect';
width?: string | number;
height?: string | number;
count?: number; // number of skeleton units to render (useful for multiple text lines or repeating blocks)
className?: string;
}

export const Skeleton: React.FC<SkeletonProps> = ({
variant = 'rect',
width,
height,
count = 1,
className = '',
}) => {
const getVariantClass = () => {
switch (variant) {
case 'text':
return 'skeleton-text';
case 'title':
return 'skeleton-title';
case 'avatar':
return 'skeleton-avatar';
case 'rect':
default:
return 'skeleton-rect';
}
};

const style: React.CSSProperties = {
width: width !== undefined ? (typeof width === 'number' ? `${width}px` : width) : undefined,
height: height !== undefined ? (typeof height === 'number' ? `${height}px` : height) : undefined,
};

const elements = Array.from({ length: count }).map((_, index) => {
// If we're rendering multiple text lines, make the last one shorter (standard typographic placeholder pattern)
const isLastTextLine = variant === 'text' && count > 1 && index === count - 1;
const finalClass = `${getVariantClass()} ${isLastTextLine ? 'skeleton-text-short' : ''}`.trim();

return (
<span
key={index}
className={`skeleton ${finalClass} ${className}`}
style={style}
aria-hidden="true"
/>
);
});

if (count > 1) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--spacing-xs)',
width: '100%'
}}
>
{elements}
</div>
);
}

return elements[0];
};

Skeleton.displayName = 'Skeleton';
5 changes: 3 additions & 2 deletions src/components/StatusTimeline/StatusTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ import {
Check,
Circle,
AlertTriangle,
Loader2,
ChevronDown,
Clock,
Minus,
} from 'lucide-react';
import { LoadingSpinner } from '../LoadingSpinner';
import './StatusTimeline.css';

/* ─── Types ─────────────────────────────────────────────────── */
Expand Down Expand Up @@ -86,7 +86,7 @@ function getDefaultIcon(status: MilestoneStatus): React.ReactNode {
case 'completed':
return <Check size={16} aria-hidden="true" />;
case 'in-progress':
return <Loader2 size={16} aria-hidden="true" />;
return <LoadingSpinner size={16} aria-hidden="true" />;
case 'blocked':
return <AlertTriangle size={14} aria-hidden="true" />;
case 'skipped':
Expand All @@ -97,6 +97,7 @@ function getDefaultIcon(status: MilestoneStatus): React.ReactNode {
}
}


function getConnectorState(
currentStatus: MilestoneStatus,
nextStatus: MilestoneStatus,
Expand Down
Loading