Promote to main: Feed browser luxury redesign #7
+8
-2
@@ -95,7 +95,8 @@
|
||||
|
||||
/* Reduced motion, app-wide (ADR 0001, section 6; spec "Motion"). Switches off
|
||||
card rise-ins, the feed pill and skeleton shimmer, sparks, heart bursts and
|
||||
the light sweep on buttons, and stops snap and smooth scrolling. State
|
||||
the light sweep on buttons, the featured glow, caption fade and slide (its
|
||||
autoplay is stopped in FeaturedCarousel via matchMedia), and stops snap and smooth scrolling. State
|
||||
changes still happen instantly. The loading spinner is kept, only slowed,
|
||||
because it is the only sign that something is loading. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -111,7 +112,12 @@
|
||||
.btn-gloss::before,
|
||||
.btn-gloss::after,
|
||||
.btn-sweep::before,
|
||||
.account-dot { animation: none !important; }
|
||||
.account-dot,
|
||||
.featured-cf::before,
|
||||
.featured-cap { animation: none !important; }
|
||||
.featured-item,
|
||||
.featured-ring,
|
||||
.featured-dot { transition: none !important; }
|
||||
.btn-sweep::before,
|
||||
.btn-gloss::after { transition: none !important; }
|
||||
.panel-scroll, .feed-list { scroll-behavior: auto !important; scroll-snap-type: none !important; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../styles/avatar.css";
|
||||
import "../styles/signin.css";
|
||||
import "../styles/post.css";
|
||||
import "../styles/composer.css";
|
||||
import "../styles/featured.css";
|
||||
import "../styles/feed.css";
|
||||
import "../styles/legacy.css";
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DiscoverData } from "@/lib/discover";
|
||||
import { fetchDiscover } from "@/lib/discover-client";
|
||||
import { FeaturedExpertsRail } from "./FeaturedExpertsRail";
|
||||
import { FeaturedCarousel } from "./FeaturedCarousel";
|
||||
import { TrendingMinnectsRail } from "./TrendingMinnectsRail";
|
||||
|
||||
export function DiscoverRailsView({ data }: { data: DiscoverData | null }) {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<>
|
||||
<FeaturedExpertsRail experts={data.featuredExperts} />
|
||||
<FeaturedCarousel experts={data.featuredExperts} />
|
||||
<TrendingMinnectsRail minnects={data.trendingMinnects} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia(REDUCED_MOTION);
|
||||
setReduced(mq.matches);
|
||||
const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return reduced;
|
||||
}
|
||||
|
||||
function Star() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Photo({ expert, center }: { expert: DiscoverExpert; center: boolean }) {
|
||||
const name = expert.displayName || "Expert";
|
||||
if (expert.pictureUrl) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img className="featured-photo" src={expert.pictureUrl} alt={center ? name : ""} />;
|
||||
}
|
||||
return (
|
||||
<span className={`featured-photo featured-initials hex-avatar-${toneFor(name)}`} role="img" aria-label={center ? name : undefined}>
|
||||
<span aria-hidden="true">{initials(name)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FeaturedCarouselProps {
|
||||
experts: DiscoverExpert[];
|
||||
/** Width of the center photo in px: 96 on desktop, 150 to 156 on mobile. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function FeaturedCarousel({ experts, size = 96 }: FeaturedCarouselProps) {
|
||||
const count = experts.length;
|
||||
const [index, setIndex] = useState(0);
|
||||
const reduced = useReducedMotion();
|
||||
const autoplay = useRef<FeaturedAutoplay | null>(null);
|
||||
const hovered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (count < 2 || reduced) return;
|
||||
const ctl = startFeaturedAutoplay({ count, start: index % count, onChange: setIndex });
|
||||
if (hovered.current) ctl.pause();
|
||||
autoplay.current = ctl;
|
||||
return () => { ctl.stop(); autoplay.current = null; };
|
||||
// The index only seeds a restart; ticking must not recreate the timer.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [count, reduced]);
|
||||
|
||||
if (count === 0) return null;
|
||||
const current = index % count;
|
||||
const cap = experts[current];
|
||||
const style = { "--cfw": `${size}px` } as CSSProperties;
|
||||
|
||||
return (
|
||||
<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(); }}
|
||||
>
|
||||
<div className="panel-head">
|
||||
<h2 id="featured-title" className="type-title type-title-strong featured-title">
|
||||
<Star />Featured experts
|
||||
</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}>
|
||||
{experts.map((e, i) => {
|
||||
const slot = featuredSlot(i, current, count);
|
||||
return (
|
||||
<div
|
||||
key={String(e.id)}
|
||||
className={`featured-item featured-${slot}`}
|
||||
aria-hidden={slot === "c0" ? undefined : true}
|
||||
data-testid="featured-item"
|
||||
>
|
||||
<span className="featured-ring"><Photo expert={e} center={slot === "c0"} /></span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="featured-cap" key={current}>
|
||||
<strong className="type-title type-title-strong featured-name">{cap.displayName || "Expert"}</strong>
|
||||
{cap.byline ? <span className="featured-by">{cap.byline}</span> : null}
|
||||
{cap.rating !== null || cap.reviewsCount !== null ? (
|
||||
<span className="featured-rate">
|
||||
{cap.rating !== null ? (
|
||||
<span aria-label={`Rated ${cap.rating.toFixed(1)} out of 5`}><Star />{cap.rating.toFixed(1)}</span>
|
||||
) : null}
|
||||
{cap.reviewsCount !== null ? (
|
||||
<em>({cap.reviewsCount} {cap.reviewsCount === 1 ? "review" : "reviews"})</em>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{count > 1 ? (
|
||||
<div className="featured-dots" aria-hidden="true">
|
||||
{experts.map((e, i) => (
|
||||
<span key={String(e.id)} className={i === current ? "featured-dot featured-dot-on" : "featured-dot"} />
|
||||
))}
|
||||
</div>
|
||||
) : <div className="featured-dots-gap" />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { HexAvatar } from "@/components/HexAvatar";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
|
||||
function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) {
|
||||
if (rating === null && reviews === null) return null;
|
||||
return (
|
||||
<span className="rail-meta">
|
||||
{rating !== null ? <span aria-label={`Rated ${rating.toFixed(1)} out of 5`}>★ {rating.toFixed(1)}</span> : null}
|
||||
{reviews !== null ? <span> ({reviews} {reviews === 1 ? "review" : "reviews"})</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) {
|
||||
if (experts.length === 0) return null;
|
||||
return (
|
||||
<section className="tile panel rail rail-featured" aria-labelledby="rail-experts">
|
||||
<div className="panel-head">
|
||||
<h2 id="rail-experts" className="type-title type-title-strong">Featured experts</h2>
|
||||
</div>
|
||||
<ul className="rail-list panel-scroll fade-edges">
|
||||
{experts.map((e) => (
|
||||
<li key={String(e.id)} className="rail-card expert-card" data-testid="expert-card">
|
||||
<HexAvatar name={e.displayName} pictureUrl={e.pictureUrl} size="md" />
|
||||
<div className="rail-text">
|
||||
<strong className="rail-name">{e.displayName || "Expert"}</strong>
|
||||
{e.byline ? <span className="rail-byline">{e.byline}</span> : null}
|
||||
<Rating rating={e.rating} reviews={e.reviewsCount} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** Rotation timing from the spec ("Featured experts carousel"). */
|
||||
export const FEATURED_INTERVAL_MS = 2800;
|
||||
|
||||
export type FeaturedSlot = "c0" | "l1" | "r1" | "l2" | "r2" | "l3" | "r3";
|
||||
|
||||
/**
|
||||
* Where item `i` sits when `current` is in the center: by its signed offset
|
||||
* from the current index, wrapped so the shorter way round wins. With two
|
||||
* items the other one is a single right neighbor, never an empty pair.
|
||||
*/
|
||||
export function featuredSlot(i: number, current: number, count: number): FeaturedSlot {
|
||||
let off = (((i - current) % count) + count) % count;
|
||||
if (off > count / 2) off -= count;
|
||||
if (off === 0) return "c0";
|
||||
if (off === 1) return "r1";
|
||||
if (off === -1) return "l1";
|
||||
if (off === 2) return "r2";
|
||||
if (off === -2) return "l2";
|
||||
return off > 0 ? "r3" : "l3";
|
||||
}
|
||||
|
||||
export interface FeaturedAutoplay {
|
||||
readonly index: number;
|
||||
readonly paused: boolean;
|
||||
pause(): void;
|
||||
resume(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances the index every `intervalMs` while not paused. A single item never
|
||||
* rotates. The caller stops it on unmount.
|
||||
*/
|
||||
export function startFeaturedAutoplay(opts: {
|
||||
count: number;
|
||||
onChange: (index: number) => void;
|
||||
intervalMs?: number;
|
||||
start?: number;
|
||||
}): FeaturedAutoplay {
|
||||
const { count, onChange, intervalMs = FEATURED_INTERVAL_MS } = opts;
|
||||
let index = opts.start ?? 0;
|
||||
let paused = false;
|
||||
const timer =
|
||||
count > 1
|
||||
? setInterval(() => {
|
||||
if (paused) return;
|
||||
index = (index + 1) % count;
|
||||
onChange(index);
|
||||
}, intervalMs)
|
||||
: null;
|
||||
return {
|
||||
get index() { return index; },
|
||||
get paused() { return paused; },
|
||||
pause() { paused = true; },
|
||||
resume() { paused = false; },
|
||||
stop() { if (timer) clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
+14
-2
@@ -127,12 +127,19 @@ export const FIXTURE_FEED_ITEMS: unknown[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const expert = (id: string, displayName: string, byline: string, rating: number, reviewsCount: number): DiscoverExpert => ({
|
||||
const expert = (
|
||||
id: string,
|
||||
displayName: string,
|
||||
byline: string,
|
||||
rating: number,
|
||||
reviewsCount: number,
|
||||
pictureUrl: string | null = null,
|
||||
): DiscoverExpert => ({
|
||||
id,
|
||||
slug: displayName.replace(/\s+/g, ""),
|
||||
displayName,
|
||||
byline,
|
||||
pictureUrl: null,
|
||||
pictureUrl,
|
||||
rating,
|
||||
reviewsCount,
|
||||
available: true,
|
||||
@@ -145,6 +152,11 @@ export const FIXTURE_FEATURED: DiscoverExpert[] = [
|
||||
expert("e3", "Elena Kovac", "Product leadership", 4.7, 58),
|
||||
expert("e4", "Samuel Okafor", "Seed-stage fundraising", 5.0, 140),
|
||||
expert("e5", "Hannah Lindqvist", "Customer research", 4.6, 33),
|
||||
// Photo placeholders exercise the image path of the featured carousel; the
|
||||
// rest show initials.
|
||||
expert("e9", "Leila Haddad", "Marketplace operations", 4.9, 181, placeholderImage(10)),
|
||||
expert("e10", "Tomas Ferreira", "Hiring and team design", 4.7, 64, placeholderImage(190)),
|
||||
expert("e11", "Grace Whitfield", "Brand and storytelling", 4.8, 102, placeholderImage(300)),
|
||||
];
|
||||
|
||||
const minnect = (id: string, who: DiscoverExpert, question: string, views: number, hue: number): DiscoverMinnect => ({
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* Featured experts carousel (ADR 0001, section 6; spec "Featured experts
|
||||
carousel"). Ported from the mockup's .cf rules; colors are tokens only.
|
||||
--cfw is the center photo width, set by the component's size prop. */
|
||||
|
||||
.featured { flex: none; }
|
||||
.featured-title { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.featured-title svg { width: 16px; height: 16px; color: var(--accent); }
|
||||
.featured-count { font-variant-numeric: tabular-nums; }
|
||||
|
||||
.featured-cf {
|
||||
--cfw: 96px;
|
||||
position: relative;
|
||||
height: calc(var(--cfw) * 1.155 + 6px);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
overflow-clip-margin: 24px;
|
||||
}
|
||||
.featured-cf::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: calc(var(--cfw) * 1.9);
|
||||
height: calc(var(--cfw) * 1.3);
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(closest-side, color-mix(in oklab, var(--accent), transparent 70%), transparent);
|
||||
filter: blur(12px);
|
||||
animation: featured-breathe 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes featured-breathe { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
||||
|
||||
.featured-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
width: var(--cfw);
|
||||
margin-left: calc(var(--cfw) / -2);
|
||||
transition: transform 1.1s var(--ease), opacity 1.1s, filter 1.1s;
|
||||
}
|
||||
.featured-ring {
|
||||
display: block;
|
||||
padding: 3px;
|
||||
background: var(--line-2);
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
transition: background .8s;
|
||||
}
|
||||
.featured-photo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: .866;
|
||||
object-fit: cover;
|
||||
clip-path: polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%);
|
||||
}
|
||||
/* Photos sit on the tile while loading; initials keep their HexAvatar tone. */
|
||||
img.featured-photo { background: var(--tile-3); }
|
||||
.featured-initials {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--avatar-ink);
|
||||
font-weight: 600;
|
||||
letter-spacing: .02em;
|
||||
font-size: calc(var(--cfw) * .3);
|
||||
}
|
||||
|
||||
.featured-c0 { z-index: 5; }
|
||||
.featured-c0 .featured-ring { background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo)); }
|
||||
.featured-r1 { transform: translateX(76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||
.featured-l1 { transform: translateX(-76%) scale(.7); opacity: .85; z-index: 4; filter: grayscale(.7) brightness(.72); }
|
||||
.featured-r2 { transform: translateX(132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||
.featured-l2 { transform: translateX(-132%) scale(.48); opacity: .45; z-index: 3; filter: grayscale(1) brightness(.55); }
|
||||
.featured-r3 { transform: translateX(170%) scale(.3); opacity: 0; z-index: 1; }
|
||||
.featured-l3 { transform: translateX(-170%) scale(.3); opacity: 0; z-index: 1; }
|
||||
|
||||
.featured-cap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 3px;
|
||||
padding: 12px 16px 2px;
|
||||
animation: featured-cap .8s var(--ease) both;
|
||||
}
|
||||
@keyframes featured-cap { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
.featured-name { font-size: 23px; line-height: 1.1; }
|
||||
.featured-by {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.featured-rate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.featured-rate > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.featured-rate svg { width: 13px; height: 13px; color: var(--accent); }
|
||||
.featured-rate em { font-style: normal; font-weight: 500; color: var(--ink-3); }
|
||||
|
||||
.featured-dots { display: flex; justify-content: center; gap: 5px; padding: 10px 0 16px; }
|
||||
.featured-dots-gap { height: 16px; }
|
||||
.featured-dot {
|
||||
display: block;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--line-2);
|
||||
transition: width .5s var(--ease), background-color .5s;
|
||||
}
|
||||
.featured-dot-on { width: 18px; background: var(--accent); }
|
||||
@@ -91,7 +91,7 @@
|
||||
.bento-left .expert-search { flex: 1 1 0; }
|
||||
.bento-center .feed { flex: 1 1 0; }
|
||||
.bento-right { justify-content: flex-start; }
|
||||
.bento-right .rail-featured { flex: 0 1 auto; max-height: 40%; }
|
||||
.bento-right .rail-featured { flex: none; }
|
||||
.bento-right .rail-trending { flex: 1 1 0; }
|
||||
|
||||
/* Panel contents keep their inner padding off the scroll edge. */
|
||||
@@ -107,7 +107,6 @@
|
||||
@container app (max-width: 1000px) {
|
||||
.bento-col .panel { flex: 0 0 auto; }
|
||||
.bento-col .panel-scroll { flex: 0 0 auto; max-height: 70vh; }
|
||||
.bento-right .rail-featured { max-height: none; }
|
||||
}
|
||||
|
||||
/* Account panel ---------------------------------------------------------- */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeaturedExpertsRail } from "@/components/FeaturedExpertsRail";
|
||||
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||
import { TrendingMinnectsRail } from "@/components/TrendingMinnectsRail";
|
||||
import { DiscoverRailsView } from "@/components/DiscoverRails";
|
||||
import { fetchDiscover } from "@/lib/discover-client";
|
||||
@@ -18,9 +18,9 @@ const minnect = (o: Partial<DiscoverMinnect> = {}): DiscoverMinnect => ({
|
||||
});
|
||||
const html = (el: ReturnType<typeof createElement>) => renderToStaticMarkup(el);
|
||||
|
||||
describe("FeaturedExpertsRail", () => {
|
||||
it("renders a card with name, byline, rating and reviews", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, { experts: [expert()] }));
|
||||
describe("FeaturedCarousel (static render)", () => {
|
||||
it("renders the center expert with name, byline, rating and reviews", () => {
|
||||
const out = html(createElement(FeaturedCarousel, { experts: [expert()] }));
|
||||
expect(out).toContain("<h2");
|
||||
expect(out).toContain("Ann Lee");
|
||||
expect(out).toContain("Growth coach");
|
||||
@@ -29,7 +29,7 @@ describe("FeaturedExpertsRail", () => {
|
||||
expect(out).toContain("<img");
|
||||
});
|
||||
it("falls back to initials and omits missing fields", () => {
|
||||
const out = html(createElement(FeaturedExpertsRail, {
|
||||
const out = html(createElement(FeaturedCarousel, {
|
||||
experts: [expert({ pictureUrl: null, byline: null, rating: null, reviewsCount: null })],
|
||||
}));
|
||||
expect(out).not.toContain("<img");
|
||||
@@ -37,7 +37,7 @@ describe("FeaturedExpertsRail", () => {
|
||||
expect(out).not.toContain("review");
|
||||
});
|
||||
it("renders nothing when empty", () => {
|
||||
expect(html(createElement(FeaturedExpertsRail, { experts: [] }))).toBe("");
|
||||
expect(html(createElement(FeaturedCarousel, { experts: [] }))).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FeaturedCarousel } from "@/components/FeaturedCarousel";
|
||||
import { FEATURED_INTERVAL_MS, featuredSlot, startFeaturedAutoplay } from "@/components/featured-carousel-model";
|
||||
import type { DiscoverExpert } from "@/lib/discover";
|
||||
|
||||
const expert = (i: number): DiscoverExpert => ({
|
||||
id: i, slug: `e${i}`, displayName: `Expert ${i}`, byline: `Byline ${i}`, pictureUrl: i % 2 ? `https://x/${i}.jpg` : null,
|
||||
rating: 4.5, reviewsCount: i, available: true, location: { city: null, country: null },
|
||||
});
|
||||
const experts = (n: number) => Array.from({ length: n }, (_, i) => expert(i + 1));
|
||||
|
||||
describe("featuredSlot", () => {
|
||||
it("places items by offset from the current index, wrapping round", () => {
|
||||
const slots = Array.from({ length: 8 }, (_, i) => featuredSlot(i, 0, 8));
|
||||
expect(slots).toEqual(["c0", "r1", "r2", "r3", "r3", "l3", "l2", "l1"]);
|
||||
expect(featuredSlot(0, 7, 8)).toBe("r1");
|
||||
expect(featuredSlot(6, 7, 8)).toBe("l1");
|
||||
});
|
||||
it("degrades with one or two experts: no empty neighbors", () => {
|
||||
expect(featuredSlot(0, 0, 1)).toBe("c0");
|
||||
expect([featuredSlot(0, 0, 2), featuredSlot(1, 0, 2)]).toEqual(["c0", "r1"]);
|
||||
expect([featuredSlot(0, 1, 2), featuredSlot(1, 1, 2)]).toEqual(["r1", "c0"]);
|
||||
expect([0, 1, 2].map((i) => featuredSlot(i, 0, 3))).toEqual(["c0", "r1", "l1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startFeaturedAutoplay", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("advances every 2.8s and wraps to the start", () => {
|
||||
const seen: number[] = [];
|
||||
const ctl = startFeaturedAutoplay({ count: 3, onChange: (i) => seen.push(i) });
|
||||
expect(FEATURED_INTERVAL_MS).toBe(2800);
|
||||
vi.advanceTimersByTime(2799);
|
||||
expect(seen).toEqual([]);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(seen).toEqual([1]);
|
||||
vi.advanceTimersByTime(2800 * 2);
|
||||
expect(seen).toEqual([1, 2, 0]);
|
||||
expect(ctl.index).toBe(0);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("does not advance while paused and resumes after", () => {
|
||||
const onChange = vi.fn();
|
||||
const ctl = startFeaturedAutoplay({ count: 5, onChange });
|
||||
ctl.pause();
|
||||
expect(ctl.paused).toBe(true);
|
||||
vi.advanceTimersByTime(4000 * 3);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(ctl.index).toBe(0);
|
||||
ctl.resume();
|
||||
vi.advanceTimersByTime(2800);
|
||||
expect(onChange).toHaveBeenCalledWith(1);
|
||||
ctl.stop();
|
||||
});
|
||||
|
||||
it("never rotates a single expert and stops cleanly", () => {
|
||||
const one = vi.fn();
|
||||
startFeaturedAutoplay({ count: 1, onChange: one });
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(one).not.toHaveBeenCalled();
|
||||
const many = vi.fn();
|
||||
const ctl = startFeaturedAutoplay({ count: 4, onChange: many });
|
||||
ctl.stop();
|
||||
vi.advanceTimersByTime(10_000);
|
||||
expect(many).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeaturedCarousel markup", () => {
|
||||
it("has a polite live region, hides non-center items and shows the counter and dots", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(8) }));
|
||||
expect(out).toContain('aria-live="polite"');
|
||||
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).toContain("featured-c0");
|
||||
expect(out).toContain(">1 / 8<");
|
||||
expect(out.match(/class="featured-dot( featured-dot-on)?"/g)).toHaveLength(8);
|
||||
expect(out.match(/featured-dot-on/g)).toHaveLength(1);
|
||||
expect(out).toContain("--cfw:96px");
|
||||
});
|
||||
it("takes a size prop for mobile", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(3), size: 156 }));
|
||||
expect(out).toContain("--cfw:156px");
|
||||
});
|
||||
it("shows no dots for a single expert", () => {
|
||||
const out = renderToStaticMarkup(createElement(FeaturedCarousel, { experts: experts(1) }));
|
||||
expect(out).not.toContain("featured-dot ");
|
||||
expect(out).toContain(">1 / 1<");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user