mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
feat(web): add appearance setting (follow system / dark / light) (#253)
* feat(web): add appearance setting (follow system / dark / light) Add user-facing appearance preference to the settings page, allowing users to choose between Follow System, Dark, and Light themes. The preference is persisted to localStorage and takes priority over automatic detection in the existing theme pipeline. * fix(web): update theme on cross-tab appearance change The storage event handler only updated React state without calling updateScheme(), leaving data-theme and useTheme subscribers stale. * fix(web): move cross-tab appearance sync to global initializeTheme The storage event listener was inside useAppearance(), which is only mounted on the settings page. Other pages never received cross-tab theme updates. Move the listener into initializeTheme() so all pages respond to appearance changes from other tabs.
This commit is contained in:
@@ -22,6 +22,16 @@ vi.mock('@/hooks/useFontScale', () => ({
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks/useTheme', () => ({
|
||||
useAppearance: () => ({ appearance: 'system', setAppearance: vi.fn() }),
|
||||
getAppearanceOptions: () => [
|
||||
{ value: 'system', labelKey: 'settings.display.appearance.system' },
|
||||
{ value: 'dark', labelKey: 'settings.display.appearance.dark' },
|
||||
{ value: 'light', labelKey: 'settings.display.appearance.light' },
|
||||
],
|
||||
}))
|
||||
|
||||
// Mock languages
|
||||
vi.mock('@/lib/languages', () => ({
|
||||
getElevenLabsSupportedLanguages: () => [
|
||||
@@ -98,4 +108,17 @@ describe('SettingsPage', () => {
|
||||
expect(calledKeys).toContain('settings.about.appVersion')
|
||||
expect(calledKeys).toContain('settings.about.protocolVersion')
|
||||
})
|
||||
|
||||
it('renders the Appearance setting', () => {
|
||||
renderWithProviders(<SettingsPage />)
|
||||
expect(screen.getAllByText('Appearance').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getAllByText('Follow System').length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('uses correct i18n keys for Appearance setting', () => {
|
||||
const spyT = renderWithSpyT(<SettingsPage />)
|
||||
const calledKeys = spyT.mock.calls.map((call) => call[0])
|
||||
expect(calledKeys).toContain('settings.display.appearance')
|
||||
expect(calledKeys).toContain('settings.display.appearance.system')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation, type Locale } from '@/lib/use-translation'
|
||||
import { useAppGoBack } from '@/hooks/useAppGoBack'
|
||||
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
|
||||
import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale'
|
||||
import { useAppearance, getAppearanceOptions, type AppearancePreference } from '@/hooks/useTheme'
|
||||
import { PROTOCOL_VERSION } from '@hapi/protocol'
|
||||
|
||||
const locales: { value: Locale; nativeLabel: string }[] = [
|
||||
@@ -73,12 +74,15 @@ export default function SettingsPage() {
|
||||
const { t, locale, setLocale } = useTranslation()
|
||||
const goBack = useAppGoBack()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
|
||||
const [isFontOpen, setIsFontOpen] = useState(false)
|
||||
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const appearanceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const fontContainerRef = useRef<HTMLDivElement>(null)
|
||||
const voiceContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { fontScale, setFontScale } = useFontScale()
|
||||
const { appearance, setAppearance } = useAppearance()
|
||||
|
||||
// Voice language state - read from localStorage
|
||||
const [voiceLanguage, setVoiceLanguage] = useState<string | null>(() => {
|
||||
@@ -86,7 +90,9 @@ export default function SettingsPage() {
|
||||
})
|
||||
|
||||
const fontScaleOptions = getFontScaleOptions()
|
||||
const appearanceOptions = getAppearanceOptions()
|
||||
const currentLocale = locales.find((loc) => loc.value === locale)
|
||||
const currentAppearanceLabel = appearanceOptions.find((opt) => opt.value === appearance)?.labelKey ?? 'settings.display.appearance.system'
|
||||
const currentFontScaleLabel = fontScaleOptions.find((opt) => opt.value === fontScale)?.label ?? '100%'
|
||||
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
|
||||
|
||||
@@ -95,6 +101,11 @@ export default function SettingsPage() {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleAppearanceChange = (pref: AppearancePreference) => {
|
||||
setAppearance(pref)
|
||||
setIsAppearanceOpen(false)
|
||||
}
|
||||
|
||||
const handleFontScaleChange = (newScale: FontScale) => {
|
||||
setFontScale(newScale)
|
||||
setIsFontOpen(false)
|
||||
@@ -112,12 +123,15 @@ export default function SettingsPage() {
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isOpen && !isFontOpen && !isVoiceOpen) return
|
||||
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
if (isAppearanceOpen && appearanceContainerRef.current && !appearanceContainerRef.current.contains(event.target as Node)) {
|
||||
setIsAppearanceOpen(false)
|
||||
}
|
||||
if (isFontOpen && fontContainerRef.current && !fontContainerRef.current.contains(event.target as Node)) {
|
||||
setIsFontOpen(false)
|
||||
}
|
||||
@@ -128,15 +142,16 @@ export default function SettingsPage() {
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen, isFontOpen, isVoiceOpen])
|
||||
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
|
||||
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen && !isFontOpen && !isVoiceOpen) return
|
||||
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isVoiceOpen) return
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
setIsAppearanceOpen(false)
|
||||
setIsFontOpen(false)
|
||||
setIsVoiceOpen(false)
|
||||
}
|
||||
@@ -144,7 +159,7 @@ export default function SettingsPage() {
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen, isFontOpen, isVoiceOpen])
|
||||
}, [isOpen, isAppearanceOpen, isFontOpen, isVoiceOpen])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -223,6 +238,54 @@ export default function SettingsPage() {
|
||||
<div className="px-3 py-2 text-xs font-semibold text-[var(--app-hint)] uppercase tracking-wide">
|
||||
{t('settings.display.title')}
|
||||
</div>
|
||||
<div ref={appearanceContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsAppearanceOpen(!isAppearanceOpen)}
|
||||
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
|
||||
aria-expanded={isAppearanceOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className="text-[var(--app-fg)]">{t('settings.display.appearance')}</span>
|
||||
<span className="flex items-center gap-1 text-[var(--app-hint)]">
|
||||
<span>{t(currentAppearanceLabel)}</span>
|
||||
<ChevronDownIcon className={`transition-transform ${isAppearanceOpen ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isAppearanceOpen && (
|
||||
<div
|
||||
className="absolute right-3 top-full mt-1 min-w-[160px] rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg overflow-hidden z-50"
|
||||
role="listbox"
|
||||
aria-label={t('settings.display.appearance')}
|
||||
>
|
||||
{appearanceOptions.map((opt) => {
|
||||
const isSelected = appearance === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={() => handleAppearanceChange(opt.value)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 text-base text-left transition-colors ${
|
||||
isSelected
|
||||
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
|
||||
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
|
||||
}`}
|
||||
>
|
||||
<span>{t(opt.labelKey)}</span>
|
||||
{isSelected && (
|
||||
<span className="ml-2 text-[var(--app-link)]">
|
||||
<CheckIcon />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div ref={fontContainerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user