Skip to content

feat(sidebar): add scrollToItem for imperative scroll to a tagged nav item - #736

Merged
mattrothenberg merged 1 commit into
cloudflare:mainfrom
7michele7:michi/ONEUX-1206-sidebar-scroll-to-item
Aug 19, 2026
Merged

feat(sidebar): add scrollToItem for imperative scroll to a tagged nav item#736
mattrothenberg merged 1 commit into
cloudflare:mainfrom
7michele7:michi/ONEUX-1206-sidebar-scroll-to-item

Conversation

@7michele7

@7michele7 7michele7 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds scrollToItem(id, options?) to useSidebar() for imperatively scrolling a nav item into the sidebar viewport. Tag items with the new itemId prop on Sidebar.MenuButton or Sidebar.MenuItem. Scrolls only the sidebar viewport — never the document — and honors prefers-reduced-motion.

Kapture 2026-08-18 at 17 34 02

Why

Cross-app navigation lands users at the top of an unfamiliar sidebar. Consumers currently have no way to programmatically scroll the sidebar without reaching into DOM refs, and any hand-rolled solution using scrollIntoView ends up scrolling every ancestor (including the document), which is jarring inside a scrollable page.

API

// Tag any nav item
<Sidebar.MenuButton itemId="zero-trust" href="/zt">
  Zero Trust
</Sidebar.MenuButton>

// Scroll to it from anywhere in the sidebar tree
const { scrollToItem } = useSidebar()
scrollToItem("zero-trust", { align: "center", behavior: "smooth" })

align — where the item lands in the viewport. Matches the vocabulary used by Tanstack Virtual and React Virtuoso.

Value Behavior
"start" Align the item with the top of the viewport
"center" Center the item vertically in the viewport
"end" Align the item with the bottom of the viewport
"auto" (default) No-op if visible; otherwise scroll the minimum distance

behavior — scroll behavior, matching the native scrollTo API.

Value Behavior
"auto" (default) Instant jump. Best for cross-app landings so users don't watch the sidebar animate on entry.
"smooth" Animated scroll. Best for in-app "jump to section" flows.

prefers-reduced-motion forces "auto" regardless of what you pass.

How consumers benefit

The load-bearing use case is cross-app soft-pin on entry. Two Cloudflare apps that share a nav (e.g. stratus + Zero Trust) can each derive an item id from the URL and land users on the right section, without watching the sidebar animate on mount:

function AppSidebar() {
  const { scrollToItem } = useSidebar()
  const { pathname } = useLocation()

  useEffect(() => {
    const id = deriveItemIdFromPath(pathname)
    if (id) scrollToItem(id, { align: "center" })  // instant, no animation
  }, [])  // mount only

  return <Sidebar>{/* items with itemId={...} */}</Sidebar>
}

The URL is the source of truth — no storage, no cross-origin coordination, no restoration timing bugs. Shareable links land users on the right item every time. For in-app "jump to section" flows, callers opt into behavior: "smooth" to get the animation.

Implementation notes

  • Ancestor-safe scrolling. The implementation computes the target scrollTop from getBoundingClientRect() and calls viewport.scrollTo({ top }) directly. Using element.scrollIntoView() would walk the ancestor chain and scroll the document too — a regression test locks this in.
  • Item registry, not querySelector. Sidebar.MenuItem and Sidebar.MenuButton register their DOM node on mount into a Map<itemId, HTMLElement> on the provider. scrollToItem(id) looks up the node and walks up via .closest('[data-sidebar="viewport"]') to find the owning viewport. This is correct even inside Sidebar.SlidingViews, where multiple Sidebar.Content viewports can be mounted at once — a querySelector-based approach would silently pick the first viewport in DOM order.
  • itemId works in both usage patterns. On Sidebar.MenuButton directly (auto-wraps in <li>, and the <li> is the scroll target) or on Sidebar.MenuItem when you wrap a Collapsible (the <li> is the target). If you put itemId on a MenuButton nested inside an explicit MenuItem — the pattern needed for Collapsible — it still works: the button/anchor itself becomes the scroll target.

Changes

  • sidebar.tsx: SidebarScrollAlign / SidebarScrollToItemOptions types; registerItem + scrollToItem on context; itemId prop; item registry; ancestor-safe scroll math
  • sidebar.test.tsx: 8 tests exercising start/center/end/auto/clamp/ancestor-safety/no-op/MenuItem/SlidingViews multi-viewport/MenuItem-wrapping-MenuButton
  • sidebar/index.ts + root index.ts: export SidebarScrollAlign, SidebarScrollToItemOptions, SidebarMenuItemProps
  • Docs: SidebarScrollToItemDemo in SidebarDemo.tsx + "Scroll to Item" section in sidebar.astro
  • Changeset: patch

Testing

  • pnpm --filter @cloudflare/kumo test src/components/sidebar/sidebar.test.tsx — 57/57 pass
  • pnpm --filter @cloudflare/kumo typecheck
  • pnpm --filter @cloudflare/kumo lint
  • pnpm --filter @cloudflare/kumo build — attw + publint clean
  • Manual verification on kumo-docs-astro dev server: clicking any button in the "Scroll to Item" demo scrolls only the sidebar viewport (scrollTop changes), never the document (window.scrollY stays fixed).

  • Reviews
  • bonk has reviewed the change
  • automated review not possible because: targeted DOM math and interaction change; needs human sign-off on the position semantics and the docs demo
  • Tests
  • Tests included/updated
  • Automated tests not possible - manual testing has been completed as follows: verified in docs dev server that only the viewport scrolls, not the page; SlidingViews correctness covered by unit test
  • Additional testing not necessary because: n/a

