zz-shopify-components 0.3.1-beta.2 → 0.3.1-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/zz-modal.js +395 -0
- package/blocks/zz-button.liquid +13 -1
- package/docs/zz-modal.md +170 -0
- package/package.json +1 -1
- package/sections/zz-modal.liquid +147 -0
- package/snippets/zz-button.liquid +4 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/*
|
|
2
|
+
- Uses <dialog> when available for native a11y and focus management
|
|
3
|
+
- Shadow DOM for internal styles; slots for user content (tailwind utilities apply to slotted nodes)
|
|
4
|
+
- Attribute reflection and programmatic API: show(), hide(), toggle()
|
|
5
|
+
- Options via attributes: close-on-esc, close-on-backdrop, scroll-lock, inert-others
|
|
6
|
+
- Global open/close triggers: [data-zz-modal-target], [data-zz-modal-close]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
(() => {
|
|
10
|
+
if (customElements.get('zz-modal')) return;
|
|
11
|
+
|
|
12
|
+
const STYLE_TEXT = `
|
|
13
|
+
:host {
|
|
14
|
+
position: relative;
|
|
15
|
+
display: contents; /* do not affect layout where placed */
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dialog[part="dialog"] {
|
|
19
|
+
border: none;
|
|
20
|
+
padding: 0;
|
|
21
|
+
margin: 0;
|
|
22
|
+
width: auto;
|
|
23
|
+
max-width: var(--zz-modal-max-width, 90vw);
|
|
24
|
+
max-height: var(--zz-modal-max-height, 85vh);
|
|
25
|
+
background: transparent; /* panel carries background */
|
|
26
|
+
overflow: visible;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Backdrop for native <dialog>. */
|
|
30
|
+
dialog[part="dialog"]::backdrop {
|
|
31
|
+
background: var(--zz-modal-backdrop, rgba(0,0,0,0.5));
|
|
32
|
+
backdrop-filter: var(--zz-modal-backdrop-filter, blur(0px));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.panel {
|
|
36
|
+
position: fixed;
|
|
37
|
+
inset: 0;
|
|
38
|
+
z-index: var(--zz-modal-z-index, 9999);
|
|
39
|
+
display: grid;
|
|
40
|
+
place-items: center;
|
|
41
|
+
pointer-events: none; /* enable backdrop clicks via dialog */
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.panel-inner {
|
|
45
|
+
pointer-events: auto;
|
|
46
|
+
background: var(--zz-modal-background, #ffffff);
|
|
47
|
+
color: inherit;
|
|
48
|
+
border-radius: var(--zz-modal-radius, 12px);
|
|
49
|
+
box-shadow: var(--zz-modal-shadow, 0 20px 60px rgba(0,0,0,0.2));
|
|
50
|
+
width: var(--zz-modal-width, min(720px, 92vw));
|
|
51
|
+
max-height: var(--zz-modal-max-height, 85vh);
|
|
52
|
+
display: flex;
|
|
53
|
+
flex-direction: column;
|
|
54
|
+
overflow: hidden;
|
|
55
|
+
transform-origin: center center;
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(8px) scale(0.98);
|
|
58
|
+
transition: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
:host([open]) .panel-inner {
|
|
62
|
+
opacity: 1;
|
|
63
|
+
transform: translateY(0) scale(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
67
|
+
.panel-inner {
|
|
68
|
+
transition: opacity 160ms ease, transform 160ms ease;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.header, .footer {
|
|
73
|
+
padding: var(--zz-modal-padding, 16px);
|
|
74
|
+
flex: 0 0 auto;
|
|
75
|
+
}
|
|
76
|
+
:host([no-header]) .header, :host([no-header-auto]) .header { display: none; padding: 0; }
|
|
77
|
+
:host([no-footer]) .footer, :host([no-footer-auto]) .footer { display: none; padding: 0; }
|
|
78
|
+
.body {
|
|
79
|
+
padding: var(--zz-modal-padding, 16px);
|
|
80
|
+
overflow: auto;
|
|
81
|
+
flex: 1 1 auto;
|
|
82
|
+
}
|
|
83
|
+
.close-btn {
|
|
84
|
+
all: unset;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
position: absolute;
|
|
87
|
+
top: 8px;
|
|
88
|
+
right: 8px;
|
|
89
|
+
width: 32px;
|
|
90
|
+
height: 32px;
|
|
91
|
+
border-radius: 9999px;
|
|
92
|
+
display: grid;
|
|
93
|
+
place-items: center;
|
|
94
|
+
}
|
|
95
|
+
.close-btn:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const TEMPLATE = document.createElement('template');
|
|
99
|
+
TEMPLATE.innerHTML = `
|
|
100
|
+
<dialog part="dialog" aria-modal="true">
|
|
101
|
+
<div class="panel" part="backdrop">
|
|
102
|
+
<div class="panel-inner" role="document" part="panel">
|
|
103
|
+
<button class="close-btn" part="close-button" aria-label="Close" data-zz-modal-close>
|
|
104
|
+
<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>
|
|
105
|
+
</button>
|
|
106
|
+
<header class="header" part="header"><slot name="header"></slot></header>
|
|
107
|
+
<section class="body" part="body"><slot></slot></section>
|
|
108
|
+
<footer class="footer" part="footer"><slot name="footer"></slot></footer>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
</dialog>
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
class ZZModal extends HTMLElement {
|
|
115
|
+
static get observedAttributes() { return ['open']; }
|
|
116
|
+
|
|
117
|
+
constructor() {
|
|
118
|
+
super();
|
|
119
|
+
this._onKeydown = this._onKeydown.bind(this);
|
|
120
|
+
this._onNativeClose = this._onNativeClose.bind(this);
|
|
121
|
+
this._onMouseDown = this._onMouseDown.bind(this);
|
|
122
|
+
this._onClick = this._onClick.bind(this);
|
|
123
|
+
this._previousActive = null;
|
|
124
|
+
this._mouseDownInsidePanel = false;
|
|
125
|
+
|
|
126
|
+
const shadow = this.attachShadow({ mode: 'open' });
|
|
127
|
+
const style = document.createElement('style');
|
|
128
|
+
style.textContent = STYLE_TEXT;
|
|
129
|
+
shadow.appendChild(style);
|
|
130
|
+
shadow.appendChild(TEMPLATE.content.cloneNode(true));
|
|
131
|
+
|
|
132
|
+
this._dialog = shadow.querySelector('dialog');
|
|
133
|
+
this._panel = shadow.querySelector('.panel-inner');
|
|
134
|
+
this._closeBtn = shadow.querySelector('[data-zz-modal-close]');
|
|
135
|
+
this._slotHeader = shadow.querySelector('slot[name="header"]');
|
|
136
|
+
this._slotFooter = shadow.querySelector('slot[name="footer"]');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
connectedCallback() {
|
|
140
|
+
// Delegated internal events
|
|
141
|
+
this._closeBtn.addEventListener('click', () => this.hide());
|
|
142
|
+
this._dialog.addEventListener('close', this._onNativeClose);
|
|
143
|
+
this._dialog.addEventListener('mousedown', this._onMouseDown);
|
|
144
|
+
this._dialog.addEventListener('click', this._onClick);
|
|
145
|
+
|
|
146
|
+
// Reflect open state if attribute present at mount
|
|
147
|
+
if (this.hasAttribute('open')) {
|
|
148
|
+
// Defer to ensure upgraded before open
|
|
149
|
+
queueMicrotask(() => this.show());
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Auto hide empty header/footer
|
|
153
|
+
this._slotHeader.addEventListener('slotchange', () => this._updateSlotVisibility());
|
|
154
|
+
this._slotFooter.addEventListener('slotchange', () => this._updateSlotVisibility());
|
|
155
|
+
// Initial
|
|
156
|
+
this._updateSlotVisibility();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
disconnectedCallback() {
|
|
160
|
+
this._dialog.removeEventListener('close', this._onNativeClose);
|
|
161
|
+
this._dialog.removeEventListener('mousedown', this._onMouseDown);
|
|
162
|
+
this._dialog.removeEventListener('click', this._onClick);
|
|
163
|
+
document.removeEventListener('keydown', this._onKeydown);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
167
|
+
if (name === 'open' && oldValue !== newValue) {
|
|
168
|
+
if (this.hasAttribute('open')) {
|
|
169
|
+
this.show();
|
|
170
|
+
} else {
|
|
171
|
+
this.hide();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get open() { return this._dialog?.open || this.hasAttribute('open'); }
|
|
177
|
+
set open(val) { if (val) this.setAttribute('open', ''); else this.removeAttribute('open'); }
|
|
178
|
+
|
|
179
|
+
show() { this._openInternal(true); }
|
|
180
|
+
hide() { this._closeInternal(true); }
|
|
181
|
+
toggle() { this.open ? this.hide() : this.show(); }
|
|
182
|
+
|
|
183
|
+
// Aliases
|
|
184
|
+
showModal() { this.show(); }
|
|
185
|
+
close() { this.hide(); }
|
|
186
|
+
|
|
187
|
+
_emit(name, detail = {}) {
|
|
188
|
+
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_openInternal(emit = false) {
|
|
192
|
+
if (this._dialog.open) return;
|
|
193
|
+
this._previousActive = document.activeElement;
|
|
194
|
+
|
|
195
|
+
// Scroll lock
|
|
196
|
+
if (!this.hasAttribute('no-scroll-lock')) {
|
|
197
|
+
this._lockScroll();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Inert others
|
|
201
|
+
if (this.hasAttribute('inert-others')) {
|
|
202
|
+
this._toggleInert(true);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Native dialog path
|
|
206
|
+
try {
|
|
207
|
+
if ('showModal' in HTMLDialogElement.prototype) {
|
|
208
|
+
this._dialog.showModal();
|
|
209
|
+
} else {
|
|
210
|
+
// Fallback
|
|
211
|
+
this._dialog.setAttribute('open', '');
|
|
212
|
+
}
|
|
213
|
+
} catch (_) {
|
|
214
|
+
this._dialog.setAttribute('open', '');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Sync attribute for CSS animations
|
|
218
|
+
this.setAttribute('open', '');
|
|
219
|
+
|
|
220
|
+
// Listeners
|
|
221
|
+
document.addEventListener('keydown', this._onKeydown);
|
|
222
|
+
|
|
223
|
+
// Focus first focusable within panel
|
|
224
|
+
this._focusInitial();
|
|
225
|
+
|
|
226
|
+
if (emit) this._emit('zz-modal:open');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_closeInternal(emit = false) {
|
|
230
|
+
if (!this._dialog.open && !this.hasAttribute('open')) return;
|
|
231
|
+
|
|
232
|
+
// Remove listeners
|
|
233
|
+
document.removeEventListener('keydown', this._onKeydown);
|
|
234
|
+
|
|
235
|
+
// Native dialog close
|
|
236
|
+
try {
|
|
237
|
+
if (this._dialog.open) this._dialog.close();
|
|
238
|
+
} catch (_) {
|
|
239
|
+
// ignore
|
|
240
|
+
}
|
|
241
|
+
this.removeAttribute('open');
|
|
242
|
+
|
|
243
|
+
// Restore scroll and inert
|
|
244
|
+
if (!this.hasAttribute('no-scroll-lock')) {
|
|
245
|
+
this._unlockScroll();
|
|
246
|
+
}
|
|
247
|
+
if (this.hasAttribute('inert-others')) {
|
|
248
|
+
this._toggleInert(false);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Restore focus
|
|
252
|
+
if (this._previousActive && typeof this._previousActive.focus === 'function') {
|
|
253
|
+
this._previousActive.focus({ preventScroll: true });
|
|
254
|
+
}
|
|
255
|
+
this._previousActive = null;
|
|
256
|
+
|
|
257
|
+
if (emit) this._emit('zz-modal:close');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
_onKeydown(e) {
|
|
261
|
+
const escAllowed = !this.hasAttribute('no-esc-close');
|
|
262
|
+
if (e.key === 'Escape' && escAllowed) {
|
|
263
|
+
e.stopPropagation();
|
|
264
|
+
this.hide();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Basic focus trap for fallback scenario
|
|
268
|
+
if (e.key === 'Tab' && !('showModal' in HTMLDialogElement.prototype)) {
|
|
269
|
+
const focusables = this._getFocusable();
|
|
270
|
+
if (focusables.length === 0) return;
|
|
271
|
+
const first = focusables[0];
|
|
272
|
+
const last = focusables[focusables.length - 1];
|
|
273
|
+
const active = this.shadowRoot.activeElement || document.activeElement;
|
|
274
|
+
if (e.shiftKey) {
|
|
275
|
+
if (active === first || !this.contains(active)) {
|
|
276
|
+
e.preventDefault();
|
|
277
|
+
last.focus();
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
if (active === last || !this.contains(active)) {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
first.focus();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_onNativeClose() {
|
|
289
|
+
// Ensure attribute sync and cleanup
|
|
290
|
+
this._closeInternal(true);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_onMouseDown(e) {
|
|
294
|
+
// Track whether mousedown originated inside the panel to differentiate drags
|
|
295
|
+
const path = e.composedPath();
|
|
296
|
+
this._mouseDownInsidePanel = path.includes(this._panel);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
_onClick(e) {
|
|
300
|
+
const backdropClose = !this.hasAttribute('no-backdrop-close');
|
|
301
|
+
if (!backdropClose) return;
|
|
302
|
+
|
|
303
|
+
// If click ended outside panel and started outside panel -> treat as backdrop click
|
|
304
|
+
const path = e.composedPath();
|
|
305
|
+
const clickInsidePanel = path.includes(this._panel);
|
|
306
|
+
if (!clickInsidePanel && !this._mouseDownInsidePanel) {
|
|
307
|
+
this.hide();
|
|
308
|
+
}
|
|
309
|
+
this._mouseDownInsidePanel = false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
_updateSlotVisibility() {
|
|
313
|
+
try {
|
|
314
|
+
const hasHeader = (this._slotHeader.assignedNodes({ flatten: true }).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim())).length) > 0;
|
|
315
|
+
const hasFooter = (this._slotFooter.assignedNodes({ flatten: true }).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim())).length) > 0;
|
|
316
|
+
if (!hasHeader) this.setAttribute('no-header-auto', ''); else this.removeAttribute('no-header-auto');
|
|
317
|
+
if (!hasFooter) this.setAttribute('no-footer-auto', ''); else this.removeAttribute('no-footer-auto');
|
|
318
|
+
} catch (_) {
|
|
319
|
+
// ignore
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
_getFocusable() {
|
|
324
|
+
const root = this.shadowRoot;
|
|
325
|
+
const within = root.querySelector('.panel-inner');
|
|
326
|
+
return Array.from(within.querySelectorAll([
|
|
327
|
+
'a[href]','area[href]','input:not([disabled])','select:not([disabled])','textarea:not([disabled])',
|
|
328
|
+
'button:not([disabled])','iframe','object','embed','[contenteditable]','[tabindex]:not([tabindex="-1"])'
|
|
329
|
+
].join(',')));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_focusInitial() {
|
|
333
|
+
const focusables = this._getFocusable();
|
|
334
|
+
if (focusables.length > 0) {
|
|
335
|
+
focusables[0].focus({ preventScroll: true });
|
|
336
|
+
} else {
|
|
337
|
+
// Focus the dialog so that ESC works consistently
|
|
338
|
+
this._dialog.focus({ preventScroll: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
_lockScroll() {
|
|
343
|
+
const body = document.body;
|
|
344
|
+
this._prevBodyOverflow = body.style.overflow;
|
|
345
|
+
body.style.overflow = 'hidden';
|
|
346
|
+
body.setAttribute('data-zz-modal-open', '');
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
_unlockScroll() {
|
|
350
|
+
const body = document.body;
|
|
351
|
+
body.style.overflow = this._prevBodyOverflow || '';
|
|
352
|
+
body.removeAttribute('data-zz-modal-open');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
_toggleInert(isInert) {
|
|
356
|
+
const toInert = [];
|
|
357
|
+
const root = document.body;
|
|
358
|
+
for (const child of Array.from(root.children)) {
|
|
359
|
+
if (child === this.parentElement) continue;
|
|
360
|
+
toInert.push(child);
|
|
361
|
+
}
|
|
362
|
+
toInert.forEach((el) => {
|
|
363
|
+
if (isInert) el.setAttribute('inert', ''); else el.removeAttribute('inert');
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
customElements.define('zz-modal', ZZModal);
|
|
369
|
+
|
|
370
|
+
// Global open/close triggers
|
|
371
|
+
document.addEventListener('click', (e) => {
|
|
372
|
+
const openTrigger = e.target.closest('[data-zz-modal-target]');
|
|
373
|
+
if (openTrigger) {
|
|
374
|
+
const selector = openTrigger.getAttribute('data-zz-modal-target');
|
|
375
|
+
if (selector) {
|
|
376
|
+
const modal = document.querySelector(selector);
|
|
377
|
+
if (modal && modal.show) {
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
modal.show();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const closeTrigger = e.target.closest('[data-zz-modal-close]');
|
|
385
|
+
if (closeTrigger) {
|
|
386
|
+
const hostModal = closeTrigger.closest('zz-modal');
|
|
387
|
+
if (hostModal && hostModal.hide) {
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
hostModal.hide();
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}, { capture: true });
|
|
393
|
+
})();
|
|
394
|
+
|
|
395
|
+
|
package/blocks/zz-button.liquid
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
"label": "按钮文字",
|
|
10
10
|
"default": "按钮"
|
|
11
11
|
},
|
|
12
|
+
{
|
|
13
|
+
"type": "text",
|
|
14
|
+
"id": "btn_id",
|
|
15
|
+
"label": "按钮id属性值",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"type": "text",
|
|
19
|
+
"id": "modal_id",
|
|
20
|
+
"label": "按钮触发modal的id",
|
|
21
|
+
},
|
|
12
22
|
{
|
|
13
23
|
"type": "select",
|
|
14
24
|
"id": "function_type",
|
|
@@ -188,7 +198,9 @@
|
|
|
188
198
|
icon_left_margin: block.settings.icon_left_margin,
|
|
189
199
|
icon_right_margin: block.settings.icon_right_margin,
|
|
190
200
|
width: block.settings.mobile_width,
|
|
191
|
-
class_name: btn_class
|
|
201
|
+
class_name: btn_class,
|
|
202
|
+
btn_id: block.settings.btn_id,
|
|
203
|
+
modal_id: block.settings.modal_id
|
|
192
204
|
%}
|
|
193
205
|
|
|
194
206
|
|
package/docs/zz-modal.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
轻量、高性能、可扩展、可访问(a11y)的 Web Component 弹窗,基于原生 `<dialog>` 能力并内置降级方案。
|
|
2
|
+
|
|
3
|
+
### 特性
|
|
4
|
+
- 原生 `<dialog>`:内建焦点管理、`aria-modal`,退化时提供焦点陷阱与交互保障
|
|
5
|
+
- 高性能:零依赖、轻量动画、滚动锁、Backdrop 点击关闭、ESC 关闭
|
|
6
|
+
- 可扩展:Shadow DOM、`::part(...)`、CSS 变量、事件、编程式 API
|
|
7
|
+
- 易用:全局触发器 `[data-zz-modal-target]`、`[data-zz-modal-close]`
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
### 快速开始(Shopify / Theme Section)
|
|
12
|
+
在 Section 中确保加载脚本(`sections/zz-modal.liquid` 已内置):
|
|
13
|
+
|
|
14
|
+
```liquid
|
|
15
|
+
<script defer id="zz-modal-js" src="{{ 'zz-modal.js' | asset_url }}"></script>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
无 JS 安全:
|
|
19
|
+
```css
|
|
20
|
+
zz-modal:not(:defined) { display: none !important; }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Section 中通过 CSS 变量响应式配置:
|
|
24
|
+
```css
|
|
25
|
+
#your-modal-id {
|
|
26
|
+
--zz-modal-radius: {{ section.settings.radius_mobile }}px;
|
|
27
|
+
--zz-modal-padding: {{ section.settings.padding_mobile }}px;
|
|
28
|
+
--zz-modal-width: {{ section.settings.width_mobile }};
|
|
29
|
+
}
|
|
30
|
+
@media (min-width: 768px) {
|
|
31
|
+
#your-modal-id {
|
|
32
|
+
--zz-modal-radius: {{ section.settings.radius_pc }}px;
|
|
33
|
+
--zz-modal-padding: {{ section.settings.padding_pc }}px;
|
|
34
|
+
--zz-modal-width: {{ section.settings.width_pc }};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Section 设置项(已实现):
|
|
40
|
+
- 文案:`button_text`
|
|
41
|
+
- Header/Footer 显示:`show_header`、`show_footer`
|
|
42
|
+
- 宽度:`width_mobile`、`width_pc`(支持 `px` 或 `vw`)
|
|
43
|
+
- 圆角:`radius_mobile`、`radius_pc`(单位 px)
|
|
44
|
+
- 内边距:`padding_mobile`、`padding_pc`(单位 px)
|
|
45
|
+
|
|
46
|
+
> 当 slot 为空时,组件会自动隐藏 Header/Footer(属性 `no-header-auto` / `no-footer-auto`)。
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
### 基本用法
|
|
51
|
+
|
|
52
|
+
触发按钮 + 弹窗结构:
|
|
53
|
+
```html
|
|
54
|
+
<button data-zz-modal-target="#demo-modal">Open Modal</button>
|
|
55
|
+
|
|
56
|
+
<zz-modal id="demo-modal">
|
|
57
|
+
<div slot="header">标题</div>
|
|
58
|
+
<div>这里是内容</div>
|
|
59
|
+
<div slot="footer">
|
|
60
|
+
<button data-zz-modal-close>取消</button>
|
|
61
|
+
<button data-zz-modal-close>确定</button>
|
|
62
|
+
</div>
|
|
63
|
+
<!-- 内置关闭按钮也可用:shadow 内提供 part="close-button" 按钮 -->
|
|
64
|
+
<!-- 任何带 data-zz-modal-close 的元素都会关闭当前弹窗 -->
|
|
65
|
+
<!-- 任意元素 data-zz-modal-target="#id" 可打开对应弹窗 -->
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
### 编程式 API
|
|
71
|
+
```js
|
|
72
|
+
const modal = document.querySelector('#demo-modal');
|
|
73
|
+
modal.show(); // 打开
|
|
74
|
+
modal.hide(); // 关闭
|
|
75
|
+
modal.toggle(); // 切换
|
|
76
|
+
// 等价别名:modal.showModal() / modal.close()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
### 属性(Attributes)
|
|
82
|
+
- `open`:受控显示状态(加上即显示,移除即关闭)
|
|
83
|
+
- `no-esc-close`:禁用 ESC 关闭
|
|
84
|
+
- `no-backdrop-close`:禁用点击遮罩关闭
|
|
85
|
+
- `no-scroll-lock`:禁用页面滚动锁
|
|
86
|
+
- `inert-others`:打开时让页面其它区域 inert(更强的可访问隔离)
|
|
87
|
+
- `no-header` / `no-footer`:强制隐藏 Header/Footer(无视 slot 内容)
|
|
88
|
+
- `no-header-auto` / `no-footer-auto`:组件自动加的属性,用于在对应 slot 为空时隐藏区域
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### 事件(Events)
|
|
93
|
+
- `zz-modal:open`
|
|
94
|
+
- `zz-modal:close`
|
|
95
|
+
|
|
96
|
+
监听示例:
|
|
97
|
+
```js
|
|
98
|
+
document.querySelector('#demo-modal')
|
|
99
|
+
.addEventListener('zz-modal:open', () => console.log('opened'));
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### 样式定制
|
|
105
|
+
|
|
106
|
+
CSS 变量:
|
|
107
|
+
- `--zz-modal-width`(默认 `min(720px, 92vw)`)
|
|
108
|
+
- `--zz-modal-max-width`(默认 `90vw`)
|
|
109
|
+
- `--zz-modal-max-height`(默认 `85vh`)
|
|
110
|
+
- `--zz-modal-radius`(默认 `12px`)
|
|
111
|
+
- `--zz-modal-padding`(默认 `16px`)
|
|
112
|
+
- `--zz-modal-background`(默认 `#fff`)
|
|
113
|
+
- `--zz-modal-shadow`(默认 `0 20px 60px rgba(0,0,0,0.2)`)
|
|
114
|
+
- `--zz-modal-backdrop`(默认 `rgba(0,0,0,0.5)`)
|
|
115
|
+
- `--zz-modal-backdrop-filter`(默认 `blur(0px)`)
|
|
116
|
+
- `--zz-modal-z-index`(默认 `9999`)
|
|
117
|
+
|
|
118
|
+
Shadow Parts:
|
|
119
|
+
- `dialog`、`backdrop`、`panel`、`header`、`body`、`footer`、`close-button`
|
|
120
|
+
|
|
121
|
+
示例:
|
|
122
|
+
```css
|
|
123
|
+
/* 外部覆盖内部部件样式 */
|
|
124
|
+
zz-modal::part(dialog) { max-width: 720px; }
|
|
125
|
+
zz-modal::part(panel) { border-radius: 16px; }
|
|
126
|
+
|
|
127
|
+
/* 使用变量 */
|
|
128
|
+
#demo-modal { --zz-modal-background: #111; color: #fff; }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
### 可访问性(a11y)
|
|
134
|
+
- 原生 `<dialog>` 优先,具备 `aria-modal`,ESC 关闭、焦点管理
|
|
135
|
+
- 降级路径提供基本焦点陷阱与键盘导航保障
|
|
136
|
+
- `inert-others` 可在打开时禁用页面其他区域交互
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### 无 JS 安全
|
|
141
|
+
网络不佳或脚本未加载时隐藏 Light DOM,避免内容外露:
|
|
142
|
+
```css
|
|
143
|
+
zz-modal:not(:defined) { display: none !important; }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### 多实例
|
|
149
|
+
```html
|
|
150
|
+
<button data-zz-modal-target="#m1">打开 1</button>
|
|
151
|
+
<button data-zz-modal-target="#m2">打开 2</button>
|
|
152
|
+
|
|
153
|
+
<zz-modal id="m1">...</zz-modal>
|
|
154
|
+
<zz-modal id="m2">...</zz-modal>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### 常见问题
|
|
160
|
+
- 遮罩点击无效?检查是否设置了 `no-backdrop-close`。
|
|
161
|
+
- 页面滚动未锁定?移除 `no-scroll-lock` 或确认 CSS 未覆盖 `overflow`。
|
|
162
|
+
- Header/Footer 不显示?若未显式设置 `show_header/show_footer`,请确保 slot 有内容;或移除 `no-header/no-footer`。
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
### 变更位置
|
|
167
|
+
- 组件实现:`assets/zz-modal.js`
|
|
168
|
+
- Section:`sections/zz-modal.liquid`
|
|
169
|
+
|
|
170
|
+
|
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
zz-modal:not(:defined) { display: none !important; }
|
|
3
|
+
/* Responsive modal variables from section settings */
|
|
4
|
+
#{{ section.settings.modal_id }} {
|
|
5
|
+
--zz-modal-radius: {{ section.settings.radius_mobile }}px;
|
|
6
|
+
--zz-modal-padding: {{ section.settings.padding_mobile }}px;
|
|
7
|
+
--zz-modal-width: {{ section.settings.width_mobile }};
|
|
8
|
+
}
|
|
9
|
+
@media (min-width: 768px) {
|
|
10
|
+
#{{ section.settings.modal_id }} {
|
|
11
|
+
--zz-modal-radius: {{ section.settings.radius_pc }}px;
|
|
12
|
+
--zz-modal-padding: {{ section.settings.padding_pc }}px;
|
|
13
|
+
--zz-modal-width: {{ section.settings.width_pc }};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
</style>
|
|
17
|
+
|
|
18
|
+
<zz-modal {% if section.settings.no_backdrop_close %} no-backdrop-close {% endif %} id="{{ section.settings.modal_id }}"{% unless section.settings.show_header %} no-header{% endunless %}{% unless section.settings.show_footer %} no-footer{% endunless %}>
|
|
19
|
+
{% if section.settings.show_header %}
|
|
20
|
+
<div slot="header" class="tw-text-[24px] tw-font-semibold">{{ section.settings.header_text }}</div>
|
|
21
|
+
{% endif %}
|
|
22
|
+
{% content_for 'blocks' %}
|
|
23
|
+
{% if section.settings.show_footer %}
|
|
24
|
+
<div slot="footer" class="tw-flex tw-justify-end tw-gap-2 tw-pt-8">
|
|
25
|
+
<button class=" tw-text-[16px] " data-zz-modal-close>{{ section.settings.footer_text }}</button>
|
|
26
|
+
</div>
|
|
27
|
+
{% endif %}
|
|
28
|
+
</zz-modal>
|
|
29
|
+
|
|
30
|
+
<script>
|
|
31
|
+
(function(){
|
|
32
|
+
var id = 'zz-modal-js';
|
|
33
|
+
if (!document.getElementById(id)) {
|
|
34
|
+
var s = document.createElement('script');
|
|
35
|
+
s.id = id;
|
|
36
|
+
s.src = '{{ 'zz-modal.js' | asset_url }}';
|
|
37
|
+
s.defer = true;
|
|
38
|
+
document.head.appendChild(s);
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
{% schema %}
|
|
44
|
+
{
|
|
45
|
+
"name": "ZZ Modal",
|
|
46
|
+
"settings": [
|
|
47
|
+
{
|
|
48
|
+
"type": "text",
|
|
49
|
+
"id": "modal_id",
|
|
50
|
+
"label": "弹窗id (必填)",
|
|
51
|
+
"default": "demo-modal"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"type": "checkbox",
|
|
55
|
+
"id": "show_header",
|
|
56
|
+
"label": "显示 Header",
|
|
57
|
+
"default": false
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"type": "checkbox",
|
|
61
|
+
"id": "show_footer",
|
|
62
|
+
"label": "显示 Footer",
|
|
63
|
+
"default": false
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"type": "checkbox",
|
|
67
|
+
"id": "no_backdrop_close",
|
|
68
|
+
"label": "是否禁用遮罩点击关闭",
|
|
69
|
+
"default": false
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"type": "text",
|
|
73
|
+
"id": "header_text",
|
|
74
|
+
"label": "弹窗标题",
|
|
75
|
+
"default": "弹窗标题"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"type": "text",
|
|
79
|
+
"id": "width_mobile",
|
|
80
|
+
"label": "移动端宽度(如 92vw 或 360px)",
|
|
81
|
+
"default": "92vw"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "text",
|
|
85
|
+
"id": "width_pc",
|
|
86
|
+
"label": "PC 端宽度(如 720px 或 60vw)",
|
|
87
|
+
"default": "720px"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"type": "range",
|
|
91
|
+
"id": "radius_mobile",
|
|
92
|
+
"label": "移动端圆角(px)",
|
|
93
|
+
"min": 0,
|
|
94
|
+
"max": 40,
|
|
95
|
+
"step": 1,
|
|
96
|
+
"default": 12
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "range",
|
|
100
|
+
"id": "radius_pc",
|
|
101
|
+
"label": "PC 端圆角(px)",
|
|
102
|
+
"min": 0,
|
|
103
|
+
"max": 40,
|
|
104
|
+
"step": 1,
|
|
105
|
+
"default": 12
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "range",
|
|
109
|
+
"id": "padding_mobile",
|
|
110
|
+
"label": "移动端内边距(px)",
|
|
111
|
+
"min": 0,
|
|
112
|
+
"max": 48,
|
|
113
|
+
"step": 2,
|
|
114
|
+
"default": 16
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"type": "range",
|
|
118
|
+
"id": "padding_pc",
|
|
119
|
+
"label": "PC 端内边距(px)",
|
|
120
|
+
"min": 0,
|
|
121
|
+
"max": 64,
|
|
122
|
+
"step": 2,
|
|
123
|
+
"default": 20
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"type": "text",
|
|
127
|
+
"id": "footer_text",
|
|
128
|
+
"label": "弹窗底部按钮文案",
|
|
129
|
+
"default": "Close"
|
|
130
|
+
}
|
|
131
|
+
],
|
|
132
|
+
"blocks": [
|
|
133
|
+
{
|
|
134
|
+
"type": "@theme",
|
|
135
|
+
}
|
|
136
|
+
],
|
|
137
|
+
"presets": [
|
|
138
|
+
{
|
|
139
|
+
"name": "ZZ Modal",
|
|
140
|
+
"category": "Custom"
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|
|
144
|
+
{% endschema %}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
@@ -38,7 +38,7 @@ size:按钮尺寸
|
|
|
38
38
|
|
|
39
39
|
{% if href %}
|
|
40
40
|
{% if btn_type == 'link' %}
|
|
41
|
-
<a href="{{ href | default: '#' }}" class="zz-btn-link tw-text-[#378DDD] tw-inline-flex tw-items-center tw-text-[12px] lg:tw-text-[14px] tw-no-underline zz-btn-link-{{ btn_size }} {% if width == 'full' %} tw-w-full {% endif %} {{ class_name }}">
|
|
41
|
+
<a href="{{ href | default: '#' }}" {% if btn_id != blank %} id="{{ btn_id }}" {% endif %} class="zz-btn-link tw-text-[#378DDD] tw-inline-flex tw-items-center tw-text-[12px] lg:tw-text-[14px] tw-no-underline zz-btn-link-{{ btn_size }} {% if width == 'full' %} tw-w-full {% endif %} {{ class_name }}">
|
|
42
42
|
<span class="zz-btn-text">{{ text }}</span>
|
|
43
43
|
{% if postfix_icon %}<span class="zz-btn-icon" style="margin-left: {{ icon_left_margin }}px; margin-right: {{ icon_right_margin }}px;">
|
|
44
44
|
{% render 'zz-icon', icon_name: postfix_icon, icon_size: icon_size %}
|
|
@@ -47,6 +47,7 @@ size:按钮尺寸
|
|
|
47
47
|
{% else %}
|
|
48
48
|
<a
|
|
49
49
|
href="{{ href | default: '#' }}"
|
|
50
|
+
{% if btn_id != blank %} id="{{ btn_id }}" {% endif %}
|
|
50
51
|
class="zz-btn zz-btn-{{ btn_type }} zz-btn-{{ btn_color }} zz-btn-{{ btn_size }} {% if width == 'full' %} tw-w-full {% endif %} {{ class_name }}"
|
|
51
52
|
{% if backdrop_filter %}
|
|
52
53
|
style="backdrop-filter: blur(12px);background: #FFFFFF0F;"
|
|
@@ -61,6 +62,8 @@ size:按钮尺寸
|
|
|
61
62
|
|
|
62
63
|
<button
|
|
63
64
|
class="zz-btn zz-btn-{{ btn_type }} zz-btn-{{ btn_color }} zz-btn-{{ btn_size }} {% if width == 'full' %} tw-w-full {% endif %} {{ width }} {{ class_name }}"
|
|
65
|
+
{% if modal_id != blank %} data-zz-modal-target="#{{ modal_id }}" {% endif %}
|
|
66
|
+
{% if btn_id != blank %} id="{{ btn_id }}" {% endif %}
|
|
64
67
|
{% if backdrop_filter %}
|
|
65
68
|
style="backdrop-filter: blur(12px);background: #FFFFFF0F;"
|
|
66
69
|
{% endif %}
|