diff --git a/.changeset/scrollbars.md b/.changeset/scrollbars.md new file mode 100644 index 00000000..9bd86225 --- /dev/null +++ b/.changeset/scrollbars.md @@ -0,0 +1,34 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Paint scrollbars on scroll boxes, and add `scrollbar-width`, `scrollbar-color` and `scrollbar-gutter`. + +A box with `overflow: scroll` or `overflow: auto` now gets a scrollbar +on each axis it scrolls. The OS picks the kind of bar, as a browser does. +When the OS auto-hides scrollbars, an overlay bar floats over the content, +shows for a second after a scroll and fades out, and reserves no space. +Otherwise a classic bar sits in a 15px gutter that the layout reserves. +`overflow: scroll` keeps the classic bar at all times and `auto` shows it +only while the content overflows. A drag of the thumb scrolls, a click in +the track moves one page, and the thumb widens under the mouse. +`scrollbar-width: thin` narrows the bar and `none` removes it. +`scrollbar-color` sets the thumb and the track. `scrollbar-gutter: stable` +reserves the gutter of a classic bar even while the content fits, and +`stable both-edges` reserves one at the start of the axis too. +`overflow: auto` used to do nothing and `clip` now clips like `hidden`. +`GPUIX_SCROLLBARS=overlay` or `classic` in the environment overrides the +OS choice, for tests. + +A bar paints after the whole frame, above any effect a sibling of the +content paints, so a blurred sticky header does not cover it. When one +axis of `overflow` computes to `visible` or `clip` and the other axis +scrolls, the first becomes `auto` or `hidden`, as in CSS. + +`scrollIntoView(elementId, block, inline)` on the renderer scrolls every +scroll box around an element until the element shows. `block` and +`inline` take `start`, `center`, `end` or `nearest`, with the web +defaults. `scroll-margin` on the target keeps space around it, and +`scroll-padding` on a scroll box keeps space inside the box, each as one +value or as the one-to-four shorthand. diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx index 4d7cf8de..60a97b98 100644 --- a/examples/demo.test.tsx +++ b/examples/demo.test.tsx @@ -23,6 +23,7 @@ import { Inheritance } from "./demo/inheritance" import { Lengths } from "./demo/lengths" import { motion } from "@gpuix/react" import { Motion } from "./demo/motion-panel" +import { IntoView, Scrollbars } from "./demo/scrollbars" import { Selectors } from "./demo/selectors" import { Variables } from "./demo/variables" import { resolveClassName } from "./demo/classes" @@ -49,6 +50,7 @@ const PANELS = [ ["classes", ], ["selectors", ], ["motion", ], + ["scrollbars", ], ] as const describeNative("demo panels", () => { @@ -285,6 +287,41 @@ describeNative("height: auto", () => { }) }) +describeNative("the scrollbars panel", () => { + it("scrollIntoView honours scroll-padding and scroll-margin", () => { + const test = root() + test.render( +
+ +
+ ) + const box = test.renderer.findByTestId("into-view-box")! + expect(test.renderer.getScrollOffset(box.id)![1]).toBe(0) + + const start = test.renderer.findByText("start")! + const [x, y] = test.renderer.getElementBounds(start.id)! + test.renderer.nativeSimulateClick(x + 4, y + 4) + + expect(test.renderer.getScrollOffset(box.id)![1]).toBeLessThan(0) + const [, boxY] = test.renderer.getElementBounds(box.id)! + const row = test.renderer.findByTestId("into-view-target")! + const [, rowY] = test.renderer.getElementBounds(row.id)! + // 12px of scroll-padding plus 16px of scroll-margin, inside the border. + expect(rowY - boxY).toBeGreaterThanOrEqual(28) + expect(rowY - boxY).toBeLessThanOrEqual(30) + test.unmount() + }) +}) + describeNative("the whole application", () => { /// Walk the sidebar and paint each section, so the whole application is /// covered rather than the one it opens on. The test renderer has the frame @@ -294,7 +331,7 @@ describeNative("the whole application", () => { test.render() expect(test.renderer.getPaintedText()).toContain("GPUIX") - for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Performance", "Colours"]) { + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Performance", "Colours"]) { const item = test.renderer.findByText(title) expect(item, `no sidebar item named ${title}`).toBeDefined() const bounds = test.renderer.getElementBounds(item!.id) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx index 5ae0e36e..d5f7a2e2 100644 --- a/examples/demo/app.tsx +++ b/examples/demo/app.tsx @@ -17,6 +17,7 @@ import { Inheritance } from "./inheritance.js" import { Lengths } from "./lengths.js" import { Motion } from "./motion-panel.js" import { frameOverlay, Perf } from "./perf.js" +import { Scrollbars } from "./scrollbars.js" import { Selectors } from "./selectors.js" import { Variables } from "./variables.js" @@ -78,6 +79,7 @@ const SECTIONS = [ { id: "classes", title: "className", render: () => }, { id: "selectors", title: "Selectors", render: () => }, { id: "motion", title: "Motion", render: () => }, + { id: "scrollbars", title: "Scrollbars", render: () => }, ] as const type SectionId = (typeof SECTIONS)[number]["id"] | "perf" diff --git a/examples/demo/scrollbars.tsx b/examples/demo/scrollbars.tsx new file mode 100644 index 00000000..2cb11f9c --- /dev/null +++ b/examples/demo/scrollbars.tsx @@ -0,0 +1,195 @@ +/// Scroll boxes, the bars they paint, and scrollIntoView. +/// +/// The OS picks the kind of bar. An overlay bar floats over the content and +/// fades out after a scroll. A classic bar keeps a track and reserves a +/// gutter in the layout. Every box here also scrolls with the wheel, with a +/// drag on the thumb, and with a click in the track, which moves one page. + +import React, { useRef } from "react" +import { useGpuix } from "@gpuix/react" +import type { StyleDesc } from "@gpuix/react" +import { Button, Grid, Panel, Row, Sample } from "./ui.js" + +/// Rows tall enough to overflow the box, so a bar shows. +function Rows({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( +
+
+ {`row ${i + 1}`} +
+ ))} +
+ ) +} + +function ScrollBox({ style, count = 14 }: { style: StyleDesc; count?: number }) { + return ( +
+ +
+ ) +} + +function Bars() { + return ( + + + + + + + + + + + + + + + + + ) +} + +/// The content fits, so only the reserved gutter tells the boxes apart. +/// The full-width band paints the content area, and the gutter is the strip +/// the band does not cover. +function Gutters() { + const band: StyleDesc = { + height: 100, + margin: 8, + borderRadius: 6, + backgroundColor: "var(--color-track)", + } + return ( + + + +
+
+
+ + +
+
+
+ + +
+
+
+ + + + ) +} + +function BothAxes() { + return ( + +
+
+ 900 x 400 of content in a smaller box. +
+
+
+ ) +} + +export function IntoView() { + const { renderer } = useGpuix() + const target = useRef<{ id: number } | null>(null) + const show = (block: string) => { + if (renderer && target.current) { + renderer.scrollIntoView?.(target.current.id, block) + } + } + return ( + + +