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: 6 additions & 0 deletions .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

.tedi-table-of-contents--transparent .tedi-table-of-contents__list {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { TableOfContentsItemComponent } from "./table-of-contents-item/table-of-
[bordered]="bordered()"
[sticky]="sticky()"
[ariaLabel]="ariaLabel()"
[scrollActiveIntoView]="scrollActiveIntoView()"
>
<tedi-table-of-contents-item itemId="a">
<a href="#a">Alpha</a>
Expand Down Expand Up @@ -53,6 +54,7 @@ class TreeHostComponent {
readonly bordered = input(false);
readonly sticky = input(true);
readonly ariaLabel = input<string>();
readonly scrollActiveIntoView = input(false);
}

const setup = async (component: unknown) => {
Expand Down Expand Up @@ -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<string, number>) => {
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<HTMLElement>(
":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();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -117,7 +118,7 @@ export class TableOfContentsComponent {
*/
readonly stickyOffset = input<string>();
/**
* 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
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Loading
Loading