diff --git a/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx b/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx new file mode 100644 index 00000000..d6342cc8 --- /dev/null +++ b/apps/readest-app/src/__tests__/components/settings/ThemeModeSelector.test.tsx @@ -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( {}} />); + + expect(screen.getByRole('radiogroup')).not.toBeNull(); + const segments = screen.getAllByRole('radio'); + expect(segments).toHaveLength(3); + }); + + it('marks the active segment via aria-checked', () => { + render( {}} />); + + 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(); + + 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( {}} />); + + 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( {}} />); + + // 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'); + }); +}); diff --git a/apps/readest-app/src/components/settings/color/ThemeModeSelector.tsx b/apps/readest-app/src/components/settings/color/ThemeModeSelector.tsx index 7483bb71..24ccf3bf 100644 --- a/apps/readest-app/src/components/settings/color/ThemeModeSelector.tsx +++ b/apps/readest-app/src/components/settings/color/ThemeModeSelector.tsx @@ -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 = ({ 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 = ({ themeMode, onThem } }; - const handleAutoClick = () => { - deactivate(); - onThemeModeChange('auto'); - }; + const segments = [ + { mode: 'auto' as const, title: _('Auto Mode'), onClick: handleAutoClick, icon: }, + { + mode: 'light' as const, + title: _('Light Mode'), + onClick: handleLightClick, + icon: ( + + + + ), + }, + { + mode: 'dark' as const, + title: _('Dark Mode'), + onClick: handleDarkClick, + icon: ( + + + + ), + }, + ]; return (
{_('Theme Mode')} -
- - - + {/* 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). */} +
+ {segments.map(({ mode, title, onClick, icon }) => { + const active = themeMode === mode; + return ( + + ); + })}
);