Skip to content

Commit 0388a3e

Browse files
PttCodingManclaude
andcommitted
feat: add mobile TOC drawer (FAB + bottom sheet) for page and public page views
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 67c3107 commit 0388a3e

4 files changed

Lines changed: 180 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { useEffect, useRef } from 'react'
2+
import { useTranslation } from 'react-i18next'
3+
import TableOfContents from './TableOfContents'
4+
5+
/**
6+
* Mobile-only TOC: a fixed FAB at bottom-right that slides up a bottom sheet.
7+
* Hidden on lg+ (desktop shows the right-rail TOC instead).
8+
*/
9+
export default function TocDrawer({ headings, open, onOpen, onClose }) {
10+
const { t } = useTranslation()
11+
const sheetRef = useRef(null)
12+
13+
// Close on backdrop click
14+
const handleBackdrop = (e) => {
15+
if (e.target === e.currentTarget) onClose()
16+
}
17+
18+
// Close on Escape
19+
useEffect(() => {
20+
if (!open) return
21+
const handler = (e) => { if (e.key === 'Escape') onClose() }
22+
document.addEventListener('keydown', handler)
23+
return () => document.removeEventListener('keydown', handler)
24+
}, [open, onClose])
25+
26+
// Trap body scroll while drawer is open
27+
useEffect(() => {
28+
if (open) {
29+
document.body.style.overflow = 'hidden'
30+
} else {
31+
document.body.style.overflow = ''
32+
}
33+
return () => { document.body.style.overflow = '' }
34+
}, [open])
35+
36+
if (!headings || headings.length === 0) return null
37+
38+
return (
39+
<>
40+
{/* FAB — only visible on mobile */}
41+
<button
42+
className="toc-fab lg:hidden no-print"
43+
onClick={onOpen}
44+
aria-label={t('toc.label')}
45+
title={t('toc.label')}
46+
>
47+
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
48+
<line x1="3" y1="5" x2="17" y2="5" />
49+
<line x1="3" y1="10" x2="13" y2="10" />
50+
<line x1="3" y1="15" x2="10" y2="15" />
51+
</svg>
52+
</button>
53+
54+
{/* Backdrop + bottom sheet */}
55+
{open && (
56+
<div
57+
className="toc-drawer-backdrop lg:hidden"
58+
onClick={handleBackdrop}
59+
aria-hidden="true"
60+
>
61+
<div
62+
ref={sheetRef}
63+
className="toc-drawer-sheet"
64+
role="dialog"
65+
aria-label={t('toc.label')}
66+
aria-modal="true"
67+
>
68+
<div className="toc-drawer-handle" />
69+
<div
70+
className="toc-drawer-body"
71+
onClick={(e) => {
72+
// Close after a heading is clicked (user wants to navigate)
73+
if (e.target.closest('a')) onClose()
74+
}}
75+
>
76+
<TableOfContents headings={headings} />
77+
</div>
78+
</div>
79+
</div>
80+
)}
81+
</>
82+
)
83+
}

frontend/src/index.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,6 +1099,81 @@ mark {
10991099
opacity: 0.6;
11001100
}
11011101

