diff --git a/panel/models/html.ts b/panel/models/html.ts index fe549519e2..64f985e158 100644 --- a/panel/models/html.ts +++ b/panel/models/html.ts @@ -6,6 +6,20 @@ import {Markup} from "@bokehjs/models/widgets/markup" import {PanelMarkupView} from "./layout" import {serializeEvent} from "./event-to-object" +function searchAllDOMs(node: Element | ShadowRoot, selector: string): (Element | ShadowRoot)[] { + let found: (Element | ShadowRoot)[] = [] + if (node instanceof Element && node.matches(selector)) { + found.push(node) + } + node.children && Array.from(node.children).forEach(child => { + found = found.concat(searchAllDOMs(child, selector)) + }) + if (node instanceof Element && node.shadowRoot) { + found = found.concat(searchAllDOMs(node.shadowRoot, selector)) + } + return found +} + @server_event("html_stream") export class HTMLStreamEvent extends ModelEvent { constructor(readonly model: HTML, readonly patch: string, readonly start: number) { @@ -127,12 +141,27 @@ export class HTMLView extends PanelMarkupView { } set_html(html: string | null): void { - if (html !== null) { - this.container.innerHTML = html - if (this.model.run_scripts) { - run_scripts(this.container) + if (html === null) { + return + } + this.container.innerHTML = html + if (this.model.run_scripts) { + run_scripts(this.container) + } + this._setup_event_listeners() + for (const anchor of this.container.querySelectorAll("a")) { + const link = anchor.getAttribute("href") + if (link && link.startsWith("#")) { + anchor.addEventListener("click", () => { + const found = searchAllDOMs(document.body, link) + if ((found.length > 0) && found[0] instanceof Element) { + found[0].scrollIntoView() + } + }) + if (!this.root.has_finished() && this.model.document && window.location.hash === link) { + this.model.document.on_event("document_ready", () => setTimeout(() => anchor.scrollIntoView(), 5)) + } } - this._setup_event_listeners() } } diff --git a/panel/tests/ui/pane/test_markup.py b/panel/tests/ui/pane/test_markup.py index 3e43a9eacf..71399b79d0 100644 --- a/panel/tests/ui/pane/test_markup.py +++ b/panel/tests/ui/pane/test_markup.py @@ -6,6 +6,7 @@ from playwright.sync_api import expect +from panel.layout import Row from panel.models import HTML from panel.pane import Markdown from panel.tests.util import serve_component, wait_until @@ -115,3 +116,40 @@ def test_html_model_no_stylesheet(page): header_element = page.locator('h1:has-text("Header")') assert header_element.is_visible() assert header_element.text_content() == "Header" + +def test_anchor_scroll(page): + md = '' + for tag in ['tag1', 'tag2', 'tag3']: + md += f'# {tag}\n\n' + md += f'{tag} content\n' * 50 + + content = Markdown(md) + link = Markdown('Link1Link') + + serve_component(page, Row(link, content)) + + expect(page.locator('#tag1')).to_be_in_viewport() + expect(page.locator('#tag3')).not_to_be_in_viewport() + + page.locator('#link3').click() + + expect(page.locator('#tag1')).not_to_be_in_viewport() + expect(page.locator('#tag3')).to_be_in_viewport() + + page.locator('#link1').click() + + expect(page.locator('#tag1')).to_be_in_viewport() + expect(page.locator('#tag3')).not_to_be_in_viewport() + +def test_anchor_scroll_on_init(page): + md = '' + for tag in ['tag1', 'tag2', 'tag3']: + md += f'# {tag}\n\n' + md += f'{tag} content\n' * 50 + + content = Markdown(md) + + serve_component(page, content, suffix='#tag3') + + expect(page.locator('#tag1')).not_to_be_in_viewport() + expect(page.locator('#tag3')).to_be_in_viewport()