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
79 changes: 79 additions & 0 deletions apps/docs/src/components/page-toc/current-section.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
83 changes: 83 additions & 0 deletions apps/docs/src/components/page-toc/current-section.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
20 changes: 16 additions & 4 deletions apps/docs/src/components/page-toc/page-toc.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
61 changes: 22 additions & 39 deletions apps/docs/src/components/page-toc/page-toc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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] {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/development/animation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading