Skip to content
Open
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
6 changes: 5 additions & 1 deletion web-admin/src/routes/-/embed/canvas/[name]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

{#key `${instanceId}::${canvasName}`}
<CanvasProvider {canvasName} {instanceId} showBanner>
<CanvasDashboardEmbed {canvasName} {navigationEnabled} />
<CanvasDashboardEmbed
{canvasName}
{navigationEnabled}
tableOfContents={false}
/>
</CanvasProvider>
{/key}
9 changes: 8 additions & 1 deletion web-common/src/features/canvas/CanvasDashboardEmbed.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

export let canvasName: string;
export let navigationEnabled: boolean = true;
export let tableOfContents: boolean = true;

const runtimeClient = useRuntimeClient();

Expand Down Expand Up @@ -62,7 +63,13 @@
</script>

{#if canvasName}
<CanvasDashboardWrapper {maxWidth} {canvasName} {filtersEnabled} embedded>
<CanvasDashboardWrapper
{maxWidth}
{canvasName}
{filtersEnabled}
showTableOfContents={tableOfContents}
embedded
>
{#each blocks as block (block.kind === "tab-group" ? `g-${block.group.name}` : `r-${block.rowIndex}`)}
{#if block.kind === "tab-group"}
<CanvasTabGroupView
Expand Down
114 changes: 71 additions & 43 deletions web-common/src/features/canvas/CanvasDashboardWrapper.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { getCanvasStore } from "./state-managers/state-managers";
import ThemeProvider from "../dashboards/ThemeProvider.svelte";
import CanvasPdfExportView from "../exports/pdf/CanvasPdfExportView.svelte";
import CanvasTableOfContents from "./toc/CanvasTableOfContents.svelte";

const client = useRuntimeClient();

Expand All @@ -17,9 +18,21 @@
export let canvasName: string;
export let embedded: boolean = false;
export let builder = false;
export let showTableOfContents = false;
export let onClick: () => void = () => {};

let contentRect = new DOMRectReadOnly(0, 0, 0, 0);
let scrollContainer: HTMLElement | undefined;
let bandWidth = 0;

// Collapse the rail's bars to dots once the viewport is too narrow for the bars to sit in the
// content's left margin (i.e. the centered content is near full width). Based on the stable
// `maxWidth` rather than the measured content width, so there's no feedback loop.
const RAIL_BAR_MARGIN_PX = 48;
$: compactRail =
showTableOfContents &&
bandWidth > 0 &&
bandWidth < maxWidth + RAIL_BAR_MARGIN_PX;

$: ({ instanceId } = client);

Expand Down Expand Up @@ -76,55 +89,70 @@

<div
role="presentation"
id="canvas-scroll-container"
class="p-2 flex flex-col items-center bg-surface-background select-none overflow-y-auto overflow-x-hidden"
class:!cursor-grabbing={showGrabCursor}
class="relative flex overflow-hidden"
class:w-full={$dynamicHeight}
class:size-full={!$dynamicHeight}
class:pb-48={!embedded}
onclick={(e) => {
if (e.target === e.currentTarget) onClick();
}}
bind:clientWidth={bandWidth}
>
{#if hasMissingRequired}
<div class="w-full flex justify-center px-6 pt-24 pb-12">
<!-- Overlays the content's left margin without reserving layout width, so the content stays
centered in the full viewport. On narrow screens the bars collapse to dots so they don't
overlap the content. -->
{#if showTableOfContents && scrollContainer}
<CanvasTableOfContents {scrollContainer} compact={compactRail} />
{/if}

<div
role="presentation"
id="canvas-scroll-container"
bind:this={scrollContainer}
class="p-2 flex flex-col items-center bg-surface-background select-none overflow-y-auto overflow-x-hidden flex-1 min-w-0"
class:!cursor-grabbing={showGrabCursor}
class:h-full={!$dynamicHeight}
class:pb-48={!embedded}
onclick={(e) => {
if (e.target === e.currentTarget) onClick();
}}
>
{#if hasMissingRequired}
<div class="w-full flex justify-center px-6 pt-24 pb-12">
<div
class="flex flex-col items-center text-center gap-y-3 px-8 py-10 rounded-lg border border-gray-200 bg-surface-subtle shadow-sm w-full max-w-lg"
role="alert"
>
<WarningIcon size="32px" className="text-amber-500" />
<h2 class="text-lg font-semibold text-fg-primary">
Select a value to continue
</h2>
<p class="text-sm text-fg-secondary">
This dashboard requires values for the following filter{missingRequiredFilters.length >
1
? "s"
: ""}:
</p>
<ul
class="text-sm text-fg-primary flex flex-wrap justify-center gap-x-2 gap-y-1"
>
{#each missingRequiredFilters as missing (missing.key)}
<li
class="px-2 py-0.5 rounded-md bg-red-50 border border-red-200 text-red-700"
>
{missing.label}
</li>
{/each}
</ul>
</div>
</div>
{:else}
<div
class="flex flex-col items-center text-center gap-y-3 px-8 py-10 rounded-lg border border-gray-200 bg-surface-subtle shadow-sm w-full max-w-lg"
role="alert"
class="w-full h-fit flex flex-col items-center row-container relative"
style:max-width="{maxWidth}px"
style:min-width="420px"
bind:contentRect
>
<WarningIcon size="32px" className="text-amber-500" />
<h2 class="text-lg font-semibold text-fg-primary">
Select a value to continue
</h2>
<p class="text-sm text-fg-secondary">
This dashboard requires values for the following filter{missingRequiredFilters.length >
1
? "s"
: ""}:
</p>
<ul
class="text-sm text-fg-primary flex flex-wrap justify-center gap-x-2 gap-y-1"
>
{#each missingRequiredFilters as missing (missing.key)}
<li
class="px-2 py-0.5 rounded-md bg-red-50 border border-red-200 text-red-700"
>
{missing.label}
</li>
{/each}
</ul>
<slot />
</div>
</div>
{:else}
<div
class="w-full h-fit flex flex-col items-center row-container relative"
style:max-width="{maxWidth}px"
style:min-width="420px"
bind:contentRect
>
<slot />
</div>
{/if}
{/if}
</div>
</div>

<CellInspector />
Expand Down
148 changes: 148 additions & 0 deletions web-common/src/features/canvas/toc/CanvasTableOfContents.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script lang="ts">
import { CanvasTocController } from "./toc-controller.svelte";

type Props = {
// The canvas scroll container (`#canvas-scroll-container`). The TOC is derived entirely from the
// headings rendered inside its `.row-container`, so the component needs nothing else.
scrollContainer: HTMLElement;
// On narrow viewports the rail collapses its bars to small dots that tuck into the content's
// left padding, so they don't overlap the content without reserving any layout width.
compact?: boolean;
};

let { scrollContainer, compact = false }: Props = $props();

let toc = $state<CanvasTocController>();
$effect(() => {
const controller = new CanvasTocController(scrollContainer);
toc = controller;
return () => controller.destroy();
});
</script>

{#if toc && toc.entries.length > 1}
<!-- Rest state shows a rail of tick bars; hovering or focusing into it reveals the full list.
The same links back both states, so the accessible name is always the full heading text.
Hidden when there's only one section, where a table of contents adds no value. -->
<nav aria-label="Table of contents" class="toc" class:toc-compact={compact}>
<div class="toc-panel">
<ul class="toc-list">
{#each toc.entries as entry (entry.id)}
<li>
<a
href={`#${entry.id}`}
class="toc-item"
class:toc-item-active={toc.activeId === entry.id}
style:--depth={entry.depth}
aria-current={toc.activeId === entry.id ? "location" : undefined}
onclick={(e) => toc?.jumpTo(e, entry)}
>
<span class="toc-bar" aria-hidden="true"></span>
<span class="toc-text">{entry.text}</span>
</a>
</li>
{/each}
</ul>
</div>
</nav>
{/if}

<style lang="postcss">
/* Full-height positioning anchor pinned to the left edge, out of flow, so the rail sits in the
content's left margin without shifting the centered content or reserving layout width. It's
transparent to pointer events (only `.toc-panel` is interactive), so hovering the empty gutter
above/below the rail never holds the flyout open. */
.toc {
@apply pointer-events-none absolute left-0 top-0 z-40 h-full;
}

/* 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 {
@apply pointer-events-auto absolute left-0 top-1/2 flex max-h-[calc(100%-1rem)] -translate-y-1/2 flex-col overflow-y-auto overflow-x-hidden;
@apply rounded-lg border border-transparent p-2;
@apply transition-[width,background-color,box-shadow,border-color] duration-150 motion-reduce:transition-none;
width: 2rem;
}

.toc-panel:hover,
.toc-panel:focus-within {
@apply w-64 border-gray-200 bg-surface-card p-3 shadow-lg;
}

/* Rail: bars evenly spaced; rows stretch full width so the expanded hover highlight fills the row. */
.toc-list {
@apply flex flex-col gap-y-2;
}

.toc-panel:hover .toc-list,
.toc-panel:focus-within .toc-list {
@apply gap-y-0.5;
}

.toc-item {
@apply flex w-full items-center rounded-md;
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
}

/* Rail tick: darker and clearly sized, shrinking one step per nesting level so the hierarchy
reads at a glance. Hidden once the panel expands (decorative only). */
.toc-bar {
@apply block h-[3px] flex-none rounded-full bg-gray-300;
width: max(0.4rem, calc(1rem - var(--depth, 0) * 0.25rem));
}

.toc-item-active .toc-bar {
@apply h-1 bg-primary-500;
}

.toc-panel:hover .toc-bar,
.toc-panel:focus-within .toc-bar {
@apply hidden;
}

/* Compact (narrow viewport): the collapsed rail shrinks to a column of small dots hugging the
left edge so it tucks into the content's padding instead of overlapping it. The `:not(:hover)`
guard keeps the hover/focus flyout expanding to the full panel as usual. */
.toc-compact .toc-panel:not(:hover):not(:focus-within) {
@apply w-4 px-1;
}

.toc-compact .toc-bar {
@apply h-1 w-1 rounded-full;
}

.toc-compact .toc-item-active .toc-bar {
@apply h-1.5 w-1.5;
}

/* Heading text: visually hidden but kept in the accessibility tree while in the rail (so each
link's accessible name stays the full heading), then revealed as a normal list item. */
.toc-text {
@apply absolute h-px w-px overflow-hidden;
clip: rect(0 0 0 0);
}

.toc-panel:hover .toc-text,
.toc-panel:focus-within .toc-text {
@apply static h-auto w-auto truncate;
clip: auto;
}

/* Expanded list item: full-width row, indented per depth, muted by default, with a full-row hover
highlight (base `.toc-item` already supplies `rounded-md`). */
.toc-panel:hover .toc-item,
.toc-panel:focus-within .toc-item {
@apply py-1.5 pr-2 text-sm text-fg-secondary transition-colors;
@apply hover:cursor-pointer hover:bg-surface-subtle hover:text-fg-primary;
padding-left: calc(0.5rem + var(--depth, 0) * 0.875rem);
}

/* Active section: a soft tinted pill rather than a hard left bar. Text stays the normal
foreground colour (white in dark mode) for readability; the tint conveys selection. */
.toc-panel:hover .toc-item-active,
.toc-panel:focus-within .toc-item-active {
@apply bg-primary-50 font-medium text-fg-primary hover:bg-primary-100;
}
</style>
Loading
Loading