forked from akai/readest
The three theme-mode toggles were small btn-circle btn-sm icons spaced by gap-4, so on mobile they were hard to hit and easy to mis-tap. Replace them with a segmented control: an ARIA radiogroup of three adjacent radio segments sharing one track. Each segment is a full-height tap target (min 44px wide, 36px tall) with no dead space between them, and the active segment gets the app's canonical base-300 fill. In e-ink mode the active segment uses a solid eink-inverted fill instead of a nested border, so it stays legible without a second border clashing with the track outline. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
|
||||
/**
|
||||
* Redesign guard for issue #4831 (theme switcher hit targets & spacing).
|
||||
*
|
||||
* The three theme-mode toggles used to be tiny `btn-circle btn-sm` icons
|
||||
* separated by `gap-4`, so on mobile they were both hard to hit and easy to
|
||||
* mis-hit. They are now a segmented control: an ARIA `radiogroup` of three
|
||||
* adjacent `radio` segments, each with a comfortable tap target and no dead
|
||||
* space between them.
|
||||
*/
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/store/atmosphereStore', () => ({
|
||||
useAtmosphereStore: () => ({
|
||||
spinDirection: null,
|
||||
shaking: false,
|
||||
toggle: vi.fn(),
|
||||
toggleWithShake: vi.fn(),
|
||||
deactivate: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import ThemeModeSelector from '@/components/settings/color/ThemeModeSelector';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('ThemeModeSelector segmented control', () => {
|
||||
it('renders the three modes as a radiogroup of radio segments', () => {
|
||||
render(<ThemeModeSelector themeMode='light' onThemeModeChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole('radiogroup')).not.toBeNull();
|
||||
const segments = screen.getAllByRole('radio');
|
||||
expect(segments).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('marks the active segment via aria-checked', () => {
|
||||
render(<ThemeModeSelector themeMode='dark' onThemeModeChange={() => {}} />);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Dark Mode' }).getAttribute('aria-checked')).toBe(
|
||||
'true',
|
||||
);
|
||||
expect(screen.getByRole('radio', { name: 'Light Mode' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
);
|
||||
expect(screen.getByRole('radio', { name: 'Auto Mode' }).getAttribute('aria-checked')).toBe(
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
it('switches mode when an inactive segment is clicked', () => {
|
||||
const onThemeModeChange = vi.fn();
|
||||
render(<ThemeModeSelector themeMode='light' onThemeModeChange={onThemeModeChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Auto Mode' }));
|
||||
expect(onThemeModeChange).toHaveBeenCalledWith('auto');
|
||||
});
|
||||
|
||||
it('gives each segment a mobile-friendly tap target, not a tiny circle icon', () => {
|
||||
render(<ThemeModeSelector themeMode='auto' onThemeModeChange={() => {}} />);
|
||||
|
||||
for (const segment of screen.getAllByRole('radio')) {
|
||||
expect(segment.className).toContain('min-w-[2.75rem]');
|
||||
expect(segment.className).toContain('h-9');
|
||||
expect(segment.className).not.toContain('btn-circle');
|
||||
expect(segment.className).not.toContain('btn-sm');
|
||||
}
|
||||
});
|
||||
|
||||
it('marks the active segment for e-ink with a solid fill, not a nested border', () => {
|
||||
render(<ThemeModeSelector themeMode='light' onThemeModeChange={() => {}} />);
|
||||
|
||||
// The track carries the outer e-ink border...
|
||||
expect(screen.getByRole('radiogroup').className).toContain('eink-bordered');
|
||||
// ...and the active thumb inverts (solid base-content fill) rather than
|
||||
// adding a second border that would nest inside the track's outline.
|
||||
const active = screen.getByRole('radio', { name: 'Light Mode' });
|
||||
expect(active.className).toContain('eink-inverted');
|
||||
expect(active.className).not.toContain('eink-bordered');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { MdOutlineLightMode, MdOutlineDarkMode } from 'react-icons/md';
|
||||
import { TbSunMoon } from 'react-icons/tb';
|
||||
@@ -14,6 +15,11 @@ const ThemeModeSelector: React.FC<ThemeModeSelectorProps> = ({ themeMode, onThem
|
||||
const _ = useTranslation();
|
||||
const { spinDirection, shaking, toggle, toggleWithShake, deactivate } = useAtmosphereStore();
|
||||
|
||||
const handleAutoClick = () => {
|
||||
deactivate();
|
||||
onThemeModeChange('auto');
|
||||
};
|
||||
|
||||
const handleLightClick = () => {
|
||||
if (themeMode === 'light') {
|
||||
toggle();
|
||||
@@ -32,48 +38,77 @@ const ThemeModeSelector: React.FC<ThemeModeSelectorProps> = ({ themeMode, onThem
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoClick = () => {
|
||||
deactivate();
|
||||
onThemeModeChange('auto');
|
||||
};
|
||||
const segments = [
|
||||
{ mode: 'auto' as const, title: _('Auto Mode'), onClick: handleAutoClick, icon: <TbSunMoon /> },
|
||||
{
|
||||
mode: 'light' as const,
|
||||
title: _('Light Mode'),
|
||||
onClick: handleLightClick,
|
||||
icon: (
|
||||
<span
|
||||
className={
|
||||
spinDirection === 'cw'
|
||||
? 'animate-spin-cw'
|
||||
: spinDirection === 'ccw'
|
||||
? 'animate-spin-ccw'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<MdOutlineLightMode />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
mode: 'dark' as const,
|
||||
title: _('Dark Mode'),
|
||||
onClick: handleDarkClick,
|
||||
icon: (
|
||||
<span className={shaking ? 'animate-shake' : ''}>
|
||||
<MdOutlineDarkMode />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4'>
|
||||
<SettingLabel>{_('Theme Mode')}</SettingLabel>
|
||||
<div className='flex gap-4'>
|
||||
<button
|
||||
title={_('Auto Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'auto' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={handleAutoClick}
|
||||
>
|
||||
<TbSunMoon />
|
||||
</button>
|
||||
<button
|
||||
title={_('Light Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'light' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={handleLightClick}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
spinDirection === 'cw'
|
||||
? 'animate-spin-cw'
|
||||
: spinDirection === 'ccw'
|
||||
? 'animate-spin-ccw'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<MdOutlineLightMode />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
title={_('Dark Mode')}
|
||||
className={`btn btn-ghost btn-circle btn-sm ${themeMode === 'dark' ? 'btn-active bg-base-300' : ''}`}
|
||||
onClick={handleDarkClick}
|
||||
>
|
||||
<span className={shaking ? 'animate-shake' : ''}>
|
||||
<MdOutlineDarkMode />
|
||||
</span>
|
||||
</button>
|
||||
{/* Segmented control: three adjacent, equally sized radio segments share
|
||||
a single track. No `gap` between them, so there is no dead space to
|
||||
mis-tap, and each segment is a full-height rectangle instead of a
|
||||
32px icon — far easier to hit on touch screens (issue #4831). */}
|
||||
<div
|
||||
role='radiogroup'
|
||||
aria-label={_('Theme Mode')}
|
||||
className='bg-base-200 eink-bordered inline-flex items-center rounded-full p-0.5'
|
||||
>
|
||||
{segments.map(({ mode, title, onClick, icon }) => {
|
||||
const active = themeMode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={active}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'flex h-9 min-w-[2.75rem] items-center justify-center rounded-full px-3 text-lg transition-colors',
|
||||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||||
// e-ink: mark the active segment with a solid `eink-inverted`
|
||||
// fill (base-content bg, base-100 icon) instead of a border —
|
||||
// a bordered thumb would nest awkwardly inside the track's own
|
||||
// border. The track keeps its `eink-bordered` outline.
|
||||
active
|
||||
? 'bg-base-300 text-base-content eink-inverted shadow-sm'
|
||||
: 'text-base-content/60 hover:text-base-content',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user