feat(settings): redesign theme mode toggle as a segmented control (#4831) (#4913)

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:
Huang Xin
2026-07-04 00:23:24 +09:00
committed by GitHub
parent 745f28f346
commit 6391bfe788
2 changed files with 158 additions and 38 deletions
@@ -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>
);