@gemboss/gem-framework 0.18.13 → 0.18.15
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 +28 -0
- package/dist/SOURCE.json +3 -3
- package/dist/editions.js +2 -0
- package/dist/gem-framework.css +7 -6
- package/dist/gem-runtime.js +655 -1
- package/dist/index.js +692 -11
- package/dist/plugins/reviews.d.ts +9 -0
- package/dist/plugins/reviews.js +670 -0
- package/dist/popup-shell.css +4 -4
- package/dist/primitives.css +1 -1
- package/dist/section-gate.js +681 -8
- package/dist/sections.css +1 -1
- package/dist/tokens.css +1 -0
- package/package.json +1 -1
package/dist/gem-runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
(() => {
|
|
2
|
-
const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]}]};
|
|
2
|
+
const config = {"planId":"gem-framework-package","base":"gem-native","dawnAdapter":false,"plugins":[{"key":"cart-drawer","loadStrategy":"interaction","dependsOn":[]},{"key":"mobile-menu","loadStrategy":"interaction","dependsOn":[]},{"key":"quantity-input","loadStrategy":"interaction","dependsOn":[]},{"key":"variant-selects","loadStrategy":"interaction","dependsOn":[]},{"key":"predictive-search","loadStrategy":"critical","dependsOn":[]},{"key":"localization","loadStrategy":"interaction","dependsOn":[]},{"key":"disclosure","loadStrategy":"interaction","dependsOn":[]},{"key":"tabs","loadStrategy":"interaction","dependsOn":[]},{"key":"media-gallery","loadStrategy":"viewport","dependsOn":[]},{"key":"carousel","loadStrategy":"viewport","dependsOn":[]},{"key":"hero-slideshow","loadStrategy":"idle","dependsOn":[]},{"key":"quick-view","loadStrategy":"interaction","dependsOn":[]},{"key":"promo-popup","loadStrategy":"idle","dependsOn":[]},{"key":"before_after","loadStrategy":"viewport","dependsOn":[]},{"key":"runtime-slot-loader","loadStrategy":"idle","dependsOn":[]},{"key":"sticky-atc","loadStrategy":"viewport","dependsOn":[]},{"key":"recently-viewed","loadStrategy":"idle","dependsOn":[]},{"key":"countdown","loadStrategy":"viewport","dependsOn":[]},{"key":"reveal-on-scroll","loadStrategy":"viewport","dependsOn":[]},{"key":"img-in","loadStrategy":"critical","dependsOn":[]},{"key":"seasonal-effects","loadStrategy":"idle","dependsOn":[]},{"key":"context-adapter","loadStrategy":"idle","dependsOn":[]},{"key":"offer-engine","loadStrategy":"interaction","dependsOn":[]},{"key":"reviews","loadStrategy":"idle","dependsOn":[]}]};
|
|
3
3
|
const target = window;
|
|
4
4
|
const existing = target.GemTheme || {};
|
|
5
5
|
const listeners = existing.listeners || {};
|
|
@@ -1054,6 +1054,660 @@
|
|
|
1054
1054
|
})();
|
|
1055
1055
|
api.emit('gem:offer:ready', { source: 'gem-runtime' });
|
|
1056
1056
|
});
|
|
1057
|
+
registerPlugin('reviews', ({ api }) => {
|
|
1058
|
+
let boundReviews = 0;
|
|
1059
|
+
document.querySelectorAll('[data-rv-root]').forEach((root) => {
|
|
1060
|
+
if (!mark(root, 'reviews')) return;
|
|
1061
|
+
boundReviews += 1;
|
|
1062
|
+
const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
|
|
1063
|
+
const all = (sel) => Array.from(root.querySelectorAll(sel));
|
|
1064
|
+
const isEl = (t) => t instanceof window.Element;
|
|
1065
|
+
const list = root.querySelector('[data-rv-list]');
|
|
1066
|
+
const items = list ? Array.from(list.querySelectorAll('[data-rv-item]')) : [];
|
|
1067
|
+
let pageSize = Math.max(1, parseInt((list && list.getAttribute('data-rv-page-size')) || '5', 10) || 5);
|
|
1068
|
+
const moreSize = () => Math.max(1, parseInt((list && list.getAttribute('data-rv-more-size')) || '', 10) || pageSize);
|
|
1069
|
+
const collapsedN = Math.max(0, parseInt((list && list.getAttribute('data-rv-collapsed')) || '0', 10) || 0);
|
|
1070
|
+
const st = { sort: '', star: 0, media: false, topic: '', q: '', country: '', page: 1, shown: pageSize, collapsed: collapsedN > 0, staged: 0 };
|
|
1071
|
+
const section = root.getAttribute('data-gem-section-library-id') || undefined;
|
|
1072
|
+
const sectionId = root.getAttribute('data-gem-section-id') || root.id || undefined;
|
|
1073
|
+
const pagesNavs = all('[data-rv-pages]');
|
|
1074
|
+
const pagesMq = pagesNavs.length && window.matchMedia ? window.matchMedia(pagesNavs[0].getAttribute('data-rv-pages-media') || 'all') : null;
|
|
1075
|
+
const usesPagesNow = () => pagesNavs.length > 0 && (!pagesMq || pagesMq.matches);
|
|
1076
|
+
const prevPages = all('[data-rv-prev]');
|
|
1077
|
+
const nextPages = all('[data-rv-next]');
|
|
1078
|
+
const none = root.querySelector('[data-rv-none]');
|
|
1079
|
+
const more = root.querySelector('[data-rv-more]');
|
|
1080
|
+
const expand = root.querySelector('[data-rv-expand]');
|
|
1081
|
+
const num = (el, a) => Number(el.getAttribute(a) || 0);
|
|
1082
|
+
const isOn = (el, a) => el.getAttribute(a) === '1';
|
|
1083
|
+
const byOrder = (a, b) => num(a, 'data-rv-order') - num(b, 'data-rv-order');
|
|
1084
|
+
const newest = (a, b) => (a.hasAttribute('data-rv-recent') ? num(a, 'data-rv-recent') - num(b, 'data-rv-recent') : byOrder(a, b));
|
|
1085
|
+
const leastHelpful = (a, b) => num(a, 'data-rv-helpful') - num(b, 'data-rv-helpful') || byOrder(a, b);
|
|
1086
|
+
const SORTS = {
|
|
1087
|
+
relevant: byOrder,
|
|
1088
|
+
featured: byOrder,
|
|
1089
|
+
recent: newest,
|
|
1090
|
+
oldest: (a, b) => (a.hasAttribute('data-rv-recent') ? num(b, 'data-rv-recent') - num(a, 'data-rv-recent') : byOrder(b, a)),
|
|
1091
|
+
verified: (a, b) => isOn(b, 'data-rv-verified') - isOn(a, 'data-rv-verified') || byOrder(a, b),
|
|
1092
|
+
highest: (a, b) => num(b, 'data-rv-rating') - num(a, 'data-rv-rating') || byOrder(a, b),
|
|
1093
|
+
lowest: (a, b) => num(a, 'data-rv-rating') - num(b, 'data-rv-rating') || byOrder(a, b),
|
|
1094
|
+
helpful: (a, b) => num(b, 'data-rv-helpful') - num(a, 'data-rv-helpful') || byOrder(a, b),
|
|
1095
|
+
leasthelpful: leastHelpful,
|
|
1096
|
+
unhelpful: leastHelpful,
|
|
1097
|
+
media: (a, b) => isOn(b, 'data-rv-media') - isOn(a, 'data-rv-media') || byOrder(a, b),
|
|
1098
|
+
'media-only': newest,
|
|
1099
|
+
videos: (a, b) => isOn(b, 'data-rv-video') - isOn(a, 'data-rv-video') || newest(a, b),
|
|
1100
|
+
};
|
|
1101
|
+
const matches = (it) => {
|
|
1102
|
+
if (st.star && num(it, 'data-rv-rating') !== st.star) return false;
|
|
1103
|
+
if ((st.media || st.sort === 'media-only') && !isOn(it, 'data-rv-media')) return false;
|
|
1104
|
+
if (st.country && it.getAttribute('data-rv-country-code') !== st.country) return false;
|
|
1105
|
+
if (st.topic) {
|
|
1106
|
+
const tags = (it.getAttribute('data-rv-topics') || '').toLowerCase().split('|').filter(Boolean);
|
|
1107
|
+
if (tags.length ? !tags.includes(st.topic) : !(it.textContent || '').toLowerCase().includes(st.topic)) return false;
|
|
1108
|
+
}
|
|
1109
|
+
if (st.q && !(it.textContent || '').toLowerCase().includes(st.q)) return false;
|
|
1110
|
+
return true;
|
|
1111
|
+
};
|
|
1112
|
+
const filterKeys = ['star', 'media', 'topic', 'q', 'country'];
|
|
1113
|
+
const only = (k) => filterKeys.every((x) => x === k || !st[x]);
|
|
1114
|
+
// Counts as written ("5284") unless the list names a locale: data-rv-locale="en-US" → 5,284; "auto" → the page's lang.
|
|
1115
|
+
const locale = list ? list.getAttribute('data-rv-locale') : null;
|
|
1116
|
+
const fmtCount = (n) => {
|
|
1117
|
+
const v = Number(n) || 0;
|
|
1118
|
+
if (!locale) return String(v);
|
|
1119
|
+
try { return v.toLocaleString(locale === 'auto' ? (document.documentElement && document.documentElement.lang) || undefined : locale); } catch (err) { return String(v); }
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
// Read more: the button opens the body of its scope; short text hides the button. Measured after every draw, on
|
|
1123
|
+
// resize and once the theme's fonts are in, because a review on a later page (or under a filter) measures 0.
|
|
1124
|
+
const moreScope = (b) => b.closest('[data-rv-more-scope]') || b.closest('[data-rv-item], [data-rv-scope]') || root;
|
|
1125
|
+
const syncReadMore = () => all('[data-rv-readmore]').forEach((b) => {
|
|
1126
|
+
const it = b.closest('[data-rv-item]');
|
|
1127
|
+
if ((it && it.hidden) || b.hasAttribute('data-rv-keep')) return;
|
|
1128
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
1129
|
+
if (!body || !body.getClientRects().length) return;
|
|
1130
|
+
b.hidden = !body.hasAttribute('data-rv-open') && body.scrollHeight <= body.clientHeight + 2;
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Search (and, with data-rv-mark-topic, topic) words marked in [data-rv-hl]; the original nodes come back on every draw.
|
|
1134
|
+
const hlOrig = new Map();
|
|
1135
|
+
const markClass = (list && list.getAttribute('data-rv-mark-class')) || '';
|
|
1136
|
+
const highlight = () => {
|
|
1137
|
+
const terms = [st.q, list && list.hasAttribute('data-rv-mark-topic') ? st.topic : ''].filter((x) => x && x.length > 1);
|
|
1138
|
+
const re = terms.length ? new RegExp('(' + terms.map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi') : null;
|
|
1139
|
+
all('[data-rv-hl]').forEach((el) => {
|
|
1140
|
+
if (!hlOrig.has(el)) { if (!re) return; hlOrig.set(el, Array.from(el.childNodes).map((n) => n.cloneNode(true))); }
|
|
1141
|
+
else el.replaceChildren(...hlOrig.get(el).map((n) => n.cloneNode(true)));
|
|
1142
|
+
if (!re) return;
|
|
1143
|
+
const walker = document.createTreeWalker(el, 4);
|
|
1144
|
+
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
1145
|
+
nodes.forEach((n) => {
|
|
1146
|
+
const parts = (n.nodeValue || '').split(re);
|
|
1147
|
+
if (parts.length < 2) return;
|
|
1148
|
+
const frag = document.createDocumentFragment();
|
|
1149
|
+
parts.forEach((p, i) => { if (i % 2 === 0) { if (p) frag.append(p); return; } const m = document.createElement('mark'); m.className = markClass; m.textContent = p; frag.append(m); });
|
|
1150
|
+
n.replaceWith(frag);
|
|
1151
|
+
});
|
|
1152
|
+
});
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const drawPages = (nav, pages) => {
|
|
1156
|
+
const cur = st.page;
|
|
1157
|
+
const win = Number(nav.getAttribute('data-rv-page-window') || 0);
|
|
1158
|
+
let seq;
|
|
1159
|
+
if (win > 0) {
|
|
1160
|
+
// A window of N numbers around the current page, no … (Yotpo: 1 2 3 4 5).
|
|
1161
|
+
const start = Math.max(1, Math.min(cur - Math.floor(win / 2), pages - win + 1));
|
|
1162
|
+
seq = Array.from({ length: Math.min(win, pages) }, (_, k) => start + k);
|
|
1163
|
+
} else {
|
|
1164
|
+
// 1 … n around the current page (Judge.me: 1 2 3 4 … 18).
|
|
1165
|
+
const want = new Set([1, pages, cur - 1, cur, cur + 1].filter((p) => p >= 1 && p <= pages));
|
|
1166
|
+
if (cur <= 3) [2, 3, 4].forEach((p) => { if (p <= pages) want.add(p); });
|
|
1167
|
+
seq = Array.from(want).sort((a, b) => a - b);
|
|
1168
|
+
}
|
|
1169
|
+
const tpl = nav.querySelector('[data-rv-page-tpl]');
|
|
1170
|
+
const anchor = nextPages.find((b) => nav.contains(b)) || null;
|
|
1171
|
+
nav.querySelectorAll('[data-rv-page]:not([data-rv-page-tpl]), [data-rv-gap]').forEach((e) => e.remove());
|
|
1172
|
+
let last = 0;
|
|
1173
|
+
seq.forEach((p) => {
|
|
1174
|
+
if (!win && p - last > 1) {
|
|
1175
|
+
const g = document.createElement('span');
|
|
1176
|
+
g.setAttribute('data-rv-gap', ''); g.setAttribute('aria-hidden', 'true');
|
|
1177
|
+
g.className = (tpl && tpl.getAttribute('data-rv-gap-class')) || ''; g.textContent = '…';
|
|
1178
|
+
nav.insertBefore(g, anchor);
|
|
1179
|
+
}
|
|
1180
|
+
const b = document.createElement('button');
|
|
1181
|
+
b.type = 'button'; b.className = tpl ? tpl.className : ''; b.textContent = String(p);
|
|
1182
|
+
b.setAttribute('data-rv-page', String(p)); b.setAttribute('aria-label', 'Page ' + p);
|
|
1183
|
+
if (p === cur) b.setAttribute('aria-current', 'page');
|
|
1184
|
+
nav.insertBefore(b, anchor);
|
|
1185
|
+
last = p;
|
|
1186
|
+
});
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
// Active-filter chips: one per filter in force, a click removes that filter.
|
|
1190
|
+
const chipsBox = root.querySelector('[data-rv-chips]');
|
|
1191
|
+
const chipTpl = root.querySelector('[data-rv-chip-tpl]');
|
|
1192
|
+
const starText = (v) => { const e = root.querySelector('[data-rv-star="' + v + '"][data-rv-star-text]'); return e ? e.getAttribute('data-rv-star-text') || '' : ''; };
|
|
1193
|
+
const countryText = (c) => { const e = root.querySelector('[data-rv-country="' + c + '"][data-rv-country-text]'); return e ? e.getAttribute('data-rv-country-text') || c : c; };
|
|
1194
|
+
const drawChips = () => {
|
|
1195
|
+
if (!chipsBox || !chipTpl) return;
|
|
1196
|
+
chipsBox.replaceChildren();
|
|
1197
|
+
const add = (kind, label, star) => {
|
|
1198
|
+
const b = chipTpl.cloneNode(true);
|
|
1199
|
+
b.removeAttribute('data-rv-chip-tpl'); b.hidden = false;
|
|
1200
|
+
const t = b.querySelector('[data-rv-chip-text]'); if (t) t.textContent = label;
|
|
1201
|
+
const s = b.querySelector('[data-rv-chip-star]'); if (s) s.hidden = !star;
|
|
1202
|
+
const v = b.querySelector('[data-rv-chip-value]'); if (v) v.textContent = star || '';
|
|
1203
|
+
b.setAttribute('data-rv-chip', kind);
|
|
1204
|
+
b.setAttribute('aria-label', ((b.getAttribute('data-rv-remove') || 'Remove') + ' ' + label + ' ' + (star || '')).trim());
|
|
1205
|
+
chipsBox.appendChild(b);
|
|
1206
|
+
};
|
|
1207
|
+
if (st.star) add('star', chipsBox.getAttribute('data-rv-chip-rating') || '', starText(st.star) || String(st.star));
|
|
1208
|
+
if (st.media) add('media', chipsBox.getAttribute('data-rv-chip-media') || '', '');
|
|
1209
|
+
if (st.country) add('country', countryText(st.country), '');
|
|
1210
|
+
chipsBox.hidden = !chipsBox.childElementCount;
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const render = (focusFirstNew, scroll) => {
|
|
1214
|
+
if (!list) return;
|
|
1215
|
+
const sorted = st.sort && SORTS[st.sort] ? items.slice().sort(SORTS[st.sort]) : items;
|
|
1216
|
+
if (st.sort) sorted.forEach((it) => list.appendChild(it));
|
|
1217
|
+
const hit = sorted.filter(matches);
|
|
1218
|
+
const hitSet = new Set(hit);
|
|
1219
|
+
const total = hit.length;
|
|
1220
|
+
const usesPages = !st.collapsed && usesPagesNow();
|
|
1221
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
1222
|
+
if (st.page > pages) st.page = pages;
|
|
1223
|
+
const from = usesPages ? (st.page - 1) * pageSize : 0;
|
|
1224
|
+
const to = st.collapsed ? collapsedN : usesPages ? from + pageSize : st.shown;
|
|
1225
|
+
let firstNew = null;
|
|
1226
|
+
items.forEach((it) => { if (!hitSet.has(it)) it.hidden = true; });
|
|
1227
|
+
hit.forEach((it, i) => {
|
|
1228
|
+
const show = i >= from && i < to;
|
|
1229
|
+
if (show && it.hidden && !firstNew && focusFirstNew) firstNew = it;
|
|
1230
|
+
it.hidden = !show;
|
|
1231
|
+
});
|
|
1232
|
+
if (none) none.hidden = total > 0;
|
|
1233
|
+
if (more) more.hidden = st.collapsed || usesPages || to >= total;
|
|
1234
|
+
if (expand) expand.hidden = !st.collapsed || total <= collapsedN;
|
|
1235
|
+
if (list.hasAttribute('data-rv-collapsed')) root.toggleAttribute('data-rv-expanded', !st.collapsed);
|
|
1236
|
+
const filtered = filterKeys.some((k) => !!st[k]);
|
|
1237
|
+
// Found: the store's own count when nothing filters; the star row's (or topic chip's) count when only that filters
|
|
1238
|
+
// (the breakdown of the WHOLE store, as review widgets show it); otherwise what matched on the page.
|
|
1239
|
+
const starEl = st.star ? root.querySelector('[data-rv-star="' + st.star + '"][data-rv-star-count]') : null;
|
|
1240
|
+
const topicEl = st.topic ? all('[data-rv-topic][data-rv-topic-count]').find((x) => (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic) : null;
|
|
1241
|
+
const found = !filtered ? Number(list.getAttribute('data-rv-total') || total)
|
|
1242
|
+
: starEl && only('star') ? Number(starEl.getAttribute('data-rv-star-count'))
|
|
1243
|
+
: topicEl && only('topic') ? Number(topicEl.getAttribute('data-rv-topic-count'))
|
|
1244
|
+
: total;
|
|
1245
|
+
const shown = Math.max(0, Math.min(to, total) - from);
|
|
1246
|
+
root.toggleAttribute('data-rv-filtered', filtered);
|
|
1247
|
+
root.setAttribute('data-rv-f-star', String(st.star || ''));
|
|
1248
|
+
root.setAttribute('data-rv-f-topic', st.topic);
|
|
1249
|
+
root.setAttribute('data-rv-f-country', st.country);
|
|
1250
|
+
root.toggleAttribute('data-rv-f-media', st.media);
|
|
1251
|
+
all('[data-rv-star-value]').forEach((e) => (e.querySelector('.gem-stars') || e).style.setProperty('--gem-stars-value', String(st.star || 0)));
|
|
1252
|
+
all('[data-rv-star-text]:not([data-rv-star])').forEach((e) => { e.textContent = String(st.star || ''); });
|
|
1253
|
+
all('[data-rv-star-label]').forEach((e) => { e.textContent = st.star ? starText(st.star) : ''; });
|
|
1254
|
+
all('[data-rv-country-label]').forEach((e) => { e.textContent = st.country ? countryText(st.country) : ''; });
|
|
1255
|
+
all('[data-rv-filter-count]').forEach((e) => { const n = (st.star ? 1 : 0) + (st.media ? 1 : 0) + (st.topic ? 1 : 0) + (st.country ? 1 : 0); e.textContent = String(n); e.hidden = n === 0; });
|
|
1256
|
+
all('[data-rv-count-label]').forEach((e) => { e.textContent = ((found === 1 && e.getAttribute('data-rv-count-one')) || e.getAttribute('data-rv-count-label') || '').replace('{count}', fmtCount(found)); });
|
|
1257
|
+
all('[data-rv-status]').forEach((s) => {
|
|
1258
|
+
const tpl = (found === 1 && s.getAttribute('data-rv-status-one')) || s.getAttribute('data-rv-status-template') || '';
|
|
1259
|
+
s.textContent = tpl && filtered ? tpl.replace('{count}', fmtCount(found)).replace('{shown}', String(shown)).replace('{total}', String(total)) : '';
|
|
1260
|
+
});
|
|
1261
|
+
all('[data-rv-hl-topic]').forEach((m) => m.toggleAttribute('data-rv-hl-on', !!st.topic && (m.getAttribute('data-rv-hl-topic') || '').toLowerCase() === st.topic));
|
|
1262
|
+
highlight();
|
|
1263
|
+
drawChips();
|
|
1264
|
+
pagesNavs.forEach((nav) => {
|
|
1265
|
+
if (usesPages && nav.querySelector('[data-rv-page-tpl]')) drawPages(nav, pages);
|
|
1266
|
+
nav.hidden = !usesPages || pages <= 1;
|
|
1267
|
+
});
|
|
1268
|
+
all('[data-rv-page-label]').forEach((l) => { l.textContent = (l.getAttribute('data-rv-page-template') || '{page}').replace('{page}', String(st.page)).replace('{pages}', String(pages)); });
|
|
1269
|
+
prevPages.forEach((b) => { b.toggleAttribute('disabled', st.page <= 1); b.setAttribute('aria-disabled', String(st.page <= 1)); });
|
|
1270
|
+
nextPages.forEach((b) => { b.toggleAttribute('disabled', st.page >= pages); b.setAttribute('aria-disabled', String(st.page >= pages)); });
|
|
1271
|
+
syncReadMore();
|
|
1272
|
+
if (firstNew) {
|
|
1273
|
+
const f = firstNew.querySelector('h3, [tabindex], a, button') || firstNew;
|
|
1274
|
+
if (f === firstNew && !f.hasAttribute('tabindex')) f.setAttribute('tabindex', '-1');
|
|
1275
|
+
f.focus({ preventScroll: false });
|
|
1276
|
+
}
|
|
1277
|
+
if (scroll) { const t = root.querySelector('[data-rv-scroll-to]') || list; if (t.scrollIntoView) t.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
1278
|
+
const detail = { source: 'gem-runtime', section, sectionId, total, found, shown, page: st.page, pages, filtered, star: st.star, sort: st.sort };
|
|
1279
|
+
if (typeof window.CustomEvent === 'function') root.dispatchEvent(new window.CustomEvent('gem:reviews:render', { detail }));
|
|
1280
|
+
api.emit('gem:reviews:render', detail);
|
|
1281
|
+
};
|
|
1282
|
+
const reset = () => { st.page = 1; st.shown = pageSize; };
|
|
1283
|
+
|
|
1284
|
+
// Menus: one open at a time; Escape, a click outside, or choosing an item closes it.
|
|
1285
|
+
const menus = all('[data-rv-menu]');
|
|
1286
|
+
const panelOf = (b) => root.querySelector('[data-rv-menu-panel="' + b.getAttribute('data-rv-menu') + '"]');
|
|
1287
|
+
const closeMenus = (except) => menus.forEach((b) => {
|
|
1288
|
+
if (b === except) return;
|
|
1289
|
+
b.setAttribute('aria-expanded', 'false');
|
|
1290
|
+
const p = panelOf(b); if (p) p.hidden = true;
|
|
1291
|
+
});
|
|
1292
|
+
// A choice made inside a menu panel closes it, and focus goes back to the menu's button (the section behaviors left
|
|
1293
|
+
// it on a now hidden item, so it fell to <body>). A filter row in a panel with data-rv-menu-keep stays open where
|
|
1294
|
+
// that media query matches; sort and Show N always close.
|
|
1295
|
+
const closeAfterChoice = (el, always) => {
|
|
1296
|
+
const p = el.closest('[data-rv-menu-panel]');
|
|
1297
|
+
if (!p) { if (always) closeMenus(); return; }
|
|
1298
|
+
const keep = p.getAttribute('data-rv-menu-keep');
|
|
1299
|
+
if (!always && keep && window.matchMedia && window.matchMedia(keep).matches) return;
|
|
1300
|
+
const owner = menus.find((b) => panelOf(b) === p);
|
|
1301
|
+
closeMenus();
|
|
1302
|
+
if (owner && p.contains(document.activeElement)) owner.focus({ preventScroll: true });
|
|
1303
|
+
};
|
|
1304
|
+
menus.forEach((b) => on(b, 'click', (e) => {
|
|
1305
|
+
e.preventDefault();
|
|
1306
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
1307
|
+
closeMenus(b);
|
|
1308
|
+
b.setAttribute('aria-expanded', String(open));
|
|
1309
|
+
const p = panelOf(b);
|
|
1310
|
+
if (p) { p.hidden = !open; if (open) { const f = p.querySelector('[aria-checked=true], [aria-current=true], button, a, input') || p; if (f.focus) f.focus({ preventScroll: true }); } }
|
|
1311
|
+
}));
|
|
1312
|
+
if (menus.length) {
|
|
1313
|
+
on(window, 'keydown', (e) => {
|
|
1314
|
+
if (e.key !== 'Escape') return;
|
|
1315
|
+
const openMenu = menus.find((x) => x.getAttribute('aria-expanded') === 'true');
|
|
1316
|
+
if (openMenu) { closeMenus(); openMenu.focus(); }
|
|
1317
|
+
});
|
|
1318
|
+
on(window, 'click', (e) => {
|
|
1319
|
+
if (!(e.target instanceof window.Node)) return;
|
|
1320
|
+
if (menus.some((b) => b.contains(e.target)) || all('[data-rv-menu-panel]').some((p) => p.contains(e.target))) return;
|
|
1321
|
+
closeMenus();
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Every filter control reads its state from st (buttons, menu items, chips, Clear).
|
|
1326
|
+
const syncStars = () => all('[data-rv-star]').forEach((x) => {
|
|
1327
|
+
const yes = Number(x.getAttribute('data-rv-star')) === st.star;
|
|
1328
|
+
if (x.hasAttribute('aria-checked')) x.setAttribute('aria-checked', String(yes));
|
|
1329
|
+
else if (x.hasAttribute('aria-current')) x.setAttribute('aria-current', String(yes && st.star > 0));
|
|
1330
|
+
else x.setAttribute('aria-pressed', String(yes && st.star > 0));
|
|
1331
|
+
});
|
|
1332
|
+
const syncControls = () => {
|
|
1333
|
+
syncStars();
|
|
1334
|
+
all('[data-rv-media-filter]').forEach((x) => { if (x.matches('input')) x.checked = st.media; else x.setAttribute('aria-pressed', String(st.media)); });
|
|
1335
|
+
all('[data-rv-country]').forEach((x) => x.setAttribute('aria-checked', String((x.getAttribute('data-rv-country') || '') === st.country)));
|
|
1336
|
+
all('[data-rv-topic]').forEach((x) => x.setAttribute('aria-pressed', String(!!st.topic && (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic)));
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// Toggles: an inline panel / region / collapse opened by its button, not closed by a click outside. Closing a region
|
|
1340
|
+
// that has data-rv-region-clear drops the filters inside it (closing the filter row = no star filter; closing the
|
|
1341
|
+
// search box = no search), as Judge.me does.
|
|
1342
|
+
const PANEL_ATTRS = ['data-rv-toggle-panel', 'data-rv-region', 'data-rv-collapse-panel'];
|
|
1343
|
+
const togglePanels = (id) => all('[data-rv-toggle-panel], [data-rv-region], [data-rv-collapse-panel]').filter((p) => PANEL_ATTRS.some((a) => p.hasAttribute(a) && p.getAttribute(a) === id));
|
|
1344
|
+
const toggleId = (t) => (t.hasAttribute('data-rv-toggle') ? t.getAttribute('data-rv-toggle') : t.getAttribute('data-rv-collapse')) || '';
|
|
1345
|
+
const setToggle = (id, open) => {
|
|
1346
|
+
const ps = togglePanels(id);
|
|
1347
|
+
if (!ps.length) return;
|
|
1348
|
+
const was = ps.some((p) => !p.hidden);
|
|
1349
|
+
ps.forEach((p) => { p.hidden = !open; });
|
|
1350
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => { if (toggleId(t) === id) t.setAttribute('aria-expanded', String(open)); });
|
|
1351
|
+
const r = !open && was ? ps.find((p) => p.hasAttribute('data-rv-region-clear')) : null;
|
|
1352
|
+
if (!r) return;
|
|
1353
|
+
let changed = false;
|
|
1354
|
+
if (r.querySelector('[data-rv-star]') && st.star) { st.star = 0; changed = true; syncControls(); }
|
|
1355
|
+
r.querySelectorAll('input[data-rv-search]').forEach((x) => { if (x.value || st.q) { x.value = ''; st.q = ''; changed = true; } });
|
|
1356
|
+
if (changed) { reset(); render(); }
|
|
1357
|
+
};
|
|
1358
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => on(t, 'click', (e) => {
|
|
1359
|
+
e.preventDefault();
|
|
1360
|
+
const ps = togglePanels(toggleId(t));
|
|
1361
|
+
setToggle(toggleId(t), ps.length ? ps.every((p) => p.hidden) : t.getAttribute('aria-expanded') !== 'true');
|
|
1362
|
+
}));
|
|
1363
|
+
|
|
1364
|
+
const setSort = (key, label) => {
|
|
1365
|
+
st.sort = key; reset();
|
|
1366
|
+
all('[data-rv-sort-option]').forEach((o) => o.setAttribute('aria-checked', String(o.getAttribute('data-rv-sort-option') === key)));
|
|
1367
|
+
all('select[data-rv-sort]').forEach((s) => { if (s.value !== key) s.value = key; });
|
|
1368
|
+
if (label) {
|
|
1369
|
+
all('[data-rv-sort-label]').forEach((l) => { l.textContent = label; });
|
|
1370
|
+
all('[data-rv-menu][data-rv-sort-aria]').forEach((b) => b.setAttribute('aria-label', (b.getAttribute('data-rv-sort-aria') || '').replace('{label}', label)));
|
|
1371
|
+
}
|
|
1372
|
+
render();
|
|
1373
|
+
};
|
|
1374
|
+
all('select[data-rv-sort]').forEach((s) => on(s, 'change', () => setSort(s.value, '')));
|
|
1375
|
+
all('[data-rv-sort-option]').forEach((o) => on(o, 'click', (e) => {
|
|
1376
|
+
e.preventDefault();
|
|
1377
|
+
setSort(o.getAttribute('data-rv-sort-option') || '', (o.getAttribute('data-rv-sort-text') || o.textContent || '').trim());
|
|
1378
|
+
closeAfterChoice(o, true);
|
|
1379
|
+
}));
|
|
1380
|
+
const starKeep = root.getAttribute('data-rv-star-keep');
|
|
1381
|
+
all('[data-rv-star]').forEach((b) => on(b, 'click', (e) => {
|
|
1382
|
+
e.preventDefault();
|
|
1383
|
+
const v = Number(b.getAttribute('data-rv-star')) || 0;
|
|
1384
|
+
const keep = starKeep !== null && (starKeep !== 'menu' || !!b.closest('[data-rv-menu-panel]'));
|
|
1385
|
+
st.star = keep ? v : st.star === v ? 0 : v; reset();
|
|
1386
|
+
syncControls();
|
|
1387
|
+
if (b.hasAttribute('data-rv-open-region')) setToggle(b.getAttribute('data-rv-open-region') || '', true);
|
|
1388
|
+
closeAfterChoice(b);
|
|
1389
|
+
render();
|
|
1390
|
+
}));
|
|
1391
|
+
all('[data-rv-media-filter]').forEach((m) => on(m, m.matches('input') ? 'change' : 'click', (e) => {
|
|
1392
|
+
if (!m.matches('input')) e.preventDefault();
|
|
1393
|
+
st.media = m.matches('input') ? m.checked : !st.media; reset();
|
|
1394
|
+
syncControls();
|
|
1395
|
+
render();
|
|
1396
|
+
}));
|
|
1397
|
+
all('[data-rv-country]').forEach((c) => on(c, 'click', (e) => {
|
|
1398
|
+
e.preventDefault();
|
|
1399
|
+
st.country = c.getAttribute('data-rv-country') || ''; reset();
|
|
1400
|
+
syncControls();
|
|
1401
|
+
closeAfterChoice(c);
|
|
1402
|
+
render();
|
|
1403
|
+
}));
|
|
1404
|
+
all('[data-rv-topic]').forEach((t) => on(t, 'click', (e) => {
|
|
1405
|
+
e.preventDefault();
|
|
1406
|
+
const v = (t.getAttribute('data-rv-topic') || '').toLowerCase();
|
|
1407
|
+
st.topic = st.topic === v ? '' : v; reset();
|
|
1408
|
+
syncControls();
|
|
1409
|
+
render();
|
|
1410
|
+
}));
|
|
1411
|
+
all('input[data-rv-search]').forEach((s) => {
|
|
1412
|
+
let wait = 0;
|
|
1413
|
+
const apply = () => { st.q = s.value.trim().toLowerCase(); reset(); render(); };
|
|
1414
|
+
on(s, 'input', () => { window.clearTimeout(wait); wait = window.setTimeout(apply, 180); });
|
|
1415
|
+
on(s.form || s, 'submit', (e) => { e.preventDefault(); apply(); });
|
|
1416
|
+
});
|
|
1417
|
+
// Clear: data-rv-clear="star" | "topic" drops that filter only (the "× Clear" beside a label); empty drops them all.
|
|
1418
|
+
all('[data-rv-clear]').forEach((c) => on(c, 'click', (e) => {
|
|
1419
|
+
e.preventDefault();
|
|
1420
|
+
const scope = c.getAttribute('data-rv-clear') || '';
|
|
1421
|
+
if (!scope || scope === 'star') st.star = 0;
|
|
1422
|
+
if (!scope || scope === 'topic') st.topic = '';
|
|
1423
|
+
if (!scope) { st.media = false; st.q = ''; st.country = ''; all('input[data-rv-search]').forEach((x) => { x.value = ''; }); }
|
|
1424
|
+
reset();
|
|
1425
|
+
syncControls();
|
|
1426
|
+
render();
|
|
1427
|
+
}));
|
|
1428
|
+
on(chipsBox, 'click', (e) => {
|
|
1429
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-chip]') : null;
|
|
1430
|
+
if (!b) return;
|
|
1431
|
+
e.preventDefault();
|
|
1432
|
+
const k = b.getAttribute('data-rv-chip');
|
|
1433
|
+
if (k === 'star') st.star = 0;
|
|
1434
|
+
if (k === 'media') st.media = false;
|
|
1435
|
+
if (k === 'country') st.country = '';
|
|
1436
|
+
reset(); syncControls(); render();
|
|
1437
|
+
});
|
|
1438
|
+
// A filter sheet with Apply: its star chips only stage a choice, Apply filters, Reset un-stages.
|
|
1439
|
+
const syncStage = () => all('[data-rv-stage]').forEach((c) => c.setAttribute('aria-pressed', String(Number(c.getAttribute('data-rv-stage')) === st.staged)));
|
|
1440
|
+
all('[data-rv-stage]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = Number(c.getAttribute('data-rv-stage')) || 0; syncStage(); }));
|
|
1441
|
+
all('[data-rv-reset]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = 0; syncStage(); }));
|
|
1442
|
+
all('[data-rv-apply]').forEach((c) => on(c, 'click', (e) => {
|
|
1443
|
+
e.preventDefault();
|
|
1444
|
+
st.star = st.staged; reset(); syncControls(); render();
|
|
1445
|
+
const d = c.closest('dialog'); if (d && d.open) d.close();
|
|
1446
|
+
}));
|
|
1447
|
+
|
|
1448
|
+
on(more, 'click', (e) => { e.preventDefault(); st.shown += moreSize(); render(true); });
|
|
1449
|
+
on(expand, 'click', (e) => { e.preventDefault(); st.collapsed = false; st.page = 1; render(); });
|
|
1450
|
+
pagesNavs.forEach((nav) => on(nav, 'click', (e) => {
|
|
1451
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-page]') : null;
|
|
1452
|
+
if (!b || b.hasAttribute('data-rv-page-tpl')) return;
|
|
1453
|
+
e.preventDefault(); st.page = Number(b.getAttribute('data-rv-page')) || 1; render(false, true);
|
|
1454
|
+
}));
|
|
1455
|
+
prevPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); if (st.page > 1) { st.page -= 1; render(false, true); } }));
|
|
1456
|
+
nextPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); st.page += 1; render(false, true); }));
|
|
1457
|
+
all('[data-rv-per-page-option]').forEach((o) => on(o, 'click', (e) => {
|
|
1458
|
+
e.preventDefault();
|
|
1459
|
+
pageSize = Math.max(1, Number(o.getAttribute('data-rv-per-page-option')) || pageSize); reset();
|
|
1460
|
+
all('[data-rv-per-page-option]').forEach((x) => x.setAttribute('aria-checked', String(x === o)));
|
|
1461
|
+
all('[data-rv-per-page-label]').forEach((l) => { l.textContent = String(pageSize); });
|
|
1462
|
+
closeAfterChoice(o, true);
|
|
1463
|
+
render();
|
|
1464
|
+
}));
|
|
1465
|
+
if (pagesMq) on(pagesMq, 'change', () => { reset(); render(); });
|
|
1466
|
+
|
|
1467
|
+
all('[data-rv-readmore]').forEach((b) => on(b, 'click', (e) => {
|
|
1468
|
+
e.preventDefault();
|
|
1469
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
1470
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
1471
|
+
b.setAttribute('aria-expanded', String(open));
|
|
1472
|
+
if (body) body.toggleAttribute('data-rv-open', open);
|
|
1473
|
+
const less = b.getAttribute('data-rv-less'); const moreText = b.getAttribute('data-rv-more-text');
|
|
1474
|
+
if (less && moreText) b.textContent = open ? less : moreText;
|
|
1475
|
+
}));
|
|
1476
|
+
let resizeWait = 0;
|
|
1477
|
+
on(window, 'resize', () => { window.clearTimeout(resizeWait); resizeWait = window.setTimeout(syncReadMore, 150); });
|
|
1478
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncReadMore).catch(() => {});
|
|
1479
|
+
on(root, 'gem:reviews:refresh', () => render());
|
|
1480
|
+
|
|
1481
|
+
// Tracks (a strip of photos or cards): back / forward one card; the buttons switch off at either end. A looping
|
|
1482
|
+
// track (data-rv-loop) is the section's own carousel and is left to it.
|
|
1483
|
+
all('[data-rv-track]').forEach((track) => {
|
|
1484
|
+
if (track.hasAttribute('data-rv-loop')) return;
|
|
1485
|
+
const scope = track.closest('[data-rv-carousel]') || root;
|
|
1486
|
+
const prev = scope.querySelector('[data-rv-slide-prev]'); const next = scope.querySelector('[data-rv-slide-next]');
|
|
1487
|
+
const step = () => { const c = track.firstElementChild; const gap = parseFloat(window.getComputedStyle(track).columnGap || '0') || 0; return c ? c.getBoundingClientRect().width + gap : track.clientWidth; };
|
|
1488
|
+
const sync = () => { const max = track.scrollWidth - track.clientWidth - 2; if (prev) prev.toggleAttribute('disabled', track.scrollLeft <= 2); if (next) next.toggleAttribute('disabled', track.scrollLeft >= max); };
|
|
1489
|
+
on(prev, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: -step(), behavior: 'smooth' }); });
|
|
1490
|
+
on(next, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: step(), behavior: 'smooth' }); });
|
|
1491
|
+
on(track, 'scroll', sync, { passive: true });
|
|
1492
|
+
sync();
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
// Filter sheet (phones): a button opens dialog[data-rv-sheet], delegated on the root so copies made after binding
|
|
1496
|
+
// (an inline card a section repeats) open it too; closing returns focus to that button.
|
|
1497
|
+
let sheetFrom = null;
|
|
1498
|
+
on(root, 'click', (e) => {
|
|
1499
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-sheet-open]') : null;
|
|
1500
|
+
if (!b) return;
|
|
1501
|
+
const d = root.querySelector('dialog[data-rv-sheet="' + b.getAttribute('data-rv-sheet-open') + '"]');
|
|
1502
|
+
if (!d || typeof d.showModal !== 'function') return;
|
|
1503
|
+
e.preventDefault(); sheetFrom = b; st.staged = st.star; syncStage(); closeMenus(); if (!d.open) d.showModal();
|
|
1504
|
+
});
|
|
1505
|
+
all('dialog[data-rv-sheet]').forEach((d) => {
|
|
1506
|
+
d.querySelectorAll('[data-rv-sheet-close]').forEach((c) => on(c, 'click', (e) => {
|
|
1507
|
+
e.preventDefault(); if (d.open) d.close();
|
|
1508
|
+
if (c.hasAttribute('data-rv-scroll-list') && list) { sheetFrom = null; list.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
1509
|
+
}));
|
|
1510
|
+
on(d, 'click', (e) => {
|
|
1511
|
+
if (e.target !== d || !d.hasAttribute('data-rv-backdrop-close')) return;
|
|
1512
|
+
const r = d.getBoundingClientRect();
|
|
1513
|
+
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) d.close();
|
|
1514
|
+
});
|
|
1515
|
+
on(d, 'close', () => { const opener = sheetFrom && sheetFrom.isConnected ? sheetFrom : null; sheetFrom = null; if (opener) opener.focus({ preventScroll: true }); });
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
// Helpful: once per review (or per [data-rv-vote-scope]: a question, an answer), counted in place.
|
|
1519
|
+
all('[data-rv-vote]').forEach((b) => on(b, 'click', (e) => {
|
|
1520
|
+
e.preventDefault();
|
|
1521
|
+
const scope = b.closest('[data-rv-item], [data-rv-vote-scope]');
|
|
1522
|
+
if (!scope || scope.hasAttribute('data-rv-voted')) return;
|
|
1523
|
+
scope.setAttribute('data-rv-voted', b.getAttribute('data-rv-vote') || '');
|
|
1524
|
+
b.setAttribute('aria-pressed', 'true');
|
|
1525
|
+
const c = b.querySelector('[data-rv-vote-count]'); if (c) c.textContent = String((Number(c.textContent) || 0) + 1);
|
|
1526
|
+
scope.querySelectorAll('[data-rv-vote]').forEach((x) => x.setAttribute('disabled', ''));
|
|
1527
|
+
const detail = { source: 'gem-runtime', section, sectionId, review: scope.id, value: b.getAttribute('data-rv-vote') };
|
|
1528
|
+
api.emit('gem:review:vote', detail);
|
|
1529
|
+
if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:review:vote', { detail }));
|
|
1530
|
+
}));
|
|
1531
|
+
|
|
1532
|
+
// Lightbox. The set it walks: every [data-rv-zoom] of the section in page order (data-rv-lb-dedupe: one entry per
|
|
1533
|
+
// photo), the opener's [data-rv-group], the opener's review (data-rv-lb-scope="review"), or the opener's own
|
|
1534
|
+
// data-rv-media-list. Ends: hide the arrow (default), disable it, or wrap. The review the photo belongs to is copied
|
|
1535
|
+
// beside it from [data-rv-lb-part]; Escape closes (native dialog) and focus returns to the opener.
|
|
1536
|
+
const lb = root.querySelector('dialog[data-rv-lightbox]');
|
|
1537
|
+
const zooms = all('[data-rv-zoom]');
|
|
1538
|
+
const lbScoped = !!lb && lb.getAttribute('data-rv-lb-scope') === 'review';
|
|
1539
|
+
const lbEnds = lb ? lb.getAttribute('data-rv-lb-ends') || (lbScoped ? 'disable' : 'hide') : 'hide';
|
|
1540
|
+
const lbVotes = !lb || lb.getAttribute('data-rv-lb-votes') !== 'off';
|
|
1541
|
+
const entryOf = (z) => {
|
|
1542
|
+
const img = z.querySelector('img');
|
|
1543
|
+
return { el: z, src: z.getAttribute('data-rv-src') || '', kind: z.getAttribute('data-rv-kind') || '', poster: z.getAttribute('data-rv-poster') || '',
|
|
1544
|
+
alt: z.getAttribute('data-rv-alt') || '', thumb: z.getAttribute('data-rv-thumb') || (img && img.getAttribute('src')) || z.getAttribute('data-rv-src') || '',
|
|
1545
|
+
review: z.getAttribute('data-rv-review') || '' };
|
|
1546
|
+
};
|
|
1547
|
+
const lbAll = [];
|
|
1548
|
+
const seenSrc = new Set();
|
|
1549
|
+
zooms.forEach((z) => {
|
|
1550
|
+
const en = entryOf(z);
|
|
1551
|
+
if (lb && lb.hasAttribute('data-rv-lb-dedupe')) { if (seenSrc.has(en.src)) return; seenSrc.add(en.src); }
|
|
1552
|
+
lbAll.push(en);
|
|
1553
|
+
});
|
|
1554
|
+
const setFor = (z) => {
|
|
1555
|
+
const raw = z.getAttribute('data-rv-media-list');
|
|
1556
|
+
if (raw !== null) {
|
|
1557
|
+
let parsed = [];
|
|
1558
|
+
try { parsed = JSON.parse(raw || '[]'); } catch (err) { parsed = []; }
|
|
1559
|
+
return (Array.isArray(parsed) ? parsed : []).filter((m) => m && m.src).map((m) => ({ el: null, src: String(m.src), kind: m.kind || '', poster: m.poster || '', alt: m.alt || '', thumb: m.poster || String(m.src), review: z.getAttribute('data-rv-review') || '' }));
|
|
1560
|
+
}
|
|
1561
|
+
const g = z.getAttribute('data-rv-group');
|
|
1562
|
+
if (g) return lbAll.filter((x) => x.el.getAttribute('data-rv-group') === g);
|
|
1563
|
+
if (lbScoped) {
|
|
1564
|
+
const r = z.getAttribute('data-rv-review') || '';
|
|
1565
|
+
const own = lbAll.filter((x) => x.review === r && !x.el.closest('[data-rv-carousel]'));
|
|
1566
|
+
return own.length ? own : [entryOf(z)];
|
|
1567
|
+
}
|
|
1568
|
+
return lbAll;
|
|
1569
|
+
};
|
|
1570
|
+
const indexIn = (set, z) => {
|
|
1571
|
+
const i = set.findIndex((x) => x.el === z);
|
|
1572
|
+
if (i >= 0) return i;
|
|
1573
|
+
const src = z.getAttribute('data-rv-src');
|
|
1574
|
+
const j = set.findIndex((x) => x.src === src && x.review === (z.getAttribute('data-rv-review') || ''));
|
|
1575
|
+
return Math.max(0, j >= 0 ? j : set.findIndex((x) => x.src === src));
|
|
1576
|
+
};
|
|
1577
|
+
let lbSet = lbAll; let lbAt = 0; let lbFrom = null; let stripFor = null;
|
|
1578
|
+
const strip = lb ? lb.querySelector('[data-rv-lb-thumbs]') : null;
|
|
1579
|
+
const buildStrip = () => {
|
|
1580
|
+
if (!strip || stripFor === lbSet) return;
|
|
1581
|
+
stripFor = lbSet;
|
|
1582
|
+
strip.replaceChildren();
|
|
1583
|
+
if (lbSet.length > 1) lbSet.forEach((x, k) => {
|
|
1584
|
+
const b = document.createElement('button');
|
|
1585
|
+
b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
|
|
1586
|
+
b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
|
|
1587
|
+
const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
|
|
1588
|
+
b.appendChild(img); strip.appendChild(b);
|
|
1589
|
+
});
|
|
1590
|
+
strip.hidden = lbSet.length <= 1;
|
|
1591
|
+
};
|
|
1592
|
+
const lbShow = (i) => {
|
|
1593
|
+
if (!lb) return;
|
|
1594
|
+
const n = lbSet.length;
|
|
1595
|
+
lbAt = lbEnds === 'wrap' && n ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));
|
|
1596
|
+
const x = lbSet[lbAt];
|
|
1597
|
+
lb.toggleAttribute('data-rv-lb-empty', !n);
|
|
1598
|
+
lb.toggleAttribute('data-rv-lb-multi', n > 1);
|
|
1599
|
+
const media = lb.querySelector('[data-rv-lb-media]');
|
|
1600
|
+
if (media) {
|
|
1601
|
+
media.replaceChildren();
|
|
1602
|
+
if (x && x.kind === 'video') { const v = document.createElement('video'); v.src = x.src; if (x.poster) v.setAttribute('poster', x.poster); v.controls = true; v.playsInline = true; v.autoplay = true; media.appendChild(v); }
|
|
1603
|
+
else if (x) { const img = document.createElement('img'); img.src = x.src; img.alt = x.alt; media.appendChild(img); }
|
|
1604
|
+
}
|
|
1605
|
+
const stage = lb.querySelector('[data-rv-lb-stage]');
|
|
1606
|
+
if (stage && x) {
|
|
1607
|
+
const img = x.el && x.el.querySelector('img');
|
|
1608
|
+
let bg = img ? img.currentSrc || img.src : '';
|
|
1609
|
+
if (!bg) { try { bg = new URL(x.src, window.location.href).href; } catch (err) { bg = x.src; } }
|
|
1610
|
+
stage.style.setProperty('--rv-lb-bg', 'url("' + String(bg).replace(/"/g, '%22') + '")');
|
|
1611
|
+
}
|
|
1612
|
+
const panel = lb.querySelector('[data-rv-lb-review]');
|
|
1613
|
+
if (panel) {
|
|
1614
|
+
panel.replaceChildren();
|
|
1615
|
+
// A review with no photos (an empty media list) still shows its review: the opener names it.
|
|
1616
|
+
const rid = x ? x.review : (lbFrom && lbFrom.getAttribute && lbFrom.getAttribute('data-rv-review')) || '';
|
|
1617
|
+
const item = rid ? items.find((it) => it.id === rid) : null;
|
|
1618
|
+
if (item) item.querySelectorAll('[data-rv-lb-part]').forEach((part) => {
|
|
1619
|
+
const copy = part.cloneNode(true);
|
|
1620
|
+
copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
|
1621
|
+
copy.querySelectorAll('input, label[for], [data-rv-readmore]').forEach((el) => el.remove());
|
|
1622
|
+
copy.querySelectorAll('[data-rv-zoom]').forEach((t) => { t.removeAttribute('data-rv-zoom'); t.setAttribute('tabindex', '-1'); if (t.getAttribute('data-rv-src') === x.src) t.setAttribute('aria-current', 'true'); });
|
|
1623
|
+
copy.querySelectorAll('[data-rv-zoom-alias]').forEach((t) => { if (lbSet === lbAll && t.getAttribute('data-rv-zoom-alias') === String(lbAt)) t.setAttribute('aria-current', 'true'); });
|
|
1624
|
+
if (!lbVotes) copy.querySelectorAll('button').forEach((el) => { el.setAttribute('tabindex', '-1'); el.setAttribute('disabled', ''); });
|
|
1625
|
+
panel.appendChild(copy);
|
|
1626
|
+
});
|
|
1627
|
+
panel.hidden = !item;
|
|
1628
|
+
lb.toggleAttribute('data-rv-lb-has-review', !!item);
|
|
1629
|
+
}
|
|
1630
|
+
const peek = lb.querySelector('[data-rv-lb-peek]');
|
|
1631
|
+
if (peek) {
|
|
1632
|
+
peek.replaceChildren();
|
|
1633
|
+
const nx = lbSet[lbAt + 1];
|
|
1634
|
+
const url = nx ? nx.poster || (nx.kind === 'video' ? '' : nx.src) : '';
|
|
1635
|
+
if (url) { const img = document.createElement('img'); img.src = url; img.alt = ''; peek.appendChild(img); }
|
|
1636
|
+
peek.hidden = !nx;
|
|
1637
|
+
}
|
|
1638
|
+
const count = lb.querySelector('[data-rv-lb-count]'); if (count) count.textContent = n ? (lbAt + 1) + ' / ' + n : '';
|
|
1639
|
+
const pv = lb.querySelector('[data-rv-lb-prev]'); const nxb = lb.querySelector('[data-rv-lb-next]');
|
|
1640
|
+
[[pv, lbAt <= 0], [nxb, lbAt >= n - 1]].forEach(([b, atEnd]) => {
|
|
1641
|
+
if (!b) return;
|
|
1642
|
+
if (lbEnds === 'wrap') { b.hidden = n < 2; b.disabled = false; }
|
|
1643
|
+
else if (lbEnds === 'disable') { b.hidden = n < 2; b.disabled = atEnd; }
|
|
1644
|
+
else { b.hidden = atEnd; b.disabled = false; }
|
|
1645
|
+
});
|
|
1646
|
+
if (strip) Array.from(strip.children).forEach((t, k) => {
|
|
1647
|
+
if (k !== lbAt) { t.removeAttribute('aria-current'); return; }
|
|
1648
|
+
t.setAttribute('aria-current', 'true');
|
|
1649
|
+
if (strip.scrollTo) strip.scrollTo({ left: t.offsetLeft - (strip.clientWidth - t.offsetWidth) / 2, behavior: lb.open ? 'smooth' : 'auto' });
|
|
1650
|
+
});
|
|
1651
|
+
};
|
|
1652
|
+
const lbOpen = (e, set, at, opener) => {
|
|
1653
|
+
if (!lb || typeof lb.showModal !== 'function') return;
|
|
1654
|
+
e.preventDefault();
|
|
1655
|
+
closeMenus();
|
|
1656
|
+
lbFrom = opener; lbSet = set; buildStrip();
|
|
1657
|
+
lb.querySelectorAll('[data-rv-toggle]').forEach((t) => setToggle(toggleId(t), false));
|
|
1658
|
+
lbShow(at);
|
|
1659
|
+
if (!lb.open) lb.showModal();
|
|
1660
|
+
};
|
|
1661
|
+
zooms.forEach((z) => {
|
|
1662
|
+
const open = (e) => { const set = setFor(z); lbOpen(e, set, z.hasAttribute('data-rv-media-list') ? 0 : indexIn(set, z), z); };
|
|
1663
|
+
on(z, 'click', open);
|
|
1664
|
+
on(z, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') open(e); });
|
|
1665
|
+
});
|
|
1666
|
+
all('[data-rv-zoom-alias]').forEach((z) => on(z, 'click', (e) => {
|
|
1667
|
+
if (!lb || !lbAll.length) return;
|
|
1668
|
+
e.stopPropagation();
|
|
1669
|
+
lbOpen(e, lbAll, Number(z.getAttribute('data-rv-zoom-alias')) || 0, z);
|
|
1670
|
+
}));
|
|
1671
|
+
if (lb) {
|
|
1672
|
+
on(lb.querySelector('[data-rv-lb-prev]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt - 1); });
|
|
1673
|
+
on(lb.querySelector('[data-rv-lb-next]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
1674
|
+
on(lb.querySelector('[data-rv-lb-close]'), 'click', (e) => { e.preventDefault(); lb.close(); });
|
|
1675
|
+
on(lb.querySelector('[data-rv-lb-peek]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
1676
|
+
on(strip, 'click', (e) => {
|
|
1677
|
+
const t = isEl(e.target) ? e.target.closest('[data-rv-lb-thumb]') : null;
|
|
1678
|
+
if (!t) return;
|
|
1679
|
+
e.preventDefault(); lbShow(Number(t.getAttribute('data-rv-lb-thumb')) || 0);
|
|
1680
|
+
});
|
|
1681
|
+
on(lb, 'keydown', (e) => { if (e.key === 'ArrowLeft') lbShow(lbAt - 1); if (e.key === 'ArrowRight') lbShow(lbAt + 1); });
|
|
1682
|
+
on(lb, 'click', (e) => { if (e.target === lb) lb.close(); });
|
|
1683
|
+
// Swipe on the photo (some widgets show no arrows on phones).
|
|
1684
|
+
const swipe = lb.querySelector('[data-rv-lb-stage]') || lb.querySelector('[data-rv-lb-media]');
|
|
1685
|
+
let sx = 0; let sy = 0; let tracking = false;
|
|
1686
|
+
on(swipe, 'touchstart', (e) => { const t = e.touches[0]; if (!t) return; sx = t.clientX; sy = t.clientY; tracking = true; }, { passive: true });
|
|
1687
|
+
on(swipe, 'touchend', (e) => {
|
|
1688
|
+
if (!tracking) return; tracking = false;
|
|
1689
|
+
const t = e.changedTouches[0]; if (!t) return;
|
|
1690
|
+
const dx = t.clientX - sx; const dy = t.clientY - sy;
|
|
1691
|
+
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) lbShow(lbAt + (dx < 0 ? 1 : -1));
|
|
1692
|
+
});
|
|
1693
|
+
// Helpful from inside the lightbox: the copy's button presses the real one, then the copy redraws with the count.
|
|
1694
|
+
on(lb.querySelector('[data-rv-lb-review]'), 'click', (e) => {
|
|
1695
|
+
const v = lbVotes && isEl(e.target) ? e.target.closest('[data-rv-vote]') : null;
|
|
1696
|
+
if (!v) return;
|
|
1697
|
+
e.preventDefault();
|
|
1698
|
+
const x = lbSet[lbAt];
|
|
1699
|
+
const item = x && x.review ? items.find((it) => it.id === x.review) : null;
|
|
1700
|
+
const real = item && item.querySelector('[data-rv-vote="' + v.getAttribute('data-rv-vote') + '"]');
|
|
1701
|
+
if (real) real.click();
|
|
1702
|
+
lbShow(lbAt);
|
|
1703
|
+
});
|
|
1704
|
+
on(lb, 'close', () => { const vid = lb.querySelector('video'); if (vid) vid.pause(); if (lbFrom && lbFrom.focus) lbFrom.focus({ preventScroll: true }); });
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
render();
|
|
1708
|
+
});
|
|
1709
|
+
api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });
|
|
1710
|
+
});
|
|
1057
1711
|
registerNoopPlugin("seasonal-effects", "gem:seasonal:ready");
|
|
1058
1712
|
registerNoopPlugin("context-adapter", "gem:context:ready");
|
|
1059
1713
|
const mark = (element, key) => {
|