wire shop LiveViews to DB queries and improve search UX

Replace PreviewData indirection in all shop LiveViews with direct
Products context queries. Home, collection, product detail and error
pages now query the database. Categories loaded once in ThemeHook.
Cart hydration no longer falls back to mock data. PreviewData kept
only for the theme editor.

Search modal gains keyboard navigation (arrow keys, Enter, Escape),
Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate
links, and 150ms debounce. SearchModal JS hook manages selection
state and highlight. search.ex gets transaction safety on reindex
and a public remove_product/1. 10 new integration tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-13 08:27:26 +00:00
parent 037cd168cd
commit 57c3ba0e28
22 changed files with 745 additions and 330 deletions

View File

@@ -324,10 +324,123 @@ const ProductImageScroll = {
}
}
// Hook for search modal keyboard navigation and shortcuts
const SearchModal = {
mounted() {
this.selectedIndex = -1
this._globalKeydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault()
if (this.isOpen()) {
this.close()
} else {
this.open()
}
}
}
document.addEventListener("keydown", this._globalKeydown)
this.el.addEventListener("keydown", (e) => {
if (!this.isOpen()) return
switch (e.key) {
case "Escape":
e.preventDefault()
this.close()
break
case "ArrowDown":
e.preventDefault()
this.moveSelection(1)
break
case "ArrowUp":
e.preventDefault()
this.moveSelection(-1)
break
case "Enter":
e.preventDefault()
this.navigateToSelected()
break
}
})
},
destroyed() {
document.removeEventListener("keydown", this._globalKeydown)
},
updated() {
this.selectedIndex = -1
this.updateHighlight()
},
isOpen() {
return this.el.style.display !== "none" && this.el.style.display !== ""
},
open() {
this.el.style.display = "flex"
const input = this.el.querySelector("#search-input")
if (input) {
input.focus()
input.select()
}
this.selectedIndex = -1
this.updateHighlight()
},
close() {
this.el.style.display = "none"
this.pushEvent("clear_search", {})
this.selectedIndex = -1
this.updateHighlight()
},
getResults() {
return this.el.querySelectorAll('[role="option"]')
},
moveSelection(delta) {
const results = this.getResults()
if (results.length === 0) return
this.selectedIndex += delta
if (this.selectedIndex < -1) this.selectedIndex = results.length - 1
if (this.selectedIndex >= results.length) this.selectedIndex = 0
this.updateHighlight()
},
updateHighlight() {
const results = this.getResults()
results.forEach((r, i) => {
const isSelected = i === this.selectedIndex
r.style.background = isSelected ? "var(--t-surface-sunken)" : ""
r.setAttribute("aria-selected", isSelected)
})
if (this.selectedIndex >= 0 && results[this.selectedIndex]) {
results[this.selectedIndex].scrollIntoView({ block: "nearest" })
}
},
navigateToSelected() {
const results = this.getResults()
const index = this.selectedIndex >= 0 ? this.selectedIndex : 0
if (results[index]) {
const link = results[index].querySelector("a")
if (link) {
this.el.style.display = "none"
link.click()
}
}
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal},
})
// Show progress bar on live navigation and form submits