diff --git a/apps/docs/src/components/page-toc/current-section.test.ts b/apps/docs/src/components/page-toc/current-section.test.ts new file mode 100644 index 00000000..ef65870e --- /dev/null +++ b/apps/docs/src/components/page-toc/current-section.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; + +import { pickCurrentSection } from './current-section'; + +// Three sections, as on every component page: the component itself, Usage, +// and API Reference. Tops are px from the top of the viewport. +const base = { chosenIndex: -1, viewportHeight: 1100, scrollTop: 500, scrollHeight: 3000 }; + +describe('pickCurrentSection', () => { + it('lights the last section at the bottom of a short page in a tall window', () => { + // Measured on /components/grain at 1440x1100 (SHA-131): Usage's title is + // still on screen at the bottom, and API Reference's title sits below the + // reading line. The reader can scroll no further, so API Reference it is. + const result = pickCurrentSection({ + ...base, + tops: [-548, 106, 618], + scrollTop: 820, + scrollHeight: 1920, + }); + + expect(result).toEqual({ index: 2, held: false }); + }); + + it('treats a fractional scroll position within a pixel of the end as the bottom', () => { + const result = pickCurrentSection({ + ...base, + tops: [-548, 106, 618], + scrollTop: 819.5, + scrollHeight: 1920, + }); + + expect(result.index).toBe(2); + }); + + it('does not count a page that fits the window as being at its bottom', () => { + // Nothing to scroll, so the reading line decides: only the first title + // has passed it. + const result = pickCurrentSection({ + ...base, + tops: [100, 600, 900], + scrollTop: 0, + scrollHeight: 1000, + }); + + expect(result).toEqual({ index: 0, held: false }); + }); + + it('picks the last section whose title has passed the reading line mid-page', () => { + const result = pickCurrentSection({ ...base, tops: [-500, 100, 700] }); + + expect(result).toEqual({ index: 1, held: false }); + }); + + it('falls back to the first section before any title reaches the line', () => { + const result = pickCurrentSection({ ...base, tops: [300, 900, 1500], scrollTop: 0 }); + + expect(result).toEqual({ index: 0, held: false }); + }); + + it('holds the chosen section while its title is on screen, even at the bottom', () => { + // A menu jump to Usage that landed at the bottom of a short page: the + // reader said Usage, and its title is in view, so Usage stays lit. + const result = pickCurrentSection({ + ...base, + chosenIndex: 1, + tops: [-548, 106, 618], + scrollTop: 820, + scrollHeight: 1920, + }); + + expect(result).toEqual({ index: 1, held: true }); + }); + + it('releases the chosen section once its title leaves the screen', () => { + const result = pickCurrentSection({ ...base, chosenIndex: 1, tops: [-900, -50, 150] }); + + expect(result).toEqual({ index: 2, held: false }); + }); +}); diff --git a/apps/docs/src/components/page-toc/current-section.ts b/apps/docs/src/components/page-toc/current-section.ts new file mode 100644 index 00000000..8f79671a --- /dev/null +++ b/apps/docs/src/components/page-toc/current-section.ts @@ -0,0 +1,83 @@ +/** + * The rule behind the floating table of contents: given where every section + * title sits in the viewport, which section is the reader in? It is a pure + * function of the measurements so that page-toc.tsx can stay a thin loop that + * reads the DOM once a frame and hands the numbers here, and so the rule can + * be tested in plain Node, where the docs Vitest runs with no DOM. + */ + +// How far down the viewport the reading line sits, in px. A section is +// current once its title has scrolled up past this line. A fixed distance +// rather than a share of the viewport, so that a jump from the menu (which +// lands a section's title 24px from the top) always puts that section, and +// never the next one, under the line, however tall the window is. +export const READING_LINE_PX = 200; + +// How far short of the end a scroll position may sit and still count as the +// bottom, in px. Scroll positions are fractional on zoomed and Retina +// displays while the document height is an integer, so an exact comparison +// can miss the bottom by a fraction of a pixel. +const BOTTOM_TOLERANCE_PX = 1; + +export interface SectionMeasurements { + /** Each section title's top edge, in px from the top of the viewport. */ + tops: number[]; + /** Index of the section the reader last chose from the menu, or -1. */ + chosenIndex: number; + /** The viewport's height, in px. */ + viewportHeight: number; + /** How far the page has scrolled, in px. */ + scrollTop: number; + /** The whole page's height, in px, including the part scrolled away. */ + scrollHeight: number; +} + +export interface CurrentSection { + /** Index into `tops` of the section to light. */ + index: number; + /** True when the chosen section won by choice rather than by position. */ + held: boolean; +} + +/** + * Three rules, checked in order: + * + * 1. A section the reader chose from the menu stays current while its title + * is on screen. A jump that lands at the bottom of a short page can leave + * two titles in view, and the reader has already said which one they + * meant. Scrolling the title off releases it. + * 2. At the bottom of a page that scrolls, the last section is current. A + * short page can end before its last title ever reaches the reading line, + * and once the reader can scroll no further, nothing else is left to + * reach. A page that fits its window has no bottom to reach, so this + * rule does not apply to it. + * 3. Otherwise the last section whose title has passed the reading line is + * current, and the first section before any title has. + * + * Reading positions each frame rather than watching for crossings means a + * jump that skips a whole section still lands on the right answer. + */ +export function pickCurrentSection({ + tops, + chosenIndex, + viewportHeight, + scrollTop, + scrollHeight, +}: SectionMeasurements): CurrentSection { + const chosenTop = tops[chosenIndex]; + + if (chosenTop !== undefined && chosenTop >= 0 && chosenTop <= viewportHeight) { + return { index: chosenIndex, held: true }; + } + + const scrolls = scrollHeight > viewportHeight + BOTTOM_TOLERANCE_PX; + const atBottom = scrolls && viewportHeight + scrollTop >= scrollHeight - BOTTOM_TOLERANCE_PX; + + if (atBottom) return { index: tops.length - 1, held: false }; + + for (let candidate = tops.length - 1; candidate >= 0; candidate -= 1) { + if ((tops[candidate] ?? Infinity) <= READING_LINE_PX) return { index: candidate, held: false }; + } + + return { index: 0, held: false }; +} diff --git a/apps/docs/src/components/page-toc/page-toc.module.css b/apps/docs/src/components/page-toc/page-toc.module.css index b398b85b..bba9728c 100644 --- a/apps/docs/src/components/page-toc/page-toc.module.css +++ b/apps/docs/src/components/page-toc/page-toc.module.css @@ -74,18 +74,30 @@ outline-offset: var(--spacing-1); } -/* A 2px rule in the mock's resting gray. The current section's rule is - lime, and the change eases as the reader scrolls from one section into - the next, at the same hover-color timing as the rows. */ +/* A 2px rule in the mock's resting gray, 18px of the stack's 24px. The + current section's rule is lime and runs the full 24px, and both changes + ease as the reader scrolls from one section into the next, at the same + 100ms step so they read as one event. The color rides a fade token and the + width a duration token, so Reduce Motion snaps the width and keeps the + fade. Animating width runs layout, which the site otherwise avoids, but + the stack is three 2px rules in a fixed-width column that nothing else + depends on, the same trade the props table's panel makes on height + (docs/development/animation.md). An explicit width on a flex-column item + sits at the column's left edge, so the rule grows rightward from the + sidebar side without a transform. */ .line { display: block; + width: calc(var(--spacing-6) * 0.75); height: var(--spacing-0-5); border-radius: var(--radius-full); background: var(--gray-600); - transition: background-color var(--fade-xs) var(--ease-hover); + transition: + background-color var(--fade-xs) var(--ease-hover), + width var(--duration-xs) var(--ease-out); } .line[data-active] { + width: var(--spacing-6); background: var(--accent); } diff --git a/apps/docs/src/components/page-toc/page-toc.tsx b/apps/docs/src/components/page-toc/page-toc.tsx index 100fa723..b1ad2204 100644 --- a/apps/docs/src/components/page-toc/page-toc.tsx +++ b/apps/docs/src/components/page-toc/page-toc.tsx @@ -14,6 +14,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Popover } from '@base-ui/react/popover'; +import { pickCurrentSection } from './current-section'; import styles from './page-toc.module.css'; export interface PageTocSection { @@ -29,30 +30,23 @@ export interface PageTocSection { // enough to tell a rest from a crossing, short enough to feel like hover. const OPEN_DELAY_MS = 150; -// How far down the viewport the reading line sits, in px. A section is -// current once its top has scrolled up past this line. A fixed distance -// rather than a share of the viewport, so that a jump from the menu (which -// lands a section's title 24px from the top) always puts that section, and -// never the next one, under the line, however tall the window is. -const READING_LINE_PX = 200; - // ---------------------------------------------------------------------------- // Which section is current // ---------------------------------------------------------------------------- /** - * Tracks which of the given elements the reader is in as the page scrolls: - * the last one whose top has passed the reading line. Reading positions on - * every scroll frame rather than watching for crossings, so a jump that - * skips a whole section still lands on the right answer. - * The bottom of the page is the one exception. A short page can end before - * its last section's title ever reaches the line, so once the page can - * scroll no further, the first section whose title is still on screen - * counts as reached. That lights API Reference at the foot of a short page. - * A section the reader chose from the menu overrides both rules for as long - * as its title stays on screen, because a jump that lands at the bottom of - * a short page can leave two titles in view, and the reader has already - * said which one they meant. Scrolling the title off releases it. + * Tracks which of the given elements the reader is in as the page scrolls. + * This hook only measures: once a frame it reads where every section's title + * sits and how far the page has scrolled, and pickCurrentSection in + * current-section.ts applies the rules (a chosen section holds while its + * title is on screen, the last section wins at the bottom of the page, and + * otherwise the reading line decides). The hold on a chosen section is the + * one rule that can keep a title other than the last one lit at the bottom. + * That happens only when a menu jump landed there because the page ran out + * of scroll, and the reader has just said which section they meant, so it + * stays. Releasing it on the next wheel or key press instead would flip the + * lit row to the last section after a one-pixel nudge, with the chosen title + * still in the middle of the screen. * Returns the current id and the function the menu calls with a choice. */ function useCurrentSection(ids: string[]): [string | undefined, (id: string) => void] { @@ -81,28 +75,17 @@ function useCurrentSection(ids: string[]): [string | undefined, (id: string) => const measure = () => { frame = 0; - const tops = elements.map((element) => element.getBoundingClientRect().top); - const chosenIndex = elements.findIndex((element) => element.id === chosenRef.current); - const chosenTop = tops[chosenIndex]; - - if (chosenTop !== undefined && chosenTop >= 0 && chosenTop <= window.innerHeight) return; - - chosenRef.current = null; - - const atBottom = - window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 1; - let index = atBottom ? tops.findIndex((top) => top >= 0) : -1; + const { index, held } = pickCurrentSection({ + tops: elements.map((element) => element.getBoundingClientRect().top), + chosenIndex: elements.findIndex((element) => element.id === chosenRef.current), + viewportHeight: window.innerHeight, + scrollTop: window.scrollY, + scrollHeight: document.documentElement.scrollHeight, + }); - if (index === -1) { - for (let candidate = tops.length - 1; candidate >= 0; candidate -= 1) { - if ((tops[candidate] ?? Infinity) <= READING_LINE_PX) { - index = candidate; - break; - } - } - } + if (!held) chosenRef.current = null; - setCurrentId(elements[Math.max(index, 0)]?.id); + setCurrentId(elements[index]?.id); }; // One measurement per frame however many scroll events arrive in it. diff --git a/docs/development/animation.md b/docs/development/animation.md index f27a2925..9cd8f256 100644 --- a/docs/development/animation.md +++ b/docs/development/animation.md @@ -67,6 +67,8 @@ Google names exactly two compositor-only properties: "Today there are only two p The accordion panel animates `height`, so it pays for layout on every frame. That is the trade the Base UI pattern makes, and it is acceptable at 150ms on a panel that holds a few rows of text. Two things keep it cheap. The panel already sets `overflow: hidden`, and Base UI supplies a pixel value in `--accordion-panel-height`, so the transition runs between two lengths and needs no `interpolate-size`. Chrome's `interpolate-size: allow-keywords` and `calc-size()` exist for the `height: auto` case, but as of the Chrome article they ship in "Chrome: 129+" and "Edge: 129+" with "Firefox: Not supported" and "Safari: Not supported" ([Chrome, Animate to height: auto](https://developer.chrome.com/docs/css-ui/animate-to-height-auto)). Don't reach for them here. +The floating table of contents makes the same trade on `width`. Its rules grow from 18px to 24px as the current section changes, at `--duration-xs`. The stack is three 2px spans in a fixed-width column that nothing else depends on, so the layout pass touches three boxes and the rounded end caps stay round, which a `scaleX()` on a 2px rule would not quite manage. See `.line` in `page-toc.module.css`. + ## Proposed tokens for `tokens.css` Naming follows the file's existing shapes: t-shirt sizes as in `--radius-xl` and `--font-size-sm`, and plain nouns as in `--font-mono`. Three families, one for movement durations, one for fade durations, and one for easing, plus a reduced-motion override.