Skip to content
Closed
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
34 changes: 34 additions & 0 deletions .changeset/scrollbars.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 38 additions & 1 deletion examples/demo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -49,6 +50,7 @@ const PANELS = [
["classes", <ClassNames />],
["selectors", <Selectors />],
["motion", <Motion />],
["scrollbars", <Scrollbars />],
] as const

describeNative("demo panels", () => {
Expand Down Expand Up @@ -285,6 +287,41 @@ describeNative("height: auto", () => {
})
})

describeNative("the scrollbars panel", () => {
it("scrollIntoView honours scroll-padding and scroll-margin", () => {
const test = root()
test.render(
<div
style={{
...BASE,
...PALETTES.midnight,
width: "100%",
height: "100%",
padding: 16,
backgroundColor: "var(--color-bg)",
}}
>
<IntoView />
</div>
)
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
Expand All @@ -294,7 +331,7 @@ describeNative("the whole application", () => {
test.render(<App />)
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)
Expand Down
2 changes: 2 additions & 0 deletions examples/demo/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -78,6 +79,7 @@ const SECTIONS = [
{ id: "classes", title: "className", render: () => <ClassNames /> },
{ id: "selectors", title: "Selectors", render: () => <Selectors /> },
{ id: "motion", title: "Motion", render: () => <Motion /> },
{ id: "scrollbars", title: "Scrollbars", render: () => <Scrollbars /> },
] as const

type SectionId = (typeof SECTIONS)[number]["id"] | "perf"
Expand Down
195 changes: 195 additions & 0 deletions examples/demo/scrollbars.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="col gap-2 p-3">
{Array.from({ length: count }, (_, i) => (
<div key={i} className="row items-center gap-2" style={{ flexShrink: 0 }}>
<div
style={{
width: 22,
height: 22,
borderRadius: 6,
backgroundColor: "var(--color-track)",
}}
/>
<text className="text-xs text-muted">{`row ${i + 1}`}</text>
</div>
))}
</div>
)
}

function ScrollBox({ style, count = 14 }: { style: StyleDesc; count?: number }) {
return (
<div
className="col rounded border w-full"
style={{ height: 150, backgroundColor: "var(--color-raised)", ...style }}
>
<Rows count={count} />
</div>
)
}

function Bars() {
return (
<Panel
title="scrollbar-width and scrollbar-color"
note="Scroll any box. The wheel, a drag on the thumb, and a click in the track all work. The track click moves one page."
>
<Grid>
<Sample label={`overflowY: "auto"`} hint="The bar the OS picks.">
<ScrollBox style={{ overflowY: "auto" }} />
</Sample>
<Sample label={`scrollbarWidth: "thin"`}>
<ScrollBox style={{ overflowY: "auto", scrollbarWidth: "thin" }} />
</Sample>
<Sample label={`scrollbarWidth: "none"`} hint="No bar and no gutter. The wheel still scrolls.">
<ScrollBox style={{ overflowY: "auto", scrollbarWidth: "none" }} />
</Sample>
<Sample label={`scrollbarColor: "…brand …track"`}>
<ScrollBox
style={{
overflowY: "auto",
scrollbarColor: "var(--color-brand) var(--color-track)",
}}
/>
</Sample>
</Grid>
</Panel>
)
}

/// 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 (
<Panel
title="scrollbar-gutter"
note="The content of these boxes fits. With classic bars, stable reserves the gutter anyway, and both-edges adds one more at the start. Overlay bars reserve nothing, so the three boxes then look the same. GPUIX_SCROLLBARS=classic|overlay picks the kind of bar."
>
<Grid>
<Sample label={`scrollbarGutter: "auto"`}>
<div className="col rounded border w-full" style={{ height: 126, overflowY: "auto", backgroundColor: "var(--color-raised)" }}>
<div style={band} />
</div>
</Sample>
<Sample label={`scrollbarGutter: "stable"`}>
<div className="col rounded border w-full" style={{ height: 126, overflowY: "auto", scrollbarGutter: "stable", backgroundColor: "var(--color-raised)" }}>
<div style={band} />
</div>
</Sample>
<Sample label={`"stable both-edges"`}>
<div className="col rounded border w-full" style={{ height: 126, overflowY: "auto", scrollbarGutter: "stable both-edges", backgroundColor: "var(--color-raised)" }}>
<div style={band} />
</div>
</Sample>
</Grid>
</Panel>
)
}

function BothAxes() {
return (
<Panel
title="Two axes"
note="overflow: scroll on both axes. The content is wider and taller than the box, so each axis gets its own bar."
>
<div
className="rounded border w-full"
style={{ height: 180, overflow: "scroll", backgroundColor: "var(--color-raised)" }}
>
<div
className="col gap-2 p-3"
style={{
width: 900,
height: 400,
backgroundImage: "linear-gradient(135deg, var(--color-brand-soft), var(--color-raised))",
}}
>
<text className="text-xs text-muted">900 x 400 of content in a smaller box.</text>
</div>
</div>
</Panel>
)
}

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 (
<Panel
title="scrollIntoView, scroll-margin and scroll-padding"
note="The buttons scroll row 10 into view. The box keeps 12px of scroll-padding inside its edges, and the row asks for 16px of scroll-margin around itself, so 28px of space separates the row from the edge."
>
<Row>
<Button label="start" onClick={() => show("start")} />
<Button label="center" onClick={() => show("center")} />
<Button label="end" onClick={() => show("end")} />
<Button label="nearest" onClick={() => show("nearest")} />
</Row>
<div
testId="into-view-box"
className="col rounded border w-full"
style={{ height: 180, overflowY: "auto", scrollPadding: 12, backgroundColor: "var(--color-raised)" }}
>
<div className="col gap-2 p-3">
{Array.from({ length: 24 }, (_, i) => {
const isTarget = i === 9
return (
<div
key={i}
ref={isTarget ? target : undefined}
testId={isTarget ? "into-view-target" : undefined}
className="row items-center gap-2 rounded px-2 py-1"
style={{
flexShrink: 0,
scrollMargin: isTarget ? 16 : undefined,
backgroundColor: isTarget ? "var(--color-brand-soft)" : undefined,
}}
>
<text className={isTarget ? "text-xs font-semibold text-fg" : "text-xs text-muted"}>
{isTarget ? "row 10, the target" : `row ${i + 1}`}
</text>
</div>
)
})}
</div>
</div>
</Panel>
)
}

