meno-interactions 0.1.0

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.
@@ -0,0 +1,458 @@
1
+ const DEFAULTS = {
2
+ root: null,
3
+ activeClass: 'is-active',
4
+ openClass: 'is-open',
5
+ scrolledClass: 'is-scrolled',
6
+ hiddenClass: 'meno-hidden',
7
+ reducedMotion: false
8
+ };
9
+
10
+ const registry = new Map();
11
+ const cleanups = new WeakMap();
12
+
13
+ function on(target, event, handler, options) {
14
+ target.addEventListener(event, handler, options);
15
+ return () => target.removeEventListener(event, handler, options);
16
+ }
17
+
18
+ function emit(target, name, detail) {
19
+ target.dispatchEvent(new CustomEvent(name, { bubbles: true, detail: detail || {} }));
20
+ }
21
+
22
+ function parseBool(value, fallback) {
23
+ if (value == null || value === '') return fallback;
24
+ return !/^(false|0|no|off)$/i.test(String(value));
25
+ }
26
+
27
+ function closestWithin(target, selector, root) {
28
+ const node = target instanceof Element ? target.closest(selector) : null;
29
+ return node && root.contains(node) ? node : null;
30
+ }
31
+
32
+ function setHidden(el, hidden) {
33
+ if (!el) return;
34
+ el.hidden = hidden;
35
+ el.classList.toggle(DEFAULTS.hiddenClass, hidden);
36
+ }
37
+
38
+ function getDefaultRoot(root) {
39
+ if (root) return root;
40
+ return typeof document !== 'undefined' ? document : null;
41
+ }
42
+
43
+ function getTarget(root, attr, fallbackSelector) {
44
+ const selector = root.getAttribute(attr);
45
+ if (selector) return document.querySelector(selector);
46
+ return fallbackSelector ? root.querySelector(fallbackSelector) : null;
47
+ }
48
+
49
+ function initDisclosure(root, options) {
50
+ const buttons = root.querySelectorAll('[data-meno-toggle], [data-action="toggle"], [data-action="toggle-disclosure"]');
51
+ const disposers = [];
52
+ buttons.forEach((button) => {
53
+ const selector = button.getAttribute('data-meno-target') || button.getAttribute('aria-controls');
54
+ const panel = selector && selector.startsWith('#') ? document.querySelector(selector) : selector ? root.querySelector(selector) : button.nextElementSibling;
55
+ if (!panel) return;
56
+ button.setAttribute('aria-expanded', button.getAttribute('aria-expanded') || 'false');
57
+ const handler = () => {
58
+ const open = !panel.classList.contains(options.openClass) && panel.hidden;
59
+ panel.classList.toggle(options.openClass, open);
60
+ setHidden(panel, !open);
61
+ button.setAttribute('aria-expanded', String(open));
62
+ emit(root, open ? 'meno:open' : 'meno:close', { trigger: button, panel });
63
+ };
64
+ disposers.push(on(button, 'click', handler));
65
+ });
66
+ return disposers;
67
+ }
68
+
69
+ function initAccordion(root, options) {
70
+ const accordions = root.querySelectorAll('[data-meno-accordion]');
71
+ const disposers = [];
72
+ accordions.forEach((accordion) => {
73
+ const single = parseBool(accordion.getAttribute('data-meno-single'), true);
74
+ const buttons = accordion.querySelectorAll('[data-meno-accordion-trigger], [data-action="toggle-accordion"]');
75
+ buttons.forEach((button) => {
76
+ const panel = button.getAttribute('aria-controls')
77
+ ? document.getElementById(button.getAttribute('aria-controls'))
78
+ : button.nextElementSibling;
79
+ if (!panel) return;
80
+ button.setAttribute('aria-expanded', button.getAttribute('aria-expanded') || 'false');
81
+ setHidden(panel, button.getAttribute('aria-expanded') !== 'true');
82
+ disposers.push(on(button, 'click', () => {
83
+ const willOpen = button.getAttribute('aria-expanded') !== 'true';
84
+ if (single && willOpen) {
85
+ buttons.forEach((other) => {
86
+ const otherPanel = other.getAttribute('aria-controls')
87
+ ? document.getElementById(other.getAttribute('aria-controls'))
88
+ : other.nextElementSibling;
89
+ other.setAttribute('aria-expanded', 'false');
90
+ otherPanel?.classList.remove(options.openClass);
91
+ setHidden(otherPanel, true);
92
+ });
93
+ }
94
+ button.setAttribute('aria-expanded', String(willOpen));
95
+ panel.classList.toggle(options.openClass, willOpen);
96
+ setHidden(panel, !willOpen);
97
+ emit(accordion, 'meno:accordion', { trigger: button, panel, open: willOpen });
98
+ }));
99
+ });
100
+ });
101
+ return disposers;
102
+ }
103
+
104
+ function initTabs(root, options) {
105
+ const tabsets = root.querySelectorAll('[data-meno-tabs]');
106
+ const disposers = [];
107
+ tabsets.forEach((tabset) => {
108
+ const buttons = tabset.querySelectorAll('[data-meno-tab], [data-action="select-tab"]');
109
+ const panels = tabset.querySelectorAll('[data-meno-tab-panel], [data-el="tab-panel"]');
110
+ const select = (id) => {
111
+ buttons.forEach((button) => {
112
+ const active = (button.getAttribute('data-meno-tab') || button.getAttribute('data-tab-id')) === id;
113
+ button.classList.toggle(options.activeClass, active);
114
+ button.setAttribute('aria-selected', String(active));
115
+ });
116
+ panels.forEach((panel) => {
117
+ const active = (panel.getAttribute('data-meno-tab-panel') || panel.getAttribute('data-tab-id')) === id;
118
+ panel.classList.toggle(options.activeClass, active);
119
+ setHidden(panel, !active);
120
+ });
121
+ emit(tabset, 'meno:tab', { id });
122
+ };
123
+ buttons.forEach((button, index) => {
124
+ const id = button.getAttribute('data-meno-tab') || button.getAttribute('data-tab-id') || String(index);
125
+ disposers.push(on(button, 'click', () => select(id)));
126
+ });
127
+ const initial = tabset.getAttribute('data-meno-initial-tab')
128
+ || buttons[0]?.getAttribute('data-meno-tab')
129
+ || buttons[0]?.getAttribute('data-tab-id');
130
+ if (initial) select(initial);
131
+ });
132
+ return disposers;
133
+ }
134
+
135
+ function initDropdown(root, options) {
136
+ const disposers = [];
137
+ const triggers = root.querySelectorAll('[data-meno-dropdown], [data-action="toggle-dropdown"]');
138
+ triggers.forEach((trigger) => {
139
+ const id = trigger.getAttribute('data-meno-dropdown') || trigger.getAttribute('data-dropdown-id');
140
+ const panel = id
141
+ ? root.querySelector(`[data-meno-dropdown-panel="${id}"], [data-el="dropdown-panel"][data-dropdown-id="${id}"]`)
142
+ : trigger.parentElement?.querySelector('[data-meno-dropdown-panel], [data-el="dropdown-panel"]');
143
+ if (!panel) return;
144
+ trigger.setAttribute('aria-expanded', trigger.getAttribute('aria-expanded') || 'false');
145
+ disposers.push(on(trigger, 'click', (event) => {
146
+ event.preventDefault();
147
+ const open = !panel.classList.contains(options.openClass);
148
+ root.querySelectorAll('[data-meno-dropdown-panel], [data-el="dropdown-panel"]').forEach((item) => {
149
+ if (item !== panel) item.classList.remove(options.openClass);
150
+ });
151
+ panel.classList.toggle(options.openClass, open);
152
+ trigger.setAttribute('aria-expanded', String(open));
153
+ }));
154
+ });
155
+ disposers.push(on(document, 'click', (event) => {
156
+ if (root.contains(event.target)) return;
157
+ root.querySelectorAll('[data-meno-dropdown-panel], [data-el="dropdown-panel"]').forEach((panel) => panel.classList.remove(options.openClass));
158
+ triggers.forEach((trigger) => trigger.setAttribute('aria-expanded', 'false'));
159
+ }));
160
+ return disposers;
161
+ }
162
+
163
+ function initModal(root, options) {
164
+ const disposers = [];
165
+ const modals = root.querySelectorAll('[data-meno-modal]');
166
+ const openModal = (modal) => {
167
+ modal.classList.add(options.openClass);
168
+ setHidden(modal, false);
169
+ if (typeof document !== 'undefined') document.body.style.overflow = 'hidden';
170
+ emit(modal, 'meno:modal-open', { modal });
171
+ };
172
+ const closeModal = (modal) => {
173
+ modal.classList.remove(options.openClass);
174
+ setHidden(modal, true);
175
+ if (typeof document !== 'undefined') document.body.style.overflow = '';
176
+ emit(modal, 'meno:modal-close', { modal });
177
+ };
178
+ modals.forEach((modal) => {
179
+ if (!modal.classList.contains(options.openClass)) setHidden(modal, true);
180
+ modal.querySelectorAll('[data-meno-close], [data-action="close-modal"]').forEach((button) => {
181
+ disposers.push(on(button, 'click', () => closeModal(modal)));
182
+ });
183
+ disposers.push(on(modal, 'click', (event) => {
184
+ if (event.target === modal && parseBool(modal.getAttribute('data-meno-backdrop-close'), true)) closeModal(modal);
185
+ }));
186
+ });
187
+ root.querySelectorAll('[data-meno-open-modal], [data-action="open-modal"]').forEach((button) => {
188
+ disposers.push(on(button, 'click', (event) => {
189
+ event.preventDefault();
190
+ const key = button.getAttribute('data-meno-open-modal') || button.getAttribute('data-modal-target') || 'default';
191
+ const modal = document.querySelector(`[data-meno-modal="${key}"], [data-meno-modal][id="${key}"]`);
192
+ if (modal) openModal(modal);
193
+ }));
194
+ });
195
+ disposers.push(on(document, 'keydown', (event) => {
196
+ if (event.key !== 'Escape') return;
197
+ document.querySelectorAll(`[data-meno-modal].${options.openClass}`).forEach(closeModal);
198
+ }));
199
+ return disposers;
200
+ }
201
+
202
+ function initDrawer(root, options) {
203
+ const disposers = [];
204
+ const drawers = root.querySelectorAll('[data-meno-drawer]');
205
+ const openDrawer = (drawer) => {
206
+ drawer.classList.add(options.openClass);
207
+ setHidden(drawer, false);
208
+ if (typeof document !== 'undefined') document.body.style.overflow = 'hidden';
209
+ };
210
+ const closeDrawer = (drawer) => {
211
+ drawer.classList.remove(options.openClass);
212
+ setHidden(drawer, true);
213
+ if (typeof document !== 'undefined') document.body.style.overflow = '';
214
+ };
215
+ drawers.forEach((drawer) => {
216
+ if (!drawer.classList.contains(options.openClass)) setHidden(drawer, true);
217
+ drawer.querySelectorAll('[data-meno-close], [data-action="close-drawer"]').forEach((button) => {
218
+ disposers.push(on(button, 'click', () => closeDrawer(drawer)));
219
+ });
220
+ });
221
+ root.querySelectorAll('[data-meno-open-drawer], [data-action="open-drawer"], [data-action="toggle-mobile-nav"]').forEach((button) => {
222
+ disposers.push(on(button, 'click', (event) => {
223
+ event.preventDefault();
224
+ const key = button.getAttribute('data-meno-open-drawer') || button.getAttribute('data-drawer-target') || 'default';
225
+ const drawer = document.querySelector(`[data-meno-drawer="${key}"], [data-meno-drawer][id="${key}"]`);
226
+ if (drawer) openDrawer(drawer);
227
+ }));
228
+ });
229
+ disposers.push(on(document, 'keydown', (event) => {
230
+ if (event.key !== 'Escape') return;
231
+ document.querySelectorAll(`[data-meno-drawer].${options.openClass}`).forEach(closeDrawer);
232
+ }));
233
+ return disposers;
234
+ }
235
+
236
+ function initCarousel(root) {
237
+ const disposers = [];
238
+ root.querySelectorAll('[data-meno-carousel]').forEach((carousel) => {
239
+ const track = carousel.querySelector('[data-meno-carousel-track], [data-el="carousel-track"]') || carousel;
240
+ const step = () => track.firstElementChild?.getBoundingClientRect().width || track.clientWidth;
241
+ const move = (direction) => track.scrollBy({ left: direction * step(), behavior: 'smooth' });
242
+ carousel.querySelectorAll('[data-meno-carousel-prev], [data-action="carousel-prev"]').forEach((button) => {
243
+ disposers.push(on(button, 'click', () => move(-1)));
244
+ });
245
+ carousel.querySelectorAll('[data-meno-carousel-next], [data-action="carousel-next"]').forEach((button) => {
246
+ disposers.push(on(button, 'click', () => move(1)));
247
+ });
248
+ });
249
+ return disposers;
250
+ }
251
+
252
+ function initSmoothScroll(root, options) {
253
+ const disposers = [];
254
+ root.querySelectorAll('[data-meno-smooth-scroll] a[href^="#"], a[data-meno-smooth-scroll][href^="#"]').forEach((anchor) => {
255
+ disposers.push(on(anchor, 'click', (event) => {
256
+ const id = anchor.getAttribute('href')?.slice(1);
257
+ const target = id ? document.getElementById(id) : null;
258
+ if (!target) return;
259
+ event.preventDefault();
260
+ target.scrollIntoView({ behavior: options.reducedMotion ? 'auto' : 'smooth', block: 'start' });
261
+ history.replaceState(null, '', `#${id}`);
262
+ }));
263
+ });
264
+ return disposers;
265
+ }
266
+
267
+ function initSticky(root, options) {
268
+ const disposers = [];
269
+ root.querySelectorAll('[data-meno-sticky], [data-el="site-header"]').forEach((header) => {
270
+ const threshold = Number(header.getAttribute('data-meno-sticky-threshold') || 8);
271
+ const update = () => header.classList.toggle(options.scrolledClass, window.scrollY > threshold);
272
+ update();
273
+ disposers.push(on(window, 'scroll', update, { passive: true }));
274
+ });
275
+ return disposers;
276
+ }
277
+
278
+ function initCopy(root) {
279
+ const disposers = [];
280
+ root.querySelectorAll('[data-meno-copy], [data-action="copy"]').forEach((button) => {
281
+ disposers.push(on(button, 'click', async () => {
282
+ const selector = button.getAttribute('data-meno-copy') || button.getAttribute('data-copy-target');
283
+ const source = selector
284
+ ? (selector.startsWith('#') ? document.querySelector(selector) : document.getElementById(selector) || document.querySelector(selector))
285
+ : button.previousElementSibling;
286
+ const text = source?.textContent?.trim() || button.getAttribute('data-meno-copy-text') || '';
287
+ try {
288
+ await navigator.clipboard.writeText(text);
289
+ } catch {
290
+ const area = document.createElement('textarea');
291
+ area.value = text;
292
+ document.body.appendChild(area);
293
+ area.select();
294
+ document.execCommand('copy');
295
+ area.remove();
296
+ }
297
+ const original = button.textContent;
298
+ button.textContent = button.getAttribute('data-meno-copied-label') || 'Copied';
299
+ setTimeout(() => {
300
+ button.textContent = original;
301
+ }, Number(button.getAttribute('data-meno-copy-timeout') || 1500));
302
+ }));
303
+ });
304
+ return disposers;
305
+ }
306
+
307
+ function initForm(root) {
308
+ const disposers = [];
309
+ root.querySelectorAll('form[data-meno-form], [data-meno-form] form').forEach((form) => {
310
+ const status = form.querySelector('[data-meno-form-status], [data-el="form-status"]');
311
+ disposers.push(on(form, 'submit', (event) => {
312
+ event.preventDefault();
313
+ const data = Object.fromEntries(new FormData(form).entries());
314
+ const required = form.querySelectorAll('[required]');
315
+ const invalid = Array.from(required).find((field) => !String(field.value || '').trim());
316
+ if (invalid) {
317
+ if (status) status.textContent = invalid.getAttribute('data-meno-error') || `${invalid.name || 'This field'} is required`;
318
+ invalid.focus();
319
+ emit(form, 'meno:form-error', { field: invalid, data });
320
+ return;
321
+ }
322
+ if (status) status.textContent = form.getAttribute('data-meno-success') || "Thanks! We'll be in touch.";
323
+ emit(form, 'meno:form-submit', { data });
324
+ if (parseBool(form.getAttribute('data-meno-reset-on-submit'), true)) form.reset();
325
+ }));
326
+ });
327
+ return disposers;
328
+ }
329
+
330
+ function initLightbox(root, options) {
331
+ const disposers = [];
332
+ const lightbox = root.querySelector('[data-meno-lightbox]');
333
+ const image = lightbox?.querySelector('img');
334
+ const close = lightbox?.querySelector('[data-meno-close]');
335
+ const open = (src, alt) => {
336
+ if (!lightbox || !image) return;
337
+ image.src = src;
338
+ image.alt = alt || '';
339
+ lightbox.classList.add(options.openClass);
340
+ setHidden(lightbox, false);
341
+ };
342
+ if (lightbox) {
343
+ setHidden(lightbox, !lightbox.classList.contains(options.openClass));
344
+ disposers.push(on(lightbox, 'click', (event) => {
345
+ if (event.target === lightbox) {
346
+ lightbox.classList.remove(options.openClass);
347
+ setHidden(lightbox, true);
348
+ }
349
+ }));
350
+ if (close) disposers.push(on(close, 'click', () => {
351
+ lightbox.classList.remove(options.openClass);
352
+ setHidden(lightbox, true);
353
+ }));
354
+ }
355
+ root.querySelectorAll('[data-meno-open-lightbox]').forEach((trigger) => {
356
+ disposers.push(on(trigger, 'click', (event) => {
357
+ event.preventDefault();
358
+ const src = trigger.getAttribute('data-meno-open-lightbox') || trigger.getAttribute('href') || trigger.querySelector('img')?.src;
359
+ const alt = trigger.getAttribute('data-meno-lightbox-alt') || trigger.querySelector('img')?.alt;
360
+ if (src) open(src, alt);
361
+ }));
362
+ });
363
+ return disposers;
364
+ }
365
+
366
+ function initAttributeActions(root) {
367
+ const disposers = [];
368
+ disposers.push(on(root, 'click', (event) => {
369
+ const toggler = closestWithin(event.target, '[data-meno-class-toggle]', root);
370
+ if (toggler) {
371
+ const className = toggler.getAttribute('data-meno-class-toggle');
372
+ const target = getTarget(toggler, 'data-meno-target') || toggler;
373
+ if (className && target) target.classList.toggle(className);
374
+ }
375
+ const adder = closestWithin(event.target, '[data-meno-class-add]', root);
376
+ if (adder) {
377
+ const className = adder.getAttribute('data-meno-class-add');
378
+ const target = getTarget(adder, 'data-meno-target') || adder;
379
+ if (className && target) target.classList.add(className);
380
+ }
381
+ const remover = closestWithin(event.target, '[data-meno-class-remove]', root);
382
+ if (remover) {
383
+ const className = remover.getAttribute('data-meno-class-remove');
384
+ const target = getTarget(remover, 'data-meno-target') || remover;
385
+ if (className && target) target.classList.remove(className);
386
+ }
387
+ const eventer = closestWithin(event.target, '[data-meno-dispatch]', root);
388
+ if (eventer) {
389
+ emit(eventer, eventer.getAttribute('data-meno-dispatch'), { source: eventer });
390
+ }
391
+ }));
392
+ return disposers;
393
+ }
394
+
395
+ const initializers = [
396
+ initDisclosure,
397
+ initAccordion,
398
+ initTabs,
399
+ initDropdown,
400
+ initModal,
401
+ initDrawer,
402
+ initCarousel,
403
+ initSmoothScroll,
404
+ initSticky,
405
+ initCopy,
406
+ initForm,
407
+ initLightbox,
408
+ initAttributeActions
409
+ ];
410
+
411
+ function init(userOptions = {}) {
412
+ const options = { ...DEFAULTS, ...userOptions };
413
+ const root = getDefaultRoot(options.root);
414
+ if (!root) {
415
+ return {
416
+ root: null,
417
+ destroy: () => {},
418
+ refresh: () => init(options)
419
+ };
420
+ }
421
+ destroy(root);
422
+ const customInitializers = Array.from(registry.values());
423
+ const disposers = initializers.concat(customInitializers).flatMap((initializer) => initializer(root, options) || []);
424
+ cleanups.set(root, disposers);
425
+ return {
426
+ root,
427
+ destroy: () => destroy(root),
428
+ refresh: () => init(options)
429
+ };
430
+ }
431
+
432
+ function destroy(root = document) {
433
+ const disposers = cleanups.get(root) || [];
434
+ disposers.forEach((dispose) => dispose());
435
+ cleanups.delete(root);
436
+ }
437
+
438
+ function register(name, initializer) {
439
+ registry.set(name, initializer);
440
+ }
441
+
442
+ const MenoInteractions = {
443
+ init,
444
+ destroy,
445
+ register,
446
+ version: '0.1.0'
447
+ };
448
+
449
+ if (typeof window !== 'undefined') {
450
+ window.MenoInteractions = MenoInteractions;
451
+ if (document.readyState === 'loading') {
452
+ document.addEventListener('DOMContentLoaded', () => init());
453
+ } else {
454
+ init();
455
+ }
456
+ }
457
+
458
+ module.exports = { init, destroy, register, MenoInteractions };
@@ -0,0 +1,54 @@
1
+ [hidden],
2
+ .meno-hidden {
3
+ display: none !important;
4
+ }
5
+
6
+ [data-meno-modal],
7
+ [data-meno-drawer],
8
+ [data-meno-lightbox] {
9
+ opacity: 0;
10
+ pointer-events: none;
11
+ transition: opacity 180ms ease;
12
+ }
13
+
14
+ [data-meno-modal].is-open,
15
+ [data-meno-drawer].is-open,
16
+ [data-meno-lightbox].is-open {
17
+ opacity: 1;
18
+ pointer-events: auto;
19
+ }
20
+
21
+ [data-meno-carousel-track],
22
+ [data-el="carousel-track"] {
23
+ overflow-x: auto;
24
+ scroll-behavior: smooth;
25
+ scroll-snap-type: x mandatory;
26
+ -webkit-overflow-scrolling: touch;
27
+ }
28
+
29
+ [data-meno-carousel-item],
30
+ [data-el="carousel-item"] {
31
+ scroll-snap-align: start;
32
+ }
33
+
34
+ [data-meno-tab],
35
+ [data-action="select-tab"],
36
+ [data-meno-filter-field],
37
+ [data-meno-sort],
38
+ [data-meno-page],
39
+ [data-meno-load-more],
40
+ [data-meno-clear],
41
+ [data-meno-reset] {
42
+ cursor: pointer;
43
+ }
44
+
45
+ @media (prefers-reduced-motion: reduce) {
46
+ [data-meno-modal],
47
+ [data-meno-drawer],
48
+ [data-meno-lightbox],
49
+ [data-meno-carousel-track],
50
+ [data-el="carousel-track"] {
51
+ scroll-behavior: auto;
52
+ transition: none;
53
+ }
54
+ }