zz-shopify-components
Version:
Reusable Shopify components for theme projects
535 lines (471 loc) • 17.8 kB
JavaScript
/*
- Uses <dialog> when available for native a11y and focus management
- Shadow DOM for internal styles; slots for user content (tailwind utilities apply to slotted nodes)
- Attribute reflection and programmatic API: show(), hide(), toggle()
- Options via attributes: close-on-esc, close-on-backdrop, scroll-lock, inert-others
- Global open/close triggers: [data-zz-modal-target], [data-zz-modal-close]
- Analytics tracking: modal open/close/confirm events
*/
(() => {
if (customElements.get('zz-modal')) return;
// Analytics tracking utility
const trackModalEvent = (eventType, modalId, additionalData = {}) => {
try {
const eventData = {
event: eventType,
modal_action: eventType,
modal_id: modalId || 'unknown',
timestamp: new Date().toISOString(),
...additionalData
};
window.zzAnalytics && window.zzAnalytics.trackEvent(eventType, eventData);
console.log('[ZZ-Modal Analytics]', eventData);
} catch (error) {
console.warn('[ZZ-Modal Analytics] Tracking failed:', error);
}
};
const STYLE_TEXT = `
:host {
position: relative;
display: contents; /* do not affect layout where placed */
}
dialog[part="dialog"] {
border: none;
padding: 0;
margin: 0;
width: auto;
max-width: var(--zz-modal-max-width, 90vw);
max-height: var(--zz-modal-max-height, 85vh);
background: transparent; /* panel carries background */
overflow: visible;
}
/* Backdrop for native <dialog>. */
dialog[part="dialog"]::backdrop {
background: var(--zz-modal-backdrop, rgba(0,0,0,0.5));
backdrop-filter: var(--zz-modal-backdrop-filter, blur(0px));
}
.panel {
will-change: transform, opacity;
position: fixed;
inset: 0;
z-index: var(--zz-modal-z-index, 9999);
display: grid;
place-items: center;
pointer-events: none; /* enable backdrop clicks via dialog */
}
.panel-inner {
will-change: transform, opacity;
pointer-events: auto;
background: var(--zz-modal-background, #ffffff);
color: inherit;
border-radius: var(--zz-modal-radius, 12px);
box-shadow: var(--zz-modal-shadow, 0 20px 60px rgba(0,0,0,0.2));
width: var(--zz-modal-width, min(720px, 92vw));
max-height: var(--zz-modal-max-height, 100vh);
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
transition: none;
}
(min-width: 768px) {
.panel-inner {
transform-origin: center center;
transform: translateY(8px) scale(0.98);
max-height: var(--zz-modal-max-height, 85vh);
}
}
:host([open]) .panel-inner {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 移动端底部抽屉模式 */
(max-width: 768px) {
:host([sheet-on-mobile]) .panel { place-items: end center; }
:host([sheet-on-mobile]) .panel-inner {
width: var(--zz-sheet-width, 100%);
max-width: 100%;
border-top-left-radius: var(--zz-modal-radius, 12px);
border-top-right-radius: var(--zz-modal-radius, 12px);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
margin: 0;
transform: translateY(100%) scale(1);
}
:host([sheet-on-mobile][open]) .panel-inner { transform: translateY(0) scale(1); }
:host([sheet-on-mobile]) .body { padding-bottom: calc(var(--zz-modal-padding, 16px) + env(safe-area-inset-bottom)); }
}
(prefers-reduced-motion: no-preference) {
.panel-inner {
transition: opacity 160ms ease, transform 160ms ease;
}
}
.header, .footer {
padding: var(--zz-modal-padding, 16px);
flex: 0 0 auto;
}
:host([no-header]) .header, :host([no-header-auto]) .header { display: none; padding: 0; }
:host([no-footer]) .footer, :host([no-footer-auto]) .footer { display: none; padding: 0; }
.body {
padding: var(--zz-modal-padding, 16px);
overflow: auto;
flex: 1 1 auto;
}
.close-btn {
all: unset;
cursor: pointer;
position: absolute;
top: 20px;
right: 20px;
width: 32px;
height: 32px;
border-radius: 9999px;
display: grid;
place-items: center;
}
.close-btn-white {
color: white;
background-color: rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
}
.close-btn-black {
color: white;
background-color: #333333;
/* backdrop-filter: blur(10px); */
}
.close-btn:focus-visible { outline: none; }
`;
const TEMPLATE = document.createElement('template');
TEMPLATE.innerHTML = `
<dialog part="dialog" aria-modal="true">
<div class="panel" part="backdrop">
<div class="panel-inner" role="document" part="panel">
<button style="z-index: 1000;" class="close-btn" part="close-button" aria-label="Close" data-zz-modal-close>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<header class="header" part="header"><slot name="header"></slot></header>
<section class="body" part="body"><slot></slot></section>
<footer class="footer" part="footer"><slot name="footer"></slot></footer>
</div>
</div>
</dialog>
`;
class ZZModal extends HTMLElement {
static get observedAttributes() { return ['open']; }
constructor() {
super();
this._onKeydown = this._onKeydown.bind(this);
this._onNativeClose = this._onNativeClose.bind(this);
this._onMouseDown = this._onMouseDown.bind(this);
this._onClick = this._onClick.bind(this);
this._previousActive = null;
this._mouseDownInsidePanel = false;
this._openTimestamp = null;
this._lastTriggerSource = null;
this._lastCloseMethod = null;
const shadow = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = STYLE_TEXT;
shadow.appendChild(style);
shadow.appendChild(TEMPLATE.content.cloneNode(true));
const closeBtnColor = this.getAttribute('close-btn-color');
if (closeBtnColor) {
const closeBtn = shadow.querySelector('.close-btn');
closeBtn.classList.add(closeBtnColor);
}
this._dialog = shadow.querySelector('dialog');
this._panel = shadow.querySelector('.panel-inner');
this._closeBtn = shadow.querySelector('[data-zz-modal-close]');
this._slotHeader = shadow.querySelector('slot[name="header"]');
this._slotFooter = shadow.querySelector('slot[name="footer"]');
this._closeIconPath = shadow.querySelector('.close-btn svg path');
if (!this._closeIconPath) return;
const color = this.getAttribute('close-icon-color') || 'currentColor';
this._closeIconPath.setAttribute('stroke', color);
}
connectedCallback() {
// Delegated internal events
this._closeBtn.addEventListener('click', () => {
this._lastCloseMethod = 'close_button';
this.hide();
});
this._dialog.addEventListener('close', this._onNativeClose);
this._dialog.addEventListener('mousedown', this._onMouseDown);
this._dialog.addEventListener('click', this._onClick);
// Reflect open state if attribute present at mount
if (this.hasAttribute('open')) {
queueMicrotask(() => this._openInternal(false));
}
// Auto hide empty header/footer
this._slotHeader.addEventListener('slotchange', () => this._updateSlotVisibility());
this._slotFooter.addEventListener('slotchange', () => this._updateSlotVisibility());
// Initial
this._updateSlotVisibility();
}
disconnectedCallback() {
this._dialog.removeEventListener('close', this._onNativeClose);
this._dialog.removeEventListener('mousedown', this._onMouseDown);
this._dialog.removeEventListener('click', this._onClick);
document.removeEventListener('keydown', this._onKeydown);
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open' && oldValue !== newValue) {
if (this.hasAttribute('open')) {
this._openInternal(false);
} else {
this._closeInternal(false);
}
}
}
get open() { return this._dialog?.open || this.hasAttribute('open'); }
set open(val) { if (val) this.setAttribute('open', ''); else this.removeAttribute('open'); }
show() {
if (!this._lastTriggerSource) {
this._lastTriggerSource = 'unknown';
}
this._openInternal(true);
}
hide() {
if (!this._lastCloseMethod) {
this._lastCloseMethod = 'unknown';
}
this._closeInternal(true);
}
toggle() { this.open ? this.hide() : this.show(); }
// Aliases
showModal() { this.show(); }
close() { this.hide(); }
_emit(name, detail = {}) {
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
}
_openInternal(emit = false) {
if (this._dialog.open) return;
this._previousActive = document.activeElement;
this._openTimestamp = Date.now();
// Scroll lock
if (!this.hasAttribute('no-scroll-lock')) {
this._lockScroll();
}
// Inert others
if (this.hasAttribute('inert-others')) {
this._toggleInert(true);
}
// Native dialog path
try {
if ('showModal' in HTMLDialogElement.prototype) {
this._dialog.showModal();
} else {
// Fallback
this._dialog.setAttribute('open', '');
}
} catch (_) {
this._dialog.setAttribute('open', '');
}
// Sync attribute for CSS animations
this.setAttribute('open', '');
// Listeners
document.addEventListener('keydown', this._onKeydown);
// Focus first focusable within panel
this._focusInitial();
if (emit) {
this._emit('zz-modal:open');
const modalId = this.getAttribute('data-modal-id') || this.id || 'unnamed-modal';
trackModalEvent('modal_open', modalId, {
trigger_source: this._getTriggerSource(),
modal_type: this.getAttribute('data-modal-type') || 'default'
});
}
}
_closeInternal(emit = false) {
if (!this._dialog.open && !this.hasAttribute('open')) return;
// Remove listeners
document.removeEventListener('keydown', this._onKeydown);
// Native dialog close
try {
if (this._dialog.open) this._dialog.close();
} catch (_) {
// ignore
}
this.removeAttribute('open');
// Restore scroll and inert
if (!this.hasAttribute('no-scroll-lock')) {
this._unlockScroll();
}
if (this.hasAttribute('inert-others')) {
this._toggleInert(false);
}
// Restore focus
if (this._previousActive && typeof this._previousActive.focus === 'function') {
this._previousActive.focus({ preventScroll: true });
}
this._previousActive = null;
if (emit) {
this._emit('zz-modal:close');
const modalId = this.getAttribute('data-modal-id') || this.id || 'unnamed-modal';
trackModalEvent('modal_close', modalId, {
close_method: this._getCloseMethod(),
modal_type: this.getAttribute('data-modal-type') || 'default',
time_open: this._getTimeOpen()
});
}
this._lastTriggerSource = null;
this._lastCloseMethod = null;
this._openTimestamp = null;
}
_onKeydown(e) {
const escAllowed = !this.hasAttribute('no-esc-close');
if (e.key === 'Escape' && escAllowed) {
e.stopPropagation();
this._lastCloseMethod = 'escape_key';
this.hide();
}
// Basic focus trap for fallback scenario
if (e.key === 'Tab' && !('showModal' in HTMLDialogElement.prototype)) {
const focusables = this._getFocusable();
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const active = this.shadowRoot.activeElement || document.activeElement;
if (e.shiftKey) {
if (active === first || !this.contains(active)) {
e.preventDefault();
last.focus();
}
} else {
if (active === last || !this.contains(active)) {
e.preventDefault();
first.focus();
}
}
}
}
_onNativeClose() {
// Ensure attribute sync and cleanup
const shouldEmit = this.hasAttribute('open');
this._closeInternal(shouldEmit);
}
_onMouseDown(e) {
// Track whether mousedown originated inside the panel to differentiate drags
const path = e.composedPath();
this._mouseDownInsidePanel = path.includes(this._panel);
}
_onClick(e) {
const backdropClose = !this.hasAttribute('no-backdrop-close');
if (!backdropClose) return;
// If click ended outside panel and started outside panel -> treat as backdrop click
const path = e.composedPath();
const clickInsidePanel = path.includes(this._panel);
if (!clickInsidePanel && !this._mouseDownInsidePanel) {
this._lastCloseMethod = 'backdrop_click';
this.hide();
}
this._mouseDownInsidePanel = false;
}
_updateSlotVisibility() {
try {
const hasHeader = (this._slotHeader.assignedNodes({ flatten: true }).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim())).length) > 0;
const hasFooter = (this._slotFooter.assignedNodes({ flatten: true }).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim())).length) > 0;
if (!hasHeader) this.setAttribute('no-header-auto', ''); else this.removeAttribute('no-header-auto');
if (!hasFooter) this.setAttribute('no-footer-auto', ''); else this.removeAttribute('no-footer-auto');
} catch (_) {
// ignore
}
}
_getFocusable() {
const root = this.shadowRoot;
const within = root.querySelector('.panel-inner');
return Array.from(within.querySelectorAll([
'a[href]','area[href]','input:not([disabled])','select:not([disabled])','textarea:not([disabled])',
'button:not([disabled])','iframe','object','embed','[contenteditable]','[tabindex]:not([tabindex="-1"])'
].join(',')));
}
_focusInitial() {
const focusables = this._getFocusable();
if (focusables.length > 0) {
focusables[0].focus({ preventScroll: true });
} else {
// Focus the dialog so that ESC works consistently
this._dialog.focus({ preventScroll: true });
}
}
_lockScroll() {
const body = document.body;
this._prevBodyOverflow = body.style.overflow;
body.style.overflow = 'hidden';
body.setAttribute('data-zz-modal-open', '');
}
_unlockScroll() {
const body = document.body;
body.style.overflow = this._prevBodyOverflow || '';
body.removeAttribute('data-zz-modal-open');
}
_toggleInert(isInert) {
const toInert = [];
const root = document.body;
for (const child of Array.from(root.children)) {
if (child === this.parentElement) continue;
toInert.push(child);
}
toInert.forEach((el) => {
if (isInert) el.setAttribute('inert', ''); else el.removeAttribute('inert');
});
}
// Analytics helper methods
_getTriggerSource() {
return this._lastTriggerSource || 'unknown';
}
_getCloseMethod() {
return this._lastCloseMethod || 'unknown';
}
_getTimeOpen() {
if (!this._openTimestamp) return 0;
return Date.now() - this._openTimestamp;
}
trackConfirm(confirmType = 'default', additionalData = {}) {
const modalId = this.getAttribute('data-modal-id') || this.id || 'unnamed-modal';
trackModalEvent('modal_confirm', modalId, {
confirm_type: confirmType,
modal_type: this.getAttribute('data-modal-type') || 'default',
time_to_confirm: this._getTimeOpen(),
...additionalData
});
}
}
customElements.define('zz-modal', ZZModal);
// Global open/close triggers
document.addEventListener('click', (e) => {
const openTrigger = e.target.closest('[data-zz-modal-target]');
if (openTrigger) {
const selector = openTrigger.getAttribute('data-zz-modal-target');
if (selector) {
const modal = document.querySelector(selector);
if (modal && modal.show) {
e.preventDefault();
modal._lastTriggerSource = 'data_attribute';
modal.show();
}
}
return;
}
const closeTrigger = e.target.closest('[data-zz-modal-close]');
if (closeTrigger) {
const hostModal = closeTrigger.closest('zz-modal');
if (hostModal && hostModal.hide) {
e.preventDefault();
hostModal._lastCloseMethod = 'data_attribute';
hostModal.hide();
}
}
const confirmTrigger = e.target.closest('[data-zz-modal-confirm]');
if (confirmTrigger) {
const hostModal = confirmTrigger.closest('zz-modal');
if (hostModal && hostModal.trackConfirm) {
const confirmType = confirmTrigger.getAttribute('data-zz-modal-confirm') || 'default';
hostModal.trackConfirm(confirmType, {
button_text: confirmTrigger.textContent?.trim() || '',
button_class: confirmTrigger.className || ''
});
}
}
}, { capture: true });
})();