fixed site docs behavior on narrow screens

This commit is contained in:
Sergey Chernov 2026-04-08 10:53:21 +03:00
parent 12fb4fe0ba
commit c6cfd52b01
3 changed files with 145 additions and 35 deletions

View File

@ -20,6 +20,10 @@ import kotlinx.browser.window
import org.jetbrains.compose.web.dom.* import org.jetbrains.compose.web.dom.*
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
private const val DESKTOP_TOC_BREAKPOINT_PX = 992
fun isDesktopTocLayout(viewportWidthPx: Int): Boolean = viewportWidthPx >= DESKTOP_TOC_BREAKPOINT_PX
@Composable @Composable
fun App() { fun App() {
var route by remember { mutableStateOf(currentRoute()) } var route by remember { mutableStateOf(currentRoute()) }
@ -29,6 +33,8 @@ fun App() {
var activeTocId by remember { mutableStateOf<String?>(null) } var activeTocId by remember { mutableStateOf<String?>(null) }
var contentEl by remember { mutableStateOf<HTMLElement?>(null) } var contentEl by remember { mutableStateOf<HTMLElement?>(null) }
var navEl by remember { mutableStateOf<HTMLElement?>(null) } var navEl by remember { mutableStateOf<HTMLElement?>(null) }
var mobileTocExpanded by remember { mutableStateOf(false) }
var isDesktopToc by remember { mutableStateOf(isDesktopTocLayout(window.innerWidth)) }
val isDocsRoute = route.startsWith("docs/") val isDocsRoute = route.startsWith("docs/")
val docKey = stripFragment(route) val docKey = stripFragment(route)
@ -50,7 +56,11 @@ fun App() {
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
val handler: (org.w3c.dom.events.Event) -> Unit = { updateNavbarOffsetVar() } val handler: (org.w3c.dom.events.Event) -> Unit = {
updateNavbarOffsetVar()
isDesktopToc = isDesktopTocLayout(window.innerWidth)
}
isDesktopToc = isDesktopTocLayout(window.innerWidth)
window.addEventListener("resize", handler) window.addEventListener("resize", handler)
onDispose { window.removeEventListener("resize", handler) } onDispose { window.removeEventListener("resize", handler) }
} }
@ -61,13 +71,18 @@ fun App() {
onDispose { window.removeEventListener("hashchange", listener) } onDispose { window.removeEventListener("hashchange", listener) }
} }
LaunchedEffect(activeTocId) { LaunchedEffect(activeTocId, isDesktopToc) {
if (!isDesktopToc) return@LaunchedEffect
val activeId = activeTocId ?: return@LaunchedEffect val activeId = activeTocId ?: return@LaunchedEffect
val nav = navEl ?: return@LaunchedEffect val nav = navEl ?: return@LaunchedEffect
val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement
activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})")) activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})"))
} }
LaunchedEffect(docKey) {
mobileTocExpanded = false
}
PageTemplate(title = when { PageTemplate(title = when {
isDocsRoute -> null isDocsRoute -> null
route.startsWith("authors") -> "Authors" route.startsWith("authors") -> "Authors"
@ -78,41 +93,30 @@ fun App() {
Div({ classes("row", "gy-4") }) { Div({ classes("row", "gy-4") }) {
if (isDocsRoute) { if (isDocsRoute) {
Div({ classes("col-12", "col-lg-3") }) { Div({ classes("col-12", "col-lg-3") }) {
Nav({ if (toc.isNotEmpty() && !isDesktopToc) {
classes("position-sticky") Button(attrs = {
attr("style", "top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;") classes("btn", "btn-outline-secondary", "w-100", "mb-3", "d-lg-none")
ref { attr("type", "button")
navEl = it attr("aria-expanded", mobileTocExpanded.toString())
onDispose { navEl = null } attr("aria-controls", "docs-toc-nav")
} onClick { mobileTocExpanded = !mobileTocExpanded }
}) { }) {
H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") } Text(if (mobileTocExpanded) "Hide contents" else "Show contents")
Ul({ classes("list-unstyled") }) {
toc.forEach { item ->
Li({ classes("mb-1") }) {
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
val routeNoFrag = route.substringBefore('#')
val tocHref = "#/$routeNoFrag#${item.id}"
A(attrs = {
attr("href", tocHref)
attr("data-toc-id", item.id)
attr("style", "padding-left: $pad")
classes("link-body-emphasis", "text-decoration-none")
if (activeTocId == item.id) {
classes("fw-semibold", "text-primary")
attr("aria-current", "true")
}
onClick {
it.preventDefault()
window.location.hash = tocHref
contentEl?.ownerDocument?.getElementById(item.id)
?.let { (it as? HTMLElement)?.scrollIntoView() }
}
}) { Text(item.title) }
}
}
} }
} }
if (toc.isNotEmpty() && (isDesktopToc || mobileTocExpanded)) {
TocNav(
toc = toc,
route = route,
activeTocId = activeTocId,
isDesktopToc = isDesktopToc,
contentEl = contentEl,
onNavigate = {
if (!isDesktopToc) mobileTocExpanded = false
},
onNavEl = { navEl = it }
)
}
} }
} }
@ -141,3 +145,59 @@ fun App() {
} }
} }
} }
@Composable
private fun TocNav(
toc: List<TocItem>,
route: String,
activeTocId: String?,
isDesktopToc: Boolean,
contentEl: HTMLElement?,
onNavigate: () -> Unit,
onNavEl: (HTMLElement?) -> Unit,
) {
Nav({
id("docs-toc-nav")
classes(if (isDesktopToc) "position-sticky" else "docs-mobile-toc", "mb-3", "mb-lg-0")
attr(
"style",
if (isDesktopToc) {
"top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;"
} else {
"max-height: min(50vh, 24rem); overflow-y: auto;"
}
)
ref {
onNavEl(it)
onDispose { onNavEl(null) }
}
}) {
H2({ classes("h6", "text-uppercase", "text-muted", "mb-2") }) { Text("On this page") }
Ul({ classes("list-unstyled", "mb-0") }) {
toc.forEach { item ->
Li({ classes("mb-1") }) {
val pad = when (item.level) { 1 -> "0"; 2 -> "0.75rem"; else -> "1.5rem" }
val routeNoFrag = route.substringBefore('#')
val tocHref = "#/$routeNoFrag#${item.id}"
A(attrs = {
attr("href", tocHref)
attr("data-toc-id", item.id)
attr("style", "display: block; padding-left: $pad")
classes("link-body-emphasis", "text-decoration-none")
if (activeTocId == item.id) {
classes("fw-semibold", "text-primary")
attr("aria-current", "true")
}
onClick {
it.preventDefault()
onNavigate()
window.location.hash = tocHref
contentEl?.ownerDocument?.getElementById(item.id)
?.let { heading -> (heading as? HTMLElement)?.scrollIntoView() }
}
}) { Text(item.title) }
}
}
}
}
}

View File

@ -180,17 +180,34 @@ fun ensureDocsLayoutStyles() {
.markdown-body h1:first-child { .markdown-body h1:first-child {
margin-top: 0 !important; margin-top: 0 !important;
} }
.docs-mobile-toc {
position: static !important;
padding: 0.875rem 1rem;
border: 1px solid rgba(128, 128, 128, 0.2);
border-radius: 0.75rem;
background: var(--bs-body-bg, #fff);
}
/* Hide scrollbar for the TOC nav but allow scrolling */ /* Hide scrollbar for the TOC nav but allow scrolling */
nav.position-sticky::-webkit-scrollbar { nav.position-sticky::-webkit-scrollbar {
width: 4px; width: 4px;
} }
.docs-mobile-toc::-webkit-scrollbar {
width: 4px;
}
nav.position-sticky::-webkit-scrollbar-thumb { nav.position-sticky::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.2); background: rgba(128,128,128,0.2);
border-radius: 4px; border-radius: 4px;
} }
.docs-mobile-toc::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.2);
border-radius: 4px;
}
nav.position-sticky:hover::-webkit-scrollbar-thumb { nav.position-sticky:hover::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.5); background: rgba(128,128,128,0.5);
} }
.docs-mobile-toc:hover::-webkit-scrollbar-thumb {
background: rgba(128,128,128,0.5);
}
""" """
.trimIndent() .trimIndent()
) )

View File

@ -0,0 +1,33 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AppResponsiveTocTest {
@Test
fun tocIsCollapsedBelowLgBreakpoint() {
assertFalse(isDesktopTocLayout(991))
}
@Test
fun tocStaysDesktopAtLgBreakpointAndAbove() {
assertTrue(isDesktopTocLayout(992))
assertTrue(isDesktopTocLayout(1280))
}
}