From e60d1bfe1c406d58b97a265a2f9d5e89da854d9c Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Thu, 17 Sep 2026 14:10:44 -0700 Subject: [PATCH] Render navigation search without shadow DOM, refs #2790 --- datasette/static/app.css | 228 ++++++++++++++++++++++ datasette/static/navigation-search.js | 271 +++----------------------- tests/test_playwright.py | 35 ++++ 3 files changed, 286 insertions(+), 248 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 6971635b..f825b273 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -981,6 +981,234 @@ p.zero-results { display: none; } +/* navigation-search */ +navigation-search { + display: contents; +} + +navigation-search dialog.datasette-modal { + max-width: 90vw; + width: 600px; + max-height: 80vh; +} + +navigation-search .search-container { + display: flex; + flex-direction: column; +} + +navigation-search .search-input-wrapper { + padding: 1.25rem; + border-bottom: 1px solid #e5e7eb; + display: flex; + gap: 0.5rem; + align-items: center; +} + +navigation-search .search-input { + width: 100%; + flex: 1; + min-width: 0; + padding: 0.75rem 1rem; + font-size: 1rem; + border: 2px solid #e5e7eb; + border-radius: 0.5rem; + outline: none; + transition: border-color 0.2s; + box-sizing: border-box; +} + +navigation-search .search-input:focus { + border-color: #2563eb; +} + +navigation-search .close-search { + background: transparent; + border: 1px solid transparent; + border-radius: 0.375rem; + color: #4b5563; + cursor: pointer; + flex: 0 0 auto; + font: inherit; + font-size: 1.5rem; + height: 2.75rem; + line-height: 1; + width: 2.75rem; +} + +navigation-search .close-search:hover, +navigation-search .close-search:focus { + background-color: #f3f4f6; + border-color: #d1d5db; +} + +navigation-search .results-container { + box-sizing: content-box; + overflow-y: auto; + height: calc(80vh - 180px); + padding: 0.5rem; +} + +navigation-search .results-list:empty { + display: none; +} + +navigation-search .result-item { + padding: 0.875rem 1rem; + cursor: pointer; + border-radius: 0.5rem; + transition: background-color 0.15s; + display: flex; + align-items: center; + gap: 0.75rem; +} + +navigation-search .result-item:hover { + background-color: #f3f4f6; +} + +navigation-search .result-item.selected { + background-color: #dbeafe; +} + +navigation-search .result-item > div { + flex: 1; + min-width: 0; +} + +navigation-search .jump-start-content { + border-bottom: 1px solid #e5e7eb; + margin-bottom: 0.5rem; + padding: 0.5rem 0.5rem 1rem; +} + +navigation-search .jump-start-content:empty { + display: none; +} + +navigation-search .result-name { + font-weight: 500; + color: #111827; +} + +navigation-search .result-label { + font-size: 0.875rem; + color: #4b5563; +} + +navigation-search .result-type { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + +navigation-search .result-url { + font-size: 0.875rem; + color: #6b7280; +} + +navigation-search .result-description { + color: #374151; + display: -webkit-box; + font-size: 0.8125rem; + line-height: 1.35; + margin-top: 0.35rem; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +navigation-search .results-heading { + color: #4b5563; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0; + padding: 0.5rem 1rem 0.25rem; + text-transform: uppercase; +} + +navigation-search .recent-actions { + padding: 0.25rem 1rem 0.75rem; +} + +navigation-search .clear-recent { + background: transparent; + border: 0; + color: #2563eb; + cursor: pointer; + font: inherit; + font-size: 0.875rem; + padding: 0; +} + +navigation-search .clear-recent:hover { + text-decoration: underline; +} + +navigation-search .no-results { + padding: 2rem; + text-align: center; + color: #6b7280; +} + +navigation-search .hint-text { + padding: 0.75rem 1.25rem; + font-size: 0.875rem; + color: #6b7280; + border-top: 1px solid #e5e7eb; + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +navigation-search .hint-text kbd { + background: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.75rem; + border: 1px solid #d1d5db; + font-family: monospace; +} + +navigation-search .visually-hidden { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (max-width: 640px) { + navigation-search dialog.datasette-modal { + width: 95vw; + max-height: 85vh; + border-radius: 0.5rem; + } + + navigation-search .search-input-wrapper { + padding: 1rem; + } + + navigation-search .search-input { + font-size: 16px; + } + + navigation-search .result-item { + padding: 1rem 0.75rem; + } + + navigation-search .hint-text { + font-size: 0.8rem; + padding: 0.5rem 1rem; + } +} + + dialog.mobile-column-actions-dialog { width: min(420px, calc(100vw - 32px)); max-height: min(640px, calc(100vh - 32px)); diff --git a/datasette/static/navigation-search.js b/datasette/static/navigation-search.js index 02136466..2f9b723c 100644 --- a/datasette/static/navigation-search.js +++ b/datasette/static/navigation-search.js @@ -10,247 +10,22 @@ class NavigationSearch extends HTMLElement { this.recentHeadingId = `navigation-search-recent-${this.instanceId}`; this.statusId = `navigation-search-status-${this.instanceId}`; this.titleId = `navigation-search-title-${this.instanceId}`; - this.attachShadow({ mode: "open" }); this.selectedIndex = -1; this.matches = []; this.renderedMatches = []; this.debounceTimer = null; + } + connectedCallback() { + if (this._initialized) return; + this._initialized = true; this.render(); this.setupEventListeners(); } render() { - this.shadowRoot.innerHTML = ` - - - + this.innerHTML = ` +

