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 // - dialog focus handling: focus moves into the panel on open, Tab is kept // inside it, and focus returns to whatever opened it on close // // 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 { static targets = ['panel', 'body', 'error', 'heading', 'status'] 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) { // 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) } open() { if (!this.element.hidden) return this.returnFocusTo = document.activeElement this.element.hidden = false // Next frame, so the panel transitions from its off-canvas start. requestAnimationFrame(() => { this.element.setAttribute('data-bbk-cart-state', 'open') if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true }) }) } close() { this.element.removeAttribute('data-bbk-cart-state') if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true }) this.returnFocusTo = null 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') } } // 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() } } 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) { const restore = this.focusSnapshot() this.bodyTarget.innerHTML = html restore() this.announce() this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) } // 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 }) } 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), }, })) } }