Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@
ehthumbs.db
Thumbs.db
.shopify
node_modules
*.zip
3 changes: 0 additions & 3 deletions .vscode/extensions.json

This file was deleted.

15 changes: 0 additions & 15 deletions .vscode/settings.json

This file was deleted.

15 changes: 15 additions & 0 deletions assets/base.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
product-component,
collection-component {
display: block;
}

:root {
--alpha-button-background: 1;
--alpha-button-border: 1;
Expand Down Expand Up @@ -146,6 +151,16 @@ body:has(.section-header .drawer-menu) .announcement-bar-section .page-width {
z-index: 0;
}

.content-for-layout {
flex: 1;
display: flex;
flex-direction: column;
}

.content-for-layout > .shopify-section:last-child {
flex-grow: 1;
}

.section + .section {
margin-top: var(--spacing-sections-mobile);
}
Expand Down
192 changes: 192 additions & 0 deletions assets/cart-disclosure-modal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
if (!customElements.get('cart-disclosure-modal')) {
class CartDisclosureModal extends HTMLElement {
constructor() {
super();

this.onOpenerClick = this.onOpenerClick.bind(this);
this.onCloseClick = this.onCloseClick.bind(this);
this.onModalClick = this.onModalClick.bind(this);
this.onKeyUp = this.onKeyUp.bind(this);
this.removeIfOpenerDisconnected = this.removeIfOpenerDisconnected.bind(this);
}

connectedCallback() {
if (this.initialized) return;

this.initialized = true;
this.dialog = this.querySelector('[role="dialog"]');
const controlsId = this.dialog?.id;
this.opener = this.previousElementSibling?.classList.contains('cart-item__disclosure')
? this.previousElementSibling
: controlsId
? document.querySelector(`[aria-controls="${controlsId}"]`)
: null;
this.closeButton = this.querySelector('[data-cart-disclosure-close]');

let shouldRestoreOpen = false;
document.querySelectorAll('cart-disclosure-modal').forEach((modal) => {
if (modal === this || modal.id !== this.id) return;

shouldRestoreOpen = shouldRestoreOpen || modal.hasAttribute('open');
modal.remove();
});

this.opener?.addEventListener('click', this.onOpenerClick);
this.closeButton?.addEventListener('click', this.onCloseClick);
this.addEventListener('click', this.onModalClick);
this.addEventListener('keyup', this.onKeyUp);

if (this.parentElement !== document.body) {
this.isMoving = true;
document.body.appendChild(this);
this.isMoving = false;
}

this.observeOpener();

if (shouldRestoreOpen) this.show();
}

disconnectedCallback() {
if (this.isMoving) return;

this.openerObserver?.disconnect();
this.opener?.removeEventListener('click', this.onOpenerClick);
this.closeButton?.removeEventListener('click', this.onCloseClick);
this.removeEventListener('click', this.onModalClick);
this.removeEventListener('keyup', this.onKeyUp);

if (this.hasAttribute('open')) this.hide(false);
}

observeOpener() {
this.openerObserver?.disconnect();

if (!this.opener) return;

this.openerObserver = new MutationObserver(this.removeIfOpenerDisconnected);
this.openerObserver.observe(document.body, { childList: true, subtree: true });
this.removeIfOpenerDisconnected();
}

removeIfOpenerDisconnected() {
if (this.isMoving || !this.isConnected || this.opener?.isConnected) return;

this.remove();
}

onOpenerClick(event) {
event.preventDefault();
event.stopPropagation();
this.show();
}

onCloseClick(event) {
event.preventDefault();
event.stopPropagation();
this.hide();
}

onModalClick(event) {
event.stopPropagation();

if (event.target === this) this.hide();
}

onKeyUp(event) {
if (event.code.toUpperCase() !== 'ESCAPE') return;

event.preventDefault();
event.stopPropagation();
this.hide();
}

show() {
if (this.hasAttribute('open')) return;

document.querySelectorAll('cart-disclosure-modal[open]').forEach((modal) => {
if (modal !== this) modal.hide(false);
});

this.lockScroll();
this.setAttribute('open', '');
this.opener?.setAttribute('aria-expanded', 'true');

if (typeof trapFocus === 'function') {
trapFocus(this, this.dialog);
} else {
this.dialog?.focus();
}

window.pauseAllMedia();
}

hide(restoreFocus = true) {
if (!this.hasAttribute('open')) return;

this.removeAttribute('open');
this.opener?.setAttribute('aria-expanded', 'false');
this.unlockScroll();

if (restoreFocus) {
this.restoreFocusTrap();
} else if (typeof removeTrapFocus === 'function') {
removeTrapFocus();
}

window.pauseAllMedia();
}

lockScroll() {
this.bodyOverflowWasHidden = document.body.classList.contains('overflow-hidden');
this.previousBodyPaddingRight = document.body.style.paddingRight;

if (this.bodyOverflowWasHidden) return;

const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

if (scrollbarWidth > 0) {
const currentPaddingRight = parseFloat(window.getComputedStyle(document.body).paddingRight) || 0;
document.body.style.paddingRight = `${currentPaddingRight + scrollbarWidth}px`;
}

document.body.classList.add('overflow-hidden');
}

unlockScroll() {
if (this.bodyOverflowWasHidden) return;

document.body.classList.remove('overflow-hidden');
document.body.style.paddingRight = this.previousBodyPaddingRight;
}

restoreFocusTrap() {
const cartDrawer = this.opener?.closest('cart-drawer.active');
const cartNotification = this.opener?.closest('cart-notification')?.querySelector('#cart-notification.active');

if (cartDrawer && typeof trapFocus === 'function') {
const containerToTrapFocusOn = cartDrawer.classList.contains('is-empty')
? cartDrawer.querySelector('.drawer__inner-empty')
: document.getElementById('CartDrawer');

if (containerToTrapFocusOn) {
trapFocus(containerToTrapFocusOn, this.opener);
return;
}
}

if (cartNotification && typeof trapFocus === 'function') {
trapFocus(cartNotification, this.opener);
return;
}

if (typeof removeTrapFocus === 'function') {
removeTrapFocus(this.opener);
} else {
this.opener?.focus();
}
}
}

customElements.define('cart-disclosure-modal', CartDisclosureModal);
}
45 changes: 45 additions & 0 deletions assets/cart-disclosure-tooltip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
(function () {
// Keeps the cart disclosure hover tooltip within the viewport.
// On hover we measure the tooltip and set a `--cart-disclosure-tooltip-shift`
// custom property that nudges it back into view;
// the arrow is counter-shifted in CSS so it keeps pointing at the icon.
const VIEWPORT_MARGIN = 16;

function positionTooltip(button) {
const tooltip = button.querySelector('.cart-item__disclosure-tooltip');
if (!tooltip) return;

// Reset before measuring so we read the natural, centered position.
tooltip.style.setProperty('--cart-disclosure-tooltip-shift', '0px');

const rect = tooltip.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth;
let shift = 0;

if (rect.right > viewportWidth - VIEWPORT_MARGIN) {
shift = viewportWidth - VIEWPORT_MARGIN - rect.right;
} else if (rect.left < VIEWPORT_MARGIN) {
shift = VIEWPORT_MARGIN - rect.left;
}

tooltip.style.setProperty('--cart-disclosure-tooltip-shift', `${Math.round(shift)}px`);
}

function onPointerOver(event) {
const button = event.target.closest?.('.cart-item__disclosure');
if (!button || button.dataset.tooltipPositioned === 'true') return;

button.dataset.tooltipPositioned = 'true';
positionTooltip(button);
}

function onPointerOut(event) {
const button = event.target.closest?.('.cart-item__disclosure');
if (button && !button.contains(event.relatedTarget)) {
delete button.dataset.tooltipPositioned;
}
}

document.addEventListener('pointerover', onPointerOver);
document.addEventListener('pointerout', onPointerOut);
})();
8 changes: 7 additions & 1 deletion assets/cart-drawer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class CartDrawer extends HTMLElement {
}

open(triggeredBy) {
if (this.classList.contains('active')) return;
if (triggeredBy) this.setActiveElement(triggeredBy);
const cartDrawerNote = this.querySelector('[id^="Details-"] summary');
if (cartDrawerNote && !cartDrawerNote.hasAttribute('role')) this.setSummaryAccessibility(cartDrawerNote);
Expand All @@ -43,10 +44,15 @@ class CartDrawer extends HTMLElement {
const focusElement = this.querySelector('.drawer__inner') || this.querySelector('.drawer__close');
trapFocus(containerToTrapFocusOn, focusElement);
},
{ once: true }
{ once: true },
);

document.body.classList.add('overflow-hidden');

// cart-drawer-items is a CartItems subclass that extends createViewEventElement.
// Its `view-event-trigger="manual"` skips auto-dispatch on connect; we fire
// it here when the drawer opens, with `context: 'dialog'` from the payload attribute.
this.querySelector('cart-drawer-items')?.dispatchViewEvent();
}

close() {
Expand Down
29 changes: 29 additions & 0 deletions assets/cart-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,35 @@ class CartNotification extends HTMLElement {
);

document.body.addEventListener('click', this.onBodyClick);

this.dispatchCartViewEvent();
}

// The notification's outer element is server-rendered once at page load, so
// its `cart` Liquid object reflects the pre-add state. The morphed children
// (inserted from the /cart/add.js sections response in renderContents) are
// post-add, but they don't expose the full cart shape we need for the event
// payload. So we keep an explicit /cart.json fetch on open. Migrating to the
// factory + filter would require re-rendering the notification element
// itself in sections, which is out of scope for this PR.
async dispatchCartViewEvent() {
const { CartViewEvent } = window.StandardEvents || {};
if (!CartViewEvent) return;

try {
const response = await fetch(`${routes.cart_url}.json`);
const cart = await response.json();
if (!cart?.currency) return;

this.dispatchEvent(
new CartViewEvent({
context: 'dialog',
cart: CartViewEvent.createCartFromAjaxResponse(cart),
})
);
} catch (e) {
// cart:view is informational; swallow fetch errors
}
}

close() {
Expand Down
Loading
Loading