Skip to content

Commit 1013e9f

Browse files
feat(customizer): add keyboard navigation and ARIA accessibility to customizer controls (#8244)
1 parent ded12c8 commit 1013e9f

14 files changed

Lines changed: 159 additions & 83 deletions

app/customize/components/ControlsPanel.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function HexInput({
3737
<label
3838
htmlFor={`${id}-picker`}
3939
title="Open color picker"
40-
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"
40+
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"
4141
style={{ backgroundColor: swatchColor ?? '#1a1a1a' }}
4242
>
4343
{!swatchColor && (
@@ -70,7 +70,7 @@ function HexInput({
7070
onChange={(e) => onChange(e.target.value.replace(/^#/, ''))}
7171
placeholder={placeholder.replace(/^#/, '')}
7272
maxLength={6}
73-
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"
73+
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"
7474
/>
7575
</div>
7676
</div>
@@ -164,7 +164,7 @@ export function ControlsPanel({
164164
value={username}
165165
onChange={(e) => onUsernameChange(e.target.value)}
166166
placeholder={t('customize.controls.username_placeholder')}
167-
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"
167+
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"
168168
/>
169169
</ControlRow>
170170

app/customize/components/ThemeQuickPresets.accessibility.test.tsx

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect, vi } from 'vitest';
22
import React from 'react';
3-
import { render, screen } from '@testing-library/react';
3+
import { fireEvent, render, screen } from '@testing-library/react';
44
import '@testing-library/jest-dom/vitest';
55
import { ThemeQuickPresets } from './ThemeQuickPresets';
66

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

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

29-
it('ensures interactive buttons maintain visible focus outline behaviors', () => {
26+
it('uses role="radio", aria-checked, and tabIndex={0} on theme tiles', () => {
3027
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
31-
const buttons = screen.getAllByRole('button');
32-
expect(buttons.length).toBeGreaterThan(0);
33-
buttons.forEach((btn) => {
28+
const darkRadio = screen.getByRole('radio', { name: /apply dark theme/i });
29+
expect(darkRadio).toHaveAttribute('aria-checked', 'true');
30+
expect(darkRadio).toHaveAttribute('tabindex', '0');
31+
expect(darkRadio).toHaveAttribute('aria-label', 'Apply dark theme');
32+
33+
const lightRadio = screen.getByRole('radio', { name: /apply light theme/i });
34+
expect(lightRadio).toHaveAttribute('aria-checked', 'false');
35+
expect(lightRadio).toHaveAttribute('tabindex', '0');
36+
});
37+
38+
it('ensures interactive buttons maintain visible focus outline behaviors with focus-visible:ring-2', () => {
39+
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
40+
const radios = screen.getAllByRole('radio');
41+
expect(radios.length).toBeGreaterThan(0);
42+
radios.forEach((btn) => {
3443
btn.focus();
3544
expect(document.activeElement).toBe(btn);
36-
expect(btn).toBeVisible();
45+
expect(btn).toHaveClass('focus-visible:ring-2');
3746
});
3847
});
3948

49+
it('announces applied theme preset via aria-live="polite" live region', () => {
50+
render(<ThemeQuickPresets theme="neon" onThemeChange={vi.fn()} />);
51+
const liveRegion = screen.getByRole('status');
52+
expect(liveRegion).toHaveAttribute('aria-live', 'polite');
53+
expect(liveRegion).toHaveTextContent(/neon theme preset applied/i);
54+
});
55+
56+
it('supports arrow key navigation (ArrowRight / ArrowLeft) to cycle through themes', () => {
57+
const onThemeChange = vi.fn();
58+
render(<ThemeQuickPresets theme="dark" onThemeChange={onThemeChange} />);
59+
const darkRadio = screen.getByRole('radio', { name: /apply dark theme/i });
60+
61+
darkRadio.focus();
62+
fireEvent.keyDown(darkRadio, { key: 'ArrowRight' });
63+
expect(onThemeChange).toHaveBeenCalledWith('light');
64+
65+
fireEvent.keyDown(darkRadio, { key: 'ArrowLeft' });
66+
expect(onThemeChange).toHaveBeenCalledWith('neon');
67+
});
68+
4069
it('announces tooltip labels via title attribute for screen reader accessibility', () => {
4170
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
42-
const darkBtn = screen.getByRole('button', { name: /apply dark theme/i });
71+
const darkBtn = screen.getByRole('radio', { name: /apply dark theme/i });
4372
expect(darkBtn).toHaveAttribute('title', 'Dark');
44-
const lightBtn = screen.getByRole('button', { name: /apply light theme/i });
73+
const lightBtn = screen.getByRole('radio', { name: /apply light theme/i });
4574
expect(lightBtn).toHaveAttribute('title', 'Light');
4675
});
4776

app/customize/components/ThemeQuickPresets.empty-fallback.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ describe('ThemeQuickPresets Edge Cases & Empty/Missing Inputs Verification', ()
1010
render(<ThemeQuickPresets theme="unknown-theme" onThemeChange={vi.fn()} />)
1111
).not.toThrow();
1212

13-
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
13+
expect(screen.getAllByRole('radio').length).toBeGreaterThan(0);
1414
});
1515

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

1919
const activeButtons = screen
20-
.getAllByRole('button')
20+
.getAllByRole('radio')
2121
.filter((button) => button.getAttribute('aria-pressed') === 'true');
2222

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

43-
const buttons = screen.getAllByRole('button');
43+
const buttons = screen.getAllByRole('radio');
4444

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

app/customize/components/ThemeQuickPresets.error-resilience.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe('ThemeQuickPresets - Error Resilience', () => {
3636
it('renders normally without crashing', () => {
3737
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
3838

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

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

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

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

7474
it('logs runtime errors through the error boundary', () => {

app/customize/components/ThemeQuickPresets.mock-integration.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ describe('ThemeQuickPresets', () => {
6565
it('renders one button for every selectable theme', () => {
6666
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
6767

68-
expect(screen.getAllByRole('button')).toHaveLength(3);
68+
expect(screen.getAllByRole('radio')).toHaveLength(3);
6969
});
7070

7171
it('shows active theme styling', () => {

app/customize/components/ThemeQuickPresets.mouse-interactivity.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('ThemeQuickPresets mouse interactivity', () => {
66
it('renders interactive theme buttons with tooltip titles', () => {
77
render(<ThemeQuickPresets theme="dark" onThemeChange={vi.fn()} />);
88

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

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

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

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

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

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

3131
fireEvent.mouseEnter(button);
3232

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

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

4141
fireEvent.mouseEnter(button);
4242

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

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

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

5454
fireEvent.touchStart(neonButton);
5555
fireEvent.click(neonButton);

app/customize/components/ThemeQuickPresets.responsive-breakpoints.test.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,27 +36,27 @@ describe('ThemeQuickPresets Responsive Breakpoints', () => {
3636

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

39-
expect(screen.getAllByRole('button').length).toBeGreaterThan(0);
39+
expect(screen.getAllByRole('radio').length).toBeGreaterThan(0);
4040
});
4141

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

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

47-
const mobileCount = screen.getAllByRole('button').length;
47+
const mobileCount = screen.getAllByRole('radio').length;
4848

4949
setViewport(1280);
5050

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

53-
expect(screen.getAllByRole('button')).toHaveLength(mobileCount);
53+
expect(screen.getAllByRole('radio')).toHaveLength(mobileCount);
5454
});
5555

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

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

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

7878
expect(
79-
screen.getByRole('button', {
79+
screen.getByRole('radio', {
8080
name: /apply dark theme/i,
8181
})
8282
).toHaveAttribute('aria-pressed', 'true');

0 commit comments

Comments
 (0)