feat: add cart page, cart drawer, and shared cart infrastructure
- Cart context with pure functions for add/remove/update/hydrate - Price formatting via ex_money (replaces all float division) - CartHook on_mount with attach_hook for shared event handlers (open/close drawer, remove item, PubSub sync) - Accessible cart drawer with focus trap, scroll lock, aria-live - Cart page with increment/decrement quantity controls - Preview mode cart drawer support in theme editor - Cart persistence to session via JS hook + API endpoint - 19 tests covering all Cart pure functions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
150
assets/js/app.js
150
assets/js/app.js
@@ -43,6 +43,154 @@ const ColorSync = {
|
||||
}
|
||||
}
|
||||
|
||||
// Hook to persist cart to session via API
|
||||
const CartPersist = {
|
||||
mounted() {
|
||||
this.handleEvent("persist_cart", ({items}) => {
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
fetch("/api/cart", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-csrf-token": csrfToken
|
||||
},
|
||||
body: JSON.stringify({items})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for accessible cart drawer (WAI-ARIA dialog pattern)
|
||||
const CartDrawer = {
|
||||
mounted() {
|
||||
this.triggerElement = null
|
||||
this.boundKeydown = this.handleKeydown.bind(this)
|
||||
this.boundTouchmove = this.handleTouchmove.bind(this)
|
||||
this.scrollY = 0
|
||||
this.isOpen = this.el.classList.contains('open')
|
||||
|
||||
if (this.isOpen) {
|
||||
this.lockScroll()
|
||||
document.addEventListener('keydown', this.boundKeydown)
|
||||
}
|
||||
},
|
||||
|
||||
updated() {
|
||||
const nowOpen = this.el.classList.contains('open')
|
||||
if (nowOpen && !this.isOpen) {
|
||||
this.onOpen()
|
||||
} else if (!nowOpen && this.isOpen) {
|
||||
this.onClose()
|
||||
}
|
||||
this.isOpen = nowOpen
|
||||
},
|
||||
|
||||
lockScroll() {
|
||||
// Store current scroll position
|
||||
this.scrollY = window.scrollY
|
||||
|
||||
// Lock body scroll (works on mobile too)
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.top = `-${this.scrollY}px`
|
||||
document.body.style.left = '0'
|
||||
document.body.style.right = '0'
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
// Prevent touchmove on document (extra safety for iOS)
|
||||
document.addEventListener('touchmove', this.boundTouchmove, { passive: false })
|
||||
},
|
||||
|
||||
unlockScroll() {
|
||||
// Restore body scroll
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.left = ''
|
||||
document.body.style.right = ''
|
||||
document.body.style.overflow = ''
|
||||
|
||||
// Restore scroll position
|
||||
window.scrollTo(0, this.scrollY)
|
||||
|
||||
// Remove touchmove listener
|
||||
document.removeEventListener('touchmove', this.boundTouchmove)
|
||||
},
|
||||
|
||||
handleTouchmove(e) {
|
||||
// Allow scrolling inside the drawer itself
|
||||
if (this.el.contains(e.target)) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
},
|
||||
|
||||
onOpen() {
|
||||
// Store trigger for focus return
|
||||
this.triggerElement = document.activeElement
|
||||
|
||||
// Lock scroll
|
||||
this.lockScroll()
|
||||
|
||||
// Focus first focusable element (close button)
|
||||
const firstFocusable = this.el.querySelector(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
if (firstFocusable) {
|
||||
setTimeout(() => firstFocusable.focus(), 50)
|
||||
}
|
||||
|
||||
// Enable focus trap and Escape handling
|
||||
document.addEventListener('keydown', this.boundKeydown)
|
||||
},
|
||||
|
||||
onClose() {
|
||||
// Unlock scroll
|
||||
this.unlockScroll()
|
||||
|
||||
// Remove keyboard listener
|
||||
document.removeEventListener('keydown', this.boundKeydown)
|
||||
|
||||
// Return focus to trigger element
|
||||
if (this.triggerElement) {
|
||||
this.triggerElement.focus()
|
||||
}
|
||||
},
|
||||
|
||||
handleKeydown(e) {
|
||||
// Close on Escape - let server handle the state change
|
||||
if (e.key === 'Escape') {
|
||||
this.pushEvent("close_cart_drawer")
|
||||
return
|
||||
}
|
||||
|
||||
// Focus trap on Tab
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = this.el.querySelectorAll(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
document.removeEventListener('keydown', this.boundKeydown)
|
||||
document.removeEventListener('touchmove', this.boundTouchmove)
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.left = ''
|
||||
document.body.style.right = ''
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for PDP image lightbox
|
||||
const Lightbox = {
|
||||
mounted() {
|
||||
@@ -140,7 +288,7 @@ const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: {...colocatedHooks, ColorSync, Lightbox},
|
||||
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer},
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
||||
Reference in New Issue
Block a user