From 885cd0ea22612519c8828e723a1db5f77dcb3735 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Fri, 13 Feb 2026 08:44:26 +0000 Subject: [PATCH] fix: render theme switcher via portal to escape overflow/stacking context --- src/components/ThemeSwitcher.tsx | 59 ++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx index afb2ba5..4e5d8d3 100644 --- a/src/components/ThemeSwitcher.tsx +++ b/src/components/ThemeSwitcher.tsx @@ -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 { useTheme } from '../hooks/useTheme'; import type { ThemeName, AccentColor } from '../contexts/ThemeContextDef'; @@ -26,31 +27,54 @@ export function ThemeSwitcher() { const { theme, accent, setTheme, setAccent } = useTheme(); const t = useT(); const [open, setOpen] = useState(false); - const ref = useRef(null); + const btnRef = useRef(null); + const panelRef = useRef(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(() => { if (!open) return; - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } + updatePos(); + const handleClickOutside = (e: MouseEvent) => { + 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('click', handler, true); - return () => document.removeEventListener('click', handler, true); - }, [open]); + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('resize', updatePos); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('resize', updatePos); + }; + }, [open, updatePos]); return ( -
+ <> - {open && ( -
+ {open && createPortal( +
{t('theme.mode')}
@@ -61,8 +85,7 @@ export function ThemeSwitcher() { return ( ))}
-
+
, + document.body )} - + ); }