@gemboss/gem-framework 0.9.3 → 0.9.8

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/README.md CHANGED
@@ -73,6 +73,33 @@ Từ 0.8.2 mỗi module có `.d.ts` cạnh nó (`mount(root?: ParentNode | null,
73
73
 
74
74
  ## Nhật ký thay đổi
75
75
 
76
+ - **0.9.8** (06/10/2026): biến chỉ được tính là "khai trên :root" khi MỌI vế của selector đúng là gốc (`:root`, `html`,
77
+ `:host`, có thể kèm điều kiện như `:root[data-theme="dark"]`), không bao giờ là con của nó. Bản 0.9.7 chỉ xét đầu
78
+ selector, nên một luật `:root .gem-row { --gem-row-auto-sm: … }` sẽ biến biến nội bộ thành công khai. Stylesheet hiện
79
+ tại chưa có luật nào như vậy, nên kết quả không đổi (review của gemboss #3842). Thêm `gemPublicCssVariablesFrom(css)`.
80
+ - **0.9.7** (06/10/2026): `validateGemFrameworkCss` nhận mọi biến CÔNG KHAI của chính framework, đọc từ stylesheet của nó ở
81
+ cả hai profile: biến khai trên `:root` (`--gem-card-radius`, `--gem-media-radius`, `--gem-ease-soft`, `--gem-grid-row-gap`…)
82
+ và điểm mở chỉ được đọc kèm dự phòng (`--gem-card-background`, `--gem-marquee-duration`…). Trước đó danh sách ghép tay
83
+ bỏ sót 25 biến như vậy, nên section đọc đúng biến framework vẫn nhận `unknown_gem_css_variable` (GemFactory gap #29).
84
+ Biến nội bộ mà framework chỉ khai trên class của nó (`--gem-row-auto-*`, `--gem-stars-*`, `--gem-section-bg`) vẫn bị
85
+ báo, và tên gõ sai cũng vậy.
86
+ - **0.9.6** (06/10/2026): `media-gallery` có chế độ ảnh chính + thumbnail (gallery có `[data-gem-media-main]`): bấm
87
+ thumbnail thì ảnh chính nhận `data-gem-media-src` / `-alt` của nó, bỏ `srcset`, `aria-current` chuyển sang thumbnail
88
+ đó, và phát `gem:media:select` `{ index }` như `behavior.js` của section. Phần tử chỉ khớp theo class mà bọc một hook
89
+ `[data-gem-media-gallery]` không còn bị gắn thành gallery thứ hai (trước đó nó đánh số lại thumbnail bằng cách đếm
90
+ mọi `<img>`, kể cả ảnh chính). Gallery không có ảnh chính chạy như cũ.
91
+ - **0.9.5** (06/10/2026): `sticky-atc` làm đủ việc của `behavior.js` section: giá trên thanh đổi theo biến thể khách
92
+ chọn (`data-gem-variant-price` trên từng `<option>`), và form KHÔNG có `action` (thanh headless) thì chặn submit, phát
93
+ `gem:cart:add` với biến thể đang chọn (không phải biến thể đầu ghi trên nút). Form có `action` (Liquid `/cart/add`)
94
+ vẫn để trình duyệt gửi như cũ. Trước đó plugin chỉ báo `gem:sticky-atc:submit`, nên trang nạp plugin mà không có
95
+ bản của section thì bấm thêm vào giỏ là tải lại trang.
96
+ - **0.9.4** (06/10/2026): nút tạm dừng của `hero-slideshow` đổi chữ theo trạng thái như `behavior.js` của section từng làm:
97
+ khi dừng nút ghi `data-gem-hero-play-label` (mặc định `Play`), khi chạy lại ghi đúng chữ ban đầu của nút (nên nhãn đã
98
+ dịch không bị đè). Nút có phần tử con (icon) giữ nguyên markup, chỉ đổi `aria-pressed`. Đây là chỗ lệch cuối cùng
99
+ giữa plugin và section, để section giao hành vi cho plugin (một chủ, Chris 06/10/2026).
100
+ - **0.9.3** (06/10/2026): CSS Dawn parity chỉ đặt `gap !important` cho lưới thẻ quick-view
101
+ (`.gem-product-card-grid--quick-view-grid`), không cho mọi `.gem-product-card-grid__grid`. Khớp bản chép của gemsite;
102
+ store không đổi gì.
76
103
  - **0.9.2** (06/10/2026): `reveal-on-scroll` bỏ qua `data-gem-reveal="none"` và `"default"` (hai giá trị "không áp gì" của
77
104
  knob reveal): section hiện ngay, không ẩn chờ cuộn. Trước đó section đặt `none` vẫn bị ẩn như mọi section reveal.
78
105
  - **0.9.1** (06/10/2026): plugin `reveal-on-scroll` không còn giấu section khách đang thấy. Section đã nằm trong màn hình
package/dist/SOURCE.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "package": "@gemboss/gem-framework",
3
- "version": "0.9.3",
4
- "gemtheme_sha": "63ccd6e35a160cee1935c6780b4db5deaef86571",
3
+ "version": "0.9.8",
4
+ "gemtheme_sha": "fea3af50909be36e9fc8968ae407409e324a1c6b",
5
5
  "gemtheme_dirty": false,
6
6
  "framework_version": "0.1.0"
7
7
  }
@@ -382,7 +382,32 @@
382
382
  const galleries = document.querySelectorAll('[data-gem-media-gallery], .gem-media-gallery, .gem-product-media-gallery');
383
383
  let bound = 0;
384
384
  galleries.forEach((gallery) => {
385
+ const hooked = !!(gallery.hasAttribute && gallery.hasAttribute('data-gem-media-gallery'));
386
+ if (!hooked && gallery.querySelector && gallery.querySelector('[data-gem-media-gallery]')) return;
385
387
  if (!mark(gallery, 'media-gallery')) return;
388
+ const main = gallery.querySelector && gallery.querySelector('[data-gem-media-main]');
389
+ const thumbs = main && gallery.querySelectorAll ? Array.prototype.slice.call(gallery.querySelectorAll('[data-gem-media-item]')) : [];
390
+ if (main && thumbs.length) {
391
+ gallery.dataset.gemMediaState = 'ready';
392
+ const choose = (item, reason) => {
393
+ const src = item.getAttribute('data-gem-media-src');
394
+ const alt = item.getAttribute('data-gem-media-alt');
395
+ if (src) { main.setAttribute('src', src); main.removeAttribute('srcset'); if (alt != null) main.setAttribute('alt', alt); }
396
+ const index = Number(item.getAttribute('data-gem-media-index') || thumbs.indexOf(item));
397
+ thumbs.forEach((thumb) => { const on = thumb === item; thumb.setAttribute('aria-current', String(on)); if (thumb.dataset) thumb.dataset.gemMediaActive = on ? 'true' : 'false'; });
398
+ gallery.dataset.gemMediaActiveIndex = String(index);
399
+ if (gallery.dispatchEvent && typeof window.CustomEvent === 'function') gallery.dispatchEvent(new window.CustomEvent('gem:media:select', { bubbles: true, detail: { index } }));
400
+ api.emit('gem:media:change', { source: 'gem-runtime', index, galleryId: gallery.id || undefined, reason });
401
+ };
402
+ if (canListen(gallery)) gallery.addEventListener('click', (event) => {
403
+ const item = event.target && event.target.closest ? event.target.closest('[data-gem-media-item]') : null;
404
+ if (!item || !gallery.contains(item)) return;
405
+ if (event.preventDefault) event.preventDefault();
406
+ choose(item, 'click');
407
+ });
408
+ bound += 1;
409
+ return;
410
+ }
386
411
  const items = gallery.querySelectorAll('[data-gem-media-item], img, video, model-viewer');
387
412
  const thumbControls = gallery.querySelectorAll('[data-gem-media-thumb]');
388
413
  const controls = thumbControls.length ? thumbControls : items;
@@ -488,7 +513,11 @@
488
513
  else if (k === 'Home') { if (e.preventDefault) e.preventDefault(); focusDot(0); }
489
514
  else if (k === 'End') { if (e.preventDefault) e.preventDefault(); focusDot(dots.length - 1); }
490
515
  }); });
491
- if (canListen(pause)) pause.addEventListener('click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); start(); });
516
+ // The visible label follows the state like the section's own behavior did: the button's own text while playing
517
+ // (kept, so a translated label survives), data-gem-hero-play-label (or 'Play') while paused. Only a text-only button.
518
+ const pauseText = pause && pause.childElementCount === 0 ? pause.textContent : null;
519
+ const playText = (pause && pause.getAttribute && pause.getAttribute('data-gem-hero-play-label')) || 'Play';
520
+ if (canListen(pause)) pause.addEventListener('click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); if (pauseText !== null) pause.textContent = paused ? playText : pauseText; start(); });
492
521
  show(active); start(); bound += 1;
493
522
  });
494
523
  api.emit('gem:hero-slideshow:ready', { source: 'gem-runtime', count: bound });
@@ -668,8 +697,22 @@
668
697
  };
669
698
  update();
670
699
  if (canListen(target)) target.addEventListener('scroll', update, { passive: true });
700
+ const priceEl = bar.querySelector && bar.querySelector('[data-gem-sticky-atc-price]');
701
+ const select = bar.querySelector && bar.querySelector('select');
702
+ const syncPrice = () => { if (!priceEl || !select) return; const opt = select.options && select.selectedIndex > -1 ? select.options[select.selectedIndex] : null; const price = opt && opt.getAttribute && opt.getAttribute('data-gem-variant-price'); if (price) priceEl.textContent = price; };
703
+ if (canListen(select)) select.addEventListener('change', syncPrice);
704
+ syncPrice();
671
705
  const form = bar.querySelector && bar.querySelector('form');
672
- if (canListen(form)) form.addEventListener('submit', () => api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined }));
706
+ if (canListen(form)) form.addEventListener('submit', (event) => {
707
+ if (!(form.hasAttribute && form.hasAttribute('action'))) {
708
+ if (event && event.preventDefault) event.preventDefault();
709
+ const choice = form.querySelector && form.querySelector('select');
710
+ const trigger = bar.querySelector && bar.querySelector('[data-gem-variant-id]');
711
+ const variantId = (choice && choice.value) || (trigger && trigger.getAttribute('data-gem-variant-id')) || undefined;
712
+ if (bar.dispatchEvent && typeof window.CustomEvent === 'function') bar.dispatchEvent(new window.CustomEvent('gem:cart:add', { bubbles: true, detail: { variantId, quantity: 1 } }));
713
+ }
714
+ api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined });
715
+ });
673
716
  bound += 1;
674
717
  });
675
718
  api.emit('gem:sticky-atc:ready', { source: 'gem-runtime', count: bound });
package/dist/index.js CHANGED
@@ -4555,7 +4555,38 @@ var mediaGalleryPluginRegistrationLines = [
4555
4555
  " const galleries = document.querySelectorAll('[data-gem-media-gallery], .gem-media-gallery, .gem-product-media-gallery');",
4556
4556
  " let bound = 0;",
4557
4557
  " galleries.forEach((gallery) => {",
4558
+ // A wrapper matched only by its class (the thumbnail-stack <section class="gem-product-media-gallery">) around a
4559
+ // [data-gem-media-gallery] hook is not a second gallery: binding both ran every click twice and the outer one
4560
+ // renumbered the thumbnails' data-gem-media-index by counting every <img>, the main image included.
4561
+ " const hooked = !!(gallery.hasAttribute && gallery.hasAttribute('data-gem-media-gallery'));",
4562
+ " if (!hooked && gallery.querySelector && gallery.querySelector('[data-gem-media-gallery]')) return;",
4558
4563
  " if (!mark(gallery, 'media-gallery')) return;",
4564
+ // Main image + thumbnails (product-media-gallery.thumbnail-stack.v1): a thumbnail click puts its
4565
+ // data-gem-media-src / -alt on [data-gem-media-main], as the section's behavior.js did. Without this the plugin
4566
+ // only moved aria-current and the big image never changed. The markup's data-gem-media-index is kept.
4567
+ " const main = gallery.querySelector && gallery.querySelector('[data-gem-media-main]');",
4568
+ " const thumbs = main && gallery.querySelectorAll ? Array.prototype.slice.call(gallery.querySelectorAll('[data-gem-media-item]')) : [];",
4569
+ " if (main && thumbs.length) {",
4570
+ " gallery.dataset.gemMediaState = 'ready';",
4571
+ " const choose = (item, reason) => {",
4572
+ " const src = item.getAttribute('data-gem-media-src');",
4573
+ " const alt = item.getAttribute('data-gem-media-alt');",
4574
+ " if (src) { main.setAttribute('src', src); main.removeAttribute('srcset'); if (alt != null) main.setAttribute('alt', alt); }",
4575
+ " const index = Number(item.getAttribute('data-gem-media-index') || thumbs.indexOf(item));",
4576
+ " thumbs.forEach((thumb) => { const on = thumb === item; thumb.setAttribute('aria-current', String(on)); if (thumb.dataset) thumb.dataset.gemMediaActive = on ? 'true' : 'false'; });",
4577
+ " gallery.dataset.gemMediaActiveIndex = String(index);",
4578
+ " if (gallery.dispatchEvent && typeof window.CustomEvent === 'function') gallery.dispatchEvent(new window.CustomEvent('gem:media:select', { bubbles: true, detail: { index } }));",
4579
+ " api.emit('gem:media:change', { source: 'gem-runtime', index, galleryId: gallery.id || undefined, reason });",
4580
+ " };",
4581
+ " if (canListen(gallery)) gallery.addEventListener('click', (event) => {",
4582
+ " const item = event.target && event.target.closest ? event.target.closest('[data-gem-media-item]') : null;",
4583
+ " if (!item || !gallery.contains(item)) return;",
4584
+ " if (event.preventDefault) event.preventDefault();",
4585
+ " choose(item, 'click');",
4586
+ " });",
4587
+ " bound += 1;",
4588
+ " return;",
4589
+ " }",
4559
4590
  " const items = gallery.querySelectorAll('[data-gem-media-item], img, video, model-viewer');",
4560
4591
  " const thumbControls = gallery.querySelectorAll('[data-gem-media-thumb]');",
4561
4592
  " const controls = thumbControls.length ? thumbControls : items;",
@@ -4667,7 +4698,11 @@ var heroSlideshowPluginRegistrationLines = [
4667
4698
  " else if (k === 'Home') { if (e.preventDefault) e.preventDefault(); focusDot(0); }",
4668
4699
  " else if (k === 'End') { if (e.preventDefault) e.preventDefault(); focusDot(dots.length - 1); }",
4669
4700
  " }); });",
4670
- " if (canListen(pause)) pause.addEventListener('click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); start(); });",
4701
+ " // The visible label follows the state like the section's own behavior did: the button's own text while playing",
4702
+ " // (kept, so a translated label survives), data-gem-hero-play-label (or 'Play') while paused. Only a text-only button.",
4703
+ " const pauseText = pause && pause.childElementCount === 0 ? pause.textContent : null;",
4704
+ " const playText = (pause && pause.getAttribute && pause.getAttribute('data-gem-hero-play-label')) || 'Play';",
4705
+ " if (canListen(pause)) pause.addEventListener('click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); if (pauseText !== null) pause.textContent = paused ? playText : pauseText; start(); });",
4671
4706
  " show(active); start(); bound += 1;",
4672
4707
  " });",
4673
4708
  " api.emit('gem:hero-slideshow:ready', { source: 'gem-runtime', count: bound });",
@@ -4882,8 +4917,27 @@ var stickyAtcPluginRegistrationLines = [
4882
4917
  " };",
4883
4918
  " update();",
4884
4919
  " if (canListen(target)) target.addEventListener('scroll', update, { passive: true });",
4920
+ // The price follows the variant <select> (each <option> carries its own data-gem-variant-price), as the section's
4921
+ // behavior.js did: otherwise the bar shows the first variant's price whatever the shopper picked.
4922
+ " const priceEl = bar.querySelector && bar.querySelector('[data-gem-sticky-atc-price]');",
4923
+ " const select = bar.querySelector && bar.querySelector('select');",
4924
+ " const syncPrice = () => { if (!priceEl || !select) return; const opt = select.options && select.selectedIndex > -1 ? select.options[select.selectedIndex] : null; const price = opt && opt.getAttribute && opt.getAttribute('data-gem-variant-price'); if (price) priceEl.textContent = price; };",
4925
+ " if (canListen(select)) select.addEventListener('change', syncPrice);",
4926
+ " syncPrice();",
4927
+ // A form WITHOUT an action (the headless bar) has nowhere to post: the plugin adds to cart by dispatching
4928
+ // gem:cart:add with the SELECTED variant (the button's data-gem-variant-id is only the first one), which the
4929
+ // storefront's cart bridge consumes. A form WITH an action (a Liquid /cart/add form) is left to the browser.
4885
4930
  " const form = bar.querySelector && bar.querySelector('form');",
4886
- " if (canListen(form)) form.addEventListener('submit', () => api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined }));",
4931
+ " if (canListen(form)) form.addEventListener('submit', (event) => {",
4932
+ " if (!(form.hasAttribute && form.hasAttribute('action'))) {",
4933
+ " if (event && event.preventDefault) event.preventDefault();",
4934
+ " const choice = form.querySelector && form.querySelector('select');",
4935
+ " const trigger = bar.querySelector && bar.querySelector('[data-gem-variant-id]');",
4936
+ " const variantId = (choice && choice.value) || (trigger && trigger.getAttribute('data-gem-variant-id')) || undefined;",
4937
+ " if (bar.dispatchEvent && typeof window.CustomEvent === 'function') bar.dispatchEvent(new window.CustomEvent('gem:cart:add', { bubbles: true, detail: { variantId, quantity: 1 } }));",
4938
+ " }",
4939
+ " api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined });",
4940
+ " });",
4887
4941
  " bound += 1;",
4888
4942
  " });",
4889
4943
  " api.emit('gem:sticky-atc:ready', { source: 'gem-runtime', count: bound });",
@@ -5662,8 +5716,38 @@ function isAllowedUntokenizedValue(property, value) {
5662
5716
  function hasRawVisualValue(value) {
5663
5717
  return /#[0-9a-f]{3,8}\b|rgb\(|hsl\(|\b\d+(?:\.\d+)?(?:px|rem|em)\b/i.test(value);
5664
5718
  }
5719
+ var frameworkPublicVariables;
5720
+ function gemFrameworkPublicCssVariables() {
5721
+ if (!frameworkPublicVariables) {
5722
+ const css = [
5723
+ createGemThemeCss(defaultGemTokens, ":root", { profile: "dawn-parity" }),
5724
+ createGemThemeCss(defaultGemTokens, ":root", { profile: "morph" }),
5725
+ createThemeProfileCss("dawn-parity"),
5726
+ createMorphProfileCss(),
5727
+ createPrimitiveCss(),
5728
+ String(createSectionCss()),
5729
+ String(createShellCss()),
5730
+ String(createGemSectionRuntimeContractCss())
5731
+ ].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
5732
+ frameworkPublicVariables = gemPublicCssVariablesFrom(css);
5733
+ }
5734
+ return frameworkPublicVariables;
5735
+ }
5736
+ var ROOT_SELECTOR = /^\s*(?::root|html|:host)(?:\[[^\]]*\]|:[a-z-]+(?:\([^()]*\))?)*\s*$/;
5737
+ function gemPublicCssVariablesFrom(stylesheet) {
5738
+ const css = stylesheet.replace(/\/\*[\s\S]*?\*\//g, " ");
5739
+ const declaredOnRoot = /* @__PURE__ */ new Set();
5740
+ const declaredElsewhere = /* @__PURE__ */ new Set();
5741
+ for (const [, selector, body] of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
5742
+ const onRoot = selector.split(",").every((part) => ROOT_SELECTOR.test(part));
5743
+ for (const [, name] of body.matchAll(/(--gem-[a-z0-9-]+)\s*:/g)) (onRoot ? declaredOnRoot : declaredElsewhere).add(name);
5744
+ }
5745
+ const mentioned = new Set([...css.matchAll(/--gem-[a-z0-9-]+/g)].map((match) => match[0]));
5746
+ return new Set([...mentioned].filter((name) => declaredOnRoot.has(name) || !declaredElsewhere.has(name)));
5747
+ }
5665
5748
  function isKnownGemCssVariable(variableName, options) {
5666
5749
  if (canonicalCssVariableNames.has(variableName)) return true;
5750
+ if (gemFrameworkPublicCssVariables().has(variableName)) return true;
5667
5751
  const allowedPrefixes = [
5668
5752
  "--gem-seasonal-",
5669
5753
  // Global style-variant knobs (--gem-style-space/radius/text/weight/stroke): the
@@ -42,7 +42,11 @@ function install({ document, window, target, mark, canListen, listen: __gemListe
42
42
  else if (k === 'Home') { if (e.preventDefault) e.preventDefault(); focusDot(0); }
43
43
  else if (k === 'End') { if (e.preventDefault) e.preventDefault(); focusDot(dots.length - 1); }
44
44
  }); });
45
- if (canListen(pause)) __gemListen(pause, 'click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); start(); });
45
+ // The visible label follows the state like the section's own behavior did: the button's own text while playing
46
+ // (kept, so a translated label survives), data-gem-hero-play-label (or 'Play') while paused. Only a text-only button.
47
+ const pauseText = pause && pause.childElementCount === 0 ? pause.textContent : null;
48
+ const playText = (pause && pause.getAttribute && pause.getAttribute('data-gem-hero-play-label')) || 'Play';
49
+ if (canListen(pause)) __gemListen(pause, 'click', () => { paused = !paused; if (pause.setAttribute) pause.setAttribute('aria-pressed', String(paused)); if (pauseText !== null) pause.textContent = paused ? playText : pauseText; start(); });
46
50
  show(active); start(); bound += 1;
47
51
  });
48
52
  api.emit('gem:hero-slideshow:ready', { source: 'gem-runtime', count: bound });
@@ -10,7 +10,32 @@ function install({ document, window, target, mark, canListen, listen: __gemListe
10
10
  const galleries = document.querySelectorAll('[data-gem-media-gallery], .gem-media-gallery, .gem-product-media-gallery');
11
11
  let bound = 0;
12
12
  galleries.forEach((gallery) => {
13
+ const hooked = !!(gallery.hasAttribute && gallery.hasAttribute('data-gem-media-gallery'));
14
+ if (!hooked && gallery.querySelector && gallery.querySelector('[data-gem-media-gallery]')) return;
13
15
  if (!mark(gallery, 'media-gallery')) return;
16
+ const main = gallery.querySelector && gallery.querySelector('[data-gem-media-main]');
17
+ const thumbs = main && gallery.querySelectorAll ? Array.prototype.slice.call(gallery.querySelectorAll('[data-gem-media-item]')) : [];
18
+ if (main && thumbs.length) {
19
+ gallery.dataset.gemMediaState = 'ready';
20
+ const choose = (item, reason) => {
21
+ const src = item.getAttribute('data-gem-media-src');
22
+ const alt = item.getAttribute('data-gem-media-alt');
23
+ if (src) { main.setAttribute('src', src); main.removeAttribute('srcset'); if (alt != null) main.setAttribute('alt', alt); }
24
+ const index = Number(item.getAttribute('data-gem-media-index') || thumbs.indexOf(item));
25
+ thumbs.forEach((thumb) => { const on = thumb === item; thumb.setAttribute('aria-current', String(on)); if (thumb.dataset) thumb.dataset.gemMediaActive = on ? 'true' : 'false'; });
26
+ gallery.dataset.gemMediaActiveIndex = String(index);
27
+ if (gallery.dispatchEvent && typeof window.CustomEvent === 'function') gallery.dispatchEvent(new window.CustomEvent('gem:media:select', { bubbles: true, detail: { index } }));
28
+ api.emit('gem:media:change', { source: 'gem-runtime', index, galleryId: gallery.id || undefined, reason });
29
+ };
30
+ if (canListen(gallery)) __gemListen(gallery, 'click', (event) => {
31
+ const item = event.target && event.target.closest ? event.target.closest('[data-gem-media-item]') : null;
32
+ if (!item || !gallery.contains(item)) return;
33
+ if (event.preventDefault) event.preventDefault();
34
+ choose(item, 'click');
35
+ });
36
+ bound += 1;
37
+ return;
38
+ }
14
39
  const items = gallery.querySelectorAll('[data-gem-media-item], img, video, model-viewer');
15
40
  const thumbControls = gallery.querySelectorAll('[data-gem-media-thumb]');
16
41
  const controls = thumbControls.length ? thumbControls : items;
@@ -27,8 +27,22 @@ function install({ document, window, target, mark, canListen, listen: __gemListe
27
27
  };
28
28
  update();
29
29
  if (canListen(target)) __gemListen(target, 'scroll', update, { passive: true });
30
+ const priceEl = bar.querySelector && bar.querySelector('[data-gem-sticky-atc-price]');
31
+ const select = bar.querySelector && bar.querySelector('select');
32
+ const syncPrice = () => { if (!priceEl || !select) return; const opt = select.options && select.selectedIndex > -1 ? select.options[select.selectedIndex] : null; const price = opt && opt.getAttribute && opt.getAttribute('data-gem-variant-price'); if (price) priceEl.textContent = price; };
33
+ if (canListen(select)) __gemListen(select, 'change', syncPrice);
34
+ syncPrice();
30
35
  const form = bar.querySelector && bar.querySelector('form');
31
- if (canListen(form)) __gemListen(form, 'submit', () => api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined }));
36
+ if (canListen(form)) __gemListen(form, 'submit', (event) => {
37
+ if (!(form.hasAttribute && form.hasAttribute('action'))) {
38
+ if (event && event.preventDefault) event.preventDefault();
39
+ const choice = form.querySelector && form.querySelector('select');
40
+ const trigger = bar.querySelector && bar.querySelector('[data-gem-variant-id]');
41
+ const variantId = (choice && choice.value) || (trigger && trigger.getAttribute('data-gem-variant-id')) || undefined;
42
+ if (bar.dispatchEvent && typeof window.CustomEvent === 'function') bar.dispatchEvent(new window.CustomEvent('gem:cart:add', { bubbles: true, detail: { variantId, quantity: 1 } }));
43
+ }
44
+ api.emit('gem:sticky-atc:submit', { source: 'gem-runtime', id: bar.id || undefined });
45
+ });
32
46
  bound += 1;
33
47
  });
34
48
  api.emit('gem:sticky-atc:ready', { source: 'gem-runtime', count: bound });