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()