fix: render theme switcher via portal to escape overflow/stacking context
This commit is contained in:
@@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user