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:
jamey
2026-02-05 22:11:16 +00:00
parent 880e7a2888
commit 1bc08bfb23
27 changed files with 1163 additions and 155 deletions

View File

@@ -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