mirror of
https://github.com/phranck/TUIkit.git
synced 2026-06-20 09:54:37 +00:00
Feat: Nav icons, theme switcher hydration fix, and pulse animation
- Add SF Symbol icons to nav links (book for Docs, </> for GitHub) - Fix theme button onClick broken by hydration mismatch: start with null on both server and client, sync via useEffect after mount - Active theme dot pulses with 3s ease-in-out glow animation - Stronger glow on active theme indicator (triple-layer box-shadow)
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
||||
SFArrowLeftArrowRight,
|
||||
SFSwift,
|
||||
SFCheckmarkCircleFill,
|
||||
SFBookFill,
|
||||
SFChevronLeftForwardslashChevronRight,
|
||||
} from "sf-symbols-lib/hierarchical";
|
||||
|
||||
/** Maps of icon names to SF Symbol constants for type-safe usage. */
|
||||
@@ -26,6 +28,8 @@ const icons = {
|
||||
arrows: SFArrowLeftArrowRight,
|
||||
swift: SFSwift,
|
||||
checkmark: SFCheckmarkCircleFill,
|
||||
book: SFBookFill,
|
||||
code: SFChevronLeftForwardslashChevronRight,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
||||
@@ -46,27 +46,21 @@ function getInitialTheme(): Theme {
|
||||
/** Provides theme state and applies it to the document root via data-theme attribute. */
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
/**
|
||||
* Initialize theme from DOM attribute that was set by blocking script in layout.tsx.
|
||||
* This avoids setState in useEffect and prevents hydration mismatches.
|
||||
* Start with null on both server and client to avoid hydration mismatch.
|
||||
* The blocking script in layout.tsx already sets data-theme on <html> before
|
||||
* first paint, so there is no FOUC. React state catches up in useEffect below.
|
||||
*/
|
||||
const [theme, setThemeState] = useState<Theme | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const attr = document.documentElement.getAttribute("data-theme") as Theme;
|
||||
return attr || null;
|
||||
});
|
||||
const [theme, setThemeState] = useState<Theme | null>(null);
|
||||
|
||||
/**
|
||||
* After hydration, ensure theme is set if it wasn't captured in initial state.
|
||||
* This is a safety net for edge cases.
|
||||
* After hydration, read the actual theme from DOM/localStorage.
|
||||
* This runs once on mount and sets the React state to match reality.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (theme === null) {
|
||||
const actual = getInitialTheme();
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setThemeState(actual);
|
||||
document.documentElement.setAttribute("data-theme", actual);
|
||||
}
|
||||
}, [theme]);
|
||||
const actual = getInitialTheme();
|
||||
setThemeState(actual);
|
||||
document.documentElement.setAttribute("data-theme", actual);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
setThemeState(next);
|
||||
|
||||
@@ -28,26 +28,28 @@ export default function ThemeSwitcher() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{themes.map((themeOption) => (
|
||||
<button
|
||||
key={themeOption}
|
||||
onClick={() => setTheme(themeOption)}
|
||||
aria-label={`Switch to ${themeLabels[themeOption]} theme`}
|
||||
className="group relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-transform hover:scale-110"
|
||||
>
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full transition-all"
|
||||
style={{
|
||||
backgroundColor: themeColors[themeOption],
|
||||
boxShadow:
|
||||
theme === themeOption
|
||||
? `0 0 6px ${themeColors[themeOption]}, 0 0 14px ${themeColors[themeOption]}60`
|
||||
{themes.map((themeOption) => {
|
||||
const isActive = theme === themeOption;
|
||||
return (
|
||||
<button
|
||||
key={themeOption}
|
||||
onClick={() => setTheme(themeOption)}
|
||||
aria-label={`Switch to ${themeLabels[themeOption]} theme`}
|
||||
className="group relative flex h-7 w-7 cursor-pointer items-center justify-center rounded-full transition-transform hover:scale-110"
|
||||
>
|
||||
<span
|
||||
className={`block h-3 w-3 rounded-full ${isActive ? "animate-[theme-pulse_3s_ease-in-out_infinite]" : "transition-all"}`}
|
||||
style={{
|
||||
backgroundColor: themeColors[themeOption],
|
||||
boxShadow: isActive
|
||||
? `0 0 8px ${themeColors[themeOption]}, 0 0 20px ${themeColors[themeOption]}90, 0 0 36px ${themeColors[themeOption]}40`
|
||||
: "none",
|
||||
opacity: theme === themeOption ? 1 : 0.4,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
opacity: isActive ? 1 : 0.35,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -298,6 +298,11 @@ code {
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes theme-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
|
||||
/* CRT scanline - moving element from top to bottom within container only */
|
||||
@keyframes crt-scanline-move {
|
||||
0% {
|
||||
|
||||
+4
-2
@@ -44,16 +44,18 @@ export default function Home() {
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="/documentation/tuikit"
|
||||
className="text-lg text-muted transition-colors hover:text-foreground"
|
||||
className="flex items-center gap-1.5 text-lg text-muted transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="book" size={16} className="text-current" />
|
||||
Documentation
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/phranck/TUIkit"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-lg text-muted transition-colors hover:text-foreground"
|
||||
className="flex items-center gap-1.5 text-lg text-muted transition-colors hover:text-foreground"
|
||||
>
|
||||
<Icon name="code" size={16} className="text-current" />
|
||||
GitHub
|
||||
</a>
|
||||
<div className="ml-2 border-l border-border pl-4">
|
||||
|
||||
Reference in New Issue
Block a user