Skip to content

Commit cf89c69

Browse files
committed
Add minimal adaptive sidebar table of contents
1 parent 8bc5c1e commit cf89c69

File tree

1 file changed

+214
-0
lines changed

1 file changed

+214
-0
lines changed

docs/index.html

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,112 @@
4949
padding: 0 1.5rem;
5050
}
5151

52+
.layout {
53+
position: relative;
54+
padding-left: 3.75rem;
55+
}
56+
57+
.toc {
58+
position: fixed;
59+
top: 2rem;
60+
left: 1rem;
61+
width: 2.25rem;
62+
max-height: calc(100vh - 4rem);
63+
overflow-y: auto;
64+
z-index: 20;
65+
display: flex;
66+
flex-direction: column;
67+
gap: 0.45rem;
68+
padding: 0.35rem;
69+
border-radius: 999px;
70+
background: color-mix(in srgb, var(--bg) 80%, transparent);
71+
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
72+
transition: width 240ms ease, border-radius 240ms ease, background 240ms ease;
73+
scrollbar-width: thin;
74+
}
75+
76+
.toc:hover,
77+
.toc:focus-within,
78+
.toc.is-expanded {
79+
width: min(14rem, calc(100vw - 2rem));
80+
border-radius: 1rem;
81+
background: color-mix(in srgb, var(--bg) 94%, transparent);
82+
}
83+
84+
.toc-link {
85+
display: flex;
86+
align-items: center;
87+
gap: 0.65rem;
88+
color: inherit;
89+
text-decoration: none;
90+
min-height: 1.5rem;
91+
border-radius: 999px;
92+
padding: 0.1rem 0.2rem;
93+
transition: background-color 180ms ease;
94+
}
95+
96+
.toc-link:hover {
97+
text-decoration: none;
98+
background: color-mix(in srgb, var(--accent) 14%, transparent);
99+
}
100+
101+
.toc-bar {
102+
height: 0.34rem;
103+
border-radius: 999px;
104+
background: var(--fg-secondary);
105+
opacity: 0.2;
106+
flex: 0 0 1.15rem;
107+
transition: opacity 220ms ease, background-color 220ms ease;
108+
}
109+
110+
.toc-label {
111+
white-space: nowrap;
112+
overflow: hidden;
113+
text-overflow: ellipsis;
114+
font-size: 0.82rem;
115+
letter-spacing: 0.01em;
116+
opacity: 0;
117+
transform: translateX(-0.2rem);
118+
transition: opacity 220ms ease, transform 220ms ease;
119+
color: var(--fg-secondary);
120+
pointer-events: none;
121+
}
122+
123+
.toc:hover .toc-label,
124+
.toc:focus-within .toc-label,
125+
.toc.is-expanded .toc-label {
126+
opacity: 1;
127+
transform: translateX(0);
128+
pointer-events: auto;
129+
}
130+
131+
.toc-link:hover .toc-bar,
132+
.toc-link:focus-visible .toc-bar,
133+
.toc-link.is-active .toc-bar {
134+
opacity: 0.95;
135+
background: var(--accent);
136+
}
137+
138+
.toc-link.is-active .toc-label {
139+
color: var(--fg);
140+
}
141+
142+
.toc-toggle {
143+
display: none;
144+
position: fixed;
145+
top: 1rem;
146+
left: 1rem;
147+
z-index: 30;
148+
border: 1px solid var(--border);
149+
background: color-mix(in srgb, var(--bg) 94%, transparent);
150+
color: var(--fg);
151+
border-radius: 999px;
152+
font: inherit;
153+
font-size: 0.9rem;
154+
padding: 0.42rem 0.8rem;
155+
cursor: pointer;
156+
}
157+
52158
/* ---- Header ---- */
53159
header {
54160
padding: 6rem 0 4rem;
@@ -199,6 +305,45 @@
199305

200306
/* ---- Responsive ---- */
201307
@media (max-width: 520px) {
308+
.layout {
309+
padding-left: 0;
310+
}
311+
312+
.toc-toggle {
313+
display: inline-flex;
314+
align-items: center;
315+
gap: 0.35rem;
316+
}
317+
318+
.toc {
319+
top: 3.8rem;
320+
left: 1rem;
321+
width: calc(100vw - 2rem);
322+
max-height: min(70vh, 28rem);
323+
border-radius: 1rem;
324+
padding: 0.6rem;
325+
transform: translateY(-0.6rem);
326+
opacity: 0;
327+
pointer-events: none;
328+
transition: opacity 220ms ease, transform 220ms ease;
329+
}
330+
331+
.toc .toc-label {
332+
opacity: 1;
333+
transform: none;
334+
pointer-events: auto;
335+
}
336+
337+
.toc.is-expanded {
338+
opacity: 1;
339+
transform: translateY(0);
340+
pointer-events: auto;
341+
}
342+
343+
.toc-link {
344+
border-radius: 0.75rem;
345+
}
346+
202347
header { padding: 4rem 0 3rem; }
203348
header h1 { font-size: 3rem; }
204349
.features { grid-template-columns: 1fr; }
@@ -207,6 +352,10 @@
207352
</head>
208353
<body>
209354

355+
<div class="layout">
356+
<button class="toc-toggle" id="toc-toggle" type="button" aria-controls="toc" aria-expanded="false">☰ Sections</button>
357+
<nav class="toc" id="toc" aria-label="Table of contents"></nav>
358+
210359
<div class="container">
211360

212361
<header>
@@ -633,5 +782,70 @@ <h2>Flags reference</h2>
633782

634783
</div>
635784

785+
<script>
786+
const sections = Array.from(document.querySelectorAll('section'));
787+
const toc = document.getElementById('toc');
788+
const tocToggle = document.getElementById('toc-toggle');
789+
const prefersTouch = window.matchMedia('(hover: none), (pointer: coarse)').matches;
790+
791+
sections.forEach((section, index) => {
792+
const heading = section.querySelector('h2');
793+
if (!heading) return;
794+
795+
const id = heading.id || heading.textContent.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
796+
heading.id = id || `section-${index + 1}`;
797+
798+
const link = document.createElement('a');
799+
link.href = `#${heading.id}`;
800+
link.className = 'toc-link';
801+
link.setAttribute('aria-label', heading.textContent.trim());
802+
803+
const bar = document.createElement('span');
804+
bar.className = 'toc-bar';
805+
806+
const label = document.createElement('span');
807+
label.className = 'toc-label';
808+
label.textContent = heading.textContent.trim();
809+
810+
link.append(bar, label);
811+
toc.append(link);
812+
});
813+
814+
const tocLinks = Array.from(toc.querySelectorAll('.toc-link'));
815+
816+
if (prefersTouch) {
817+
toc.classList.add('is-expanded');
818+
tocToggle.hidden = false;
819+
tocToggle.addEventListener('click', () => {
820+
const expanded = toc.classList.toggle('is-expanded');
821+
tocToggle.setAttribute('aria-expanded', String(expanded));
822+
});
823+
824+
tocLinks.forEach((link) => {
825+
link.addEventListener('click', () => {
826+
toc.classList.remove('is-expanded');
827+
tocToggle.setAttribute('aria-expanded', 'false');
828+
});
829+
});
830+
} else {
831+
tocToggle.hidden = true;
832+
}
833+
834+
const observer = new IntersectionObserver((entries) => {
835+
entries.forEach((entry) => {
836+
const id = entry.target.querySelector('h2')?.id;
837+
if (!id || !entry.isIntersecting) return;
838+
839+
tocLinks.forEach((link) => {
840+
link.classList.toggle('is-active', link.getAttribute('href') === `#${id}`);
841+
});
842+
});
843+
}, { rootMargin: '-45% 0px -45% 0px', threshold: [0, 1] });
844+
845+
sections.forEach((section) => observer.observe(section));
846+
</script>
847+
848+
</div>
849+
636850
</body>
637851
</html>

0 commit comments

Comments
 (0)