fixed site docs behavior on narrow screens
This commit is contained in:
parent
12fb4fe0ba
commit
c6cfd52b01
@ -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,40 +93,29 @@ 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
33
site/src/jsTest/kotlin/AppResponsiveTocTest.kt
Normal file
33
site/src/jsTest/kotlin/AppResponsiveTocTest.kt
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user