UNPKG

zz-shopify-components

Version:

Reusable Shopify components for theme projects

535 lines (471 loc) 17.8 kB
/* - 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; } @media (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); } /* 移动端底部抽屉模式 */ @media (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)); } } @media (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 }); })();