fix: render theme switcher via portal to escape overflow/stacking context

This commit is contained in:
Nicolas Varrot
2026-02-13 08:44:26 +00:00
parent f2162c6731
commit 885cd0ea22

View File

@@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Palette, Sun, Moon, Monitor, Laptop, Check } from 'lucide-react'; import { Palette, Sun, Moon, Monitor, Laptop, Check } from 'lucide-react';
import { useTheme } from '../hooks/useTheme'; import { useTheme } from '../hooks/useTheme';
import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef'; import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef';
@@ -26,31 +27,54 @@ export function ThemeSwitcher() {
const { theme, accent, setTheme, setAccent } = useTheme(); const { theme, accent, setTheme, setAccent } = useTheme();
const t = useT(); const t = useT();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const btnRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, right: 0 });
const updatePos = useCallback(() => {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 8,
right: window.innerWidth - rect.right,
});
}, []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const handler = (e: MouseEvent) => { updatePos();
if (ref.current && !ref.current.contains(e.target as Node)) { const handleClickOutside = (e: MouseEvent) => {
setOpen(false); const target = e.target as Node;
} if (
btnRef.current && btnRef.current.contains(target) ||
panelRef.current && panelRef.current.contains(target)
) return;
setOpen(false);
}; };
// Use click (not mousedown) so button onClick fires first document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('click', handler, true); window.addEventListener('resize', updatePos);
return () => document.removeEventListener('click', handler, true); return () => {
}, [open]); document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', updatePos);
};
}, [open, updatePos]);
return ( return (
<div ref={ref} className="relative"> <>
<button <button
ref={btnRef}
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 rounded-2xl border border-[var(--pc-border)] bg-[var(--pc-bg-elevated)]/30 px-2.5 py-1.5 text-xs text-[var(--pc-text-muted)] hover:text-[var(--pc-text-secondary)] hover:bg-[var(--pc-bg-elevated)]/50 transition-colors" className="flex items-center gap-1.5 rounded-2xl border border-[var(--pc-border)] bg-[var(--pc-bg-elevated)]/30 px-2.5 py-1.5 text-xs text-[var(--pc-text-muted)] hover:text-[var(--pc-text-secondary)] hover:bg-[var(--pc-bg-elevated)]/50 transition-colors"
title={t('theme.title')} title={t('theme.title')}
> >
<Palette size={14} /> <Palette size={14} />
</button> </button>
{open && ( {open && createPortal(
<div className="absolute right-0 top-full mt-2 z-50 w-52 rounded-2xl border border-[var(--pc-border-strong)] bg-[var(--pc-bg-surface)] backdrop-blur-xl shadow-2xl p-3 animate-fade-in"> <div
ref={panelRef}
className="fixed z-[9999] w-52 rounded-2xl border border-[var(--pc-border-strong)] bg-[var(--pc-bg-surface)] backdrop-blur-xl shadow-2xl p-3 animate-fade-in"
style={{ top: pos.top, right: pos.right }}
>
<div className="text-[10px] uppercase tracking-wider text-[var(--pc-text-faint)] font-semibold mb-2"> <div className="text-[10px] uppercase tracking-wider text-[var(--pc-text-faint)] font-semibold mb-2">
{t('theme.mode')} {t('theme.mode')}
</div> </div>
@@ -61,8 +85,7 @@ export function ThemeSwitcher() {
return ( return (
<button <button
key={opt.value} key={opt.value}
onMouseDown={(e) => e.stopPropagation()} onClick={() => setTheme(opt.value)}
onClick={() => { setTheme(opt.value); }}
className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${ className={`flex-1 flex flex-col items-center gap-1 py-2 rounded-xl border text-xs transition-all ${
active active
? 'border-[var(--pc-accent)]/40 bg-[var(--pc-accent)]/10 text-[var(--pc-accent-light)]' ? 'border-[var(--pc-accent)]/40 bg-[var(--pc-accent)]/10 text-[var(--pc-accent-light)]'
@@ -82,7 +105,6 @@ export function ThemeSwitcher() {
{accentOptions.map(opt => ( {accentOptions.map(opt => (
<button <button
key={opt.value} key={opt.value}
onMouseDown={(e) => e.stopPropagation()}
onClick={() => setAccent(opt.value)} onClick={() => setAccent(opt.value)}
className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center" className="relative h-7 w-7 rounded-full border-2 transition-all flex items-center justify-center"
style={{ style={{
@@ -96,8 +118,9 @@ export function ThemeSwitcher() {
</button> </button>
))} ))}
</div> </div>
</div> </div>,
document.body
)} )}
</div> </>
); );
} }