2026-09-25 20:19:11 +03:00
|
|
|
import { Controller } from '@hotwired/stimulus'
|
|
|
|
|
import { csrfToken } from './csrf'
|
|
|
|
|
|
|
|
|
|
// Drives the slide-in cart drawer. One instance, on the drawer root in
|
|
|
|
|
// checkout/drawer.blade.php.
|
|
|
|
|
//
|
|
|
|
|
// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from
|
|
|
|
|
// this drawer's own line forms) and swaps in the server-rendered cart body
|
|
|
|
|
// - handles the in-drawer quantity / remove forms (fetch + method spoofing)
|
|
|
|
|
// - re-emits `bbk-cart:updated` {count, total} after every render so the host
|
|
|
|
|
// (e.g. the header bag icon) can react
|
2026-09-29 21:25:28 +03:00
|
|
|
// - dialog focus handling: focus moves into the panel on open, Tab is kept
|
|
|
|
|
// inside it, and focus returns to whatever opened it on close
|
2026-09-25 20:19:11 +03:00
|
|
|
//
|
|
|
|
|
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
|
|
|
|
|
// attribute on the root, nothing here touches styles or class lists.
|
|
|
|
|
export default class extends Controller {
|
2026-09-29 21:25:28 +03:00
|
|
|
static targets = ['panel', 'body', 'error', 'heading', 'status']
|
2026-09-25 20:19:11 +03:00
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
this.onChanged = this.onChanged.bind(this)
|
|
|
|
|
this.onKeydown = this.onKeydown.bind(this)
|
|
|
|
|
this.updateTimers = new Map() // line id -> pending debounce timer
|
|
|
|
|
|
|
|
|
|
window.addEventListener('bbk-cart:changed', this.onChanged)
|
|
|
|
|
window.addEventListener('bbk-cart:open', this.open.bind(this))
|
|
|
|
|
document.addEventListener('keydown', this.onKeydown)
|
|
|
|
|
|
|
|
|
|
// Prime the host with the count rendered server-side on page load.
|
|
|
|
|
this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
window.removeEventListener('bbk-cart:changed', this.onChanged)
|
|
|
|
|
document.removeEventListener('keydown', this.onKeydown)
|
|
|
|
|
this.updateTimers.forEach((timer) => clearTimeout(timer))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onChanged(event) {
|
|
|
|
|
if (event.detail?.html) this.replaceBody(event.detail.html)
|
|
|
|
|
this.open()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onKeydown(event) {
|
2026-09-29 21:25:28 +03:00
|
|
|
// Only the drawer instance is a dialog — the checkout page's summary
|
|
|
|
|
// reuses this controller without a panel.
|
|
|
|
|
if (!this.hasPanelTarget || this.element.hidden) return
|
|
|
|
|
|
|
|
|
|
if (event.key === 'Escape') this.close()
|
|
|
|
|
if (event.key === 'Tab') this.trapFocus(event)
|
2026-09-25 20:19:11 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
open() {
|
|
|
|
|
if (!this.element.hidden) return
|
2026-09-29 21:25:28 +03:00
|
|
|
this.returnFocusTo = document.activeElement
|
2026-09-25 20:19:11 +03:00
|
|
|
this.element.hidden = false
|
|
|
|
|
// Next frame, so the panel transitions from its off-canvas start.
|
2026-09-29 21:25:28 +03:00
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
this.element.setAttribute('data-bbk-cart-state', 'open')
|
|
|
|
|
if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
|
|
|
|
|
})
|
2026-09-25 20:19:11 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
this.element.removeAttribute('data-bbk-cart-state')
|
|
|
|
|
|
2026-09-29 21:25:28 +03:00
|
|
|
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
|
|
|
|
|
this.returnFocusTo = null
|
|
|
|
|
|
2026-09-25 20:19:11 +03:00
|
|
|
const panel = this.panelTarget
|
|
|
|
|
const done = () => {
|
|
|
|
|
this.element.hidden = true
|
|
|
|
|
panel.removeEventListener('transitionend', done)
|
|
|
|
|
}
|
|
|
|
|
panel.addEventListener('transitionend', done)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// change on a line quantity input, or submit of a line's remove form
|
|
|
|
|
submit(event) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const form = event.target.closest('form')
|
|
|
|
|
if (!form) return
|
|
|
|
|
|
|
|
|
|
// A remove is a deliberate, one-shot action — only the quantity form
|
|
|
|
|
// (typing, or the +/- stepper below) benefits from debouncing.
|
|
|
|
|
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// +/- stepper buttons inside a line
|
|
|
|
|
step(event) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
const form = event.target.closest('form')
|
|
|
|
|
const input = form.querySelector('input[type="number"]')
|
|
|
|
|
const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir))
|
|
|
|
|
input.value = String(next)
|
|
|
|
|
this.scheduleSend(form)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Repeated clicks (or spinner nudges) update the input instantly but only
|
|
|
|
|
// send once they settle for 300ms — sending on every single click was
|
|
|
|
|
// firing overlapping requests that raced each other and made the drawer
|
|
|
|
|
// visibly flicker/lag under quick clicking.
|
|
|
|
|
scheduleSend(form) {
|
|
|
|
|
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
|
|
|
|
if (!lineId) return this.send(form)
|
|
|
|
|
|
|
|
|
|
clearTimeout(this.updateTimers.get(lineId))
|
|
|
|
|
this.updateTimers.set(lineId, setTimeout(() => {
|
|
|
|
|
this.updateTimers.delete(lineId)
|
|
|
|
|
this.send(form)
|
|
|
|
|
}, 300))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async send(form) {
|
|
|
|
|
this.bodyTarget.setAttribute('aria-busy', 'true')
|
|
|
|
|
this.clearError()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(form.action, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'X-CSRF-TOKEN': csrfToken(),
|
|
|
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: new FormData(form),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
this.replaceBody(await response.text())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json().catch(() => null)
|
|
|
|
|
this.showError(data?.error)
|
|
|
|
|
|
|
|
|
|
// The rejected quantity (typed, or from a +/- click) is left
|
|
|
|
|
// sitting in the input with nothing to correct it — the update
|
|
|
|
|
// never reached the cart, so the input must be put back to what
|
|
|
|
|
// the cart actually still holds, not just left showing whatever
|
|
|
|
|
// was rejected.
|
|
|
|
|
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
|
|
|
|
|
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
|
|
|
|
|
} finally {
|
|
|
|
|
this.bodyTarget.removeAttribute('aria-busy')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:25:28 +03:00
|
|
|
// aria-modal hides the page from screen readers but doesn't stop Tab from
|
|
|
|
|
// walking out of the panel into it — wrap at either end instead.
|
|
|
|
|
trapFocus(event) {
|
|
|
|
|
const focusable = [...this.panelTarget.querySelectorAll(
|
|
|
|
|
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
|
|
|
)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'))
|
|
|
|
|
|
|
|
|
|
if (!focusable.length) return
|
|
|
|
|
|
|
|
|
|
const first = focusable[0]
|
|
|
|
|
const last = focusable[focusable.length - 1]
|
|
|
|
|
const active = document.activeElement
|
|
|
|
|
|
|
|
|
|
if (event.shiftKey && (active === first || !this.panelTarget.contains(active) || (this.hasHeadingTarget && active === this.headingTarget))) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
last.focus()
|
|
|
|
|
} else if (!event.shiftKey && (active === last || !this.panelTarget.contains(active))) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
first.focus()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 20:19:11 +03:00
|
|
|
showError(message) {
|
|
|
|
|
if (!this.hasErrorTarget || !message) return
|
|
|
|
|
this.errorTarget.textContent = message
|
|
|
|
|
this.errorTarget.hidden = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearError() {
|
|
|
|
|
if (!this.hasErrorTarget) return
|
|
|
|
|
this.errorTarget.hidden = true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
replaceBody(html) {
|
2026-09-29 21:25:28 +03:00
|
|
|
const restore = this.focusSnapshot()
|
2026-09-25 20:19:11 +03:00
|
|
|
this.bodyTarget.innerHTML = html
|
2026-09-29 21:25:28 +03:00
|
|
|
restore()
|
|
|
|
|
this.announce()
|
2026-09-25 20:19:11 +03:00
|
|
|
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:25:28 +03:00
|
|
|
// Swapping the body destroys whatever control had focus (a qty stepper,
|
|
|
|
|
// a remove button, the coupon field), dropping keyboard/screen-reader
|
|
|
|
|
// users back at the top of the document. Returns a callback that, after
|
|
|
|
|
// the swap, re-focuses the equivalent control in the new markup — or the
|
|
|
|
|
// heading, when that control is gone (e.g. its line was just removed).
|
|
|
|
|
focusSnapshot() {
|
|
|
|
|
const active = document.activeElement
|
|
|
|
|
if (!active || !this.bodyTarget.contains(active)) return () => {}
|
|
|
|
|
|
|
|
|
|
let selector = null
|
|
|
|
|
if (active.id) {
|
|
|
|
|
selector = `#${CSS.escape(active.id)}`
|
|
|
|
|
} else {
|
|
|
|
|
const lineId = active.closest('[data-bbk-line-id]')?.dataset.bbkLineId
|
|
|
|
|
const dir = active.dataset.bbkCartDirParam
|
|
|
|
|
const control = ['bbk-cart-qty-input', 'bbk-cart-qty-btn', 'bbk-cart-item-remove']
|
|
|
|
|
.find((name) => active.classList.contains(name))
|
|
|
|
|
|
|
|
|
|
if (lineId && control) {
|
|
|
|
|
selector = `[data-bbk-line-id="${CSS.escape(lineId)}"] .${control}`
|
|
|
|
|
+ (dir ? `[data-bbk-cart-dir-param="${CSS.escape(dir)}"]` : '')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
const target = selector && this.bodyTarget.querySelector(selector)
|
|
|
|
|
if (target) target.focus({ preventScroll: true })
|
|
|
|
|
else if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Polite "Cart updated" — cleared first so an identical message is
|
|
|
|
|
// re-announced on the next update.
|
|
|
|
|
announce() {
|
|
|
|
|
if (!this.hasStatusTarget) return
|
|
|
|
|
const message = this.statusTarget.dataset.bbkCartMessage || ''
|
|
|
|
|
this.statusTarget.textContent = ''
|
|
|
|
|
requestAnimationFrame(() => { this.statusTarget.textContent = message })
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 20:19:11 +03:00
|
|
|
emitUpdated(node) {
|
|
|
|
|
if (!node) return
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('bbk-cart:updated', {
|
|
|
|
|
detail: {
|
|
|
|
|
count: parseInt(node.dataset.bbkCartCount || '0', 10),
|
|
|
|
|
total: parseInt(node.dataset.bbkCartTotal || '0', 10),
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|