Skip to content
Merged
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
17 changes: 13 additions & 4 deletions src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import { usePreferences } from '@/lib/preferences';
* - Reads `preferences.theme` via `usePreferences()`.
* - Toggles between `'light'` and `'dark'` (treats `'system'` as dark for
* the first click so the user gets an explicit state immediately).
* - Renders `null` before hydration to prevent SSR mismatch (the
* `PreferencesProvider` already guards `isHydrated`, so on the first
* client render `preferences.theme` is the resolved stored value).
* - Renders a loading skeleton before hydration to avoid layout shift while
* the resolved theme state is still settling on the client.
*/
export function ThemeToggle() {
const { preferences, updatePreference } = usePreferences();
Expand All @@ -21,7 +20,17 @@ export function ThemeToggle() {
setMounted(true);
}, []);

if (!mounted) return null;
if (!mounted) {
return (
<button
type="button"
disabled
aria-hidden="true"
aria-busy="true"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-200 animate-pulse dark:bg-slate-700"
/>
);
}

const isDark = preferences.theme === 'dark';
const next = isDark ? 'light' : 'dark';
Expand Down
41 changes: 34 additions & 7 deletions src/components/__tests__/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ThemeToggle } from '../ThemeToggle';
import { PreferencesProvider, usePreferences } from '@/lib/preferences';
Expand Down Expand Up @@ -38,13 +38,29 @@ describe('ThemeToggle', () => {
resetCache();
});

it('renders null on the server (before mount)', () => {
// Simulate pre-mount by checking no button exists before useEffect fires
// We rely on the mounted guard: the component returns null until useEffect.
// In the Jest environment useEffect runs synchronously via act, so we
// just verify the button IS present after render (positive case).
it('renders a skeleton during the server render before hydration', () => {
const { renderToStaticMarkup } = require('react-dom/server.node');
const markup = renderToStaticMarkup(
<PreferencesProvider>
<ThemeToggle />
</PreferencesProvider>,
);

expect(markup).toContain('aria-hidden="true"');
expect(markup).toContain('aria-busy="true"');
expect(markup).toContain('h-9 w-9');
expect(markup).toContain('animate-pulse');
expect(markup).toContain('disabled=""');
expect(markup).not.toContain('aria-label="Switch to dark theme"');
expect(markup).not.toContain('aria-label="Switch to light theme"');
});

it('renders the final button after hydration', async () => {
renderToggle();
expect(screen.getByRole('button')).toBeInTheDocument();

await waitFor(() => {
expect(screen.getByRole('button', { name: /switch to dark theme/i })).toBeInTheDocument();
});
});

it('shows moon icon and "Switch to dark theme" label when theme is light', () => {
Expand Down Expand Up @@ -144,4 +160,15 @@ describe('ThemeToggle', () => {
);
expect(saved.theme).toBe('dark');
});

it('keeps the skeleton size stable while loading', () => {
const { renderToStaticMarkup } = require('react-dom/server.node');
const markup = renderToStaticMarkup(
<PreferencesProvider>
<ThemeToggle />
</PreferencesProvider>,
);

expect(markup).toContain('class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-slate-200 animate-pulse dark:bg-slate-700"');
});
});
185 changes: 131 additions & 54 deletions src/components/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,110 @@
'use client';

import React, { useRef, useEffect } from 'react';
import { usePreferences, Theme, AmountFormat, ToastDensity } from '@/lib/preferences';
import { usePreferences } from '@/lib/preferences';
import { reportError } from '@/lib/errorReporter';

const FOCUSABLE_SELECTORS =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

function RadioGroup<T extends string>({
options,
value,
onChange,
labelId,
ariaLabel,
containerClassName,
textClassName,
}: {
options: readonly T[];
value: T;
onChange: (val: T) => void;
labelId: string;
ariaLabel: string;
containerClassName: string;
textClassName: string;
}) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp'].includes(e.key)) {
e.preventDefault();
const radios = Array.from(e.currentTarget.querySelectorAll('[role="radio"]')) as HTMLButtonElement[];
const currentIndex = options.indexOf(value);
const nextIndex =
e.key === 'ArrowRight' || e.key === 'ArrowDown'
? (currentIndex + 1) % options.length
: (currentIndex - 1 + options.length) % options.length;

onChange(options[nextIndex]);
radios[nextIndex]?.focus();
}
};

return (
<div
className={containerClassName}
role="radiogroup"
aria-labelledby={labelId}
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
>
{options.map((option) => (
<button
key={option}
onClick={() => onChange(option)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(option);
}
}}
role="radio"
aria-checked={value === option}
tabIndex={value === option ? 0 : -1}
className={`px-3 py-2 text-sm rounded-md border transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 ${textClassName} ${
value === option
? 'border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] hover:border-[var(--muted-foreground)]'
}`}
>
{option}
</button>
))}
</div>
);
}

