Skip to content

Commit a32bbd4

Browse files
authored
Merge pull request #281 from soomtochukwu/feat/creator-impr-266-267
feat: implement scroll-to-top button and accessible section error boundary
2 parents 1c659ab + ac4d685 commit a32bbd4

5 files changed

Lines changed: 482 additions & 195 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { ChevronUp } from 'lucide-react';
4+
import { cn } from '@/lib/utils';
5+
6+
interface ScrollToTopProps {
7+
threshold?: number;
8+
className?: string;
9+
}
10+
11+
const ScrollToTop: React.FC<ScrollToTopProps> = ({
12+
threshold = 400,
13+
className,
14+
}) => {
15+
const [isVisible, setIsVisible] = useState(false);
16+
17+
useEffect(() => {
18+
const toggleVisibility = () => {
19+
if (window.scrollY > threshold) {
20+
setIsVisible(true);
21+
} else {
22+
setIsVisible(false);
23+
}
24+
};
25+
26+
window.addEventListener('scroll', toggleVisibility);
27+
return () => window.removeEventListener('scroll', toggleVisibility);
28+
}, [threshold]);
29+
30+
const scrollToTop = () => {
31+
window.scrollTo({
32+
top: 0,
33+
behavior: 'smooth',
34+
});
35+
};
36+
37+
return (
38+
<AnimatePresence>
39+
{isVisible && (
40+
<motion.button
41+
initial={{ opacity: 0, scale: 0.8 }}
42+
animate={{ opacity: 1, scale: 1 }}
43+
exit={{ opacity: 0, scale: 0.8 }}
44+
whileHover={{ scale: 1.1 }}
45+
whileTap={{ scale: 0.9 }}
46+
onClick={scrollToTop}
47+
className={cn(
48+
'fixed bottom-8 right-8 z-50 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-colors hover:bg-primary/90 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring md:h-12 md:w-12',
49+
className
50+
)}
51+
aria-label="Scroll to top"
52+
>
53+
<ChevronUp className="h-6 w-6" />
54+
</motion.button>
55+
)}
56+
</AnimatePresence>
57+
);
58+
};
59+
60+
export default ScrollToTop;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { Component, type ErrorInfo, type ReactNode } from 'react';
2+
import { AlertCircle, RefreshCw } from 'lucide-react';
3+
import { Button } from '@/components/ui/button';
4+
import { cn } from '@/lib/utils';
5+
6+
interface Props {
7+
children: ReactNode;
8+
sectionName?: string;
9+
minHeight?: string | number;
10+
className?: string;
11+
}
12+
13+
interface State {
14+
hasError: boolean;
15+
error: Error | null;
16+
}
17+
18+
class SectionErrorBoundary extends Component<Props, State> {
19+
public state: State = {
20+
hasError: false,
21+
error: null,
22+
};
23+
24+
public static getDerivedStateFromError(error: Error): State {
25+
return { hasError: true, error };
26+
}
27+
28+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
29+
console.error(`Uncaught error in section ${this.props.sectionName || 'Unknown'}:`, error, errorInfo);
30+
}
31+
32+
private handleRetry = () => {
33+
this.setState({ hasError: false, error: null });
34+
};
35+
36+
public render() {
37+
if (this.state.hasError) {
38+
return (
39+
<div
40+
className={cn(
41+
'flex w-full flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-border p-8 text-center',
42+
this.props.className
43+
)}
44+
style={{ minHeight: this.props.minHeight }}
45+
role="alert"
46+
aria-live="assertive"
47+
>
48+
<div className="flex flex-col items-center gap-2">
49+
<AlertCircle className="h-10 w-10 text-destructive" />
50+
<h3 className="text-lg font-semibold">
51+
Something went wrong in this section
52+
</h3>
53+
<p className="max-w-md text-sm text-muted-foreground">
54+
{this.props.sectionName
55+
? `We encountered an error while loading the ${this.props.sectionName}.`
56+
: 'We encountered an error while loading this content.'}
57+
Please try again or contact support if the issue persists.
58+
</p>
59+
</div>
60+
<Button
61+
variant="outline"
62+
size="sm"
63+
onClick={this.handleRetry}
64+
className="gap-2"
65+
>
66+
<RefreshCw className="h-4 w-4" />
67+
Retry
68+
</Button>
69+
</div>
70+
);
71+
}
72+
73+
return this.props.children;
74+
}
75+
}
76+
77+
export default SectionErrorBoundary;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2+
import { render, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react';
3+
import ScrollToTop from '@/components/common/ScrollToTop';
4+
5+
describe('ScrollToTop', () => {
6+
beforeEach(() => {
7+
vi.stubGlobal('scrollTo', vi.fn());
8+
// Set initial scroll position
9+
window.scrollY = 0;
10+
});
11+
12+
afterEach(() => {
13+
vi.unstubAllGlobals();
14+
});
15+
16+
it('is initially hidden', () => {
17+
render(<ScrollToTop threshold={100} />);
18+
expect(screen.queryByLabelText('Scroll to top')).not.toBeInTheDocument();
19+
});
20+
21+
it('appears after scrolling past the threshold', () => {
22+
render(<ScrollToTop threshold={100} />);
23+
24+
// Simulate scroll
25+
window.scrollY = 150;
26+
fireEvent.scroll(window);
27+
28+
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
29+
});
30+
31+
it('hides when scrolling back above the threshold', async () => {
32+
render(<ScrollToTop threshold={100} />);
33+
34+
// Scroll down
35+
window.scrollY = 150;
36+
fireEvent.scroll(window);
37+
expect(screen.getByLabelText('Scroll to top')).toBeInTheDocument();
38+
39+
// Scroll up
40+
window.scrollY = 50;
41+
fireEvent.scroll(window);
42+
43+
await waitForElementToBeRemoved(() => screen.queryByLabelText('Scroll to top'));
44+
});
45+
46+
it('triggers window.scrollTo when clicked', () => {
47+
render(<ScrollToTop threshold={100} />);
48+
49+
window.scrollY = 150;
50+
fireEvent.scroll(window);
51+
52+
const button = screen.getByLabelText('Scroll to top');
53+
fireEvent.click(button);
54+
55+
expect(window.scrollTo).toHaveBeenCalledWith({
56+
top: 0,
57+
behavior: 'smooth',
58+
});
59+
});
60+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { describe, expect, it, vi, afterEach } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import SectionErrorBoundary from '@/components/common/SectionErrorBoundary';
4+
5+
// Mock component that throws an error
6+
const BuggyComponent = ({ shouldThrow = false }: { shouldThrow?: boolean }) => {
7+
if (shouldThrow) {
8+
throw new Error('Test error');
9+
}
10+
return <div>Normal Content</div>;
11+
};
12+
13+
describe('SectionErrorBoundary', () => {
14+
afterEach(() => {
15+
vi.restoreAllMocks();
16+
});
17+
18+
it('renders children when no error occurs', () => {
19+
render(
20+
<SectionErrorBoundary>
21+
<BuggyComponent />
22+
</SectionErrorBoundary>
23+
);
24+
expect(screen.getByText('Normal Content')).toBeInTheDocument();
25+
});
26+
27+
it('renders fallback UI when an error occurs', () => {
28+
// Suppress console.error for the expected error
29+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
30+
31+
render(
32+
<SectionErrorBoundary sectionName="Test Section">
33+
<BuggyComponent shouldThrow={true} />
34+
</SectionErrorBoundary>
35+
);
36+
37+
expect(screen.getByRole('alert')).toBeInTheDocument();
38+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
39+
expect(screen.getByText(/Test Section/i)).toBeInTheDocument();
40+
expect(consoleSpy).toHaveBeenCalled();
41+
});
42+
43+
it('resets error state when Retry button is clicked', () => {
44+
vi.spyOn(console, 'error').mockImplementation(() => {});
45+
46+
const { rerender } = render(
47+
<SectionErrorBoundary>
48+
<BuggyComponent shouldThrow={true} />
49+
</SectionErrorBoundary>
50+
);
51+
52+
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
53+
54+
// Update children to not throw anymore
55+
rerender(
56+
<SectionErrorBoundary>
57+
<BuggyComponent shouldThrow={false} />
58+
</SectionErrorBoundary>
59+
);
60+
61+
// Click retry
62+
fireEvent.click(screen.getByText(/retry/i));
63+
64+
expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
65+
expect(screen.getByText('Normal Content')).toBeInTheDocument();
66+
});
67+
68+
it('applies custom minHeight and className', () => {
69+
vi.spyOn(console, 'error').mockImplementation(() => {});
70+
71+
render(
72+
<SectionErrorBoundary minHeight={500} className="custom-class">
73+
<BuggyComponent shouldThrow={true} />
74+
</SectionErrorBoundary>
75+
);
76+
77+
const alert = screen.getByRole('alert');
78+
expect(alert).toHaveStyle('min-height: 500px');
79+
expect(alert).toHaveClass('custom-class');
80+
});
81+
});

0 commit comments

Comments
 (0)