script nits: slider hover/visibility pause + silent autoplay + iframe amp guard

ContentSlider: autoplay's setInterval now pauses on mouseenter and on
document visibilitychange (tab hidden), resumes on mouseleave/visible,
and is always clearable (timer var + clearInterval). The aria-live
region is "off" while autoplay is silently auto-rotating and only
flips to "polite" on manual next/prev/dot navigation, so screen readers
aren't spammed with an announcement every `interval` ms (F-export
review Minor). Updates the one existing test that hard-coded
aria-live="polite" as always-on to match this intentional behavior
change.

(Countdown's ticking-interval-stops-at-zero nit shipped in the previous
commit alongside its node-id migration, since both touched the same
inline script.)

Adds regression tests locking in that MapEmbed/VideoBlock iframe src
attributes (built by string concatenation with literal `&` query
params) are HTML-entity-encoded via the existing escapeAttr(safeUrl())
pipeline -- verified already correct, no source change needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:35:27 -07:00
parent 8029126ab7
commit 177eda93d0
4 changed files with 124 additions and 11 deletions
@@ -1,7 +1,7 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
interface Slide {
type: 'image' | 'content';
@@ -238,7 +238,7 @@ ContentSlider.craft = {
/* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string, nodeId?: string) => {
const {
slides = defaultSlides,
autoplay = true,
@@ -250,7 +250,11 @@ ContentSlider.craft = {
} = props;
const items = slides.length > 0 ? slides : defaultSlides;
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
// Deterministic AND unique id, scoped on the Craft node id, for this
// slider's slide/dot element ids and inline-script globals -- so two
// ContentSlider instances (e.g. both left at default slides) don't
// collide and end up driving each other's rotation.
const uid = scopeId(nodeId, JSON.stringify(items) + interval, 'cs');
const sectionStyle = cssPropsToString({
position: 'relative',
@@ -299,9 +303,18 @@ ContentSlider.craft = {
</div>`
: '';
// Autoplay ticks call show() directly (internal), while manual nav goes
// through the exposed window[...] functions -- that split lets us mark
// the live region "polite" only on manual navigation, and keep it "off"
// while autoplay is silently auto-rotating, so screen readers aren't
// spammed with an announcement every `interval` ms (F-export review
// Minor).
const autoplayActive = autoplay && items.length > 1;
const liveAttr = autoplayActive ? 'off' : 'polite';
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div aria-live="polite">
html: `<section id="${uid}"${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div id="${uid}_live" aria-live="${liveAttr}">
${slidesHtml}
</div>
${arrowsHtml}
@@ -309,6 +322,8 @@ ContentSlider.craft = {
<script>
(function(){
var current=0, total=${items.length}, uid="${uid}";
var liveRegion=document.getElementById(uid+"_live");
function markManualNav(){ if(liveRegion){ liveRegion.setAttribute("aria-live","polite"); } }
function show(idx){
document.getElementById(uid+"_s"+current).style.opacity="0";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
@@ -316,10 +331,23 @@ ContentSlider.craft = {
document.getElementById(uid+"_s"+current).style.opacity="1";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
}
window["${uid}_go"]=show;
window["${uid}_next"]=function(){show(current+1);};
window["${uid}_prev"]=function(){show(current-1);};
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
window["${uid}_go"]=function(idx){ markManualNav(); show(idx); };
window["${uid}_next"]=function(){ markManualNav(); show(current+1); };
window["${uid}_prev"]=function(){ markManualNav(); show(current-1); };
${autoplayActive ? `
var timer=null;
function start(){ if(!timer && document.visibilityState!=="hidden"){ timer=setInterval(function(){show(current+1);},${interval}); } }
function stop(){ if(timer){ clearInterval(timer); timer=null; } }
var root=document.getElementById(uid);
if(root){
root.addEventListener("mouseenter", stop);
root.addEventListener("mouseleave", start);
}
document.addEventListener("visibilitychange", function(){
if(document.visibilityState==="hidden"){ stop(); } else { start(); }
});
start();
` : ''}
})();
</script>
</section>`,