export class ThemeErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: Error) {
reportError(error, 'ThemeErrorBoundary');
}

reset = () => this.setState({ hasError: false });

render() {
if (this.state.hasError) {
return (
<div role="alert" className="p-4 border border-[var(--destructive)] rounded-md bg-[var(--destructive)]/10 text-sm space-y-3 my-4">
<p className="text-[var(--destructive)] font-medium">Theme section failed to load.</p>
<button
onClick={this.reset}
className="px-3 py-1.5 bg-[var(--surface)] border border-[var(--destructive)] text-[var(--destructive)] rounded-md hover:opacity-80 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--destructive)] focus-visible:ring-offset-2 font-medium"
aria-label="Retry loading theme section"
>
Retry
</button>
</div>
);
}
return this.props.children;
}
}

interface SettingsPanelProps {
isOpen: boolean;
onClose: () => void;
Expand Down Expand Up @@ -93,46 +192,32 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
<h3 className="text-sm font-semibold text-[var(--muted-foreground)] uppercase tracking-wider">Appearance</h3>

<div className="space-y-4">
<div>
<label id="theme-label" className="block text-sm font-medium mb-2 text-[var(--foreground)]">Theme</label>
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-labelledby="theme-label" aria-label="Theme">
{(['light', 'dark', 'system'] as Theme[]).map((t) => (
<button
key={t}
onClick={() => updatePreference('theme', t)}
role="radio"
aria-checked={preferences.theme === t}
className={`px-3 py-2 text-sm rounded-md border capitalize transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 ${
preferences.theme === t
? 'border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] hover:border-[var(--muted-foreground)]'
}`}
>
{t}
</button>
))}
<ThemeErrorBoundary>
<div>
<label id="theme-label" className="block text-sm font-medium mb-2 text-[var(--foreground)]">Theme</label>
<RadioGroup
options={['light', 'dark', 'system'] as const}
value={preferences.theme}
onChange={(val) => updatePreference('theme', val)}
labelId="theme-label"
ariaLabel="Theme"
containerClassName="grid grid-cols-3 gap-2"
textClassName="capitalize"
/>
</div>
</div>
</ThemeErrorBoundary>

<div>
<label id="currency-label" className="block text-sm font-medium mb-2 text-[var(--foreground)]">Currency Display</label>
<div className="grid grid-cols-3 gap-2" role="radiogroup" aria-labelledby="currency-label" aria-label="Currency Display">
{(['usd', 'ngn', 'compact'] as AmountFormat[]).map((f) => (
<button
key={f}
onClick={() => updatePreference('amountFormat', f)}
role="radio"
aria-checked={preferences.amountFormat === f}
className={`px-3 py-2 text-sm rounded-md border uppercase transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 ${
preferences.amountFormat === f
? 'border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] hover:border-[var(--muted-foreground)]'
}`}
>
{f}
</button>
))}
</div>
<RadioGroup
options={['usd', 'ngn', 'compact'] as const}
value={preferences.amountFormat}
onChange={(val) => updatePreference('amountFormat', val)}
labelId="currency-label"
ariaLabel="Currency Display"
containerClassName="grid grid-cols-3 gap-2"
textClassName="uppercase"
/>
</div>
</div>
</section>
Expand All @@ -144,23 +229,15 @@ export function SettingsPanel({ isOpen, onClose }: SettingsPanelProps) {
<div className="space-y-4">
<div>
<label id="density-label" className="block text-sm font-medium mb-2 text-[var(--foreground)]">Toast Density</label>
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-labelledby="density-label" aria-label="Toast Density">
{(['relaxed', 'compact'] as ToastDensity[]).map((d) => (
<button
key={d}
onClick={() => updatePreference('toastDensity', d)}
role="radio"
aria-checked={preferences.toastDensity === d}
className={`px-3 py-2 text-sm rounded-md border capitalize transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--primary)] focus-visible:ring-offset-2 ${
preferences.toastDensity === d
? 'border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]'
: 'border-[var(--border)] bg-[var(--surface)] text-[var(--foreground)] hover:border-[var(--muted-foreground)]'
}`}
>
{d}
</button>
))}
</div>
<RadioGroup
options={['relaxed', 'compact'] as const}
value={preferences.toastDensity}
onChange={(val) => updatePreference('toastDensity', val)}
labelId="density-label"
ariaLabel="Toast Density"
containerClassName="grid grid-cols-2 gap-2"
textClassName="capitalize"
/>
</div>

<div className="flex items-center justify-between">
Expand Down
Loading
Loading