Fix TOC button to be truly fixed-position using React Portal

The TOC toggle button was appearing near the end of posts instead of
floating at a fixed position. This happened because the button was
rendered inside the PostLayout component hierarchy.

Changes:
- Use React Portal to render TOC button at document.body level
- Add mounted state for proper SSR/client hydration
- Button now floats like back-to-top button, visible from start

This ensures the button is always visible and accessible, similar to
the back-to-top button behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 22:42:59 +08:00
parent d42cb46af8
commit a77cd17419
2 changed files with 29 additions and 21 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { FiList, FiChevronRight } from 'react-icons/fi'; import { FiList, FiChevronRight } from 'react-icons/fi';
import { PostToc } from './post-toc'; import { PostToc } from './post-toc';
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from 'clsx';
@@ -12,6 +13,30 @@ function cn(...inputs: ClassValue[]) {
export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) { export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) {
const [isTocOpen, setIsTocOpen] = useState(hasToc); const [isTocOpen, setIsTocOpen] = useState(hasToc);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const tocButton = hasToc && mounted ? (
<button
onClick={() => setIsTocOpen(!isTocOpen)}
className={cn(
"toc-button fixed bottom-8 right-8 z-50 flex items-center gap-2 rounded-full border border-white/20 bg-white/80 px-4 py-2.5 shadow-lg backdrop-blur-md hover:bg-white dark:border-white/10 dark:bg-slate-900/80 dark:hover:bg-slate-900",
"text-sm font-medium text-slate-600 dark:text-slate-300",
"lg:right-20" // Adjust position for desktop
)}
aria-label="Toggle Table of Contents"
>
{isTocOpen ? (
<FiChevronRight className="h-3.5 w-3.5" />
) : (
<FiList className="h-3.5 w-3.5" />
)}
<span>{isTocOpen ? 'Hide' : 'Menu'}</span>
</button>
) : null;
return ( return (
<div className="relative"> <div className="relative">
@@ -47,25 +72,8 @@ export function PostLayout({ children, hasToc = true, contentKey }: { children:
</div> </div>
)} )}
{/* Toggle Button (Glassmorphism Pill) */} {/* Toggle Button - Rendered via Portal */}
{hasToc && ( {tocButton && createPortal(tocButton, document.body)}
<button
onClick={() => setIsTocOpen(!isTocOpen)}
className={cn(
"toc-button fixed bottom-8 right-8 z-50 flex items-center gap-2 rounded-full border border-white/20 bg-white/80 px-4 py-2.5 shadow-lg backdrop-blur-md hover:bg-white dark:border-white/10 dark:bg-slate-900/80 dark:hover:bg-slate-900",
"text-sm font-medium text-slate-600 dark:text-slate-300",
"lg:right-20" // Adjust position for desktop
)}
aria-label="Toggle Table of Contents"
>
{isTocOpen ? (
<FiChevronRight className="h-3.5 w-3.5" />
) : (
<FiList className="h-3.5 w-3.5" />
)}
<span>{isTocOpen ? 'Hide' : 'Menu'}</span>
</button>
)}
</div> </div>
); );
} }

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts"; import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.