Skip to content

feat: sticky table of contents with scrollspy for canvas dashboards#9693

Open
rohithreddykota wants to merge 3 commits into
mainfrom
rohithreddykota/canvas-toc-scrollspy
Open

feat: sticky table of contents with scrollspy for canvas dashboards#9693
rohithreddykota wants to merge 3 commits into
mainfrom
rohithreddykota/canvas-toc-scrollspy

Conversation

@rohithreddykota

@rohithreddykota rohithreddykota commented Jul 16, 2026

Copy link
Copy Markdown
Member

Adds a persistent, minimap-style table of contents to canvas dashboards (view surfaces only; not the iframe embed).

  • Derives entries from the headings (h1h3) rendered inside markdown components of the active tab — no new section primitive; stays in sync as content/tabs change via a debounced MutationObserver.
  • Scrollspy via a single IntersectionObserver (no scroll-event math); exactly one active section, with the highlight pinned during click-to-scroll so it doesn't flicker through intermediate sections.
  • Collapsed rail of tick bars by default (dots on narrow viewports so they don't overlap content); hover/focus reveals the full list as an overlay flyout that never reflows the dashboard.
  • Click smooth-scrolls (respecting prefers-reduced-motion) and updates the URL hash; deep-linking on mount is supported. Accessible: <nav aria-label>, real links, aria-current="location", focus rings, full heading as the accessible name.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

Developed in collaboration with Claude Code
image image image

@AdityaHegde

Copy link
Copy Markdown
Collaborator

A couple of UXQA items,

  1. When there are 2 markdown components side-by-side,
    1. Clicking on the right one in sidebar will not highlight it, only the left one gets highlighted.
    2. Scrolling just down will quickly highlight the right one.
      Not sure if there is a legitimate use case for this, so perhaps we can keep the existing behaviour.
  2. When the last markdown component is towards the bottom it never gets highlighted. But clicking it does take the user to the bottom so probably not a bad experience?

What do you think @nishantmonu51 ?

@rohithreddykota
rohithreddykota force-pushed the rohithreddykota/canvas-toc-scrollspy branch from 0ac3794 to 52255cd Compare July 17, 2026 18:41
/* The rail itself: vertically centered, expanding to the full flyout on hover/focus (which
overlays the content to its right rather than reflowing it). `p-2` keeps the first and last bars
clear of the rounded corners so they aren't clipped. */
.toc__panel {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lets use toc-panel. The naming convention of double underscore is not standard for css. Same applies to double dash classes like toc__item--active.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done - renamed all classes to kebab-case (toc-panel, toc-item, toc-item-active, toc-bar, toc-text, toc-list, toc-compact) and updated every selector.

: "smooth";
}

function jumpTo(event: MouseEvent, entry: TocEntry) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can move this and watchScrollEnd to a separate class that has logic from loc and scrollspy. Also we should move to svelte5 way for using cross file state. Check web-common/src/lib/store-utils/types.svelte.ts for reference.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Extracted everything into toc-controller.svelte.ts - a CanvasTocController class following the types.svelte.ts convention, with $state entries/activeId as the cross-file state. It absorbs the former scrollspy.ts (IntersectionObserver tracking + lock/unlock) along with refresh, the MutationObserver, jumpTo, and watchScrollEnd; slugify/deriveTocEntries stay as pure helpers in toc.ts. The component is now thin and runes-based, with an $effect owning the controller's lifecycle.

behavior: this.scrollBehavior(),
block: "start",
});
history.replaceState(history.state, "", `#${entry.id}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need replace state here? Using goto will instead add support for back and forward navigation buttons. More of a product question @nishantmonu51

// Re-derive on any content change: tab switch, async markdown resolution, edits, filters, or the
// `.row-container` appearing (e.g. after required filters are satisfied). Observing the scroll
// container rather than `.row-container` keeps this resilient to remounts.
this.mutationObserver = new MutationObserver(() => this.scheduleRefresh());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a bit too aggressive. Lets fire events from web-common/src/features/canvas/components/markdown/Markdown.svelte on mount and destroy?

return headings.map(({ el, text, level }) => {
let id = el.id || slugify(text);
// Dedupe collisions with a numeric suffix, e.g. "overview", "overview-2".
if (usedIds.has(id)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lets use getName from web-common/src/features/entity-management/name-utils.ts . While it is not 1-1 it will suffice to give us a number appended id.

};

/** Turn arbitrary heading text into a URL-hash-safe slug. */
export function slugify(text: string): string {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lets merge this with web-common/src/lib/string-utils.ts::sanitizeSlug

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