From c6cfd52b01cce69e5b5c1c97a77bc607079a64bb Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 8 Apr 2026 10:53:21 +0300 Subject: [PATCH] fixed site docs behavior on narrow screens --- site/src/jsMain/kotlin/App.kt | 130 +++++++++++++----- site/src/jsMain/kotlin/Main.kt | 17 +++ .../src/jsTest/kotlin/AppResponsiveTocTest.kt | 33 +++++ 3 files changed, 145 insertions(+), 35 deletions(-) create mode 100644 site/src/jsTest/kotlin/AppResponsiveTocTest.kt diff --git a/site/src/jsMain/kotlin/App.kt b/site/src/jsMain/kotlin/App.kt index 670ec5e..4e5cc18 100644 --- a/site/src/jsMain/kotlin/App.kt +++ b/site/src/jsMain/kotlin/App.kt @@ -20,6 +20,10 @@ import kotlinx.browser.window import org.jetbrains.compose.web.dom.* import org.w3c.dom.HTMLElement +private const val DESKTOP_TOC_BREAKPOINT_PX = 992 + +fun isDesktopTocLayout(viewportWidthPx: Int): Boolean = viewportWidthPx >= DESKTOP_TOC_BREAKPOINT_PX + @Composable fun App() { var route by remember { mutableStateOf(currentRoute()) } @@ -29,6 +33,8 @@ fun App() { var activeTocId by remember { mutableStateOf(null) } var contentEl by remember { mutableStateOf(null) } var navEl by remember { mutableStateOf(null) } + var mobileTocExpanded by remember { mutableStateOf(false) } + var isDesktopToc by remember { mutableStateOf(isDesktopTocLayout(window.innerWidth)) } val isDocsRoute = route.startsWith("docs/") val docKey = stripFragment(route) @@ -50,7 +56,11 @@ fun App() { } 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) onDispose { window.removeEventListener("resize", handler) } } @@ -61,13 +71,18 @@ fun App() { onDispose { window.removeEventListener("hashchange", listener) } } - LaunchedEffect(activeTocId) { + LaunchedEffect(activeTocId, isDesktopToc) { + if (!isDesktopToc) return@LaunchedEffect val activeId = activeTocId ?: return@LaunchedEffect val nav = navEl ?: return@LaunchedEffect val activeLink = nav.querySelector("a[data-toc-id=\"$activeId\"]") as? HTMLElement activeLink?.scrollIntoView(js("({block: 'nearest', behavior: 'smooth'})")) } + LaunchedEffect(docKey) { + mobileTocExpanded = false + } + PageTemplate(title = when { isDocsRoute -> null route.startsWith("authors") -> "Authors" @@ -78,41 +93,30 @@ fun App() { Div({ classes("row", "gy-4") }) { if (isDocsRoute) { Div({ classes("col-12", "col-lg-3") }) { - Nav({ - classes("position-sticky") - attr("style", "top: calc(var(--navbar-offset) + 1rem); max-height: calc(100vh - var(--navbar-offset) - 2rem); overflow-y: auto;") - ref { - navEl = it - onDispose { navEl = null } - } - }) { - H2({ classes("h6", "text-uppercase", "text-muted") }) { Text("On this page") } - 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) { + Button(attrs = { + classes("btn", "btn-outline-secondary", "w-100", "mb-3", "d-lg-none") + attr("type", "button") + attr("aria-expanded", mobileTocExpanded.toString()) + attr("aria-controls", "docs-toc-nav") + onClick { mobileTocExpanded = !mobileTocExpanded } + }) { + Text(if (mobileTocExpanded) "Hide contents" else "Show contents") } } + 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, + 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) } + } + } + } + } +} diff --git a/site/src/jsMain/kotlin/Main.kt b/site/src/jsMain/kotlin/Main.kt index ea626b5..773a65e 100644 --- a/site/src/jsMain/kotlin/Main.kt +++ b/site/src/jsMain/kotlin/Main.kt @@ -180,17 +180,34 @@ fun ensureDocsLayoutStyles() { .markdown-body h1:first-child { 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 */ nav.position-sticky::-webkit-scrollbar { width: 4px; } + .docs-mobile-toc::-webkit-scrollbar { + width: 4px; + } nav.position-sticky::-webkit-scrollbar-thumb { background: rgba(128,128,128,0.2); 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 { background: rgba(128,128,128,0.5); } + .docs-mobile-toc:hover::-webkit-scrollbar-thumb { + background: rgba(128,128,128,0.5); + } """ .trimIndent() ) diff --git a/site/src/jsTest/kotlin/AppResponsiveTocTest.kt b/site/src/jsTest/kotlin/AppResponsiveTocTest.kt new file mode 100644 index 0000000..52bc281 --- /dev/null +++ b/site/src/jsTest/kotlin/AppResponsiveTocTest.kt @@ -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)) + } +}