feat: sticky table of contents with scrollspy for canvas dashboards - #9693
rohithreddykota wants to merge 4 commits into
Conversation
|
A couple of UXQA items,
What do you think @nishantmonu51 ? |
0ac3794 to
52255cd
Compare
| /* 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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}`); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
This is a bit too aggressive. Lets fire events from web-common/src/features/canvas/components/markdown/Markdown.svelte on mount and destroy?
There was a problem hiding this comment.
Done — replaced the subtree MutationObserver with a CANVAS_TOC_REFRESH_EVENT that Markdown.svelte dispatches on mount, content change, and unmount via a Svelte action; the controller just listens for it, so the TOC no longer re-derives on unrelated chart/table/tooltip updates.
| 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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done — now using getName from name-utils.ts for the dedup; duplicate anchor ids become overview_1, overview_2, etc.
| }; | ||
|
|
||
| /** Turn arbitrary heading text into a URL-hash-safe slug. */ | ||
| export function slugify(text: string): string { |
There was a problem hiding this comment.
Lets merge this with web-common/src/lib/string-utils.ts::sanitizeSlug
There was a problem hiding this comment.
Done — slugify now builds on sanitizeSlug for the character replacement, keeping the TOC-specific lowercasing, hyphen-collapsing, and "section" fallback.
AdityaHegde
left a comment
There was a problem hiding this comment.
Approving from code side. @nishantmonu51 please do a UXQA once
nishantmonu51
left a comment
There was a problem hiding this comment.
The branch no longer merges: main rewrote CanvasDashboardWrapper.svelte to Svelte 5 runes in #9746, and git merge-tree reports a content conflict in that file, so export let showTableOfContents and the bandWidth/scrollContainer bindings will need porting to the runes form on rebase.
Two smaller points:
deepLinked latches on the first derive that yields any entries, but templated markdown initially renders its raw content — resolvedContent falls back to content before the resolve query returns. A heading like ## Revenue {{ ... }} therefore produces id revenue-... on the first pass and its resolved id only later, so a deep link to the resolved id is never honored because the latch has already flipped (toc-controller.svelte.ts:100, components/markdown/Markdown.svelte:82).
web-admin/src/features/scheduled-reports/export/CanvasPdfReportExport.svelte:170 mounts CanvasDashboardEmbed with the default tableOfContents = true, so the TOC controller and its IntersectionObserver run inside the off-screen capture host for no benefit. Pass tableOfContents={false} as the embed route does.
| // existing `tabs` URL param). Use an instant jump so the page doesn't animate on load. | ||
| if (!this.deepLinked && this.entries.length > 0) { | ||
| this.deepLinked = true; | ||
| const hash = decodeURIComponent(window.location.hash.slice(1)); |
There was a problem hiding this comment.
The hash read here is usually gone by the time refresh runs. CanvasInitialization.svelte:122 calls canvasEntity.onUrlChange as soon as the store resolves, and for a non-isolated canvas handleCanvasRedirect does goto(`?${snapshotSearchParams}`, { replaceState: true }) (and the equivalent for a home bookmark or for default params) whenever searchParams.size === 0 — see stores/canvas-entity.ts, handleCanvasRedirect. Resolving a relative ?... URL against the current location discards the fragment, so /canvas/foo#overview becomes /canvas/foo?tr=... before, or racing with, the first refresh. Any canvas with a last-visited snapshot, a home bookmark, or default params therefore never honors the deep link, and even when the scroll wins the race the hash is stripped from the URL. Either carry the hash through the redirect or capture it once before onUrlChange runs.
|
|
||
| return headings.map(({ el, text, level }) => { | ||
| // Dedupe collisions with a numeric suffix, e.g. "overview", "overview_1". | ||
| const id = getName(el.id || slugify(text), usedIds); |
There was a problem hiding this comment.
getName increments a trailing number (INCREMENT = /(\d+)$/, applied as result.replace(INCREMENT, (m) => (+m + 1).toString())) rather than appending a suffix, so headings 2024, 2024, 2025 are assigned ids 2024, 2025, 2026 — #2025 then scrolls to the second 2024 heading instead of the 2025 one, and a duplicate q1-2025 is likewise renamed to q1-2026. toc.spec.ts only covers the alphabetic case (overview_1), which is why this passes. A local -1/-2 suffix loop avoids borrowing the resource-name helper's semantics.
Adds a persistent, minimap-style table of contents to canvas dashboards (view surfaces only; not the iframe embed).
h1–h3) rendered insidemarkdowncomponents of the active tab — no new section primitive; stays in sync as content/tabs change via a debouncedMutationObserver.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.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:
Developed in collaboration with Claude Code
