diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index bedcd653d..337143c3b 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -13,6 +13,12 @@
background-color: var(--general-surface-primary);
}
+ /* Stories with `docs.story.inline: false` render in an inline iframe; its
+ baseline gap overflows the docs wrapper and adds a stray scrollbar. */
+ .docs-story iframe {
+ display: block;
+ }
+
.docblock-argstable label {
color: inherit;
}
diff --git a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.scss b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.scss
index 20c1cb36c..8190ebd93 100644
--- a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.scss
+++ b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.scss
@@ -21,35 +21,13 @@
.tedi-table-of-contents__container.tedi-table-of-contents--sticky {
position: sticky;
top: var(--tedi-table-of-contents-sticky-top);
- display: flex;
- flex-direction: column;
- // Fill the viewport minus the top offset and a matching bottom gap. Override
- // via `stickyMaxHeight` when the TOC scrolls inside a fixed-height container
- // rather than the window (then 100dvh is the wrong basis).
+ // The whole card scrolls — heading included — once it exceeds the max height.
+ // Override the max height via `stickyMaxHeight` when the TOC scrolls inside a
+ // fixed-height container rather than the window (then 100dvh is the wrong basis).
max-height: var(--tedi-table-of-contents-sticky-max-height);
-}
-
-.tedi-table-of-contents--sticky > .tedi-card-content {
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- min-height: 0;
-}
-
-.tedi-table-of-contents--sticky .tedi-table-of-contents__heading {
- flex: none;
-}
-
-.tedi-table-of-contents--sticky nav {
- flex: 1 1 auto;
- min-height: 0;
- margin-block: var(--card-padding-md-default);
overflow-y: auto;
-}
-
-.tedi-table-of-contents--sticky .tedi-table-of-contents__list {
- padding-block: 0;
+ overscroll-behavior: contain;
}
.tedi-table-of-contents--transparent .tedi-table-of-contents__list {
diff --git a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.spec.ts b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.spec.ts
index cdb972dab..f2c00f2d0 100644
--- a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.spec.ts
+++ b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.spec.ts
@@ -24,6 +24,7 @@ import { TableOfContentsItemComponent } from "./table-of-contents-item/table-of-
[bordered]="bordered()"
[sticky]="sticky()"
[ariaLabel]="ariaLabel()"
+ [scrollActiveIntoView]="scrollActiveIntoView()"
>
Alpha
@@ -53,6 +54,7 @@ class TreeHostComponent {
readonly bordered = input(false);
readonly sticky = input(true);
readonly ariaLabel = input();
+ readonly scrollActiveIntoView = input(false);
}
const setup = async (component: unknown) => {
@@ -238,4 +240,60 @@ describe("TableOfContentsComponent", () => {
fixture.debugElement.query(By.css(".tedi-table-of-contents--bordered")),
).toBeTruthy();
});
+
+ describe("scrollActiveIntoView", () => {
+ const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
+
+ const createScrollableTree = async (rowTops: Record) => {
+ await createTree();
+ fixture.componentRef.setInput("scrollActiveIntoView", true);
+ const scroller = fixture.debugElement.query(
+ By.css(".tedi-table-of-contents--sticky"),
+ ).nativeElement as HTMLElement;
+ scroller.style.overflowY = "auto";
+ Object.defineProperty(scroller, "scrollHeight", { value: 500 });
+ Object.defineProperty(scroller, "clientHeight", { value: 100 });
+ scroller.getBoundingClientRect = () => rect(0, 100);
+ scroller.scrollTo = jest.fn();
+ scroller.scrollBy = jest.fn();
+ Object.entries(rowTops).forEach(([label, top]) => {
+ const row = itemByLabel(label)?.querySelector(
+ ":scope > .tedi-table-of-contents__row",
+ );
+ if (row) row.getBoundingClientRect = () => rect(top, top + 20);
+ });
+ return scroller;
+ };
+
+ it("scrolls to the top when the first item becomes active", async () => {
+ const scroller = await createScrollableTree({ Alpha: 10 });
+ fixture.componentRef.setInput("activeId", "a");
+ fixture.detectChanges();
+
+ expect(scroller.scrollTo).toHaveBeenCalledWith(
+ expect.objectContaining({ top: 0 }),
+ );
+ expect(scroller.scrollBy).not.toHaveBeenCalled();
+ });
+
+ it("scrolls just enough to reveal another item above the visible area", async () => {
+ const scroller = await createScrollableTree({ Bravo: -40 });
+ fixture.componentRef.setInput("activeId", "b");
+ fixture.detectChanges();
+
+ expect(scroller.scrollBy).toHaveBeenCalledWith(
+ expect.objectContaining({ top: -48 }),
+ );
+ expect(scroller.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it("does not scroll when the active item is already visible", async () => {
+ const scroller = await createScrollableTree({ Charlie: 40 });
+ fixture.componentRef.setInput("activeId", "c");
+ fixture.detectChanges();
+
+ expect(scroller.scrollBy).not.toHaveBeenCalled();
+ expect(scroller.scrollTo).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.ts b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.ts
index a0e113a6e..c9242395f 100644
--- a/src/tedi/components/navigation/table-of-contents/table-of-contents.component.ts
+++ b/src/tedi/components/navigation/table-of-contents/table-of-contents.component.ts
@@ -33,7 +33,7 @@ let nextUniqueId = 0;
*
* When `sticky` is enabled the container pins while the page scrolls. Two knobs
* adapt it to the surrounding layout: `stickyOffset` moves where it pins (raise
- * it to clear a fixed header), and `stickyMaxHeight` overrides the height cap
+ * it to clear a fixed header), and `stickyMaxHeight` overrides the max height
* for when the TOC scrolls inside a fixed-height container rather than the window.
*
* The gap the sticky TOC leaves above the viewport bottom has no input — override
@@ -92,7 +92,8 @@ export class TableOfContentsComponent {
/**
* Keep the active item visible inside the TOC's own scroll area. When the list
* is taller than a bounded or sticky container and `activeId` changes, the TOC
- * scrolls its internal scroll region just enough to reveal the active item.
+ * scrolls its internal scroll region just enough to reveal the active item;
+ * for the first item it scrolls back to the top so the heading shows too.
* No-op when the list isn't scrollable (short lists, unbounded layouts).
* @default false
*/
@@ -117,7 +118,7 @@ export class TableOfContentsComponent {
*/
readonly stickyOffset = input();
/**
- * Overrides the sticky height cap. The default keeps the TOC within the
+ * Overrides the sticky max height. The default keeps the TOC within the
* viewport (`calc(100dvh - offset - 1.5rem)`); set this when the TOC scrolls
* inside a fixed-height container rather than the window — e.g.
* `"calc(30rem - 3rem)"` for a 30rem scroll region. Only applies while
@@ -187,7 +188,10 @@ export class TableOfContentsComponent {
).matches
? "auto"
: "smooth";
- if (targetRect.top < scrollerRect.top + margin) {
+ const firstRow = hostEl.querySelector(".tedi-table-of-contents__row");
+ if (target === firstRow) {
+ scroller.scrollTo({ top: 0, behavior });
+ } else if (targetRect.top < scrollerRect.top + margin) {
scroller.scrollBy({
top: targetRect.top - scrollerRect.top - margin,
behavior,
@@ -200,7 +204,7 @@ export class TableOfContentsComponent {
}
}
- /** Nearest scrollable ancestor within this component (e.g. the sticky `nav`). */
+ /** Nearest scrollable ancestor within this component (e.g. the sticky card). */
private findScrollParent(el: HTMLElement): HTMLElement | null {
const hostEl = this.host.nativeElement;
let node = el.parentElement;
diff --git a/src/tedi/components/navigation/table-of-contents/table-of-contents.stories.ts b/src/tedi/components/navigation/table-of-contents/table-of-contents.stories.ts
index a9609e67f..d90ea36ce 100644
--- a/src/tedi/components/navigation/table-of-contents/table-of-contents.stories.ts
+++ b/src/tedi/components/navigation/table-of-contents/table-of-contents.stories.ts
@@ -193,7 +193,7 @@ const meta = {
},
stickyMaxHeight: {
description:
- "Overrides the sticky height cap. The default keeps the TOC within the viewport (`calc(100dvh - offset - 1.5rem)`); set it when the TOC scrolls inside a fixed-height container rather than the window, e.g. `calc(30rem - 3rem)`. Only applies while `sticky` is `true`.",
+ "Overrides the sticky max height. The default keeps the TOC within the viewport (`calc(100dvh - offset - 1.5rem)`); set it when the TOC scrolls inside a fixed-height container rather than the window, e.g. `calc(30rem - 3rem)`. Only applies while `sticky` is `true`.",
control: "text",
table: {
category: "Table of Contents",
@@ -579,11 +579,11 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => {
/**
* Example for the StickyInLayout story: a documentation page with a sticky
- * table-of-contents sidebar inside a fixed-height scrollable region. The page
- * scrolls, the sticky TOC stays alongside it — heading pinned, list scrolling on
- * its own when taller than the space available. The component is controlled via
- * `activeId`: an `IntersectionObserver` highlights the section in view (scroll-spy)
- * and clicking an item smooth-scrolls the page to it.
+ * table-of-contents sidebar. The window scrolls, the sticky TOC stays alongside
+ * it and scrolls on its own when taller than the viewport. Below `lg` the
+ * sidebar collapses into `tedi-table-of-contents-collapsible`. The component is
+ * controlled via `activeId`: an `IntersectionObserver` highlights the section in
+ * view (scroll-spy) and clicking an item smooth-scrolls the page to it.
*/
@Component({
selector: "toc-sticky-demo",
@@ -616,7 +616,7 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => {
}
-
+