feat(slideshow): auto-set interactive on inner player (#1712)

* feat(slideshow): auto-set interactive on inner player

The slideshow now sets the `interactive` attribute on its inner
<hyperframes-player> instances at mount time, so pointer events
reach the composition iframe automatically. Removes the
agent-compliance burden of having to remember to add `interactive`
on every player tag inside a slideshow.

Idempotent: an author-supplied `interactive` attribute (any value,
including `interactive="false"`) is preserved. A MutationObserver
also picks up players inserted dynamically after the initial mount.

Standalone player usage outside a slideshow still requires the
explicit attribute — that surface is unchanged.

Skill guidance at skills/slideshow/SKILL.md updated to reflect the
automatic behavior.

* docs(slideshow): clarify interactive attribute semantics

Per Rames R1 review feedback: the test comment implied
`interactive="false"` is an author opt-out, but `:host([interactive])`
is presence-matching per HTML boolean-attribute convention — so any
value (including "false") enables pointer events at runtime. The
slideshow's mechanical wire-up preserves any author-supplied value
verbatim for DOM hygiene, not as a runtime opt-out.
This commit is contained in:
Vance Ingalls
2026-06-24 21:31:39 -07:00
committed by GitHub
parent 364992203e
commit 64eaad7d69
3 changed files with 115 additions and 3 deletions
@@ -2179,3 +2179,76 @@ describe("<hyperframes-slideshow> Fix 4 — back affordance (postMessage only; c
el.remove();
});
});
// ---------------------------------------------------------------------------
// Mechanical `interactive` attribute on inner <hyperframes-player>
// ---------------------------------------------------------------------------
// The slideshow auto-applies the `interactive` attribute to every inner
// <hyperframes-player>, so clickable controls, links, native media controls,
// and custom players inside the composition iframe receive pointer events
// without the author having to remember the attribute. The player's default
// is `pointer-events: none` on the iframe; `interactive` flips it to `auto`
// via the `:host([interactive])` rule in player styles.
// ---------------------------------------------------------------------------
describe("<hyperframes-slideshow> auto-sets `interactive` on inner <hyperframes-player>", () => {
beforeEach(async () => {
await import("./hyperframes-slideshow.js");
});
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
it("inner <hyperframes-player> gets `interactive` attribute after mount", async () => {
const el = document.createElement("hyperframes-slideshow");
const player = document.createElement("hyperframes-player");
el.appendChild(player);
document.body.appendChild(el);
// Allow the deferred initTimer macrotask to run.
await tick();
expect(player.hasAttribute("interactive")).toBe(true);
expect(player.getAttribute("interactive")).toBe("");
el.remove();
});
it("preserves any author-supplied `interactive` attribute value verbatim", async () => {
const el = document.createElement("hyperframes-slideshow");
const player = document.createElement("hyperframes-player");
// Preserve any author-supplied `interactive` value verbatim. Note: the
// CSS rule `:host([interactive])` is presence-based per HTML
// boolean-attribute convention, so the runtime behavior is identical
// regardless of the value — the attribute always enables pointer
// events. The preservation guarantee here is about DOM hygiene
// (idempotent mechanical wire-up, no clobber on re-runs), not a
// runtime opt-out — `interactive="false"` is NOT an opt-out.
player.setAttribute("interactive", "false");
el.appendChild(player);
document.body.appendChild(el);
await tick();
expect(player.getAttribute("interactive")).toBe("false");
el.remove();
});
it("dynamically-inserted <hyperframes-player> children also get `interactive`", async () => {
const el = document.createElement("hyperframes-slideshow");
document.body.appendChild(el);
await tick();
// Late insertion — picked up by the MutationObserver.
const player = document.createElement("hyperframes-player");
el.appendChild(player);
// MutationObserver callbacks deliver on a microtask; flush twice to be safe.
await tick();
await tick();
expect(player.hasAttribute("interactive")).toBe(true);
el.remove();
});
});
@@ -167,6 +167,7 @@ export class HyperframesSlideshow extends HTMLElement {
private initGeneration = 0;
private _muted = false;
private mediaWireInterval: ReturnType<typeof setInterval> | null = null;
private playerObserver: MutationObserver | null = null;
private applyingRemoteMedia = false;
private lastMediaTimeBroadcastMs = 0;
private audienceMutedPlaybackKeys = new Set<string>();
@@ -215,6 +216,7 @@ export class HyperframesSlideshow extends HTMLElement {
window.addEventListener("message", this.onMessage);
document.addEventListener("fullscreenchange", this.onFsChange);
this.initChannel();
this.observeInteractivePlayers();
// Defer player-dependent init to a macrotask so that child elements are
// parsed before we query for <hyperframes-player>. This matters when the
// bundle is loaded synchronously (e.g. <script src> in <head>), where
@@ -225,7 +227,10 @@ export class HyperframesSlideshow extends HTMLElement {
// setTimeout(0) macrotask yields to the parser so the children land first.
this.initTimer = setTimeout(() => {
this.initTimer = null;
if (this.isConnected && !this.disconnected) void this.init();
if (this.isConnected && !this.disconnected) {
this.ensureInteractivePlayers();
void this.init();
}
}, 0);
}
@@ -252,6 +257,10 @@ export class HyperframesSlideshow extends HTMLElement {
clearInterval(this.mediaWireInterval);
this.mediaWireInterval = null;
}
if (this.playerObserver !== null) {
this.playerObserver.disconnect();
this.playerObserver = null;
}
this.audienceMediaUnlockButton?.remove();
this.audienceMediaUnlockButton = null;
this.audienceMutedPlaybackKeys.clear();
@@ -490,6 +499,36 @@ export class HyperframesSlideshow extends HTMLElement {
);
}
/**
* Inner `<hyperframes-player>` instances inside a slideshow need the
* `interactive` attribute so clickable controls, links, native media
* controls, and custom players inside the composition iframe receive
* pointer events (the player's default is `pointer-events: none`).
*
* Set it mechanically so authors / agents don't have to remember.
* Idempotent: if the host already declared `interactive` (any value,
* including `interactive="false"`), it is preserved.
*/
private ensureInteractivePlayers(): void {
for (const player of this.querySelectorAll("hyperframes-player")) {
if (!player.hasAttribute("interactive")) {
player.setAttribute("interactive", "");
}
}
}
/**
* Watch for `<hyperframes-player>` children added after the initial mount
* (dynamic templating, hydration, drag-drop authoring) and apply the
* `interactive` attribute to those too.
*/
private observeInteractivePlayers(): void {
if (typeof MutationObserver === "undefined") return;
if (this.playerObserver !== null) return;
this.playerObserver = new MutationObserver(() => this.ensureInteractivePlayers());
this.playerObserver.observe(this, { childList: true, subtree: true });
}
private playerFrameDocument(player: Partial<PlayerElement> & HTMLElement): Document | null {
const frame = player.iframeElement;
if (!(frame instanceof HTMLIFrameElement)) return null;