@gemboss/gem-framework 0.18.11 → 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 +38 -0
- package/dist/SOURCE.json +3 -3
- package/dist/editions.js +2 -0
- package/dist/gem-framework.css +36 -7
- package/dist/gem-runtime.js +655 -1
- package/dist/index.js +748 -14
- 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 +29 -1
- package/dist/section-gate.js +737 -11
- package/dist/sections.css +2 -2
- package/dist/tokens.css +1 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -435,7 +435,7 @@ function gemButtonGradientEndCss(accent = "var(--gem-color-accent)", accentText
|
|
|
435
435
|
}
|
|
436
436
|
|
|
437
437
|
// ../../packages/gem-framework/src/tokens.ts
|
|
438
|
-
var gemFrameworkVersion = "0.18.
|
|
438
|
+
var gemFrameworkVersion = "0.18.15";
|
|
439
439
|
var GEM_FOCUS_RING_CSS_VAR = "--gem-focus-ring: 2px solid var(--gem-color-accent);";
|
|
440
440
|
var GEM_TAP_MIN_CSS_VAR = "--gem-tap-min: 44px;";
|
|
441
441
|
var GEM_FOCUS_OFFSET_CSS_VAR = "--gem-focus-offset: 3px;";
|
|
@@ -685,6 +685,8 @@ function gemMotionScaleCssVars(motion) {
|
|
|
685
685
|
return toMotionCssVars(scale).filter((line) => !declared.has(cssVarName(line)));
|
|
686
686
|
}
|
|
687
687
|
var optionalGemTokenKeys = {
|
|
688
|
+
// Lớp phủ tối (GemFactory gap #17): xem GEM_SCRIM_FALLBACK / gemScrim bên dưới.
|
|
689
|
+
color: ["scrim"],
|
|
688
690
|
typography: [...GEM_TYPE_RAMP_KEYS, ...GEM_LINE_HEIGHT_TOKEN_KEYS, ...GEM_BUTTON_TOKEN_KEYS.typography],
|
|
689
691
|
spacing: [...Object.keys(GEM_DERIVED_SPACE_STEPS), ...GEM_PHONE_TOKEN_TARGETS.spacing.map((k) => `${k}Mobile`)],
|
|
690
692
|
layout: GEM_PHONE_TOKEN_TARGETS.layout.map((k) => `${k}Mobile`),
|
|
@@ -692,6 +694,8 @@ var optionalGemTokenKeys = {
|
|
|
692
694
|
// Khoá motion ngoài duration / easing: nền ambient (đã phát từ trước nhưng chưa khai ở đây) và thang "Gọn" (#32).
|
|
693
695
|
motion: ["easingSoft", "ambientSlow", "ambientMid", "ambientFast", "ambientAmp", ...Object.keys(GEM_MOTION_SCALE_DEFAULTS)]
|
|
694
696
|
};
|
|
697
|
+
var GEM_SCRIM_FALLBACK = "#000";
|
|
698
|
+
var gemScrim = (percent) => `color-mix(in srgb, var(--gem-color-scrim, ${GEM_SCRIM_FALLBACK}) ${percent}%, transparent)`;
|
|
695
699
|
var GEM_HEADING_SIZE_CSS_VARS = [
|
|
696
700
|
"--gem-section-heading-size: calc(var(--gem-h2) * var(--gem-typography-heading-scale, 1));",
|
|
697
701
|
"--gem-hero-heading-size: calc(var(--gem-h1) * var(--gem-typography-heading-scale, 1));",
|
|
@@ -715,7 +719,9 @@ var defaultGemTokens = {
|
|
|
715
719
|
// GemFactory sections set `color: var(--gem-color-warning)` on words; white text on a warning badge was 3.64:1 too.
|
|
716
720
|
// #9a661a keeps the hue at 4.52:1 / 4.90:1. default-colours-meet-aa.test.ts holds every foreground default to 4.5:1.
|
|
717
721
|
warning: "#9a661a",
|
|
718
|
-
danger: "#d72c0d"
|
|
722
|
+
danger: "#d72c0d",
|
|
723
|
+
// Lớp phủ tối ở độ đậm tối đa (gap #17, khoá tuỳ chọn): mỗi lớp phủ lấy N% của nó qua gemScrim.
|
|
724
|
+
scrim: "#000"
|
|
719
725
|
},
|
|
720
726
|
typography: {
|
|
721
727
|
body: "Inter, system-ui, sans-serif",
|
|
@@ -1451,6 +1457,11 @@ var gemFrameworkRuntimePlugins = [
|
|
|
1451
1457
|
key: "offer-engine",
|
|
1452
1458
|
load_strategy: "interaction",
|
|
1453
1459
|
events: ["gem:offer:ready", "gem:offer:change"]
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
key: "reviews",
|
|
1463
|
+
load_strategy: "idle",
|
|
1464
|
+
events: ["gem:reviews:ready", "gem:reviews:render", "gem:review:vote"]
|
|
1454
1465
|
}
|
|
1455
1466
|
];
|
|
1456
1467
|
var gemFrameworkValidationRules = [
|
|
@@ -1493,7 +1504,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1493
1504
|
" width: min(880px, 92vw); max-width: none; max-height: min(640px, 92vh); overflow: hidden;",
|
|
1494
1505
|
" border-radius: var(--gem-radius-lg); box-shadow: var(--gem-shadow-md);",
|
|
1495
1506
|
"}",
|
|
1496
|
-
|
|
1507
|
+
`.gem-popup-shell__dialog::backdrop { background: ${gemScrim(55)}; }`,
|
|
1497
1508
|
".gem-popup-shell__frame { position: relative; display: grid; grid-template-columns: 1fr 1fr; min-height: 520px; height: 100%; }",
|
|
1498
1509
|
".gem-popup-shell__media { position: relative; min-width: 0; overflow: hidden; background: var(--gem-color-surface); }",
|
|
1499
1510
|
".gem-popup-shell__image { display: block; width: 100%; height: 100%; object-fit: cover; }",
|
|
@@ -1520,7 +1531,7 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1520
1531
|
"}",
|
|
1521
1532
|
// Image behind the text: light text over a dark wash, every size. A card over the image has its own
|
|
1522
1533
|
// background, so on desktop it keeps the preset's text colour.
|
|
1523
|
-
|
|
1534
|
+
`.gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__media::after { content: ''; position: absolute; inset: 0; background: linear-gradient(${gemScrim(35)}, ${gemScrim(60)}); }`,
|
|
1524
1535
|
".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__panel,",
|
|
1525
1536
|
".gem-popup-shell[data-gem-popup-media='background'] .gem-popup-shell__close { color: #fff; }",
|
|
1526
1537
|
".gem-popup-shell__close {",
|
|
@@ -1584,10 +1595,10 @@ var GEM_POPUP_SHELL_CSS_LINES = [
|
|
|
1584
1595
|
// Dialog định vị ĐÚNG như modal của trình duyệt (position: fixed; inset: 0; margin: auto), nên mọi bố cục (fullscreen,
|
|
1585
1596
|
// thẻ, takeover, sheet kéo tới 88dvh) ra cùng khung chữ nhật với popup mở thật; nền chỉ thay ::backdrop (không có ở
|
|
1586
1597
|
// dialog không-modal).
|
|
1587
|
-
|
|
1598
|
+
`:root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] { position: fixed; inset: 0; z-index: 1000; padding: 0; background: ${gemScrim(55)}; }`,
|
|
1588
1599
|
":root:not(:has([data-gem-canvas])) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: fixed; inset: 0; display: block; }",
|
|
1589
1600
|
":root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] .gem-popup-shell__dialog { position: relative; display: block; inset: auto; }",
|
|
1590
|
-
|
|
1601
|
+
`:root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'] { padding: var(--gem-space-xl) var(--gem-layout-gutter); background: ${gemScrim(55)}; }`,
|
|
1591
1602
|
"@media (min-width: 750px) {",
|
|
1592
1603
|
" :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] { padding: 0; }",
|
|
1593
1604
|
" :root:has([data-gem-canvas]) .gem-popup-shell[data-gem-popup-preview='true'][data-gem-popup-desktop='fullscreen'] .gem-popup-shell__dialog { height: 720px; max-height: none; }",
|
|
@@ -1721,6 +1732,23 @@ var GEM_FORM_SET_CSS_LINES = [
|
|
|
1721
1732
|
" inline-size: var(--gem-checkbox-size);",
|
|
1722
1733
|
" block-size: var(--gem-checkbox-size);",
|
|
1723
1734
|
" accent-color: var(--gem-checkbox-accent);",
|
|
1735
|
+
"}",
|
|
1736
|
+
// THE QUANTITY STEPPER IS A FIELD OF THE SET (20/10/2026): its box takes the set's height, edge and corner (capped at
|
|
1737
|
+
// 22px, so a pill set draws a round stepper, not a 999px lozenge), its -/+ and number the set's fill and text size, and
|
|
1738
|
+
// the underline shape paints no cell, as it paints no field. The storefront carried these (gemsite #1001/#1007, with its
|
|
1739
|
+
// own element stepper in the :is() lists); the package did not, so every section the kho previewed drew a square 44px
|
|
1740
|
+
// stepper beside 48px pill fields (GemFactory Framework › Controls, measured 20/10/2026).
|
|
1741
|
+
"[data-gem-form-set] :is(.gem-quantity-input) {",
|
|
1742
|
+
" min-height: var(--gem-input-height);",
|
|
1743
|
+
" border: var(--gem-input-border-width) solid var(--gem-input-border-color);",
|
|
1744
|
+
" border-radius: min(var(--gem-input-radius), 22px);",
|
|
1745
|
+
"}",
|
|
1746
|
+
"[data-gem-form-set] :is(.gem-quantity-input__button, .gem-quantity-input__field) {",
|
|
1747
|
+
" background: var(--gem-input-bg);",
|
|
1748
|
+
" font-size: var(--gem-input-font-size);",
|
|
1749
|
+
"}",
|
|
1750
|
+
'[data-gem-form-set][data-gem-field-shape="underline"] :is(.gem-quantity-input__button, .gem-quantity-input__field) {',
|
|
1751
|
+
" background: transparent;",
|
|
1724
1752
|
"}"
|
|
1725
1753
|
];
|
|
1726
1754
|
var GEM_FORM_SET_CSS = GEM_FORM_SET_CSS_LINES.join("\n");
|
|
@@ -2169,8 +2197,9 @@ function createPrimitiveCss() {
|
|
|
2169
2197
|
" width: auto;",
|
|
2170
2198
|
" min-width: min(10rem, 50%);",
|
|
2171
2199
|
"}",
|
|
2172
|
-
// A wrapped field gets the join's height too: the dawn-parity profile
|
|
2173
|
-
// only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026).
|
|
2200
|
+
// A wrapped field gets the join's height too: the dawn-parity profile's 4.5rem floor named .gem-input until 0.18.12, and
|
|
2201
|
+
// only the direct-child rule above outranked it, so a wrapped input stood 72px next to a 44px one (02/10/2026). The
|
|
2202
|
+
// floor no longer names it; this keeps a wrapped field at the join's height whatever a profile floors it at.
|
|
2174
2203
|
".gem-field-join > .gem-form-field > :is(.gem-input, .gem-select) {",
|
|
2175
2204
|
" width: 100%;",
|
|
2176
2205
|
" height: 100%;",
|
|
@@ -2208,6 +2237,32 @@ function createPrimitiveCss() {
|
|
|
2208
2237
|
" accent-color: var(--gem-color-accent);",
|
|
2209
2238
|
"}",
|
|
2210
2239
|
...GEM_FORM_SET_CSS_LINES,
|
|
2240
|
+
// HOVER AND ERROR (§9.2 state matrix, 20/10/2026): GemFactory Framework › Controls measured both INERT, so a field a
|
|
2241
|
+
// shopper points at, or one a form marked invalid, looked like any other. Written once for every field, with or
|
|
2242
|
+
// without a form set.
|
|
2243
|
+
// HOVER thickens the edge by 1px in the field's own ink, as Dawn does (`box-shadow: 0 0 0 calc(border + .1rem)`): a
|
|
2244
|
+
// colour change alone was invisible under every form set, whose edge is already the ink (measured #1f2224 → #202223).
|
|
2245
|
+
// The ring follows the box and its corner; an underline or filled field has only a bottom rule, so only that thickens.
|
|
2246
|
+
// ERROR (`aria-invalid="true"`, set by the form) takes the danger colour mixed 70/30 with that ink, which keeps the
|
|
2247
|
+
// edge at 3:1 or more on a white field (9.7:1), an underline field on a dark band (4.2:1) and on an accent band
|
|
2248
|
+
// (3.0:1); plain #b42318 there was 2.4:1 and 1.8:1. Only the edge colour changes, so every shape keeps its edges.
|
|
2249
|
+
// Hover skips a disabled or invalid field (an error must not lose its colour under the pointer), a focused one (the
|
|
2250
|
+
// focus halo on a dark or accent band is a box-shadow, and a click focuses a text field as :focus-visible, so the
|
|
2251
|
+
// pointer still on it would wipe the halo: review) and touch screens, where a tap would leave it stuck.
|
|
2252
|
+
// Specificity: hover (0,6,0; 0,8,0 per shape) outranks the set's field rule (0,3,0); error (0,3,0) ties it and wins by
|
|
2253
|
+
// coming later in the sheet.
|
|
2254
|
+
"@media (hover: hover) {",
|
|
2255
|
+
' :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):hover:not(:disabled):not([aria-invalid="true"]):not(:focus-visible) {',
|
|
2256
|
+
" border-color: currentColor;",
|
|
2257
|
+
" box-shadow: 0 0 0 1px currentColor;",
|
|
2258
|
+
" }",
|
|
2259
|
+
' [data-gem-form-set]:is([data-gem-field-shape="underline"], [data-gem-field-shape="filled"]) :is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input)):hover:not(:disabled):not([aria-invalid="true"]):not(:focus-visible) {',
|
|
2260
|
+
" box-shadow: 0 1px 0 0 currentColor;",
|
|
2261
|
+
" }",
|
|
2262
|
+
"}",
|
|
2263
|
+
':is(.gem-input, .gem-select, .gem-textarea, .gem-shell-footer__newsletter .gem-input):where(:not(.gem-search-form .gem-input))[aria-invalid="true"] {',
|
|
2264
|
+
" border-color: color-mix(in srgb, var(--gem-color-danger, #b42318) 70%, currentColor);",
|
|
2265
|
+
"}",
|
|
2211
2266
|
".gem-submit {",
|
|
2212
2267
|
" cursor: pointer;",
|
|
2213
2268
|
"}",
|
|
@@ -2767,6 +2822,12 @@ function createPrimitiveCss() {
|
|
|
2767
2822
|
" color: var(--gem-color-text);",
|
|
2768
2823
|
" text-align: center;",
|
|
2769
2824
|
"}",
|
|
2825
|
+
// The number in the middle of its cell, as the -/+ beside it (a <button> centres its label; an <output> is inline and
|
|
2826
|
+
// put the digit 8-9px above the middle; Framework › Controls, 20/10/2026).
|
|
2827
|
+
".gem-quantity-input__field {",
|
|
2828
|
+
" display: grid;",
|
|
2829
|
+
" place-items: center;",
|
|
2830
|
+
"}",
|
|
2770
2831
|
".gem-quantity-input__button {",
|
|
2771
2832
|
" font-weight: 750;",
|
|
2772
2833
|
"}",
|
|
@@ -3065,7 +3126,7 @@ function createPrimitiveCss() {
|
|
|
3065
3126
|
".gem-cart-drawer__overlay {",
|
|
3066
3127
|
" position: fixed;",
|
|
3067
3128
|
" inset: 0;",
|
|
3068
|
-
|
|
3129
|
+
` background: ${gemScrim(36)};`,
|
|
3069
3130
|
"}",
|
|
3070
3131
|
// Opening motion (gemfactory request #13, 01/10/2026): 28 stored drawers each declared their own
|
|
3071
3132
|
// `@keyframes …-in { from { transform: translateX(24px); opacity: 0 } }`. OPT-IN on the drawer root,
|
|
@@ -3857,7 +3918,7 @@ function createDawnParityCss() {
|
|
|
3857
3918
|
".gem-hero-banner--default { position: relative; overflow: hidden; padding: 0; color: white; place-items: stretch; }",
|
|
3858
3919
|
".gem-hero-banner--default .gem-hero-banner__inner { position: static; width: 100%; min-height: 720px; height: 100%; display: grid; align-items: end; justify-items: center; padding-bottom: 0; }",
|
|
3859
3920
|
".gem-hero-banner--default .gem-hero-banner__media { position: absolute; inset: 0; z-index: 0; }",
|
|
3860
|
-
|
|
3921
|
+
`.gem-hero-banner--default .gem-hero-banner__media::after { content: ''; position: absolute; inset: 0; background: ${gemScrim(32)}; }`,
|
|
3861
3922
|
".gem-hero-banner--default .gem-hero-banner__image, .gem-hero-banner--default .gem-hero-banner__placeholder { width: 100%; height: 100%; object-fit: cover; display: block; }",
|
|
3862
3923
|
".gem-hero-banner--default .gem-hero-banner__copy { position: absolute; z-index: 1; left: 50%; bottom: 50px; transform: translateX(-50%); width: min(702px, 100%); margin: 0; padding: 40px 0; text-align: center; display: block; }",
|
|
3863
3924
|
".gem-hero-banner--default .gem-section__heading { margin: 0; font-size: 52px; line-height: 67.6px; color: currentColor; }",
|
|
@@ -4363,7 +4424,10 @@ function createShellCss() {
|
|
|
4363
4424
|
".newsletter-form__field-wrapper, .field {",
|
|
4364
4425
|
" position: relative;",
|
|
4365
4426
|
"}",
|
|
4366
|
-
|
|
4427
|
+
// Dawn's field floor, for Dawn's own field classes. It named .gem-input too, and 4.5rem is Dawn's 45px only on Dawn's
|
|
4428
|
+
// 10px root: on this 16px root a .gem-input stood 72px beside a 44px select and stepper whenever no form set ruled it
|
|
4429
|
+
// (GemFactory Framework › Controls, 20/10/2026). A .gem-input takes the primitive's floor, as every field does.
|
|
4430
|
+
".field__input, .field input {",
|
|
4367
4431
|
" min-height: 4.5rem;",
|
|
4368
4432
|
" border-radius: var(--inputs-radius, 0);",
|
|
4369
4433
|
"}",
|
|
@@ -4850,6 +4914,662 @@ function offerEngineRuntimeScript() {
|
|
|
4850
4914
|
})();`;
|
|
4851
4915
|
}
|
|
4852
4916
|
|
|
4917
|
+
// ../../packages/gem-framework/src/reviews-engine.ts
|
|
4918
|
+
function reviewsEngineRuntimeScript() {
|
|
4919
|
+
return `let boundReviews = 0;
|
|
4920
|
+
document.querySelectorAll('[data-rv-root]').forEach((root) => {
|
|
4921
|
+
if (!mark(root, 'reviews')) return;
|
|
4922
|
+
boundReviews += 1;
|
|
4923
|
+
const on = (el, type, fn, opt) => { if (canListen(el)) el.addEventListener(type, fn, opt); };
|
|
4924
|
+
const all = (sel) => Array.from(root.querySelectorAll(sel));
|
|
4925
|
+
const isEl = (t) => t instanceof window.Element;
|
|
4926
|
+
const list = root.querySelector('[data-rv-list]');
|
|
4927
|
+
const items = list ? Array.from(list.querySelectorAll('[data-rv-item]')) : [];
|
|
4928
|
+
let pageSize = Math.max(1, parseInt((list && list.getAttribute('data-rv-page-size')) || '5', 10) || 5);
|
|
4929
|
+
const moreSize = () => Math.max(1, parseInt((list && list.getAttribute('data-rv-more-size')) || '', 10) || pageSize);
|
|
4930
|
+
const collapsedN = Math.max(0, parseInt((list && list.getAttribute('data-rv-collapsed')) || '0', 10) || 0);
|
|
4931
|
+
const st = { sort: '', star: 0, media: false, topic: '', q: '', country: '', page: 1, shown: pageSize, collapsed: collapsedN > 0, staged: 0 };
|
|
4932
|
+
const section = root.getAttribute('data-gem-section-library-id') || undefined;
|
|
4933
|
+
const sectionId = root.getAttribute('data-gem-section-id') || root.id || undefined;
|
|
4934
|
+
const pagesNavs = all('[data-rv-pages]');
|
|
4935
|
+
const pagesMq = pagesNavs.length && window.matchMedia ? window.matchMedia(pagesNavs[0].getAttribute('data-rv-pages-media') || 'all') : null;
|
|
4936
|
+
const usesPagesNow = () => pagesNavs.length > 0 && (!pagesMq || pagesMq.matches);
|
|
4937
|
+
const prevPages = all('[data-rv-prev]');
|
|
4938
|
+
const nextPages = all('[data-rv-next]');
|
|
4939
|
+
const none = root.querySelector('[data-rv-none]');
|
|
4940
|
+
const more = root.querySelector('[data-rv-more]');
|
|
4941
|
+
const expand = root.querySelector('[data-rv-expand]');
|
|
4942
|
+
const num = (el, a) => Number(el.getAttribute(a) || 0);
|
|
4943
|
+
const isOn = (el, a) => el.getAttribute(a) === '1';
|
|
4944
|
+
const byOrder = (a, b) => num(a, 'data-rv-order') - num(b, 'data-rv-order');
|
|
4945
|
+
const newest = (a, b) => (a.hasAttribute('data-rv-recent') ? num(a, 'data-rv-recent') - num(b, 'data-rv-recent') : byOrder(a, b));
|
|
4946
|
+
const leastHelpful = (a, b) => num(a, 'data-rv-helpful') - num(b, 'data-rv-helpful') || byOrder(a, b);
|
|
4947
|
+
const SORTS = {
|
|
4948
|
+
relevant: byOrder,
|
|
4949
|
+
featured: byOrder,
|
|
4950
|
+
recent: newest,
|
|
4951
|
+
oldest: (a, b) => (a.hasAttribute('data-rv-recent') ? num(b, 'data-rv-recent') - num(a, 'data-rv-recent') : byOrder(b, a)),
|
|
4952
|
+
verified: (a, b) => isOn(b, 'data-rv-verified') - isOn(a, 'data-rv-verified') || byOrder(a, b),
|
|
4953
|
+
highest: (a, b) => num(b, 'data-rv-rating') - num(a, 'data-rv-rating') || byOrder(a, b),
|
|
4954
|
+
lowest: (a, b) => num(a, 'data-rv-rating') - num(b, 'data-rv-rating') || byOrder(a, b),
|
|
4955
|
+
helpful: (a, b) => num(b, 'data-rv-helpful') - num(a, 'data-rv-helpful') || byOrder(a, b),
|
|
4956
|
+
leasthelpful: leastHelpful,
|
|
4957
|
+
unhelpful: leastHelpful,
|
|
4958
|
+
media: (a, b) => isOn(b, 'data-rv-media') - isOn(a, 'data-rv-media') || byOrder(a, b),
|
|
4959
|
+
'media-only': newest,
|
|
4960
|
+
videos: (a, b) => isOn(b, 'data-rv-video') - isOn(a, 'data-rv-video') || newest(a, b),
|
|
4961
|
+
};
|
|
4962
|
+
const matches = (it) => {
|
|
4963
|
+
if (st.star && num(it, 'data-rv-rating') !== st.star) return false;
|
|
4964
|
+
if ((st.media || st.sort === 'media-only') && !isOn(it, 'data-rv-media')) return false;
|
|
4965
|
+
if (st.country && it.getAttribute('data-rv-country-code') !== st.country) return false;
|
|
4966
|
+
if (st.topic) {
|
|
4967
|
+
const tags = (it.getAttribute('data-rv-topics') || '').toLowerCase().split('|').filter(Boolean);
|
|
4968
|
+
if (tags.length ? !tags.includes(st.topic) : !(it.textContent || '').toLowerCase().includes(st.topic)) return false;
|
|
4969
|
+
}
|
|
4970
|
+
if (st.q && !(it.textContent || '').toLowerCase().includes(st.q)) return false;
|
|
4971
|
+
return true;
|
|
4972
|
+
};
|
|
4973
|
+
const filterKeys = ['star', 'media', 'topic', 'q', 'country'];
|
|
4974
|
+
const only = (k) => filterKeys.every((x) => x === k || !st[x]);
|
|
4975
|
+
// Counts as written ("5284") unless the list names a locale: data-rv-locale="en-US" \u2192 5,284; "auto" \u2192 the page's lang.
|
|
4976
|
+
const locale = list ? list.getAttribute('data-rv-locale') : null;
|
|
4977
|
+
const fmtCount = (n) => {
|
|
4978
|
+
const v = Number(n) || 0;
|
|
4979
|
+
if (!locale) return String(v);
|
|
4980
|
+
try { return v.toLocaleString(locale === 'auto' ? (document.documentElement && document.documentElement.lang) || undefined : locale); } catch (err) { return String(v); }
|
|
4981
|
+
};
|
|
4982
|
+
|
|
4983
|
+
// Read more: the button opens the body of its scope; short text hides the button. Measured after every draw, on
|
|
4984
|
+
// resize and once the theme's fonts are in, because a review on a later page (or under a filter) measures 0.
|
|
4985
|
+
const moreScope = (b) => b.closest('[data-rv-more-scope]') || b.closest('[data-rv-item], [data-rv-scope]') || root;
|
|
4986
|
+
const syncReadMore = () => all('[data-rv-readmore]').forEach((b) => {
|
|
4987
|
+
const it = b.closest('[data-rv-item]');
|
|
4988
|
+
if ((it && it.hidden) || b.hasAttribute('data-rv-keep')) return;
|
|
4989
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
4990
|
+
if (!body || !body.getClientRects().length) return;
|
|
4991
|
+
b.hidden = !body.hasAttribute('data-rv-open') && body.scrollHeight <= body.clientHeight + 2;
|
|
4992
|
+
});
|
|
4993
|
+
|
|
4994
|
+
// Search (and, with data-rv-mark-topic, topic) words marked in [data-rv-hl]; the original nodes come back on every draw.
|
|
4995
|
+
const hlOrig = new Map();
|
|
4996
|
+
const markClass = (list && list.getAttribute('data-rv-mark-class')) || '';
|
|
4997
|
+
const highlight = () => {
|
|
4998
|
+
const terms = [st.q, list && list.hasAttribute('data-rv-mark-topic') ? st.topic : ''].filter((x) => x && x.length > 1);
|
|
4999
|
+
const re = terms.length ? new RegExp('(' + terms.map((x) => x.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&')).join('|') + ')', 'gi') : null;
|
|
5000
|
+
all('[data-rv-hl]').forEach((el) => {
|
|
5001
|
+
if (!hlOrig.has(el)) { if (!re) return; hlOrig.set(el, Array.from(el.childNodes).map((n) => n.cloneNode(true))); }
|
|
5002
|
+
else el.replaceChildren(...hlOrig.get(el).map((n) => n.cloneNode(true)));
|
|
5003
|
+
if (!re) return;
|
|
5004
|
+
const walker = document.createTreeWalker(el, 4);
|
|
5005
|
+
const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
5006
|
+
nodes.forEach((n) => {
|
|
5007
|
+
const parts = (n.nodeValue || '').split(re);
|
|
5008
|
+
if (parts.length < 2) return;
|
|
5009
|
+
const frag = document.createDocumentFragment();
|
|
5010
|
+
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); });
|
|
5011
|
+
n.replaceWith(frag);
|
|
5012
|
+
});
|
|
5013
|
+
});
|
|
5014
|
+
};
|
|
5015
|
+
|
|
5016
|
+
const drawPages = (nav, pages) => {
|
|
5017
|
+
const cur = st.page;
|
|
5018
|
+
const win = Number(nav.getAttribute('data-rv-page-window') || 0);
|
|
5019
|
+
let seq;
|
|
5020
|
+
if (win > 0) {
|
|
5021
|
+
// A window of N numbers around the current page, no \u2026 (Yotpo: 1 2 3 4 5).
|
|
5022
|
+
const start = Math.max(1, Math.min(cur - Math.floor(win / 2), pages - win + 1));
|
|
5023
|
+
seq = Array.from({ length: Math.min(win, pages) }, (_, k) => start + k);
|
|
5024
|
+
} else {
|
|
5025
|
+
// 1 \u2026 n around the current page (Judge.me: 1 2 3 4 \u2026 18).
|
|
5026
|
+
const want = new Set([1, pages, cur - 1, cur, cur + 1].filter((p) => p >= 1 && p <= pages));
|
|
5027
|
+
if (cur <= 3) [2, 3, 4].forEach((p) => { if (p <= pages) want.add(p); });
|
|
5028
|
+
seq = Array.from(want).sort((a, b) => a - b);
|
|
5029
|
+
}
|
|
5030
|
+
const tpl = nav.querySelector('[data-rv-page-tpl]');
|
|
5031
|
+
const anchor = nextPages.find((b) => nav.contains(b)) || null;
|
|
5032
|
+
nav.querySelectorAll('[data-rv-page]:not([data-rv-page-tpl]), [data-rv-gap]').forEach((e) => e.remove());
|
|
5033
|
+
let last = 0;
|
|
5034
|
+
seq.forEach((p) => {
|
|
5035
|
+
if (!win && p - last > 1) {
|
|
5036
|
+
const g = document.createElement('span');
|
|
5037
|
+
g.setAttribute('data-rv-gap', ''); g.setAttribute('aria-hidden', 'true');
|
|
5038
|
+
g.className = (tpl && tpl.getAttribute('data-rv-gap-class')) || ''; g.textContent = '\u2026';
|
|
5039
|
+
nav.insertBefore(g, anchor);
|
|
5040
|
+
}
|
|
5041
|
+
const b = document.createElement('button');
|
|
5042
|
+
b.type = 'button'; b.className = tpl ? tpl.className : ''; b.textContent = String(p);
|
|
5043
|
+
b.setAttribute('data-rv-page', String(p)); b.setAttribute('aria-label', 'Page ' + p);
|
|
5044
|
+
if (p === cur) b.setAttribute('aria-current', 'page');
|
|
5045
|
+
nav.insertBefore(b, anchor);
|
|
5046
|
+
last = p;
|
|
5047
|
+
});
|
|
5048
|
+
};
|
|
5049
|
+
|
|
5050
|
+
// Active-filter chips: one per filter in force, a click removes that filter.
|
|
5051
|
+
const chipsBox = root.querySelector('[data-rv-chips]');
|
|
5052
|
+
const chipTpl = root.querySelector('[data-rv-chip-tpl]');
|
|
5053
|
+
const starText = (v) => { const e = root.querySelector('[data-rv-star="' + v + '"][data-rv-star-text]'); return e ? e.getAttribute('data-rv-star-text') || '' : ''; };
|
|
5054
|
+
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; };
|
|
5055
|
+
const drawChips = () => {
|
|
5056
|
+
if (!chipsBox || !chipTpl) return;
|
|
5057
|
+
chipsBox.replaceChildren();
|
|
5058
|
+
const add = (kind, label, star) => {
|
|
5059
|
+
const b = chipTpl.cloneNode(true);
|
|
5060
|
+
b.removeAttribute('data-rv-chip-tpl'); b.hidden = false;
|
|
5061
|
+
const t = b.querySelector('[data-rv-chip-text]'); if (t) t.textContent = label;
|
|
5062
|
+
const s = b.querySelector('[data-rv-chip-star]'); if (s) s.hidden = !star;
|
|
5063
|
+
const v = b.querySelector('[data-rv-chip-value]'); if (v) v.textContent = star || '';
|
|
5064
|
+
b.setAttribute('data-rv-chip', kind);
|
|
5065
|
+
b.setAttribute('aria-label', ((b.getAttribute('data-rv-remove') || 'Remove') + ' ' + label + ' ' + (star || '')).trim());
|
|
5066
|
+
chipsBox.appendChild(b);
|
|
5067
|
+
};
|
|
5068
|
+
if (st.star) add('star', chipsBox.getAttribute('data-rv-chip-rating') || '', starText(st.star) || String(st.star));
|
|
5069
|
+
if (st.media) add('media', chipsBox.getAttribute('data-rv-chip-media') || '', '');
|
|
5070
|
+
if (st.country) add('country', countryText(st.country), '');
|
|
5071
|
+
chipsBox.hidden = !chipsBox.childElementCount;
|
|
5072
|
+
};
|
|
5073
|
+
|
|
5074
|
+
const render = (focusFirstNew, scroll) => {
|
|
5075
|
+
if (!list) return;
|
|
5076
|
+
const sorted = st.sort && SORTS[st.sort] ? items.slice().sort(SORTS[st.sort]) : items;
|
|
5077
|
+
if (st.sort) sorted.forEach((it) => list.appendChild(it));
|
|
5078
|
+
const hit = sorted.filter(matches);
|
|
5079
|
+
const hitSet = new Set(hit);
|
|
5080
|
+
const total = hit.length;
|
|
5081
|
+
const usesPages = !st.collapsed && usesPagesNow();
|
|
5082
|
+
const pages = Math.max(1, Math.ceil(total / pageSize));
|
|
5083
|
+
if (st.page > pages) st.page = pages;
|
|
5084
|
+
const from = usesPages ? (st.page - 1) * pageSize : 0;
|
|
5085
|
+
const to = st.collapsed ? collapsedN : usesPages ? from + pageSize : st.shown;
|
|
5086
|
+
let firstNew = null;
|
|
5087
|
+
items.forEach((it) => { if (!hitSet.has(it)) it.hidden = true; });
|
|
5088
|
+
hit.forEach((it, i) => {
|
|
5089
|
+
const show = i >= from && i < to;
|
|
5090
|
+
if (show && it.hidden && !firstNew && focusFirstNew) firstNew = it;
|
|
5091
|
+
it.hidden = !show;
|
|
5092
|
+
});
|
|
5093
|
+
if (none) none.hidden = total > 0;
|
|
5094
|
+
if (more) more.hidden = st.collapsed || usesPages || to >= total;
|
|
5095
|
+
if (expand) expand.hidden = !st.collapsed || total <= collapsedN;
|
|
5096
|
+
if (list.hasAttribute('data-rv-collapsed')) root.toggleAttribute('data-rv-expanded', !st.collapsed);
|
|
5097
|
+
const filtered = filterKeys.some((k) => !!st[k]);
|
|
5098
|
+
// Found: the store's own count when nothing filters; the star row's (or topic chip's) count when only that filters
|
|
5099
|
+
// (the breakdown of the WHOLE store, as review widgets show it); otherwise what matched on the page.
|
|
5100
|
+
const starEl = st.star ? root.querySelector('[data-rv-star="' + st.star + '"][data-rv-star-count]') : null;
|
|
5101
|
+
const topicEl = st.topic ? all('[data-rv-topic][data-rv-topic-count]').find((x) => (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic) : null;
|
|
5102
|
+
const found = !filtered ? Number(list.getAttribute('data-rv-total') || total)
|
|
5103
|
+
: starEl && only('star') ? Number(starEl.getAttribute('data-rv-star-count'))
|
|
5104
|
+
: topicEl && only('topic') ? Number(topicEl.getAttribute('data-rv-topic-count'))
|
|
5105
|
+
: total;
|
|
5106
|
+
const shown = Math.max(0, Math.min(to, total) - from);
|
|
5107
|
+
root.toggleAttribute('data-rv-filtered', filtered);
|
|
5108
|
+
root.setAttribute('data-rv-f-star', String(st.star || ''));
|
|
5109
|
+
root.setAttribute('data-rv-f-topic', st.topic);
|
|
5110
|
+
root.setAttribute('data-rv-f-country', st.country);
|
|
5111
|
+
root.toggleAttribute('data-rv-f-media', st.media);
|
|
5112
|
+
all('[data-rv-star-value]').forEach((e) => (e.querySelector('.gem-stars') || e).style.setProperty('--gem-stars-value', String(st.star || 0)));
|
|
5113
|
+
all('[data-rv-star-text]:not([data-rv-star])').forEach((e) => { e.textContent = String(st.star || ''); });
|
|
5114
|
+
all('[data-rv-star-label]').forEach((e) => { e.textContent = st.star ? starText(st.star) : ''; });
|
|
5115
|
+
all('[data-rv-country-label]').forEach((e) => { e.textContent = st.country ? countryText(st.country) : ''; });
|
|
5116
|
+
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; });
|
|
5117
|
+
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)); });
|
|
5118
|
+
all('[data-rv-status]').forEach((s) => {
|
|
5119
|
+
const tpl = (found === 1 && s.getAttribute('data-rv-status-one')) || s.getAttribute('data-rv-status-template') || '';
|
|
5120
|
+
s.textContent = tpl && filtered ? tpl.replace('{count}', fmtCount(found)).replace('{shown}', String(shown)).replace('{total}', String(total)) : '';
|
|
5121
|
+
});
|
|
5122
|
+
all('[data-rv-hl-topic]').forEach((m) => m.toggleAttribute('data-rv-hl-on', !!st.topic && (m.getAttribute('data-rv-hl-topic') || '').toLowerCase() === st.topic));
|
|
5123
|
+
highlight();
|
|
5124
|
+
drawChips();
|
|
5125
|
+
pagesNavs.forEach((nav) => {
|
|
5126
|
+
if (usesPages && nav.querySelector('[data-rv-page-tpl]')) drawPages(nav, pages);
|
|
5127
|
+
nav.hidden = !usesPages || pages <= 1;
|
|
5128
|
+
});
|
|
5129
|
+
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)); });
|
|
5130
|
+
prevPages.forEach((b) => { b.toggleAttribute('disabled', st.page <= 1); b.setAttribute('aria-disabled', String(st.page <= 1)); });
|
|
5131
|
+
nextPages.forEach((b) => { b.toggleAttribute('disabled', st.page >= pages); b.setAttribute('aria-disabled', String(st.page >= pages)); });
|
|
5132
|
+
syncReadMore();
|
|
5133
|
+
if (firstNew) {
|
|
5134
|
+
const f = firstNew.querySelector('h3, [tabindex], a, button') || firstNew;
|
|
5135
|
+
if (f === firstNew && !f.hasAttribute('tabindex')) f.setAttribute('tabindex', '-1');
|
|
5136
|
+
f.focus({ preventScroll: false });
|
|
5137
|
+
}
|
|
5138
|
+
if (scroll) { const t = root.querySelector('[data-rv-scroll-to]') || list; if (t.scrollIntoView) t.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
5139
|
+
const detail = { source: 'gem-runtime', section, sectionId, total, found, shown, page: st.page, pages, filtered, star: st.star, sort: st.sort };
|
|
5140
|
+
if (typeof window.CustomEvent === 'function') root.dispatchEvent(new window.CustomEvent('gem:reviews:render', { detail }));
|
|
5141
|
+
api.emit('gem:reviews:render', detail);
|
|
5142
|
+
};
|
|
5143
|
+
const reset = () => { st.page = 1; st.shown = pageSize; };
|
|
5144
|
+
|
|
5145
|
+
// Menus: one open at a time; Escape, a click outside, or choosing an item closes it.
|
|
5146
|
+
const menus = all('[data-rv-menu]');
|
|
5147
|
+
const panelOf = (b) => root.querySelector('[data-rv-menu-panel="' + b.getAttribute('data-rv-menu') + '"]');
|
|
5148
|
+
const closeMenus = (except) => menus.forEach((b) => {
|
|
5149
|
+
if (b === except) return;
|
|
5150
|
+
b.setAttribute('aria-expanded', 'false');
|
|
5151
|
+
const p = panelOf(b); if (p) p.hidden = true;
|
|
5152
|
+
});
|
|
5153
|
+
// A choice made inside a menu panel closes it, and focus goes back to the menu's button (the section behaviors left
|
|
5154
|
+
// 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
|
|
5155
|
+
// that media query matches; sort and Show N always close.
|
|
5156
|
+
const closeAfterChoice = (el, always) => {
|
|
5157
|
+
const p = el.closest('[data-rv-menu-panel]');
|
|
5158
|
+
if (!p) { if (always) closeMenus(); return; }
|
|
5159
|
+
const keep = p.getAttribute('data-rv-menu-keep');
|
|
5160
|
+
if (!always && keep && window.matchMedia && window.matchMedia(keep).matches) return;
|
|
5161
|
+
const owner = menus.find((b) => panelOf(b) === p);
|
|
5162
|
+
closeMenus();
|
|
5163
|
+
if (owner && p.contains(document.activeElement)) owner.focus({ preventScroll: true });
|
|
5164
|
+
};
|
|
5165
|
+
menus.forEach((b) => on(b, 'click', (e) => {
|
|
5166
|
+
e.preventDefault();
|
|
5167
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
5168
|
+
closeMenus(b);
|
|
5169
|
+
b.setAttribute('aria-expanded', String(open));
|
|
5170
|
+
const p = panelOf(b);
|
|
5171
|
+
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 }); } }
|
|
5172
|
+
}));
|
|
5173
|
+
if (menus.length) {
|
|
5174
|
+
on(window, 'keydown', (e) => {
|
|
5175
|
+
if (e.key !== 'Escape') return;
|
|
5176
|
+
const openMenu = menus.find((x) => x.getAttribute('aria-expanded') === 'true');
|
|
5177
|
+
if (openMenu) { closeMenus(); openMenu.focus(); }
|
|
5178
|
+
});
|
|
5179
|
+
on(window, 'click', (e) => {
|
|
5180
|
+
if (!(e.target instanceof window.Node)) return;
|
|
5181
|
+
if (menus.some((b) => b.contains(e.target)) || all('[data-rv-menu-panel]').some((p) => p.contains(e.target))) return;
|
|
5182
|
+
closeMenus();
|
|
5183
|
+
});
|
|
5184
|
+
}
|
|
5185
|
+
|
|
5186
|
+
// Every filter control reads its state from st (buttons, menu items, chips, Clear).
|
|
5187
|
+
const syncStars = () => all('[data-rv-star]').forEach((x) => {
|
|
5188
|
+
const yes = Number(x.getAttribute('data-rv-star')) === st.star;
|
|
5189
|
+
if (x.hasAttribute('aria-checked')) x.setAttribute('aria-checked', String(yes));
|
|
5190
|
+
else if (x.hasAttribute('aria-current')) x.setAttribute('aria-current', String(yes && st.star > 0));
|
|
5191
|
+
else x.setAttribute('aria-pressed', String(yes && st.star > 0));
|
|
5192
|
+
});
|
|
5193
|
+
const syncControls = () => {
|
|
5194
|
+
syncStars();
|
|
5195
|
+
all('[data-rv-media-filter]').forEach((x) => { if (x.matches('input')) x.checked = st.media; else x.setAttribute('aria-pressed', String(st.media)); });
|
|
5196
|
+
all('[data-rv-country]').forEach((x) => x.setAttribute('aria-checked', String((x.getAttribute('data-rv-country') || '') === st.country)));
|
|
5197
|
+
all('[data-rv-topic]').forEach((x) => x.setAttribute('aria-pressed', String(!!st.topic && (x.getAttribute('data-rv-topic') || '').toLowerCase() === st.topic)));
|
|
5198
|
+
};
|
|
5199
|
+
|
|
5200
|
+
// Toggles: an inline panel / region / collapse opened by its button, not closed by a click outside. Closing a region
|
|
5201
|
+
// that has data-rv-region-clear drops the filters inside it (closing the filter row = no star filter; closing the
|
|
5202
|
+
// search box = no search), as Judge.me does.
|
|
5203
|
+
const PANEL_ATTRS = ['data-rv-toggle-panel', 'data-rv-region', 'data-rv-collapse-panel'];
|
|
5204
|
+
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));
|
|
5205
|
+
const toggleId = (t) => (t.hasAttribute('data-rv-toggle') ? t.getAttribute('data-rv-toggle') : t.getAttribute('data-rv-collapse')) || '';
|
|
5206
|
+
const setToggle = (id, open) => {
|
|
5207
|
+
const ps = togglePanels(id);
|
|
5208
|
+
if (!ps.length) return;
|
|
5209
|
+
const was = ps.some((p) => !p.hidden);
|
|
5210
|
+
ps.forEach((p) => { p.hidden = !open; });
|
|
5211
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => { if (toggleId(t) === id) t.setAttribute('aria-expanded', String(open)); });
|
|
5212
|
+
const r = !open && was ? ps.find((p) => p.hasAttribute('data-rv-region-clear')) : null;
|
|
5213
|
+
if (!r) return;
|
|
5214
|
+
let changed = false;
|
|
5215
|
+
if (r.querySelector('[data-rv-star]') && st.star) { st.star = 0; changed = true; syncControls(); }
|
|
5216
|
+
r.querySelectorAll('input[data-rv-search]').forEach((x) => { if (x.value || st.q) { x.value = ''; st.q = ''; changed = true; } });
|
|
5217
|
+
if (changed) { reset(); render(); }
|
|
5218
|
+
};
|
|
5219
|
+
all('[data-rv-toggle], [data-rv-collapse]').forEach((t) => on(t, 'click', (e) => {
|
|
5220
|
+
e.preventDefault();
|
|
5221
|
+
const ps = togglePanels(toggleId(t));
|
|
5222
|
+
setToggle(toggleId(t), ps.length ? ps.every((p) => p.hidden) : t.getAttribute('aria-expanded') !== 'true');
|
|
5223
|
+
}));
|
|
5224
|
+
|
|
5225
|
+
const setSort = (key, label) => {
|
|
5226
|
+
st.sort = key; reset();
|
|
5227
|
+
all('[data-rv-sort-option]').forEach((o) => o.setAttribute('aria-checked', String(o.getAttribute('data-rv-sort-option') === key)));
|
|
5228
|
+
all('select[data-rv-sort]').forEach((s) => { if (s.value !== key) s.value = key; });
|
|
5229
|
+
if (label) {
|
|
5230
|
+
all('[data-rv-sort-label]').forEach((l) => { l.textContent = label; });
|
|
5231
|
+
all('[data-rv-menu][data-rv-sort-aria]').forEach((b) => b.setAttribute('aria-label', (b.getAttribute('data-rv-sort-aria') || '').replace('{label}', label)));
|
|
5232
|
+
}
|
|
5233
|
+
render();
|
|
5234
|
+
};
|
|
5235
|
+
all('select[data-rv-sort]').forEach((s) => on(s, 'change', () => setSort(s.value, '')));
|
|
5236
|
+
all('[data-rv-sort-option]').forEach((o) => on(o, 'click', (e) => {
|
|
5237
|
+
e.preventDefault();
|
|
5238
|
+
setSort(o.getAttribute('data-rv-sort-option') || '', (o.getAttribute('data-rv-sort-text') || o.textContent || '').trim());
|
|
5239
|
+
closeAfterChoice(o, true);
|
|
5240
|
+
}));
|
|
5241
|
+
const starKeep = root.getAttribute('data-rv-star-keep');
|
|
5242
|
+
all('[data-rv-star]').forEach((b) => on(b, 'click', (e) => {
|
|
5243
|
+
e.preventDefault();
|
|
5244
|
+
const v = Number(b.getAttribute('data-rv-star')) || 0;
|
|
5245
|
+
const keep = starKeep !== null && (starKeep !== 'menu' || !!b.closest('[data-rv-menu-panel]'));
|
|
5246
|
+
st.star = keep ? v : st.star === v ? 0 : v; reset();
|
|
5247
|
+
syncControls();
|
|
5248
|
+
if (b.hasAttribute('data-rv-open-region')) setToggle(b.getAttribute('data-rv-open-region') || '', true);
|
|
5249
|
+
closeAfterChoice(b);
|
|
5250
|
+
render();
|
|
5251
|
+
}));
|
|
5252
|
+
all('[data-rv-media-filter]').forEach((m) => on(m, m.matches('input') ? 'change' : 'click', (e) => {
|
|
5253
|
+
if (!m.matches('input')) e.preventDefault();
|
|
5254
|
+
st.media = m.matches('input') ? m.checked : !st.media; reset();
|
|
5255
|
+
syncControls();
|
|
5256
|
+
render();
|
|
5257
|
+
}));
|
|
5258
|
+
all('[data-rv-country]').forEach((c) => on(c, 'click', (e) => {
|
|
5259
|
+
e.preventDefault();
|
|
5260
|
+
st.country = c.getAttribute('data-rv-country') || ''; reset();
|
|
5261
|
+
syncControls();
|
|
5262
|
+
closeAfterChoice(c);
|
|
5263
|
+
render();
|
|
5264
|
+
}));
|
|
5265
|
+
all('[data-rv-topic]').forEach((t) => on(t, 'click', (e) => {
|
|
5266
|
+
e.preventDefault();
|
|
5267
|
+
const v = (t.getAttribute('data-rv-topic') || '').toLowerCase();
|
|
5268
|
+
st.topic = st.topic === v ? '' : v; reset();
|
|
5269
|
+
syncControls();
|
|
5270
|
+
render();
|
|
5271
|
+
}));
|
|
5272
|
+
all('input[data-rv-search]').forEach((s) => {
|
|
5273
|
+
let wait = 0;
|
|
5274
|
+
const apply = () => { st.q = s.value.trim().toLowerCase(); reset(); render(); };
|
|
5275
|
+
on(s, 'input', () => { window.clearTimeout(wait); wait = window.setTimeout(apply, 180); });
|
|
5276
|
+
on(s.form || s, 'submit', (e) => { e.preventDefault(); apply(); });
|
|
5277
|
+
});
|
|
5278
|
+
// Clear: data-rv-clear="star" | "topic" drops that filter only (the "\xD7 Clear" beside a label); empty drops them all.
|
|
5279
|
+
all('[data-rv-clear]').forEach((c) => on(c, 'click', (e) => {
|
|
5280
|
+
e.preventDefault();
|
|
5281
|
+
const scope = c.getAttribute('data-rv-clear') || '';
|
|
5282
|
+
if (!scope || scope === 'star') st.star = 0;
|
|
5283
|
+
if (!scope || scope === 'topic') st.topic = '';
|
|
5284
|
+
if (!scope) { st.media = false; st.q = ''; st.country = ''; all('input[data-rv-search]').forEach((x) => { x.value = ''; }); }
|
|
5285
|
+
reset();
|
|
5286
|
+
syncControls();
|
|
5287
|
+
render();
|
|
5288
|
+
}));
|
|
5289
|
+
on(chipsBox, 'click', (e) => {
|
|
5290
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-chip]') : null;
|
|
5291
|
+
if (!b) return;
|
|
5292
|
+
e.preventDefault();
|
|
5293
|
+
const k = b.getAttribute('data-rv-chip');
|
|
5294
|
+
if (k === 'star') st.star = 0;
|
|
5295
|
+
if (k === 'media') st.media = false;
|
|
5296
|
+
if (k === 'country') st.country = '';
|
|
5297
|
+
reset(); syncControls(); render();
|
|
5298
|
+
});
|
|
5299
|
+
// A filter sheet with Apply: its star chips only stage a choice, Apply filters, Reset un-stages.
|
|
5300
|
+
const syncStage = () => all('[data-rv-stage]').forEach((c) => c.setAttribute('aria-pressed', String(Number(c.getAttribute('data-rv-stage')) === st.staged)));
|
|
5301
|
+
all('[data-rv-stage]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = Number(c.getAttribute('data-rv-stage')) || 0; syncStage(); }));
|
|
5302
|
+
all('[data-rv-reset]').forEach((c) => on(c, 'click', (e) => { e.preventDefault(); st.staged = 0; syncStage(); }));
|
|
5303
|
+
all('[data-rv-apply]').forEach((c) => on(c, 'click', (e) => {
|
|
5304
|
+
e.preventDefault();
|
|
5305
|
+
st.star = st.staged; reset(); syncControls(); render();
|
|
5306
|
+
const d = c.closest('dialog'); if (d && d.open) d.close();
|
|
5307
|
+
}));
|
|
5308
|
+
|
|
5309
|
+
on(more, 'click', (e) => { e.preventDefault(); st.shown += moreSize(); render(true); });
|
|
5310
|
+
on(expand, 'click', (e) => { e.preventDefault(); st.collapsed = false; st.page = 1; render(); });
|
|
5311
|
+
pagesNavs.forEach((nav) => on(nav, 'click', (e) => {
|
|
5312
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-page]') : null;
|
|
5313
|
+
if (!b || b.hasAttribute('data-rv-page-tpl')) return;
|
|
5314
|
+
e.preventDefault(); st.page = Number(b.getAttribute('data-rv-page')) || 1; render(false, true);
|
|
5315
|
+
}));
|
|
5316
|
+
prevPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); if (st.page > 1) { st.page -= 1; render(false, true); } }));
|
|
5317
|
+
nextPages.forEach((b) => on(b, 'click', (e) => { e.preventDefault(); st.page += 1; render(false, true); }));
|
|
5318
|
+
all('[data-rv-per-page-option]').forEach((o) => on(o, 'click', (e) => {
|
|
5319
|
+
e.preventDefault();
|
|
5320
|
+
pageSize = Math.max(1, Number(o.getAttribute('data-rv-per-page-option')) || pageSize); reset();
|
|
5321
|
+
all('[data-rv-per-page-option]').forEach((x) => x.setAttribute('aria-checked', String(x === o)));
|
|
5322
|
+
all('[data-rv-per-page-label]').forEach((l) => { l.textContent = String(pageSize); });
|
|
5323
|
+
closeAfterChoice(o, true);
|
|
5324
|
+
render();
|
|
5325
|
+
}));
|
|
5326
|
+
if (pagesMq) on(pagesMq, 'change', () => { reset(); render(); });
|
|
5327
|
+
|
|
5328
|
+
all('[data-rv-readmore]').forEach((b) => on(b, 'click', (e) => {
|
|
5329
|
+
e.preventDefault();
|
|
5330
|
+
const body = moreScope(b).querySelector('[data-rv-body]');
|
|
5331
|
+
const open = b.getAttribute('aria-expanded') !== 'true';
|
|
5332
|
+
b.setAttribute('aria-expanded', String(open));
|
|
5333
|
+
if (body) body.toggleAttribute('data-rv-open', open);
|
|
5334
|
+
const less = b.getAttribute('data-rv-less'); const moreText = b.getAttribute('data-rv-more-text');
|
|
5335
|
+
if (less && moreText) b.textContent = open ? less : moreText;
|
|
5336
|
+
}));
|
|
5337
|
+
let resizeWait = 0;
|
|
5338
|
+
on(window, 'resize', () => { window.clearTimeout(resizeWait); resizeWait = window.setTimeout(syncReadMore, 150); });
|
|
5339
|
+
if (document.fonts && document.fonts.ready) document.fonts.ready.then(syncReadMore).catch(() => {});
|
|
5340
|
+
on(root, 'gem:reviews:refresh', () => render());
|
|
5341
|
+
|
|
5342
|
+
// Tracks (a strip of photos or cards): back / forward one card; the buttons switch off at either end. A looping
|
|
5343
|
+
// track (data-rv-loop) is the section's own carousel and is left to it.
|
|
5344
|
+
all('[data-rv-track]').forEach((track) => {
|
|
5345
|
+
if (track.hasAttribute('data-rv-loop')) return;
|
|
5346
|
+
const scope = track.closest('[data-rv-carousel]') || root;
|
|
5347
|
+
const prev = scope.querySelector('[data-rv-slide-prev]'); const next = scope.querySelector('[data-rv-slide-next]');
|
|
5348
|
+
const step = () => { const c = track.firstElementChild; const gap = parseFloat(window.getComputedStyle(track).columnGap || '0') || 0; return c ? c.getBoundingClientRect().width + gap : track.clientWidth; };
|
|
5349
|
+
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); };
|
|
5350
|
+
on(prev, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: -step(), behavior: 'smooth' }); });
|
|
5351
|
+
on(next, 'click', (e) => { e.preventDefault(); track.scrollBy({ left: step(), behavior: 'smooth' }); });
|
|
5352
|
+
on(track, 'scroll', sync, { passive: true });
|
|
5353
|
+
sync();
|
|
5354
|
+
});
|
|
5355
|
+
|
|
5356
|
+
// Filter sheet (phones): a button opens dialog[data-rv-sheet], delegated on the root so copies made after binding
|
|
5357
|
+
// (an inline card a section repeats) open it too; closing returns focus to that button.
|
|
5358
|
+
let sheetFrom = null;
|
|
5359
|
+
on(root, 'click', (e) => {
|
|
5360
|
+
const b = isEl(e.target) ? e.target.closest('[data-rv-sheet-open]') : null;
|
|
5361
|
+
if (!b) return;
|
|
5362
|
+
const d = root.querySelector('dialog[data-rv-sheet="' + b.getAttribute('data-rv-sheet-open') + '"]');
|
|
5363
|
+
if (!d || typeof d.showModal !== 'function') return;
|
|
5364
|
+
e.preventDefault(); sheetFrom = b; st.staged = st.star; syncStage(); closeMenus(); if (!d.open) d.showModal();
|
|
5365
|
+
});
|
|
5366
|
+
all('dialog[data-rv-sheet]').forEach((d) => {
|
|
5367
|
+
d.querySelectorAll('[data-rv-sheet-close]').forEach((c) => on(c, 'click', (e) => {
|
|
5368
|
+
e.preventDefault(); if (d.open) d.close();
|
|
5369
|
+
if (c.hasAttribute('data-rv-scroll-list') && list) { sheetFrom = null; list.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
|
5370
|
+
}));
|
|
5371
|
+
on(d, 'click', (e) => {
|
|
5372
|
+
if (e.target !== d || !d.hasAttribute('data-rv-backdrop-close')) return;
|
|
5373
|
+
const r = d.getBoundingClientRect();
|
|
5374
|
+
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) d.close();
|
|
5375
|
+
});
|
|
5376
|
+
on(d, 'close', () => { const opener = sheetFrom && sheetFrom.isConnected ? sheetFrom : null; sheetFrom = null; if (opener) opener.focus({ preventScroll: true }); });
|
|
5377
|
+
});
|
|
5378
|
+
|
|
5379
|
+
// Helpful: once per review (or per [data-rv-vote-scope]: a question, an answer), counted in place.
|
|
5380
|
+
all('[data-rv-vote]').forEach((b) => on(b, 'click', (e) => {
|
|
5381
|
+
e.preventDefault();
|
|
5382
|
+
const scope = b.closest('[data-rv-item], [data-rv-vote-scope]');
|
|
5383
|
+
if (!scope || scope.hasAttribute('data-rv-voted')) return;
|
|
5384
|
+
scope.setAttribute('data-rv-voted', b.getAttribute('data-rv-vote') || '');
|
|
5385
|
+
b.setAttribute('aria-pressed', 'true');
|
|
5386
|
+
const c = b.querySelector('[data-rv-vote-count]'); if (c) c.textContent = String((Number(c.textContent) || 0) + 1);
|
|
5387
|
+
scope.querySelectorAll('[data-rv-vote]').forEach((x) => x.setAttribute('disabled', ''));
|
|
5388
|
+
const detail = { source: 'gem-runtime', section, sectionId, review: scope.id, value: b.getAttribute('data-rv-vote') };
|
|
5389
|
+
api.emit('gem:review:vote', detail);
|
|
5390
|
+
if (typeof window.CustomEvent === 'function') window.dispatchEvent(new window.CustomEvent('gem:review:vote', { detail }));
|
|
5391
|
+
}));
|
|
5392
|
+
|
|
5393
|
+
// Lightbox. The set it walks: every [data-rv-zoom] of the section in page order (data-rv-lb-dedupe: one entry per
|
|
5394
|
+
// photo), the opener's [data-rv-group], the opener's review (data-rv-lb-scope="review"), or the opener's own
|
|
5395
|
+
// data-rv-media-list. Ends: hide the arrow (default), disable it, or wrap. The review the photo belongs to is copied
|
|
5396
|
+
// beside it from [data-rv-lb-part]; Escape closes (native dialog) and focus returns to the opener.
|
|
5397
|
+
const lb = root.querySelector('dialog[data-rv-lightbox]');
|
|
5398
|
+
const zooms = all('[data-rv-zoom]');
|
|
5399
|
+
const lbScoped = !!lb && lb.getAttribute('data-rv-lb-scope') === 'review';
|
|
5400
|
+
const lbEnds = lb ? lb.getAttribute('data-rv-lb-ends') || (lbScoped ? 'disable' : 'hide') : 'hide';
|
|
5401
|
+
const lbVotes = !lb || lb.getAttribute('data-rv-lb-votes') !== 'off';
|
|
5402
|
+
const entryOf = (z) => {
|
|
5403
|
+
const img = z.querySelector('img');
|
|
5404
|
+
return { el: z, src: z.getAttribute('data-rv-src') || '', kind: z.getAttribute('data-rv-kind') || '', poster: z.getAttribute('data-rv-poster') || '',
|
|
5405
|
+
alt: z.getAttribute('data-rv-alt') || '', thumb: z.getAttribute('data-rv-thumb') || (img && img.getAttribute('src')) || z.getAttribute('data-rv-src') || '',
|
|
5406
|
+
review: z.getAttribute('data-rv-review') || '' };
|
|
5407
|
+
};
|
|
5408
|
+
const lbAll = [];
|
|
5409
|
+
const seenSrc = new Set();
|
|
5410
|
+
zooms.forEach((z) => {
|
|
5411
|
+
const en = entryOf(z);
|
|
5412
|
+
if (lb && lb.hasAttribute('data-rv-lb-dedupe')) { if (seenSrc.has(en.src)) return; seenSrc.add(en.src); }
|
|
5413
|
+
lbAll.push(en);
|
|
5414
|
+
});
|
|
5415
|
+
const setFor = (z) => {
|
|
5416
|
+
const raw = z.getAttribute('data-rv-media-list');
|
|
5417
|
+
if (raw !== null) {
|
|
5418
|
+
let parsed = [];
|
|
5419
|
+
try { parsed = JSON.parse(raw || '[]'); } catch (err) { parsed = []; }
|
|
5420
|
+
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') || '' }));
|
|
5421
|
+
}
|
|
5422
|
+
const g = z.getAttribute('data-rv-group');
|
|
5423
|
+
if (g) return lbAll.filter((x) => x.el.getAttribute('data-rv-group') === g);
|
|
5424
|
+
if (lbScoped) {
|
|
5425
|
+
const r = z.getAttribute('data-rv-review') || '';
|
|
5426
|
+
const own = lbAll.filter((x) => x.review === r && !x.el.closest('[data-rv-carousel]'));
|
|
5427
|
+
return own.length ? own : [entryOf(z)];
|
|
5428
|
+
}
|
|
5429
|
+
return lbAll;
|
|
5430
|
+
};
|
|
5431
|
+
const indexIn = (set, z) => {
|
|
5432
|
+
const i = set.findIndex((x) => x.el === z);
|
|
5433
|
+
if (i >= 0) return i;
|
|
5434
|
+
const src = z.getAttribute('data-rv-src');
|
|
5435
|
+
const j = set.findIndex((x) => x.src === src && x.review === (z.getAttribute('data-rv-review') || ''));
|
|
5436
|
+
return Math.max(0, j >= 0 ? j : set.findIndex((x) => x.src === src));
|
|
5437
|
+
};
|
|
5438
|
+
let lbSet = lbAll; let lbAt = 0; let lbFrom = null; let stripFor = null;
|
|
5439
|
+
const strip = lb ? lb.querySelector('[data-rv-lb-thumbs]') : null;
|
|
5440
|
+
const buildStrip = () => {
|
|
5441
|
+
if (!strip || stripFor === lbSet) return;
|
|
5442
|
+
stripFor = lbSet;
|
|
5443
|
+
strip.replaceChildren();
|
|
5444
|
+
if (lbSet.length > 1) lbSet.forEach((x, k) => {
|
|
5445
|
+
const b = document.createElement('button');
|
|
5446
|
+
b.type = 'button'; b.className = strip.getAttribute('data-rv-thumb-class') || ''; b.setAttribute('data-rv-lb-thumb', String(k));
|
|
5447
|
+
b.setAttribute('aria-label', (strip.getAttribute('data-rv-thumb-label') || 'Media {n} of {total}').replace('{n}', String(k + 1)).replace('{total}', String(lbSet.length)));
|
|
5448
|
+
const img = document.createElement('img'); img.src = x.thumb; img.alt = ''; img.loading = 'lazy'; img.decoding = 'async';
|
|
5449
|
+
b.appendChild(img); strip.appendChild(b);
|
|
5450
|
+
});
|
|
5451
|
+
strip.hidden = lbSet.length <= 1;
|
|
5452
|
+
};
|
|
5453
|
+
const lbShow = (i) => {
|
|
5454
|
+
if (!lb) return;
|
|
5455
|
+
const n = lbSet.length;
|
|
5456
|
+
lbAt = lbEnds === 'wrap' && n ? ((i % n) + n) % n : Math.max(0, Math.min(n - 1, i));
|
|
5457
|
+
const x = lbSet[lbAt];
|
|
5458
|
+
lb.toggleAttribute('data-rv-lb-empty', !n);
|
|
5459
|
+
lb.toggleAttribute('data-rv-lb-multi', n > 1);
|
|
5460
|
+
const media = lb.querySelector('[data-rv-lb-media]');
|
|
5461
|
+
if (media) {
|
|
5462
|
+
media.replaceChildren();
|
|
5463
|
+
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); }
|
|
5464
|
+
else if (x) { const img = document.createElement('img'); img.src = x.src; img.alt = x.alt; media.appendChild(img); }
|
|
5465
|
+
}
|
|
5466
|
+
const stage = lb.querySelector('[data-rv-lb-stage]');
|
|
5467
|
+
if (stage && x) {
|
|
5468
|
+
const img = x.el && x.el.querySelector('img');
|
|
5469
|
+
let bg = img ? img.currentSrc || img.src : '';
|
|
5470
|
+
if (!bg) { try { bg = new URL(x.src, window.location.href).href; } catch (err) { bg = x.src; } }
|
|
5471
|
+
stage.style.setProperty('--rv-lb-bg', 'url("' + String(bg).replace(/"/g, '%22') + '")');
|
|
5472
|
+
}
|
|
5473
|
+
const panel = lb.querySelector('[data-rv-lb-review]');
|
|
5474
|
+
if (panel) {
|
|
5475
|
+
panel.replaceChildren();
|
|
5476
|
+
// A review with no photos (an empty media list) still shows its review: the opener names it.
|
|
5477
|
+
const rid = x ? x.review : (lbFrom && lbFrom.getAttribute && lbFrom.getAttribute('data-rv-review')) || '';
|
|
5478
|
+
const item = rid ? items.find((it) => it.id === rid) : null;
|
|
5479
|
+
if (item) item.querySelectorAll('[data-rv-lb-part]').forEach((part) => {
|
|
5480
|
+
const copy = part.cloneNode(true);
|
|
5481
|
+
copy.removeAttribute('id'); copy.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
|
5482
|
+
copy.querySelectorAll('input, label[for], [data-rv-readmore]').forEach((el) => el.remove());
|
|
5483
|
+
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'); });
|
|
5484
|
+
copy.querySelectorAll('[data-rv-zoom-alias]').forEach((t) => { if (lbSet === lbAll && t.getAttribute('data-rv-zoom-alias') === String(lbAt)) t.setAttribute('aria-current', 'true'); });
|
|
5485
|
+
if (!lbVotes) copy.querySelectorAll('button').forEach((el) => { el.setAttribute('tabindex', '-1'); el.setAttribute('disabled', ''); });
|
|
5486
|
+
panel.appendChild(copy);
|
|
5487
|
+
});
|
|
5488
|
+
panel.hidden = !item;
|
|
5489
|
+
lb.toggleAttribute('data-rv-lb-has-review', !!item);
|
|
5490
|
+
}
|
|
5491
|
+
const peek = lb.querySelector('[data-rv-lb-peek]');
|
|
5492
|
+
if (peek) {
|
|
5493
|
+
peek.replaceChildren();
|
|
5494
|
+
const nx = lbSet[lbAt + 1];
|
|
5495
|
+
const url = nx ? nx.poster || (nx.kind === 'video' ? '' : nx.src) : '';
|
|
5496
|
+
if (url) { const img = document.createElement('img'); img.src = url; img.alt = ''; peek.appendChild(img); }
|
|
5497
|
+
peek.hidden = !nx;
|
|
5498
|
+
}
|
|
5499
|
+
const count = lb.querySelector('[data-rv-lb-count]'); if (count) count.textContent = n ? (lbAt + 1) + ' / ' + n : '';
|
|
5500
|
+
const pv = lb.querySelector('[data-rv-lb-prev]'); const nxb = lb.querySelector('[data-rv-lb-next]');
|
|
5501
|
+
[[pv, lbAt <= 0], [nxb, lbAt >= n - 1]].forEach(([b, atEnd]) => {
|
|
5502
|
+
if (!b) return;
|
|
5503
|
+
if (lbEnds === 'wrap') { b.hidden = n < 2; b.disabled = false; }
|
|
5504
|
+
else if (lbEnds === 'disable') { b.hidden = n < 2; b.disabled = atEnd; }
|
|
5505
|
+
else { b.hidden = atEnd; b.disabled = false; }
|
|
5506
|
+
});
|
|
5507
|
+
if (strip) Array.from(strip.children).forEach((t, k) => {
|
|
5508
|
+
if (k !== lbAt) { t.removeAttribute('aria-current'); return; }
|
|
5509
|
+
t.setAttribute('aria-current', 'true');
|
|
5510
|
+
if (strip.scrollTo) strip.scrollTo({ left: t.offsetLeft - (strip.clientWidth - t.offsetWidth) / 2, behavior: lb.open ? 'smooth' : 'auto' });
|
|
5511
|
+
});
|
|
5512
|
+
};
|
|
5513
|
+
const lbOpen = (e, set, at, opener) => {
|
|
5514
|
+
if (!lb || typeof lb.showModal !== 'function') return;
|
|
5515
|
+
e.preventDefault();
|
|
5516
|
+
closeMenus();
|
|
5517
|
+
lbFrom = opener; lbSet = set; buildStrip();
|
|
5518
|
+
lb.querySelectorAll('[data-rv-toggle]').forEach((t) => setToggle(toggleId(t), false));
|
|
5519
|
+
lbShow(at);
|
|
5520
|
+
if (!lb.open) lb.showModal();
|
|
5521
|
+
};
|
|
5522
|
+
zooms.forEach((z) => {
|
|
5523
|
+
const open = (e) => { const set = setFor(z); lbOpen(e, set, z.hasAttribute('data-rv-media-list') ? 0 : indexIn(set, z), z); };
|
|
5524
|
+
on(z, 'click', open);
|
|
5525
|
+
on(z, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') open(e); });
|
|
5526
|
+
});
|
|
5527
|
+
all('[data-rv-zoom-alias]').forEach((z) => on(z, 'click', (e) => {
|
|
5528
|
+
if (!lb || !lbAll.length) return;
|
|
5529
|
+
e.stopPropagation();
|
|
5530
|
+
lbOpen(e, lbAll, Number(z.getAttribute('data-rv-zoom-alias')) || 0, z);
|
|
5531
|
+
}));
|
|
5532
|
+
if (lb) {
|
|
5533
|
+
on(lb.querySelector('[data-rv-lb-prev]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt - 1); });
|
|
5534
|
+
on(lb.querySelector('[data-rv-lb-next]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
5535
|
+
on(lb.querySelector('[data-rv-lb-close]'), 'click', (e) => { e.preventDefault(); lb.close(); });
|
|
5536
|
+
on(lb.querySelector('[data-rv-lb-peek]'), 'click', (e) => { e.preventDefault(); lbShow(lbAt + 1); });
|
|
5537
|
+
on(strip, 'click', (e) => {
|
|
5538
|
+
const t = isEl(e.target) ? e.target.closest('[data-rv-lb-thumb]') : null;
|
|
5539
|
+
if (!t) return;
|
|
5540
|
+
e.preventDefault(); lbShow(Number(t.getAttribute('data-rv-lb-thumb')) || 0);
|
|
5541
|
+
});
|
|
5542
|
+
on(lb, 'keydown', (e) => { if (e.key === 'ArrowLeft') lbShow(lbAt - 1); if (e.key === 'ArrowRight') lbShow(lbAt + 1); });
|
|
5543
|
+
on(lb, 'click', (e) => { if (e.target === lb) lb.close(); });
|
|
5544
|
+
// Swipe on the photo (some widgets show no arrows on phones).
|
|
5545
|
+
const swipe = lb.querySelector('[data-rv-lb-stage]') || lb.querySelector('[data-rv-lb-media]');
|
|
5546
|
+
let sx = 0; let sy = 0; let tracking = false;
|
|
5547
|
+
on(swipe, 'touchstart', (e) => { const t = e.touches[0]; if (!t) return; sx = t.clientX; sy = t.clientY; tracking = true; }, { passive: true });
|
|
5548
|
+
on(swipe, 'touchend', (e) => {
|
|
5549
|
+
if (!tracking) return; tracking = false;
|
|
5550
|
+
const t = e.changedTouches[0]; if (!t) return;
|
|
5551
|
+
const dx = t.clientX - sx; const dy = t.clientY - sy;
|
|
5552
|
+
if (Math.abs(dx) > 40 && Math.abs(dx) > Math.abs(dy)) lbShow(lbAt + (dx < 0 ? 1 : -1));
|
|
5553
|
+
});
|
|
5554
|
+
// Helpful from inside the lightbox: the copy's button presses the real one, then the copy redraws with the count.
|
|
5555
|
+
on(lb.querySelector('[data-rv-lb-review]'), 'click', (e) => {
|
|
5556
|
+
const v = lbVotes && isEl(e.target) ? e.target.closest('[data-rv-vote]') : null;
|
|
5557
|
+
if (!v) return;
|
|
5558
|
+
e.preventDefault();
|
|
5559
|
+
const x = lbSet[lbAt];
|
|
5560
|
+
const item = x && x.review ? items.find((it) => it.id === x.review) : null;
|
|
5561
|
+
const real = item && item.querySelector('[data-rv-vote="' + v.getAttribute('data-rv-vote') + '"]');
|
|
5562
|
+
if (real) real.click();
|
|
5563
|
+
lbShow(lbAt);
|
|
5564
|
+
});
|
|
5565
|
+
on(lb, 'close', () => { const vid = lb.querySelector('video'); if (vid) vid.pause(); if (lbFrom && lbFrom.focus) lbFrom.focus({ preventScroll: true }); });
|
|
5566
|
+
}
|
|
5567
|
+
|
|
5568
|
+
render();
|
|
5569
|
+
});
|
|
5570
|
+
api.emit('gem:reviews:ready', { source: 'gem-runtime', count: boundReviews });`;
|
|
5571
|
+
}
|
|
5572
|
+
|
|
4853
5573
|
// ../../packages/gem-framework/src/runtime.ts
|
|
4854
5574
|
var runtimePluginReadyEvents = {
|
|
4855
5575
|
"cart-drawer": "gem:cart:ready",
|
|
@@ -4873,7 +5593,8 @@ var runtimePluginReadyEvents = {
|
|
|
4873
5593
|
"img-in": "gem:img-in:ready",
|
|
4874
5594
|
"seasonal-effects": "gem:seasonal:ready",
|
|
4875
5595
|
"context-adapter": "gem:context:ready",
|
|
4876
|
-
"offer-engine": "gem:offer:ready"
|
|
5596
|
+
"offer-engine": "gem:offer:ready",
|
|
5597
|
+
reviews: "gem:reviews:ready"
|
|
4877
5598
|
};
|
|
4878
5599
|
var cartDrawerPluginRegistrationLines = [
|
|
4879
5600
|
" registerPlugin('cart-drawer', ({ api }) => {",
|
|
@@ -5951,6 +6672,11 @@ var offerEnginePluginRegistrationLines = [
|
|
|
5951
6672
|
" api.emit('gem:offer:ready', { source: 'gem-runtime' });",
|
|
5952
6673
|
" });"
|
|
5953
6674
|
];
|
|
6675
|
+
var reviewsPluginRegistrationLines = [
|
|
6676
|
+
" registerPlugin('reviews', ({ api }) => {",
|
|
6677
|
+
" " + reviewsEngineRuntimeScript(),
|
|
6678
|
+
" });"
|
|
6679
|
+
];
|
|
5954
6680
|
var runtimePluginSourceLines = [
|
|
5955
6681
|
["cart-drawer", cartDrawerPluginRegistrationLines],
|
|
5956
6682
|
["mobile-menu", mobileMenuPluginRegistrationLines],
|
|
@@ -5972,7 +6698,8 @@ var runtimePluginSourceLines = [
|
|
|
5972
6698
|
["img-in", imgInPluginRegistrationLines],
|
|
5973
6699
|
["recently-viewed", recentlyViewedPluginRegistrationLines],
|
|
5974
6700
|
["runtime-slot-loader", runtimeSlotLoaderPluginRegistrationLines],
|
|
5975
|
-
["offer-engine", offerEnginePluginRegistrationLines]
|
|
6701
|
+
["offer-engine", offerEnginePluginRegistrationLines],
|
|
6702
|
+
["reviews", reviewsPluginRegistrationLines]
|
|
5976
6703
|
];
|
|
5977
6704
|
var noopPluginRegistrationLine = (key, readyEvent) => ` registerNoopPlugin(${JSON.stringify(key)}, ${JSON.stringify(readyEvent)});`;
|
|
5978
6705
|
function gemRuntimePluginSources() {
|
|
@@ -6352,7 +7079,7 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
|
|
|
6352
7079
|
if (!TOKENIZED_PROPERTIES.includes(property)) continue;
|
|
6353
7080
|
if (isAllowedUntokenizedValue(property, value)) continue;
|
|
6354
7081
|
if (!value.includes("var(--gem-") && hasRawVisualValue(value)) {
|
|
6355
|
-
issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens
|
|
7082
|
+
issues.push(ruleIssue("untokenized_visual_value", `${property}: ${value.trim()} should prefer semantic --gem-* tokens.${scrimHint(value)}`, artifactPath, {
|
|
6356
7083
|
property,
|
|
6357
7084
|
value: value.trim()
|
|
6358
7085
|
}));
|
|
@@ -6360,6 +7087,12 @@ function validateGemFrameworkCss(css, artifactPath, options = {}) {
|
|
|
6360
7087
|
}
|
|
6361
7088
|
return result(issues);
|
|
6362
7089
|
}
|
|
7090
|
+
function scrimHint(value) {
|
|
7091
|
+
const m = value.match(/rgba?\(\s*0[\s,]+0[\s,]+0\s*(?:,|\/)\s*([0-9.]+)(%?)\s*\)/i);
|
|
7092
|
+
if (!m) return "";
|
|
7093
|
+
const pct = Math.round(m[2] ? Number(m[1]) : Number(m[1]) * 100);
|
|
7094
|
+
return ` A dark overlay reads the scrim token: color-mix(in srgb, var(--gem-color-scrim, #000) ${pct}%, transparent).`;
|
|
7095
|
+
}
|
|
6363
7096
|
function validateGemFrameworkMarkup(markup, artifactPath, options = {}) {
|
|
6364
7097
|
const issues = validateSharedSafety(markup, artifactPath);
|
|
6365
7098
|
if (/\bgs-/.test(markup)) {
|
|
@@ -7717,6 +8450,7 @@ export {
|
|
|
7717
8450
|
requiredGemTokenKeys,
|
|
7718
8451
|
resolveGift,
|
|
7719
8452
|
resolveOffer,
|
|
8453
|
+
reviewsEngineRuntimeScript,
|
|
7720
8454
|
slugify,
|
|
7721
8455
|
toCssVars,
|
|
7722
8456
|
toMotionCssVars,
|