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
6 changes: 3 additions & 3 deletions app/customize/components/ControlsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function HexInput({
<label
htmlFor={`${id}-picker`}
title="Open color picker"
className="relative shrink-0 w-9 h-9 rounded-xl border border-black/10 dark:border-white/10 overflow-hidden cursor-pointer hover:border-emerald-500/50 transition-colors"
className="relative shrink-0 w-9 h-9 rounded-xl border border-black/10 dark:border-white/10 overflow-hidden cursor-pointer hover:border-emerald-500/50 focus-within:ring-2 focus-within:ring-emerald-500 transition-colors"
style={{ backgroundColor: swatchColor ?? '#1a1a1a' }}
>
{!swatchColor && (
Expand Down Expand Up @@ -70,7 +70,7 @@ function HexInput({
onChange={(e) => onChange(e.target.value.replace(/^#/, ''))}
placeholder={placeholder.replace(/^#/, '')}
maxLength={6}
className="w-full min-w-0 bg-gray-100/80 backdrop-blur-md border border-black/10 dark:bg-white/[0.03] dark:border-white/10 rounded-xl pl-7 pr-4 py-2.5 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/60 outline-none focus:border-emerald-500/50 transition-colors"
className="w-full min-w-0 bg-gray-100/80 backdrop-blur-md border border-black/10 dark:bg-white/[0.03] dark:border-white/10 rounded-xl pl-7 pr-4 py-2.5 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/60 outline-none focus:border-emerald-500/50 focus-visible:ring-2 focus-visible:ring-emerald-500 transition-colors"
/>
</div>
</div>
Expand Down Expand Up @@ -164,7 +164,7 @@ export function ControlsPanel({
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
placeholder={t('customize.controls.username_placeholder')}
className="w-full min-w-0 bg-white/60 backdrop-blur-md border border-black/10 dark:bg-black/40 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/60 outline-none focus:border-emerald-500/50 transition-all duration-300"
className="w-full min-w-0 bg-white/60 backdrop-blur-md border border-black/10 dark:bg-black/40 dark:border-white/10 rounded-xl px-4 py-2.5 text-sm font-mono text-black dark:text-emerald-300 placeholder:text-gray-400 dark:placeholder:text-white/60 outline-none focus:border-emerald-500/50 focus-visible:ring-2 focus-visible:ring-emerald-500 transition-all duration-300"
/>
</ControlRow>

Expand Down
57 changes: 43 additions & 14 deletions app/customize/components/ThemeQuickPresets.accessibility.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { ThemeQuickPresets } from './ThemeQuickPresets';

Expand All @@ -17,31 +17,60 @@ vi.mock('../types', () => ({
}));

describe('ThemeQuickPresets Accessibility Standards & Screen Reader Aria Compliance', () => {
it('uses correct accessible label coordinates (aria-label and aria-pressed)', () => {
it('renders container with role="radiogroup" and accessible label', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
const darkBtn = screen.getByRole('button', { name: /apply dark theme/i });
expect(darkBtn).toHaveAttribute('aria-label', 'Apply dark theme');
expect(darkBtn).toHaveAttribute('aria-pressed', 'true');
const lightBtn = screen.getByRole('button', { name: /apply light theme/i });
expect(lightBtn).toHaveAttribute('aria-pressed', 'false');
const group = screen.getByRole('radiogroup', { name: /theme presets/i });
expect(group).toBeInTheDocument();
});

it('ensures interactive buttons maintain visible focus outline behaviors', () => {
it('uses role="radio", aria-checked, and tabIndex={0} on theme tiles', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThan(0);
buttons.forEach((btn) => {
const darkRadio = screen.getByRole('radio', { name: /apply dark theme/i });
expect(darkRadio).toHaveAttribute('aria-checked', 'true');
expect(darkRadio).toHaveAttribute('tabindex', '0');
expect(darkRadio).toHaveAttribute('aria-label', 'Apply dark theme');

const lightRadio = screen.getByRole('radio', { name: /apply light theme/i });
expect(lightRadio).toHaveAttribute('aria-checked', 'false');
expect(lightRadio).toHaveAttribute('tabindex', '0');
});

it('ensures interactive buttons maintain visible focus outline behaviors with focus-visible:ring-2', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
const radios = screen.getAllByRole('radio');
expect(radios.length).toBeGreaterThan(0);
radios.forEach((btn) => {
btn.focus();
expect(document.activeElement).toBe(btn);
expect(btn).toBeVisible();
expect(btn).toHaveClass('focus-visible:ring-2');
});
});

it('announces applied theme preset via aria-live="polite" live region', () => {
render(<ThemeQuickPresets theme="neon" onThemeChange={vi.fn()} />);
const liveRegion = screen.getByRole('status');
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
expect(liveRegion).toHaveTextContent(/neon theme preset applied/i);
});

it('supports arrow key navigation (ArrowRight / ArrowLeft) to cycle through themes', () => {
const onThemeChange = vi.fn();
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
const darkRadio = screen.getByRole('radio', { name: /apply dark theme/i });

darkRadio.focus();
fireEvent.keyDown(darkRadio, { key: 'ArrowRight' });
expect(onThemeChange).toHaveBeenCalledWith('light');

fireEvent.keyDown(darkRadio, { key: 'ArrowLeft' });
expect(onThemeChange).toHaveBeenCalledWith('neon');
});

it('announces tooltip labels via title attribute for screen reader accessibility', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
const darkBtn = screen.getByRole('button', { name: /apply dark theme/i });
const darkBtn = screen.getByRole('radio', { name: /apply dark theme/i });
expect(darkBtn).toHaveAttribute('title', 'Dark');
const lightBtn = screen.getByRole('button', { name: /apply light theme/i });
const lightBtn = screen.getByRole('radio', { name: /apply light theme/i });
expect(lightBtn).toHaveAttribute('title', 'Light');
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ describe('ThemeQuickPresets Edge Cases & Empty/Missing Inputs Verification', ()
render(<ThemeQuickPresets theme="unknown-theme" onThemeChange={vi.fn()} />)
).not.toThrow();

expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
expect(screen.getAllByRole('radio').length).toBeGreaterThan(0);
});

it('renders preset buttons while no active theme matches', () => {
render(<ThemeQuickPresets theme="missing-theme" onThemeChange={vi.fn()} />);

const activeButtons = screen
.getAllByRole('button')
.getAllByRole('radio')
.filter((button) => button.getAttribute('aria-pressed') === 'true');

expect(activeButtons).toHaveLength(0);
Expand All @@ -40,7 +40,7 @@ describe('ThemeQuickPresets Edge Cases & Empty/Missing Inputs Verification', ()
it('renders buttons with fallback styling and accessible labels', () => {
render(<ThemeQuickPresets theme="not-configured" onThemeChange={vi.fn()} />);

const buttons = screen.getAllByRole('button');
const buttons = screen.getAllByRole('radio');

expect(buttons.length).toBeGreaterThan(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe('ThemeQuickPresets - Error Resilience', () => {
it('renders normally without crashing', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);

expect(screen.getByRole('button', { name: /apply dark theme/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /apply dark theme/i })).toBeInTheDocument();
});

it('recovers when child rendering throws', () => {
Expand Down Expand Up @@ -68,7 +68,7 @@ describe('ThemeQuickPresets - Error Resilience', () => {

rerender(<ThemeQuickPresets theme="light" onThemeChange={onThemeChange} />);

expect(screen.getByRole('button', { name: /apply light theme/i })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: /apply light theme/i })).toBeInTheDocument();
});

it('logs runtime errors through the error boundary', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('ThemeQuickPresets', () => {
it('renders one button for every selectable theme', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);

expect(screen.getAllByRole('button')).toHaveLength(3);
expect(screen.getAllByRole('radio')).toHaveLength(3);
});

it('shows active theme styling', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {
it('renders interactive theme buttons with tooltip titles', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);

const darkButton = screen.getByRole('button', { name: /apply dark theme/i });
const darkButton = screen.getByRole('radio', { name: /apply dark theme/i });

expect(darkButton).toHaveAttribute('title', 'Dark');
expect(darkButton).toHaveAttribute('aria-pressed', 'true');
Expand All @@ -17,7 +17,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {

render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

fireEvent.click(screen.getByRole('button', { name: /apply light theme/i }));
fireEvent.click(screen.getByRole('radio', { name: /apply light theme/i }));

expect(onThemeChange).toHaveBeenCalledTimes(1);
expect(onThemeChange).toHaveBeenCalledWith('light');
Expand All @@ -26,7 +26,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {
it('keeps hoverable preset buttons styled with pointer cursor class support', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);

const button = screen.getByRole('button', { name: /apply dark theme/i });
const button = screen.getByRole('radio', { name: /apply dark theme/i });

fireEvent.mouseEnter(button);

Expand All @@ -36,7 +36,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {
it('preserves active overlay visuals while hovering the selected preset', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);

const button = screen.getByRole('button', { name: /apply dark theme/i });
const button = screen.getByRole('radio', { name: /apply dark theme/i });

fireEvent.mouseEnter(button);

Expand All @@ -49,7 +49,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {

render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

const neonButton = screen.getByRole('button', { name: /apply neon theme/i });
const neonButton = screen.getByRole('radio', { name: /apply neon theme/i });

fireEvent.touchStart(neonButton);
fireEvent.click(neonButton);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,27 @@ describe('ThemeQuickPresets Responsive Breakpoints', () => {

render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
expect(screen.getAllByRole('radio').length).toBeGreaterThan(0);
});

it('renders the same preset buttons across viewport sizes', () => {
setViewport(375);

const { rerender } = render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

const mobileCount = screen.getAllByRole('button').length;
const mobileCount = screen.getAllByRole('radio').length;

setViewport(1280);

rerender(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

expect(screen.getAllByRole('button')).toHaveLength(mobileCount);
expect(screen.getAllByRole('radio')).toHaveLength(mobileCount);
});

it('does not introduce fixed-width layout issues on small screens', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

const container = screen.getAllByRole('button')[0].parentElement;
const container = screen.getAllByRole('radio')[0].parentElement;

expect(container).toBeTruthy();
expect(container?.className).toContain('theme-quick-presets');
Expand All @@ -76,7 +76,7 @@ describe('ThemeQuickPresets Responsive Breakpoints', () => {
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);

expect(
screen.getByRole('button', {
screen.getByRole('radio', {
name: /apply dark theme/i,
})
).toHaveAttribute('aria-pressed', 'true');
Expand Down
Loading
Loading