vzxipsule: Silence the carousel live region while it rotates and pause it on keyboard focus
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user