mirror of
https://github.com/simonw/datasette.git
synced 2026-09-18 22:44:07 +02:00
Render navigation search without shadow DOM, refs #2790
This commit is contained in:
parent
3b013b7ea3
commit
e60d1bfe1c
3 changed files with 286 additions and 248 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<style>
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
dialog.datasette-modal {
|
||||
max-width: 90vw;
|
||||
width: 600px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1.25rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.close-search:hover,
|
||||
.close-search:focus {
|
||||
background-color: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.results-container {
|
||||
overflow-y: auto;
|
||||
height: calc(80vh - 180px);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.results-list:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.result-item:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.result-item.selected {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.result-item > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jump-start-content {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.jump-start-content:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.875rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.result-url {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.results-heading {
|
||||
color: #4b5563;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
padding: 0.5rem 1rem 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.recent-actions {
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.clear-recent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.clear-recent:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Mobile optimizations */
|
||||
@media (max-width: 640px) {
|
||||
dialog.datasette-modal {
|
||||
width: 95vw;
|
||||
max-height: 85vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
.result-item {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||
this.innerHTML = `
|
||||
<datasette-modal><dialog aria-modal="true" aria-labelledby="${this.titleId}">
|
||||
<div class="search-container">
|
||||
<h2 id="${this.titleId}" class="visually-hidden">Jump to</h2>
|
||||
<p id="${this.instructionsId}" class="visually-hidden">Type to search. Use up and down arrow keys to move through results, Enter to select a result, and Escape to close this menu.</p>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue