Visitar URL original
feat(site): keep the page and current section stuck on phone docs by iamdavidhill · Pull Request #62751 · anomalyco/opencode · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 51 additions & 22 deletions services/site/src/docs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,6 @@ on("click", (event) => {

const section = target.closest("[data-slot=section-toggle]")
if (section) return toggleSection()

if (target.closest('[data-component="menu-toggle"]'))
return toggleMenu(!site.querySelector("[data-component=site-menu-sheet][data-open]"))
if (target.closest('[data-component="site-menu-sheet"] :is([data-slot=backdrop], a)')) return toggleMenu(false)
})

// Code tabs, and plan tabs (which switch together across the page by their sync key and are remembered).
Expand All @@ -140,7 +136,7 @@ $$("[data-plan-tabs]").forEach((tabs) => {
if (tab) selectTab(tab)
})

// The phone section menu (the sidebar folded into a button) and the phone menu sheet.
// The phone section menu (the sidebar folded into a button).
function toggleSection(open?: boolean) {
const nav = $("[data-component=section-nav]")!
const next = open ?? !nav.hasAttribute("data-open")
Expand All @@ -159,17 +155,7 @@ document.addEventListener(
true,
)

function toggleMenu(open: boolean) {
const toggle = $('[data-component="menu-toggle"]')!
$("[data-component=site-menu-sheet]")!.toggleAttribute("data-open", open)
toggle.setAttribute("aria-expanded", String(open))
toggle.setAttribute("aria-label", open ? "Close menu" : "Open menu")
for (const part of [$("[data-slot=frame]")!, $("[data-component=site-footer]")!]) part.inert = open
html.style.overflow = open ? "hidden" : ""
}

phone.addEventListener("change", () => {
toggleMenu(false)
toggleSection(false)
closeLockup()
})
Expand Down Expand Up @@ -208,10 +194,10 @@ function openLockup(root: HTMLElement, focus?: "first" | "last" | "current") {
closeNavMenus()
lockup = root
const menu = menuOf(root)
// Phones: the labels start at the bar's logo mark and the "›" centres on its menu button, from the inline
// start and end edges (docs-lockup.css).
// Phones: the labels start at the bar's logo mark and the "›" centres on the bar's last mark (the
// GitHub mark, on the menu column), from the inline start and end edges (docs-lockup.css).
const logo = $("[data-component=topbar] [data-slot=logo] [data-slot=icon]")?.getBoundingClientRect()
const button = $("[data-component=menu-toggle]")?.getBoundingClientRect()
const button = $("[data-component=topbar] [data-component=github-link]")?.getBoundingClientRect()
if (logo?.width && button?.width) {
const rtl = html.dir === "rtl"
const centre = button.left + button.width / 2
Expand Down Expand Up @@ -371,10 +357,6 @@ on("keydown", (event) => {
contents.open = false
$("summary", contents)?.focus()
}
if ($("[data-component=site-menu-sheet][data-open]")) {
toggleMenu(false)
$('[data-component="menu-toggle"]')?.focus()
}
if ($("[data-component=section-nav][data-open]")) {
toggleSection(false)
$("[data-slot=section-toggle]")?.focus()
Expand Down Expand Up @@ -424,6 +406,49 @@ const headings = [...new Set(links.map((link) => decodeURIComponent(link.hash.sl
const heading = document.getElementById(id)
return heading ? [heading] : []
})
// The phone Contents bar names the section being read. A new name replaces
// the old within the label's own line: the old leaves upward (4px, fading out, 150ms) as the new comes
// up from 4px below (fading in, 200ms), both on the strong ease-out, transform and opacity only. A swap
// mid-swap starts from where the words are; swaps closer together than 120ms (a fast scroll) cut
// straight to the latest name, so nothing queues. The first name and reduced motion swap at once. The
// accessible name follows ("Contents: <section>"); it isn't a live region, so scrolling announces nothing.
const contentsBar = $<HTMLElement>("[data-docs-contents]")
const ease = "cubic-bezier(0.23, 1, 0.32, 1)"
let swappedAt = -Infinity
function nameSection(title: string, animate: boolean) {
const label = contentsBar && $("[data-slot=label]", contentsBar)
if (!label) return
$("summary", contentsBar!)!.setAttribute("aria-label", `Contents: ${title}`)
const current = label.querySelector<HTMLElement>("[data-slot=word]:not([data-leaving])")!
if (current.textContent === title) return
const word = document.createElement("span")
word.dataset.slot = "word"
word.textContent = title
const now = performance.now()
const instant = !animate || now - swappedAt < 120 || matchMedia("(prefers-reduced-motion: reduce)").matches
swappedAt = now
$$("[data-leaving]", label).forEach((leaving) => leaving.remove())
if (instant) return current.replaceWith(word)
const style = getComputedStyle(current)
const from = { opacity: style.opacity, transform: style.transform === "none" ? "translateY(0)" : style.transform }
current.getAnimations().forEach((animation) => animation.cancel())
current.dataset.leaving = ""
label.append(word)
current
.animate([from, { opacity: 0, transform: "translateY(-4px)" }], { duration: 150, easing: ease, fill: "forwards" })
.finished.then(
() => current.remove(),
() => undefined,
)
word.animate(
[
{ opacity: 0, transform: "translateY(4px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{ duration: 200, easing: ease },
)
}

let spied = ""
function spy() {
const tops = headings.map((heading) => ({ id: heading.id, top: heading.getBoundingClientRect().top }))
Expand All @@ -438,6 +463,10 @@ function spy() {
scrollY > 0 && contents.getBoundingClientRect().top <= parseFloat(getComputedStyle(contents).top) + 0.5,
)
if (id === spied) return
const named = links.find(
(link) => link.closest("[data-docs-contents]") && decodeURIComponent(link.hash.slice(1)) === id,
)
if (named) nameSection(named.textContent ?? "", spied !== "")
spied = id ?? ""
links.forEach((link) =>
decodeURIComponent(link.hash.slice(1)) === id
Expand Down
61 changes: 24 additions & 37 deletions services/site/src/docs/layouts/DocsLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { For, Show, type JSX } from "solid-js"
import { DocsLockup } from "~/component/site/docs-lockup"
import { GitHubLink } from "~/component/site/github-link"
import { Chevron, SearchIcon } from "~/component/site/icons"
import { Chevron, NewTab, SearchIcon } from "~/component/site/icons"
import { interFaces, interLatin } from "~/component/site/inter-fonts"
import { siteLinks } from "~/component/site/links"
import { LogoIcon } from "~/component/site/marks"
import { Bracket } from "~/component/site/site-controls"
import { monoFontFaces, monoFontLatin } from "~/component/site/mono-fonts"
import { ContentsMenu, OnThisPage } from "~/component/site/on-this-page"
import { MenuSheet, MenuToggle } from "~/component/site/phone-menu"
import { OnThisPage } from "~/component/site/on-this-page"
import { defaultRootAttributes, themeScript } from "~/component/site/preload-settings"
import { navDefaults } from "~/component/site/settings"
import { ExternalArrow, NewTab } from "~/component/site/icons"
import { config } from "~/config"
import { useI18n } from "~/context/i18n"
import { base } from "../config"
Expand Down Expand Up @@ -66,8 +62,6 @@ export function DocsLayout(props: {
const contents = props.headings
.filter((heading) => heading.depth === 2 || heading.depth === 3)
.map((heading) => ({ id: heading.slug, title: heading.text, level: heading.depth as 2 | 3 }))
const links = siteLinks(i18n.t, (path) => path)
const nav = navDefaults.flatMap((entry) => (entry.visible ? links.nav.filter((item) => item.id === entry.id) : []))
const logo = () => (
<a
href="/"
Expand Down Expand Up @@ -124,32 +118,7 @@ export function DocsLayout(props: {
<SearchIcon />
</button>
<GitHubLink owner="topbar" />
<MenuToggle open={false} onToggle={() => undefined} />
</header>
<MenuSheet open={false} onClose={() => undefined}>
<For each={nav}>
{(item, index) => (
<li data-nav-id={item.id}>
<a
href={item.href}
target={"external" in item && item.external ? "_blank" : undefined}
aria-current={item.id === "docs" ? "page" : undefined}
>
<span data-slot="index" aria-hidden="true">
[<span data-slot="number">{index() + 1}</span>]
</span>
<span data-slot="label">{item.label}</span>
<Show when={"arrow" in item && item.arrow}>
<ExternalArrow />
</Show>
<Show when={"external" in item && item.external}>
<NewTab />
</Show>
</a>
</li>
)}
</For>
</MenuSheet>

<div data-slot="frame">
<aside
Expand All @@ -174,6 +143,28 @@ export function DocsLayout(props: {
</div>
</div>
</aside>
{/* Mobile layout: the Contents rail folded into a bar stuck under the
section row from load. It names the section being read (client.ts swaps the label as you scroll)
and keeps "Contents" in its accessible name. Hidden beside the rail on wide screens. */}
<Show when={contents.length > 0}>
<details data-component="contents-menu" data-docs-contents>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This copies ContentsMenu's markup (from component/site/on-this-page.tsx) into the layout. src/docs/AGENTS.md asks the docs to use the site's components instead of copying their markup, and still lists ContentsMenu and the phone menu as components the docs render. Could the swappable label go into ContentsMenu (for example as an optional prop, so brand, press and legal keep "Contents")? If not, please update src/docs/AGENTS.md so the docs no longer list ContentsMenu or the phone menu. As it stands, the two copies can drift apart.

<summary aria-label={`Contents: ${contents[0].title}`}>
<span data-slot="label">
<span data-slot="word">{contents[0].title}</span>
</span>
<Chevron />
</summary>
<ul>
<For each={contents}>
{(item) => (
<li data-level={item.level === 3 ? "3" : undefined}>
<a href={`#${item.id}`}>{item.title}</a>
</li>
)}
</For>
</ul>
</details>
</Show>
<GitHubLink owner="corner" />

<main data-component="content" id="content" tabindex={-1}>
Expand All @@ -185,10 +176,6 @@ export function DocsLayout(props: {
data-pagefind-meta={`title:${props.title}`}
>
<h1 data-pagefind-meta={`section:${section.title}`}>{props.title}</h1>
{/* Phones: the Contents rail folded into a bar under the title (Pagefind skips it, build.ts). */}
<Show when={contents.length > 0}>
<ContentsMenu items={contents} />
</Show>
{props.children}
<footer data-slot="article-footer" data-pagefind-ignore>
<Show when={previous || next}>
Expand Down
48 changes: 47 additions & 1 deletion services/site/src/docs/styles/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
@import "../../component/site/site.css";
@import "../../component/site/rtl.css";
@import "../../component/site/github-link.css";
@import "../../component/site/phone-menu.css";
@import "../../component/site/tap-targets.css";
@import "../../component/site/docs-lockup.css";
@import "../../component/site/nav-menus.css";
Expand Down Expand Up @@ -154,6 +153,53 @@
display: none;
}

/* No menu button (the two bars are the docs' navigation); the GitHub mark takes its column. */
[data-component="topbar"] [data-component="github-link"] {
margin-inline-end: -0.75rem;
}

/* The Contents bar sits right under the section row from load (it follows the sidebar in the
frame), so both rows are stuck from the start and draw their lines from the start. An open row
and its list are one surface: the line between them goes, and the list's bottom edge stays. */
[data-docs-contents] {
margin: 0 var(--edge);

summary::after {
opacity: 1;
transition: none;
}

&[open] summary::after {
display: none;
}

/* The section's name, swapped in place (client.ts): each word is one line that ends in "…",
and the label clips the swap to its own line box. The leaving word lies over the new one. */
summary [data-slot="label"] {
position: relative;
flex: 1 1 auto;
min-width: 0;
margin-inline-end: 1rem;
overflow: clip;
}

[data-slot="word"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

&[data-leaving] {
position: absolute;
inset: 0;
}
}
}

[data-component="section-nav"][data-open] [data-slot="section-toggle"]::after {
display: none;
}

/* The bar's bottom line is the Contents bar's top join too, so the Contents bar only fades in
its own bottom line when it pins: one line at each join, through the moment it sticks. */
[data-component="contents-menu"] summary::before {
Expand Down
Loading