Feed browser: Desktop bento layout #3
@@ -130,7 +130,8 @@ A compact spotlight that moves on its own. Nobody has to click or swipe. These a
|
|||||||
- Every 2.8 seconds the next expert moves to the center with a 1.1s ease.
|
- Every 2.8 seconds the next expert moves to the center with a 1.1s ease.
|
||||||
- Below: the name in serif, the byline, the rating and review count. The caption fades up on each change.
|
- Below: the name in serif, the byline, the rating and review count. The caption fades up on each change.
|
||||||
- Progress dots: the active one is a crimson bar. A counter ("3 / 10") sits in the panel header.
|
- Progress dots: the active one is a crimson bar. A counter ("3 / 10") sits in the panel header.
|
||||||
- Hover pauses the rotation. `prefers-reduced-motion` stops autoplay.
|
- Hover, or keyboard focus inside the carousel, pauses the rotation. `prefers-reduced-motion` stops autoplay.
|
||||||
|
- Live region (WAI-ARIA carousel pattern): `aria-live="off"` while autoplay is rotating, so slides are not announced every 2.8 seconds; `aria-live="polite"` when rotation is paused (hover or focus) or stopped (reduced motion, or a single expert).
|
||||||
|
|
||||||
### Trending minnects
|
### Trending minnects
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ All sheets slide up from the bottom (450ms) over a dimmed, slightly blurred back
|
|||||||
|
|
||||||
- Every control is a real `<button>`, `<a>` or `<input>` with a label. Icon-only buttons have `aria-label`.
|
- Every control is a real `<button>`, `<a>` or `<input>` with a label. Icon-only buttons have `aria-label`.
|
||||||
- The like button uses `aria-pressed` and labels its count. Comments toggles use `aria-expanded`.
|
- The like button uses `aria-pressed` and labels its count. Comments toggles use `aria-expanded`.
|
||||||
- The carousel region is `aria-live="polite"`. The non-center items are `aria-hidden`.
|
- The carousel region follows the WAI-ARIA carousel pattern: `aria-live="off"` while autoplay rotates, `aria-live="polite"` while rotation is paused by hover or keyboard focus, or stopped by reduced motion. Keyboard focus inside the carousel pauses it. The non-center items are `aria-hidden`.
|
||||||
- Visible focus ring on every control, `--ink` colored.
|
- Visible focus ring on every control, `--ink` colored.
|
||||||
- Touch targets at least 44px on mobile.
|
- Touch targets at least 44px on mobile.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, type CSSProperties } from "react";
|
|||||||
import type { DiscoverExpert } from "@/lib/discover";
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
import { initials } from "@/lib/feed-model";
|
import { initials } from "@/lib/feed-model";
|
||||||
import { toneFor } from "./HexAvatar";
|
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)";
|
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
@@ -53,12 +53,22 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
|
|||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const reduced = useReducedMotion();
|
const reduced = useReducedMotion();
|
||||||
const autoplay = useRef<FeaturedAutoplay | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (count < 2 || reduced) return;
|
if (count < 2 || reduced) return;
|
||||||
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
|
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
|
||||||
if (hovered.current) ctl.pause();
|
if (pausedRef.current) ctl.pause();
|
||||||
autoplay.current = ctl;
|
autoplay.current = ctl;
|
||||||
return () => { ctl.stop(); autoplay.current = null; };
|
return () => { ctl.stop(); autoplay.current = null; };
|
||||||
// The index only seeds a restart; ticking must not recreate the timer.
|
// The index only seeds a restart; ticking must not recreate the timer.
|
||||||
@@ -74,8 +84,11 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
|
|||||||
<section
|
<section
|
||||||
className="tile panel rail rail-featured featured"
|
className="tile panel rail rail-featured featured"
|
||||||
aria-labelledby="featured-title"
|
aria-labelledby="featured-title"
|
||||||
onMouseEnter={() => { hovered.current = true; autoplay.current?.pause(); }}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => { hovered.current = false; autoplay.current?.resume(); }}
|
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">
|
<div className="panel-head">
|
||||||
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||||
@@ -83,7 +96,7 @@ export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps)
|
|||||||
</h2>
|
</h2>
|
||||||
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
<span className="type-eyebrow featured-count" data-testid="featured-count">{`${current + 1} / ${count}`}</span>
|
||||||
</div>
|
</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) => {
|
{experts.map((e, i) => {
|
||||||
const slot = featuredSlot(i, current, count);
|
const slot = featuredSlot(i, current, count);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -56,3 +56,13 @@ export function startFeaturedAutoplay(opts: {
|
|||||||
stop() { if (timer) clearInterval(timer); },
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { createElement } from "react";
|
import { createElement } from "react";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||||
import { FEATURED_INTERVAL_MS, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
|
import { FEATURED_INTERVAL_MS, featuredLiveMode, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
|
||||||
import type { DiscoverExpert } from "@/lib/discover";
|
import type { DiscoverExpert } from "@/lib/discover";
|
||||||
|
|
||||||
const expert = (i: number): DiscoverExpert => ({
|
const expert = (i: number): DiscoverExpert => ({
|
||||||
@@ -72,9 +72,9 @@ describe("startFeaturedAutoplay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FeaturedCarousel markup", () => {
|
describe("FeaturedCarousel markup", () => {
|
||||||
it("has a polite live region, hides non-center items and shows the counter and dots", () => {
|
it("starts with the live region off while it rotates, and hides non-center items and shows the counter and dots", () => {
|
||||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
|
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
|
||||||
expect(out).toContain('aria-live="polite"');
|
expect(out).toContain('aria-live="off"');
|
||||||
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
|
expect(out.match(/data-testid="featured-item"/g)).toHaveLength(8);
|
||||||
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
|
expect(out.match(/aria-hidden="true" data-testid="featured-item"/g)).toHaveLength(7);
|
||||||
expect(out).toContain("featured-c0");
|
expect(out).toContain("featured-c0");
|
||||||
@@ -91,5 +91,19 @@ describe("FeaturedCarousel markup", () => {
|
|||||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
|
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
|
||||||
expect(out).not.toContain("featured-dot ");
|
expect(out).not.toContain("featured-dot ");
|
||||||
expect(out).toContain(">1 / 1<");
|
expect(out).toContain(">1 / 1<");
|
||||||
|
expect(out).toContain('aria-live="polite"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("featuredLiveMode (WAI-ARIA carousel live region)", () => {
|
||||||
|
it("is off while autoplay rotates", () => {
|
||||||
|
expect(featuredLiveMode({ rotating: true, paused: false })).toBe("off");
|
||||||
|
});
|
||||||
|
it("is polite while paused by hover or focus", () => {
|
||||||
|
expect(featuredLiveMode({ rotating: true, paused: true })).toBe("polite");
|
||||||
|
});
|
||||||
|
it("is polite when rotation is stopped (reduced motion or one expert)", () => {
|
||||||
|
expect(featuredLiveMode({ rotating: false, paused: false })).toBe("polite");
|
||||||
|
expect(featuredLiveMode({ rotating: false, paused: true })).toBe("polite");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user