@7michele7
7michele7 force-pushed the michi/ONEUX-1206-sidebar-scroll-to-item branch 6 times, most recently from 28fc50e to 51b4aef Compare August 19, 2026 14:08
@pkg-pr-new

pkg-pr-new Bot commented Aug 19, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/@cloudflare/kumo@736

commit: 4ebdf5c

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Docs Preview

View docs preview

Commit: 4ebdf5c

@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor
Visual Regression Report — 20 changed, 23 unchanged

20 screenshot(s) with visual changes:

Button / Sizes

1,378 px (1.56%) changed

Before After Diff
Before After Diff

Button / Loading State

27 px (0.03%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Actions

298 px (0.34%) changed

Before After Diff
Before After Diff

Dialog / Dialog Sizes

964 px (1.09%) changed

Before After Diff
Before After Diff

Dialog / Dialog Confirmation

415 px (0.47%) changed

Before After Diff
Before After Diff

Dialog / Dialog With Dropdown

499 px (0.56%) changed

Before After Diff
Before After Diff

Dialog (Open)

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Sizes

170 px (0.1%) changed

Before After Diff
Before After Diff

Select / Select Without Label

723 px (0.82%) changed

Before After Diff
Before After Diff

Select / Select With Description

1,688 px (1.62%) changed

Before After Diff
Before After Diff

Select / Select With Error

0 px (0%) changed

Before After Diff
Before After Diff

Select / Select Placeholder

237 px (0.27%) changed

Before After Diff
Before After Diff

Select / Select Loading

2,110 px (1.2%) changed

Before After Diff
Before After Diff

Select / Select Multiple

1,512 px (1.71%) changed

Before After Diff
Before After Diff

Select / Select Disabled Options

1,561 px (1.76%) changed

Before After Diff
Before After Diff

Select / Select Disabled Items

778 px (0.88%) changed

Before After Diff
Before After Diff

Select / Select Grouped

105 px (0.12%) changed

Before After Diff
Before After Diff

Select / Select Grouped With Disabled

1,354 px (1.53%) changed

Before After Diff
Before After Diff

Select / Select Long List

1,517 px (1.46%) changed

Before After Diff
Before After Diff

Select (Open)

172 px (0%) changed

Before After Diff
Before After Diff
23 screenshot(s) unchanged
  • Button / Basic
  • Button / Variant: Primary
  • Button / Variant: Secondary
  • Button / Variant: Ghost
  • Button / Variant: Destructive
  • Button / Variant: Outline
  • Button / Variant: Secondary Destructive
  • Button / With Icon
  • Button / Icon Only
  • Button / Disabled State
  • Button / Title
  • Button / Link as Button
  • Button / Link with Tooltip
  • Button / Disabled Link
  • Dialog / Dialog Basic
  • Dialog / Dialog Alert
  • Dialog / Custom Max Width
  • Dialog / Dialog With Select
  • Dialog / Dialog With Combobox
  • Select / Select Basic
  • Select / Select With Tooltip
  • Select / Select Custom Rendering
  • Select / Select Complex

Generated by Kumo Visual Regression

@7michele7
7michele7 force-pushed the michi/ONEUX-1206-sidebar-scroll-to-item branch from 51b4aef to 4ebdf5c Compare August 19, 2026 14:44
@mattrothenberg

Copy link
Copy Markdown
Collaborator

/bonk please approve

@ask-bonk

ask-bonk Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@mattrothenberg Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@mattrothenberg
mattrothenberg marked this pull request as ready for review August 19, 2026 14:56
@mattrothenberg

Copy link
Copy Markdown
Collaborator

/bonk review and approve

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Clean, well-scoped addition. The imperative scroll API is thoughtfully designed — defaults to instant scroll for cross-app landings, opt-in smooth for in-app jumps, and honors prefers-reduced-motion.

Implementation — Resolving the owning viewport by walking up from the registered node (closest('[data-sidebar="viewport"]')) is the right call for SlidingViews, where multiple viewports can coexist. The manual scrollTop math avoids the ancestor-scrolling pitfall of scrollIntoView, and the registry correctly unregisters on unmount.

Tests — Good coverage for the critical paths: alignment math, clamping, ancestor safety, multi-viewport resolution, and the two itemId placement patterns (auto-wrapped <li> vs explicit MenuItem).

Minor observation (non-blocking): there's no explicit test for align: "end", though the math is the symmetric inverse of "start" and the clamp test covers boundary behavior.

Docs — Demo and prose are clear; client:load is the correct hydration directive for this interactive example.

Approved.

@ask-bonk

ask-bonk Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Approved PR #736. The review highlighted:

  • API design — sensible defaults (align: "auto", behavior: "auto") with opt-in smooth scrolling and prefers-reduced-motion support
  • Implementation — ancestor-safe scroll math and viewport resolution via closest('[data-sidebar="viewport"]'), which correctly handles SlidingViews
  • Tests — good coverage of alignment, clamping, ancestor safety, and multi-viewport scenarios
  • Docs — clear demo with the right hydration directive

One minor non-blocking observation about the missing explicit align: "end" test case was noted, but the math is clearly symmetric to "start". No code changes requested.

github run

@mattrothenberg
mattrothenberg merged commit d586bf3 into cloudflare:main Aug 19, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants