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) => { } - +
@@ -626,7 +626,6 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => { heading="Sisukord" [activeId]="activeId()" [scrollActiveIntoView]="true" - stickyMaxHeight="calc(30rem - 3rem)" > @for (chapter of chapters; track chapter.id) { @@ -656,7 +655,7 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => {
-
+
@@ -693,12 +692,6 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => { `, styles: [ ` - .scroll-page { - height: 30rem; - overflow-y: auto; - border: var(--tedi-borders-01) solid var(--card-border-primary); - border-radius: var(--card-radius-rounded); - } .scroll-page__grid { display: grid; grid-template-columns: 1fr 16rem; @@ -715,6 +708,8 @@ const CHAPTERS: DemoSection[] = Array.from({ length: 30 }, (_, i) => { .scroll-page__section p { margin: 0.25rem 0 0; } + /* The content scrolls above the static bar, so the scrollbar ends + before it. */ .mobile-shell { display: flex; flex-direction: column; @@ -734,22 +729,25 @@ class TocStickyDemoComponent implements OnDestroy { readonly lorem = LOREM; readonly activeId = signal(CHAPTERS[0].id); - // Resolves to whichever layout is rendered — the desktop scroll box or the - // mobile scroll area. + // Resolves to whichever layout is rendered — the desktop grid or the mobile + // scroll area. private readonly page = viewChild>("page"); + // Mobile scroll area; on desktop the window scrolls, so this is undefined. + private readonly scroller = viewChild>("scroller"); private readonly platformId = inject(PLATFORM_ID); private seeking = false; private seekTimeout?: ReturnType; private observer?: IntersectionObserver; constructor() { - // Re-attach the scroll-spy to the active scroll container whenever the - // layout swaps between the desktop sidebar and the mobile collapsible. + // Re-attach the scroll-spy to the rendered sections whenever the layout + // swaps between the desktop sidebar and the mobile collapsible. effect(() => { - const root = this.page()?.nativeElement; + const page = this.page()?.nativeElement; + const root = this.scroller()?.nativeElement ?? null; this.observer?.disconnect(); - if (root && isPlatformBrowser(this.platformId)) { - this.observeSections(root); + if (page && isPlatformBrowser(this.platformId)) { + this.observeSections(page, root); } }); } @@ -759,28 +757,35 @@ class TocStickyDemoComponent implements OnDestroy { clearTimeout(this.seekTimeout); } - // Smooth-scroll the active scroll container to the clicked section; guard the - // observer during the scroll so the marker doesn't flicker through sections. + // Smooth-scroll the window (desktop) or the scroll area (mobile) to the clicked + // section; guard the observer during the scroll so the marker doesn't flicker + // through sections. `scrollTo` rather than `scrollIntoView`, which would also + // scroll the Storybook docs page around the iframe. selectSection(id: string, event: Event): void { event.preventDefault(); - const root = this.page()?.nativeElement; - const target = root?.querySelector(`#${id}`); - if (!root || !target) return; + const target = this.page()?.nativeElement.querySelector( + `#${id}`, + ); + if (!target) return; this.seeking = true; this.activeId.set(id); clearTimeout(this.seekTimeout); this.seekTimeout = setTimeout(() => (this.seeking = false), 700); - root.scrollTo({ - top: - root.scrollTop + - target.getBoundingClientRect().top - - root.getBoundingClientRect().top, - behavior: "smooth", - }); + const root = this.scroller()?.nativeElement; + const offset = target.getBoundingClientRect().top; + if (root) { + root.scrollTo({ + top: root.scrollTop + offset - root.getBoundingClientRect().top, + behavior: "smooth", + }); + } else { + window.scrollTo({ top: window.scrollY + offset, behavior: "smooth" }); + } } - // Scroll-spy: highlight the chapter currently in view within `root`. - private observeSections(root: HTMLElement): void { + // Scroll-spy: highlight the chapter currently in view within `root` (the + // viewport when null). + private observeSections(page: HTMLElement, root: HTMLElement | null): void { const ids = this.chapters.flatMap((c) => [ c.id, ...(c.children ?? []).map((child) => child.id), @@ -789,8 +794,10 @@ class TocStickyDemoComponent implements OnDestroy { const pickActive = () => { if (this.seeking) return; - const atBottom = - root.scrollTop + root.clientHeight >= root.scrollHeight - 2; + const atBottom = root + ? root.scrollTop + root.clientHeight >= root.scrollHeight - 2 + : window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 2; if (atBottom) { this.activeId.set(ids[ids.length - 1]); return; @@ -813,7 +820,7 @@ class TocStickyDemoComponent implements OnDestroy { ); this.observer = observer; ids.forEach((id) => { - const el = root.querySelector(`#${id}`); + const el = page.querySelector(`#${id}`); if (el) observer.observe(el); }); } @@ -830,8 +837,8 @@ const STICKY_LAYOUT_SOURCE = `@Component({ TextComponent, ], template: \` - +
@for (chapter of chapters; track chapter.id) { @@ -841,14 +848,7 @@ const STICKY_LAYOUT_SOURCE = `@Component({ }
- - + @for (chapter of chapters; track chapter.id) { ("#" + id); + const target = this.page().nativeElement.querySelector("#" + id); if (!target) return; this.seeking = true; this.activeId.set(id); clearTimeout(this.seekTimeout); this.seekTimeout = setTimeout(() => (this.seeking = false), 700); - root.scrollTo({ - top: - root.scrollTop + - target.getBoundingClientRect().top - - root.getBoundingClientRect().top, - behavior: "smooth", - }); + target.scrollIntoView({ behavior: "smooth", block: "start" }); } // Scroll-spy: highlight the section currently in view. private trackActiveSection(): void { - const root = this.page().nativeElement; + const page = this.page().nativeElement; const observer = new IntersectionObserver( (entries) => { if (this.seeking) return; const visible = entries.find((entry) => entry.isIntersecting); if (visible) this.activeId.set((visible.target as HTMLElement).id); }, - { root, rootMargin: "0px 0px -55% 0px" }, + { rootMargin: "0px 0px -55% 0px" }, ); this.observer = observer; this.chapters.forEach((chapter) => { - const el = root.querySelector("#" + chapter.id); + const el = page.querySelector("#" + chapter.id); if (el) observer.observe(el); }); } }`; /** - * A documentation page with a sticky table-of-contents sidebar. The page scrolls - * inside a fixed-height region, and the sticky TOC stays alongside it — its - * heading pinned while the list scrolls on its own when it is taller than the - * space available. The component is controlled via `activeId`, so the consumer + * A documentation page with a sticky table-of-contents sidebar. The page scrolls, + * and the sticky TOC stays alongside it, scrolling on its own when it is taller + * than the viewport. The component is controlled via `activeId`, so the consumer * owns scroll behavior: this demo wires an `IntersectionObserver` to highlight the * section in view (scroll-spy) and smooth-scrolls the page to a section when its * item is clicked. * * Tune the gap the sticky sidebar leaves above the viewport bottom with the * `--tedi-table-of-contents-sticky-bottom` CSS custom property (default `1.5rem`). + * When the TOC scrolls inside a fixed-height container rather than the window, + * set `stickyMaxHeight` to that container's height minus the top offset and + * bottom gap, e.g. `calc(30rem - 3rem)` for a 30rem container. */ export const StickyInLayout: Story = { parameters: { layout: "fullscreen", fullWidth: true, - docs: { source: { language: "typescript", code: STICKY_LAYOUT_SOURCE } }, + docs: { + // Own iframe, so the window scrolls and the default 100dvh max height applies. + story: { inline: false, height: "30rem" }, + source: { language: "typescript", code: STICKY_LAYOUT_SOURCE }, + }, a11y: { config: { rules: [{ id: "color-contrast", enabled: false }] } }, }, render: () => ({