export function Scrollbars() {
return (
<>
<Bars />
<Gutters />
<BothAxes />
<IntoView />
</>
)
}
14 changes: 14 additions & 0 deletions packages/native/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export declare class GpuixRenderer {
getWindowInsets(): WindowInsets
/** `"hidden"` | `"minimal"` | `"full"`. Paints into the scene after layout. */
setDebugFrameOverlay(mode: string): string
/**
* Scroll every ancestor scroll box so the element shows, like the
* web `scrollIntoView`. `block` places it on the y axis and
* `inline` on the x axis: `start`, `center`, `end` or `nearest`.
* The defaults match the web: `start` and `nearest`. The
* `scroll-margin` of the element and the `scroll-padding` of each
* box apply.
*/
scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
/** Hidden → minimal → full → hidden. */
cycleDebugFrameOverlay(): string
getDebugFrameOverlay(): string
Expand Down Expand Up @@ -323,6 +332,11 @@ export declare class TestGpuixRenderer {
* Call flush() after to apply the offset and re-render.
*/
scrollTo(elementId: number, x: number, y: number): void
/**
* Scroll every ancestor scroll box so the element shows, like the
* web scrollIntoView. Call flush() after to apply and re-render.
*/
scrollIntoView(elementId: number, block?: string | undefined | null, inline?: string | undefined | null): void
/**
* Scroll a child into view by its index in the children list.
* Call flush() after to apply and re-render. For a `<virtual-list>` the
Expand Down
19 changes: 17 additions & 2 deletions packages/native/src/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,26 @@ pub fn all_bounds() -> HashMap<u64, ElementBounds> {
BOUNDS.with(|cell| cell.borrow().clone())
}

pub fn bounds_tracker(id: u64, selection_start: Option<bool>) -> impl IntoElement {
pub fn bounds_tracker(
id: u64,
selection_start: Option<bool>,
scroll: Option<gpui::ScrollHandle>,
) -> impl IntoElement {
canvas(
|bounds, _, _| bounds,
move |bounds, _, _, _| {
record_bounds(id, bounds);
// A scroll box paints its children moved by its own offset, and
// this tracker is one of them. Take the offset back out, so the
// recorded rectangle is the box in the window, not the box in
// its own content. The selection region stays at the painted
// place, because a selection starts from the glyphs on screen.
let mut recorded = bounds;
if let Some(handle) = &scroll {
let offset = handle.offset();
recorded.origin.x -= offset.x;
recorded.origin.y -= offset.y;
}
record_bounds(id, recorded);
if let Some(selectable) = selection_start {
crate::text::record_start_region(bounds, selectable);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/native/src/custom_elements/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,7 @@ impl CustomElement for TextEditorElement {
// `Some(false)` also claims the same box as a non-selectable
// selection-start region: a drag inside an editor must move the caret,
// not start a document selection.
editor = editor.child(crate::automation::bounds_tracker(ctx.id, Some(false)));
editor = editor.child(crate::automation::bounds_tracker(ctx.id, Some(false), None));
if ctx.events.contains("click") {
let callback = ctx.event_callback.clone();
let id = ctx.id;
Expand Down
2 changes: 1 addition & 1 deletion packages/native/src/custom_elements/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub(crate) fn custom_surface(
{
el = el.relative();
}
el = el.child(crate::automation::bounds_tracker(ctx.id, None));
el = el.child(crate::automation::bounds_tracker(ctx.id, None, None));
wire_standard_events(el, ctx)
}

Expand Down
Loading
Loading