vzxipsule: Silence the carousel live region while it rotates and pause it on keyboard focus

This commit is contained in:
2026-10-19 22:31:56 -06:00
parent ce1a3e2eaa
commit cf45072f27
4 changed files with 49 additions and 11 deletions
+19 -6
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type CSSProperties } from "react";
import type { DiscoverExpert } from "@/lib/discover";
import { initials } from "@/lib/feed-model";
import { toneFor } from "./HexAvatar";
import { featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
import { featuredLiveMode, featuredSlot, startFeaturedAutoplay, type FeaturedAutoplay } from "./featured-carousel-model";
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
@@ -53,12 +53,22 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
const [index, setIndex] = useState(0);
const reduced = useReducedMotion();
const autoplay = useRef<FeaturedAutoplay | null>(null);
const hovered = useRef(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const paused = hovered || focused;
const pausedRef = useRef(false);
pausedRef.current = paused;
const rotating = count > 1 && !reduced;
useEffect(() => {
if (paused) autoplay.current?.pause();
else autoplay.current?.resume();
}, [paused]);
useEffect(() => {
if (count < 2 || reduced) return;
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
if (hovered.current) ctl.pause();
if (pausedRef.current) ctl.pause();
autoplay.current = ctl;
return () => { ctl.stop(); autoplay.current = null; };
// The index only seeds a restart; ticking must not recreate the timer.
@@ -74,8 +84,11 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
<section
className="tile panel rail rail-featured featured"
aria-labelledby="featured-title"
onMouseEnter={() => { hovered.current = true; autoplay.current?.pause(); }}
onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false); }}
data-paused={paused ? "true" : undefined}
>
<div className="panel-head">
<h2 id="featured-title" className="type-title type-title-strong featured-title">
@@ -83,7 +96,7 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
</h2>
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
</div>
<div className="featured-cf" style={style} aria-live="polite" data-index={current}>
<div className="featured-cf" style={style} aria-live={featuredLiveMode({ rotating, paused })} data-index={current}>
{experts.map((e, i) => {
const slot = featuredSlot(i, current, count);
return (
+10
View File
@@ -56,3 +56,13 @@ export function startFeaturedAutoplay(opts: {
stop() { if (timer) clearInterval(timer); },
};
}
/**
* WAI-ARIA carousel pattern: a rotating carousel must not announce every
* slide, so the region is `off` while autoplay runs and `polite` when the
* person has paused it (hover, focus) or it never rotates (reduced motion,
* a single expert).
*/
export function featuredLiveMode(opts: { rotating: boolean; paused: boolean }): "off" | "polite" {
return opts.rotating && !opts.paused ? "off" : "polite";
}