1102+
/* Mobile TOC: FAB + bottom-sheet drawer */
1103+
.toc-fab {
1104+
position: fixed;
1105+
bottom: 1.5rem;
1106+
right: 1.25rem;
1107+
z-index: 40;
1108+
display: flex;
1109+
align-items: center;
1110+
justify-content: center;
1111+
width: 3rem;
1112+
height: 3rem;
1113+
border-radius: 50%;
1114+
background: var(--color-primary);
1115+
color: var(--color-primary-text);
1116+
border: none;
1117+
cursor: pointer;
1118+
box-shadow: 0 4px 14px rgba(0,0,0,0.18);
1119+
transition: transform 0.15s, box-shadow 0.15s;
1120+
}
1121+
.toc-fab:hover {
1122+
transform: scale(1.07);
1123+
box-shadow: 0 6px 18px rgba(0,0,0,0.22);
1124+
}
1125+
.toc-fab:active {
1126+
transform: scale(0.96);
1127+
}
1128+
1129+
.toc-drawer-backdrop {
1130+
position: fixed;
1131+
inset: 0;
1132+
z-index: 50;
1133+
background: rgba(0,0,0,0.45);
1134+
display: flex;
1135+
align-items: flex-end;
1136+
}
1137+
1138+
.toc-drawer-sheet {
1139+
width: 100%;
1140+
max-height: 70vh;
1141+
background: var(--color-surface);
1142+
border-radius: 1rem 1rem 0 0;
1143+
display: flex;
1144+
flex-direction: column;
1145+
overflow: hidden;
1146+
animation: toc-slide-up 0.22s ease-out;
1147+
}
1148+
1149+
@keyframes toc-slide-up {
1150+
from { transform: translateY(100%); opacity: 0.6; }
1151+
to { transform: translateY(0); opacity: 1; }
1152+
}
1153+
1154+
.toc-drawer-handle {
1155+
flex-shrink: 0;
1156+
width: 2.5rem;
1157+
height: 4px;
1158+
border-radius: 2px;
1159+
background: var(--color-border);
1160+
margin: 0.75rem auto 0.5rem;
1161+
}
1162+
1163+
.toc-drawer-body {
1164+
flex: 1 1 auto;
1165+
overflow-y: auto;
1166+
padding: 0 1rem 1.5rem;
1167+
}
1168+
1169+
/* Inside the drawer the rail has no border/bg — let the sheet be the container */
1170+
.toc-drawer-body .toc-rail {
1171+
background: transparent;
1172+
border: none;
1173+
border-radius: 0;
1174+
padding: 0;
1175+
}
1176+
11021177
/* Headings should clear the navbar when scrolled-to via TOC */
11031178
.markdown-viewer h1,
11041179
.markdown-viewer h2,
@@ -1237,6 +1312,8 @@ mark {
12371312

12381313
/* TOC dark */
12391314
.dark .toc-rail { background: var(--color-surface); border-color: var(--color-border); }
1315+
.dark .toc-drawer-sheet { background: var(--color-surface); }
1316+
.dark .toc-drawer-handle { background: var(--color-border); }
12401317

12411318
/* Print — strip chrome so Ctrl+P / "Save as PDF" produces a clean article */
12421319
@media print {

frontend/src/pages/PageView.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import useSettings from '../store/useSettings'
1010
import MarkdownViewer from '../components/Viewer/MarkdownViewer'
1111
import MindmapView from '../components/MindmapView'
1212
import TableOfContents from '../components/Viewer/TableOfContents'
13+
import TocDrawer from '../components/Viewer/TocDrawer'
1314
import Comments from '../components/Comments'
1415
import ConfirmDialog from '../components/ConfirmDialog'
1516
import AclManager from '../components/AclManager'
@@ -41,6 +42,7 @@ export default function PageView() {
4142
const [aclManagerOpen, setAclManagerOpen] = useState(false)
4243
const [toast, setToast] = useState('')
4344
const [headings, setHeadings] = useState([])
45+
const [tocDrawerOpen, setTocDrawerOpen] = useState(false)
4446

4547
const handleHeadings = useCallback((items) => setHeadings(items), [])
4648

@@ -516,6 +518,14 @@ export default function PageView() {
516518
</div>
517519
</aside>
518520

521+
{/* Mobile TOC drawer */}
522+
<TocDrawer
523+
headings={headings}
524+
open={tocDrawerOpen}
525+
onOpen={() => setTocDrawerOpen(true)}
526+
onClose={() => setTocDrawerOpen(false)}
527+
/>
528+
519529
<ConfirmDialog
520530
open={publicConfirmOpen}
521531
title={t('pageView.confirm.makePublicTitle')}

frontend/src/pages/PublicPageView.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import MarkdownViewer from '../components/Viewer/MarkdownViewer'
66
import MindmapView from '../components/MindmapView'
77
import TableOfContents from '../components/Viewer/TableOfContents'
88
import ThemeSwitcher from '../components/ThemeSwitcher'
9+
import TocDrawer from '../components/Viewer/TocDrawer'
910
import useSettings from '../store/useSettings'
1011

1112
/**
@@ -28,6 +29,7 @@ export default function PublicPageView({ notFound }) {
2829
const footerText = useSettings((s) => s.footer_text)
2930
const [state, setState] = useState({ status: 'loading', slug: null, page: null })
3031
const [headings, setHeadings] = useState([])
32+
const [tocDrawerOpen, setTocDrawerOpen] = useState(false)
3133
const handleHeadings = useCallback((items) => setHeadings(items), [])
3234
const reqIdRef = useRef(0)
3335

@@ -142,6 +144,14 @@ export default function PublicPageView({ notFound }) {
142144
{footerText}
143145
</footer>
144146
)}
147+
148+
{/* Mobile TOC drawer */}
149+
<TocDrawer
150+
headings={headings}
151+
open={tocDrawerOpen}
152+
onOpen={() => setTocDrawerOpen(true)}
153+
onClose={() => setTocDrawerOpen(false)}
154+
/>
145155
</div>
146156
)
147157
}

0 commit comments

Comments
 (0)