带无障碍暂停行为的自动播放
前进部分只有一行——一个通过
apiRef 调用 scrollNext() 的定时器,搭建在同一套无限循环内核之上。真正的工程在于何时不前进:悬停、触摸、键盘焦点、暂停按钮、隐藏的标签页、屏幕外的轨道以及减少动态效果偏好,都会让定时器停下来,各有各的原因。悬停、触摸或按 Tab 进入轨道都会让它暂停;暂停按钮会停住它,直到你按下播放。
工作原理
useInterval(cb, active ? interval : null) 就是整个调度器。active 合并了四个标志——用户暂停、悬停暂停、聚焦暂停与 prefers-reduced-motion——传入 null 会彻底移除定时器,因此恢复时会开启一个全新且完整的间隔,而不是在指针刚离开后的半途中触发。
拒绝运行的节拍
即便是活跃的定时器也会在滚动前检查:这个节拍读取 api.menuVisible.current 与 document.visibilityState,只要有一个说“不”就跳过。隐藏的标签页会冻结 IntersectionObserver,因此在那里滚动意味着盲目前进、传送记账开始漂移;滚出页面的轨道本就不该移动。被跳过的节拍没有任何代价——下一个节拍会重新检查。
暂停的各个层面
悬停与触摸通过包装器处理器暂停,键盘焦点通过 onFocusCapture/onBlurCapture,而 prefers-reduced-motion 则让自动播放完全关闭。明确的暂停按钮才是 WCAG 2.2.2 对自动前进内容真正要求的——仅靠悬停暂停不算。
注意
- 暂停开关位于悬停包装器之外——若放在内部,点击暂停也会触发悬停暂停,这个按钮就永远看不出在做任何事。
- 循环来自与无限循环示例相同的
useInfiniteLoop克隆加传送 hook;自动播放只增加了定时器与暂停标志。 - 滚动动画是浏览器原生的平滑滚动——在默认的
noPolyfill下,transitionDuration不起作用。
完整源码
完整且可直接复制粘贴——这正是背后这份文件的 可在 Storybook 中实时编辑的版本.
import 'react-horizontal-scrolling-menu/dist/styles.css';
import styled from '@emotion/styled';
import React from 'react';
import {
type publicApiType,
ScrollMenu,
VisibilityContext,
} from 'react-horizontal-scrolling-menu';
import {
useDebounceCallback,
useInterval,
useMediaQuery,
useUnmount,
} from 'usehooks-ts';
// Autoplay on top of the InfiniteLoop recipe: a timer calls scrollNext()
// through apiRef, and the same useInfiniteLoop clone-and-teleport hook
// makes it endless. Pauses on hover, touch, focus and the Pause button,
// skips ticks in hidden tabs or when offscreen, and stays off under
// reduced motion (WCAG 2.2.2). The animation is the browser's native
// smooth scroll — `transitionDuration` has no effect with the default
// noPolyfill.
const CLONES_PER_SIDE = 6;
export function Autoplay({ interval = 2000 }: { interval?: number }) {
// NOTE: for drag by mouse; the hover pause already covers dragging.
const [dragManager] = React.useState(() => new DragDealer());
const apiRef = React.useRef<publicApiType | null>(null);
const loop = useInfiniteLoop(getItemIds());
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)', {
initializeWithValue: false,
});
const [userPaused, setUserPaused] = React.useState(false);
const [hoverPaused, setHoverPaused] = React.useState(false);
const [focusPaused, setFocusPaused] = React.useState(false);
const active = !userPaused && !hoverPaused && !focusPaused && !reducedMotion;
// normalize() inside the drag keeps the seam crossable mid-gesture.
const handleDrag =
({ scrollContainer }: publicApiType) =>
(ev: React.MouseEvent) =>
dragManager.dragMove(ev, (posDiff) => {
if (scrollContainer.current) {
scrollContainer.current.scrollLeft += posDiff;
loop.normalize();
}
});
// `null` removes the timer, so resuming starts a fresh, full interval.
useInterval(
() => {
const api = apiRef.current;
// A hidden tab freezes IntersectionObserver — skip, don't scroll blind.
if (!api?.menuVisible.current || document.visibilityState !== 'visible') {
return;
}
api.scrollNext();
},
active ? interval : null,
);
return (
<div>
<Toolbar>
{/* Outside the hover wrapper, so clicking it can't hover-pause. */}
<button
type="button"
data-testid="autoplay-toggle"
onClick={() => setUserPaused((paused) => !paused)}
>
{userPaused ? 'Play' : 'Pause'}
</button>
</Toolbar>
<NoScrollbar
onMouseEnter={() => setHoverPaused(true)}
onMouseLeave={() => {
dragManager.dragStop();
setHoverPaused(false);
}}
onTouchStart={() => setHoverPaused(true)}
onTouchEnd={() => setHoverPaused(false)}
onFocusCapture={() => setFocusPaused(true)}
onBlurCapture={() => setFocusPaused(false)}
>
<ScrollMenu
{...loop.menuProps}
LeftArrow={LeftArrow}
RightArrow={RightArrow}
apiRef={apiRef}
onMouseDown={() => dragManager.dragStart}
onMouseUp={() => dragManager.dragStop}
onMouseMove={handleDrag}
>
{loop.slides.map(({ itemId, realId }) => (
<Card
realId={realId}
itemId={itemId} // NOTE: must be unique — clones get a suffix
key={itemId}
/>
))}
</ScrollMenu>
</NoScrollbar>
</div>
);
}
export default Autoplay;
// The loop, packaged: cloned slides, the pre-paint start jump and the
// seam teleport. Spread `menuProps` onto ScrollMenu, render `slides`,
// and call `normalize()` after moving scrollLeft by hand (e.g. inside a
// drag). `itemIds` are read once, on the first render.
function useInfiniteLoop(
itemIds: string[],
clonesPerSide: number = CLONES_PER_SIDE,
) {
const [slides] = React.useState(() => getSlides(itemIds, clonesPerSide));
// Receives the scroll container div itself.
const containerRef = React.useRef<HTMLDivElement | null>(null);
// Seam markers come from the data — itemId can be anything.
const firstRealId = slides[clonesPerSide].itemId;
const firstRightCloneId = slides[slides.length - clonesPerSide].itemId;
// Shift by one loop length when settled inside a clone zone. Pure
// geometry and idempotent — visibility flags lag and must not gate it.
const normalize = React.useCallback(() => {
const el = containerRef.current;
const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
const firstClone = el?.querySelector<HTMLElement>(
`[data-key='${firstRightCloneId}']`,
);
if (!el || !first || !firstClone) {
return;
}
const realStart = first.offsetLeft;
const loopLength = firstClone.offsetLeft - realStart;
const x = el.scrollLeft;
if (x >= realStart + loopLength) {
el.scrollLeft = x - loopLength;
} else if (x < realStart) {
el.scrollLeft = x + loopLength;
}
}, [firstRealId, firstRightCloneId]);
// 'scrollend' fires when scrolling truly ends; debounce covers Safari.
const settle = useDebounceCallback(normalize, 150);
useUnmount(() => settle.cancel());
const hasScrollEnd = typeof window !== 'undefined' && 'onscrollend' in window;
React.useEffect(() => {
const el = containerRef.current;
if (!el || !hasScrollEnd) {
return;
}
el.addEventListener('scrollend', normalize);
return () => el.removeEventListener('scrollend', normalize);
}, [normalize, hasScrollEnd]);
// Start on the first real item, before first paint.
React.useLayoutEffect(() => {
const el = containerRef.current;
const first = el?.querySelector<HTMLElement>(`[data-key='${firstRealId}']`);
if (el && first) {
el.scrollLeft = first.offsetLeft;
}
}, [firstRealId]);
return {
slides,
normalize,
menuProps: {
containerRef,
onScroll: hasScrollEnd ? undefined : () => settle(),
},
};
}
const leftCloneId = (id: string) => `${id}-lc`;
const rightCloneId = (id: string) => `${id}-rc`;
// Clones render exactly like their twins; unique itemId is the only
// difference.
const getSlides = (ids: string[], clonesPerSide: number) => {
const left = ids
.slice(-clonesPerSide)
.map((id) => ({ itemId: leftCloneId(id), realId: id }));
const right = ids
.slice(0, clonesPerSide)
.map((id) => ({ itemId: rightCloneId(id), realId: id }));
const real = ids.map((id) => ({ itemId: id, realId: id }));
return [...left, ...real, ...right];
};
// An item is visible when any twin is: the raw per-element flag goes
// stale for a frame right after a teleport and would blink the header.
function useLoopItemVisible(realId: string) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
const realVisible = visibility.useIsVisible(realId, true);
const leftTwinVisible = visibility.useIsVisible(leftCloneId(realId), false);
const rightTwinVisible = visibility.useIsVisible(rightCloneId(realId), false);
return realVisible || leftTwinVisible || rightTwinVisible;
}
class DragDealer {
clicked: boolean;
dragging: boolean;
position: number;
resetId: number;
constructor() {
this.clicked = false;
this.dragging = false;
this.position = 0;
this.resetId = 0;
}
public dragStart = (ev: React.MouseEvent) => {
// A pending reset from the previous drag would kill this one.
window.cancelAnimationFrame(this.resetId);
this.position = ev.clientX;
this.clicked = true;
};
public dragStop = () => {
// Stop applying immediately; clear `dragging` a frame later so item
// onClick (which fires after mouseup) still sees it and suppresses
// the click.
this.clicked = false;
this.resetId = window.requestAnimationFrame(() => {
this.dragging = false;
});
};
public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
const newDiff = this.position - ev.clientX;
if (this.clicked && Math.abs(newDiff) > 5) {
this.dragging = true;
this.position = ev.clientX;
cb(newDiff);
}
};
}
const getId = (index: number) => `${'test'}${index}`;
const getItemIds = () =>
Array(10)
.fill(0)
.map((_, ind) => getId(ind));
const Toolbar = styled('div')({
display: 'flex',
justifyContent: 'flex-end',
marginBottom: '8px',
});
const NoScrollbar = styled('div')({
'& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
display: 'none',
},
'& .react-horizontal-scrolling-menu--scroll-container': {
scrollbarWidth: 'none',
'-ms-overflow-style': 'none',
},
});
// Always enabled: the stock arrow hooks track the outermost items — here
// those are clones, so they'd flash disabled at the seam.
function LeftArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
return (
<Arrow onClick={() => visibility.scrollPrev()} testId="left-arrow">
Left
</Arrow>
);
}
function RightArrow() {
const visibility = React.useContext<publicApiType>(VisibilityContext);
return (
<Arrow onClick={() => visibility.scrollNext()} testId="right-arrow">
Right
</Arrow>
);
}
function Arrow({
children,
onClick,
testId,
}: {
children: React.ReactNode;
onClick: VoidFunction;
testId: string;
}) {
return (
<ArrowButton onClick={onClick} data-testid={testId}>
{children}
</ArrowButton>
);
}
const ArrowButton = styled('button')({
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
marginBottom: '2px',
userSelect: 'none',
borderRadius: '6px',
borderWidth: '1px',
});
function Card({ realId, itemId }: { realId: string; itemId: string }) {
const visibility = React.useContext<publicApiType>(VisibilityContext);
// Raw flag of this element — kept on data-visible for the play tests.
const ownVisible = visibility.useIsVisible(itemId, true);
const isVisible = useLoopItemVisible(realId);
return (
<CardBody
data-cy={itemId}
data-visible={ownVisible}
data-testid="card"
className="card"
visible={isVisible}
>
<div className="header">
<div>{realId}</div>
<div className="visible">visible: {JSON.stringify(isVisible)}</div>
</div>
<div className="background" />
</CardBody>
);
}
const CardBody = styled('div')<{ visible?: boolean }>((props) => ({
border: '1px solid',
display: 'inline-block',
margin: '0 10px',
width: '160px',
userSelect: 'none',
borderRadius: '8px',
overflow: 'hidden',
'& .header': {
backgroundColor: 'white',
},
'& .visible': {
backgroundColor: props.visible ? 'transparent' : 'gray',
},
'& .background': {
backgroundColor: 'bisque',
height: '200px',
},
}));