Jump to

Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.

@@ -284,11 +59,10 @@ class NavigationSearch extends HTMLElement { } setupEventListeners() { - const dialog = this.shadowRoot.querySelector("dialog"); - const input = this.shadowRoot.querySelector(".search-input"); - const closeButton = this.shadowRoot.querySelector(".close-search"); - const resultsContainer = - this.shadowRoot.querySelector(".results-container"); + const dialog = this.querySelector("dialog"); + const input = this.querySelector(".search-input"); + const closeButton = this.querySelector(".close-search"); + const resultsContainer = this.querySelector(".results-container"); // Global keyboard listener for "/" document.addEventListener("keydown", (e) => { @@ -408,8 +182,8 @@ class NavigationSearch extends HTMLElement { } updateComboboxState() { - const dialog = this.shadowRoot.querySelector("dialog"); - const input = this.shadowRoot.querySelector(".search-input"); + const dialog = this.querySelector("dialog"); + const input = this.querySelector(".search-input"); const matches = this.renderedMatches || []; this.setElementAttribute( input, @@ -434,7 +208,7 @@ class NavigationSearch extends HTMLElement { } setStatus(message) { - const status = this.shadowRoot.querySelector(`#${this.statusId}`); + const status = this.querySelector(`#${this.statusId}`); if (status) { status.textContent = message || ""; } @@ -644,7 +418,7 @@ class NavigationSearch extends HTMLElement { section.render(node, { navigationSearch: this, container, - input: this.shadowRoot.querySelector(".search-input"), + input: this.querySelector(".search-input"), }); }); } @@ -683,8 +457,8 @@ class NavigationSearch extends HTMLElement { } renderResults() { - const container = this.shadowRoot.querySelector(".results-container"); - const input = this.shadowRoot.querySelector(".search-input"); + const container = this.querySelector(".results-container"); + const input = this.querySelector(".search-input"); const showStartContent = !input.value.trim(); const jumpSections = showStartContent ? this.jumpSections() : []; const startBlock = showStartContent @@ -797,11 +571,12 @@ class NavigationSearch extends HTMLElement { } openMenu(trigger) { - const input = this.shadowRoot.querySelector(".search-input"); + const input = this.querySelector(".search-input"); - this.shadowRoot - .querySelector("datasette-modal") - .show({ trigger, initialFocus: input }); + this.querySelector("datasette-modal").show({ + trigger, + initialFocus: input, + }); this.setNavigationTriggersExpanded(true); input.value = ""; @@ -813,11 +588,11 @@ class NavigationSearch extends HTMLElement { } closeMenu(options = {}) { - this.shadowRoot.querySelector("datasette-modal").close(options); + this.querySelector("datasette-modal").close(options); } onMenuClosed() { - const input = this.shadowRoot.querySelector(".search-input"); + const input = this.querySelector(".search-input"); this.setElementAttribute(input, "aria-expanded", "false"); this.removeElementAttribute(input, "aria-activedescendant"); this.setNavigationTriggersExpanded(false); diff --git a/tests/test_playwright.py b/tests/test_playwright.py index 753691e4..890ba076 100644 --- a/tests/test_playwright.py +++ b/tests/test_playwright.py @@ -1083,6 +1083,41 @@ def test_navigation_search_renders_jump_sections_from_javascript_plugins( page.wait_for_url("**/-/playwright-agent") +@pytest.mark.playwright +def test_navigation_search_created_from_javascript(page, datasette_server): + from playwright.sync_api import expect + + page.goto(datasette_server) + page.evaluate("""() => { + const search = document.createElement('navigation-search'); + search.id = 'additional-search'; + search.setAttribute('items', JSON.stringify([ + {name: 'Projects', url: '/data/projects'} + ])); + document.body.append(search); + const unrelated = document.createElement('div'); + unrelated.className = 'search-container'; + unrelated.id = 'outside-search'; + document.body.append(unrelated); + search.openMenu(); + }""") + search = page.locator("#additional-search") + dialog = search.get_by_role("dialog", name="Jump to", exact=True) + expect(dialog).to_be_visible() + # Page styles and ordinary DOM queries can reach the component's controls. + page.add_style_tag( + content="#additional-search .search-input { border-top-color: rgb(1, 2, 3); }" + ) + field = dialog.get_by_role("combobox", name="Jump to", exact=True) + expect(field).to_have_css("border-top-color", "rgb(1, 2, 3)") + assert field.evaluate("node => document.getElementById(node.id) === node") + expect(page.locator("#outside-search")).to_have_css("display", "block") + field.fill("projects") + expect(dialog.get_by_role("option")).to_contain_text("Projects") + field.press("Enter") + page.wait_for_url("**/data/projects") + + @pytest.mark.playwright def test_insert_row_flow_uses_custom_column_field(page, datasette_server): page.add_init_script("""