@flamingo-stack/openframe-frontend-core 0.0.394-snapshot.20260709183206 → 0.0.394
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-ZB5HSWX4.js → chunk-2J6AOHJI.js} +2 -2
- package/dist/{chunk-Y4I4462K.cjs → chunk-4FT3KQD3.cjs} +15 -15
- package/dist/{chunk-Y4I4462K.cjs.map → chunk-4FT3KQD3.cjs.map} +1 -1
- package/dist/{chunk-NN73KMIK.js → chunk-5J3LR56Z.js} +2 -2
- package/dist/{chunk-WHJEU7V7.js → chunk-A7IGCQUD.js} +4 -4
- package/dist/{chunk-PFF6Q6N5.js → chunk-AGTM2H7T.js} +2 -2
- package/dist/{chunk-PJ2YFSMY.js → chunk-AQJ5S6RS.js} +2 -2
- package/dist/{chunk-T6FEE3R2.cjs → chunk-CJMM2WZP.cjs} +5 -5
- package/dist/{chunk-T6FEE3R2.cjs.map → chunk-CJMM2WZP.cjs.map} +1 -1
- package/dist/{chunk-5U72NXGG.cjs → chunk-DFCPMBYN.cjs} +9 -9
- package/dist/{chunk-5U72NXGG.cjs.map → chunk-DFCPMBYN.cjs.map} +1 -1
- package/dist/{chunk-J2UDV3FH.cjs → chunk-DRSAMA27.cjs} +62 -62
- package/dist/{chunk-J2UDV3FH.cjs.map → chunk-DRSAMA27.cjs.map} +1 -1
- package/dist/{chunk-TWLPG5IQ.js → chunk-DXIDOGF4.js} +2 -2
- package/dist/{chunk-3VJPMRNT.cjs → chunk-FIOOW474.cjs} +7 -7
- package/dist/{chunk-3VJPMRNT.cjs.map → chunk-FIOOW474.cjs.map} +1 -1
- package/dist/{chunk-YJE3HL6Q.js → chunk-FVFPGGDA.js} +4 -4
- package/dist/{chunk-TTEAYVLT.js → chunk-G6CNEEZ3.js} +2 -2
- package/dist/{chunk-NJPMHDFG.js → chunk-GAD4OYKN.js} +2 -2
- package/dist/{chunk-BEPZ5YAN.cjs → chunk-HXDBJR4A.cjs} +59 -59
- package/dist/{chunk-BEPZ5YAN.cjs.map → chunk-HXDBJR4A.cjs.map} +1 -1
- package/dist/{chunk-OSOKWP5R.cjs → chunk-IEKEUSPV.cjs} +31 -31
- package/dist/{chunk-OSOKWP5R.cjs.map → chunk-IEKEUSPV.cjs.map} +1 -1
- package/dist/{chunk-RNWLVI3V.cjs → chunk-LP3FQTCB.cjs} +26 -26
- package/dist/{chunk-RNWLVI3V.cjs.map → chunk-LP3FQTCB.cjs.map} +1 -1
- package/dist/{chunk-4YVUL4T7.cjs → chunk-N6SH4ZYN.cjs} +12 -12
- package/dist/{chunk-4YVUL4T7.cjs.map → chunk-N6SH4ZYN.cjs.map} +1 -1
- package/dist/{chunk-RWBTSF2E.js → chunk-NYBMBTBL.js} +5181 -5295
- package/dist/chunk-NYBMBTBL.js.map +1 -0
- package/dist/{chunk-M7WTWBCY.cjs → chunk-PVMISZQT.cjs} +37 -37
- package/dist/{chunk-M7WTWBCY.cjs.map → chunk-PVMISZQT.cjs.map} +1 -1
- package/dist/{chunk-PNMX75W4.cjs → chunk-R6PS3AXI.cjs} +1057 -1171
- package/dist/chunk-R6PS3AXI.cjs.map +1 -0
- package/dist/{chunk-45Z6B6CO.js → chunk-SSZL4QYX.js} +4 -4
- package/dist/{chunk-MPN3MV5H.js → chunk-WAKXAMRC.js} +4 -4
- package/dist/{chunk-ZCKVP4SU.cjs → chunk-YIJGUS7V.cjs} +26 -26
- package/dist/{chunk-ZCKVP4SU.cjs.map → chunk-YIJGUS7V.cjs.map} +1 -1
- package/dist/components/carousel.d.ts +19 -0
- package/dist/components/carousel.d.ts.map +1 -0
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -6
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +0 -1
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +1 -5
- package/dist/components/features/video-bites-strip.d.ts +26 -15
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +120 -124
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +9 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +2 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -5
- package/dist/types/blog.d.ts +10 -9
- package/dist/types/blog.d.ts.map +1 -1
- package/dist/types/case-study.d.ts +5 -2
- package/dist/types/case-study.d.ts.map +1 -1
- package/dist/types/customer-interview.d.ts +5 -2
- package/dist/types/customer-interview.d.ts.map +1 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/components/.carousel.md +50 -0
- package/src/components/carousel.tsx +262 -0
- package/src/components/features/index.ts +0 -3
- package/src/components/features/video-bites-strip.tsx +375 -53
- package/src/types/blog.ts +10 -9
- package/src/types/case-study.ts +5 -2
- package/src/types/customer-interview.ts +5 -2
- package/src/types/index.ts +0 -1
- package/dist/chunk-PNMX75W4.cjs.map +0 -1
- package/dist/chunk-RWBTSF2E.js.map +0 -1
- package/dist/components/features/cards-strip.d.ts +0 -114
- package/dist/components/features/cards-strip.d.ts.map +0 -1
- package/dist/types/entity-platform.d.ts +0 -22
- package/dist/types/entity-platform.d.ts.map +0 -1
- package/src/components/features/cards-strip.tsx +0 -587
- package/src/stories/CardsStrip.stories.tsx +0 -101
- package/src/types/entity-platform.ts +0 -21
- /package/dist/{chunk-ZB5HSWX4.js.map → chunk-2J6AOHJI.js.map} +0 -0
- /package/dist/{chunk-NN73KMIK.js.map → chunk-5J3LR56Z.js.map} +0 -0
- /package/dist/{chunk-WHJEU7V7.js.map → chunk-A7IGCQUD.js.map} +0 -0
- /package/dist/{chunk-PFF6Q6N5.js.map → chunk-AGTM2H7T.js.map} +0 -0
- /package/dist/{chunk-PJ2YFSMY.js.map → chunk-AQJ5S6RS.js.map} +0 -0
- /package/dist/{chunk-TWLPG5IQ.js.map → chunk-DXIDOGF4.js.map} +0 -0
- /package/dist/{chunk-YJE3HL6Q.js.map → chunk-FVFPGGDA.js.map} +0 -0
- /package/dist/{chunk-TTEAYVLT.js.map → chunk-G6CNEEZ3.js.map} +0 -0
- /package/dist/{chunk-NJPMHDFG.js.map → chunk-GAD4OYKN.js.map} +0 -0
- /package/dist/{chunk-45Z6B6CO.js.map → chunk-SSZL4QYX.js.map} +0 -0
- /package/dist/{chunk-MPN3MV5H.js.map → chunk-WAKXAMRC.js.map} +0 -0
|
@@ -1,587 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* <CardsStrip> — THE horizontal card-strip engine (single source of truth).
|
|
5
|
-
*
|
|
6
|
-
* Extracted verbatim from <VideoBitesStrip> so every strip surface shares ONE
|
|
7
|
-
* marquee/chevron/seam/measure implementation ("unify means delete
|
|
8
|
-
* variations"). Two input modes, one engine:
|
|
9
|
-
*
|
|
10
|
-
* MODE A — render-prop (`items` + `itemKey` + `renderCard`): full control.
|
|
11
|
-
* The card receives the strip context (controlled activation, clone flag,
|
|
12
|
-
* shared player-mount gate) and owns its own clone a11y. Used by
|
|
13
|
-
* <VideoBitesStrip>. The engine adds NO cell wrapper in this mode — the
|
|
14
|
-
* card's root is the track's direct child (`scrollByCard` measures
|
|
15
|
-
* `track.firstElementChild.offsetWidth`, and the `flex items-stretch gap-4`
|
|
16
|
-
* sizing contract depends on it).
|
|
17
|
-
*
|
|
18
|
-
* MODE B — children (organic): ANY card components as plain JSX children.
|
|
19
|
-
* Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard
|
|
20
|
-
* marquee-pause, clone `aria-hidden` + `inert`, row-height stretch).
|
|
21
|
-
* Adding a new card type requires ZERO strip code. Contract:
|
|
22
|
-
* (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`
|
|
23
|
-
* does NOT flatten through a single <Fragment> child (it would become
|
|
24
|
-
* one giant cell; a dev-only warning fires);
|
|
25
|
-
* (b) when `autoScroll` is on, children must be ref-free / unique-DOM-id-
|
|
26
|
-
* free / mount-effect-free — the clone copy mounts the same element a
|
|
27
|
-
* second time (double-fired effects, duplicate ids, last-mounted ref).
|
|
28
|
-
*
|
|
29
|
-
* Activation invariant: hover/focus activation is wired in exactly ONE place —
|
|
30
|
-
* the managed cell (children mode) OR the card itself (render-prop mode,
|
|
31
|
-
* e.g. <VideoBiteCard>) — never both.
|
|
32
|
-
*
|
|
33
|
-
* Marquee state model (explicit — do not "simplify" into one timer):
|
|
34
|
-
* the rAF advances only when the pause-reason set {cardHovered, offViewport,
|
|
35
|
-
* tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
|
|
36
|
-
* userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD
|
|
37
|
-
* (incl. its overlay) — leaving the card resumes the marquee immediately.
|
|
38
|
-
* Chevron clicks and manual wheel/touch each set their own suppress-until
|
|
39
|
-
* timestamp; any card-level leave-grace is a separate concern that never
|
|
40
|
-
* touches marquee state.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
44
|
-
import { cn } from '../../utils/cn';
|
|
45
|
-
import { Button } from '../ui/button';
|
|
46
|
-
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
47
|
-
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
48
|
-
import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
|
|
49
|
-
|
|
50
|
-
// =============================================================================
|
|
51
|
-
// Types
|
|
52
|
-
// =============================================================================
|
|
53
|
-
|
|
54
|
-
/** Per-card strip context passed to `renderCard` (and used internally by the
|
|
55
|
-
* children-mode managed cell). */
|
|
56
|
-
export interface CardStripRenderCtx {
|
|
57
|
-
/** Item index (copy-independent — the clone copy repeats the same indices). */
|
|
58
|
-
index: number;
|
|
59
|
-
/** `${itemKey}__${copyIndex}__${index}` — unique per rendered card, even for
|
|
60
|
-
* duplicate items (the trailing index disambiguates). */
|
|
61
|
-
cardKey: string;
|
|
62
|
-
isClone: boolean;
|
|
63
|
-
isTouch: boolean;
|
|
64
|
-
/** Engine's (max-width: 767px) media query. */
|
|
65
|
-
isMobile: boolean;
|
|
66
|
-
/** activeKey === cardKey (controlled hover/focus activation). */
|
|
67
|
-
active: boolean;
|
|
68
|
-
onActivate: (key: string) => void;
|
|
69
|
-
onDeactivate: (key: string) => void;
|
|
70
|
-
/** Shared-by-index 500px near-viewport gate (opt-in — only meaningful when
|
|
71
|
-
* the card registers via `rootRef`). */
|
|
72
|
-
mounted: boolean;
|
|
73
|
-
/** Registers the card root with the mount-gate IntersectionObserver.
|
|
74
|
-
* May return a cleanup (React 19 ref contract). */
|
|
75
|
-
rootRef: (el: HTMLDivElement | null) => (() => void) | undefined;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
interface CardsStripEngineProps {
|
|
79
|
-
/** Section heading. Omit `title` (or set `showTitle={false}`) to hide. */
|
|
80
|
-
title?: string;
|
|
81
|
-
showTitle?: boolean;
|
|
82
|
-
/** Custom node rendered between the heading and the strip. */
|
|
83
|
-
headerSlot?: React.ReactNode;
|
|
84
|
-
/** Marquee auto-scroll. Auto-disabled on no-overflow and
|
|
85
|
-
* prefers-reduced-motion. Pass `autoScroll={false}` for chevron-style
|
|
86
|
-
* entity strips — the default stays `true` for bites-contract parity. */
|
|
87
|
-
autoScroll?: boolean;
|
|
88
|
-
/** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee)
|
|
89
|
-
* default around 100px/s; 60 keeps drift lively while cards stay easy to
|
|
90
|
-
* hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */
|
|
91
|
-
autoScrollSpeed?: number;
|
|
92
|
-
/** Pause the marquee while a CARD is hovered (resumes as soon as the
|
|
93
|
-
* pointer leaves the card — strip whitespace/heading never pauses). */
|
|
94
|
-
pauseOnHover?: boolean;
|
|
95
|
-
/** Floating prev/next buttons. Hidden automatically when nothing overflows. */
|
|
96
|
-
showChevrons?: boolean;
|
|
97
|
-
/** Chevron aria-labels. */
|
|
98
|
-
prevLabel?: string;
|
|
99
|
-
nextLabel?: string;
|
|
100
|
-
/** Merged into the engine's wrapper ref (e.g. `useVideoWarmup().ref`). */
|
|
101
|
-
rootRef?: (el: HTMLDivElement | null) => void;
|
|
102
|
-
className?: string;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// The two mode interfaces hand-mirror each other's field names as `never` —
|
|
106
|
-
// INVARIANT: every field added to one mode gets a `never` twin in the other,
|
|
107
|
-
// or the XOR discrimination silently weakens (mixed-mode props become
|
|
108
|
-
// assignable). Same manual pattern as social-icon-row.tsx / chart.tsx.
|
|
109
|
-
|
|
110
|
-
/** MODE A — render-prop (advanced): full ctx control, no cell wrapper. */
|
|
111
|
-
interface RenderCardMode<T> {
|
|
112
|
-
items: ReadonlyArray<T>;
|
|
113
|
-
itemKey: (item: T, index: number) => string;
|
|
114
|
-
renderCard: (item: T, ctx: CardStripRenderCtx) => React.ReactNode;
|
|
115
|
-
children?: never;
|
|
116
|
-
cardWidthDesktop?: never;
|
|
117
|
-
cardWidthMobile?: never;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** MODE B — children (organic): ANY card components, zero registration. */
|
|
121
|
-
interface ChildrenMode {
|
|
122
|
-
children: React.ReactNode;
|
|
123
|
-
/** Managed cell width in px per breakpoint (Figma: 400 desktop). */
|
|
124
|
-
cardWidthDesktop?: number;
|
|
125
|
-
cardWidthMobile?: number;
|
|
126
|
-
items?: never;
|
|
127
|
-
itemKey?: never;
|
|
128
|
-
renderCard?: never;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export type CardsStripProps<T = unknown> = CardsStripEngineProps & (RenderCardMode<T> | ChildrenMode);
|
|
132
|
-
|
|
133
|
-
// =============================================================================
|
|
134
|
-
// Constants
|
|
135
|
-
// =============================================================================
|
|
136
|
-
|
|
137
|
-
// Gap between cards (px) — keep in sync with the track's `gap-4`.
|
|
138
|
-
const TRACK_GAP_PX = 16;
|
|
139
|
-
// Marquee suppression windows after manual interaction.
|
|
140
|
-
const CHEVRON_SUPPRESS_MS = 4000;
|
|
141
|
-
const USER_SCROLL_SUPPRESS_MS = 3000;
|
|
142
|
-
|
|
143
|
-
/** Single source for the strip cell width cap (shared with <VideoBiteCard>). */
|
|
144
|
-
export const STRIP_CELL_MAX_WIDTH = '90vw';
|
|
145
|
-
|
|
146
|
-
const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
|
|
147
|
-
|
|
148
|
-
// =============================================================================
|
|
149
|
-
// Component
|
|
150
|
-
// =============================================================================
|
|
151
|
-
|
|
152
|
-
export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactElement | null {
|
|
153
|
-
const {
|
|
154
|
-
title,
|
|
155
|
-
showTitle = true,
|
|
156
|
-
headerSlot,
|
|
157
|
-
autoScroll = true,
|
|
158
|
-
autoScrollSpeed = 60,
|
|
159
|
-
pauseOnHover = true,
|
|
160
|
-
showChevrons = true,
|
|
161
|
-
prevLabel = 'Previous items',
|
|
162
|
-
nextLabel = 'Next items',
|
|
163
|
-
rootRef,
|
|
164
|
-
className,
|
|
165
|
-
} = props;
|
|
166
|
-
|
|
167
|
-
// ---- input-mode normalization ---------------------------------------------
|
|
168
|
-
// Children mode feeds the SAME engine path as render-prop mode — one
|
|
169
|
-
// track/clone/measure/scroll implementation. Engine internals operate on the
|
|
170
|
-
// erased item type (`unknown`); the only assertions live at this seam.
|
|
171
|
-
const childrenMode = props.renderCard === undefined;
|
|
172
|
-
const childItems = useMemo(
|
|
173
|
-
() => (childrenMode ? React.Children.toArray(props.children) : EMPTY_CHILDREN),
|
|
174
|
-
[childrenMode, props.children],
|
|
175
|
-
);
|
|
176
|
-
const items: ReadonlyArray<unknown> = childrenMode ? childItems : props.items;
|
|
177
|
-
const cardWidthDesktop = childrenMode ? (props.cardWidthDesktop ?? 400) : 0;
|
|
178
|
-
const cardWidthMobile = childrenMode ? (props.cardWidthMobile ?? 320) : 0;
|
|
179
|
-
|
|
180
|
-
useEffect(() => {
|
|
181
|
-
if (process.env.NODE_ENV === 'production' || !childrenMode) return;
|
|
182
|
-
const only = childItems.length === 1 ? childItems[0] : null;
|
|
183
|
-
if (React.isValidElement(only) && only.type === React.Fragment) {
|
|
184
|
-
console.warn(
|
|
185
|
-
'[CardsStrip] A single <Fragment> child becomes ONE giant cell — pass an array of card elements ({rows.map(...)}) instead.',
|
|
186
|
-
);
|
|
187
|
-
}
|
|
188
|
-
}, [childrenMode, childItems]);
|
|
189
|
-
|
|
190
|
-
const getItemKey = (item: unknown, index: number): string => {
|
|
191
|
-
if (!childrenMode) {
|
|
192
|
-
// Seam assertion: T is erased inside the engine; the props union
|
|
193
|
-
// guarantees itemKey matches the items array.
|
|
194
|
-
return (props.itemKey as (item: unknown, index: number) => string)(item, index);
|
|
195
|
-
}
|
|
196
|
-
// Children.toArray always assigns element keys ('.0' / '.$slug' — stable
|
|
197
|
-
// across renders); explicit null-check because `&&`/`??` chaining yields
|
|
198
|
-
// the string "false" for non-element children (same isValidElement guard
|
|
199
|
-
// as steps.tsx).
|
|
200
|
-
return React.isValidElement(item) && item.key != null ? String(item.key) : String(index);
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
204
|
-
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
205
|
-
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
206
|
-
|
|
207
|
-
// ---- environment ----------------------------------------------------------
|
|
208
|
-
const [reducedMotion, setReducedMotion] = useState(false);
|
|
209
|
-
const [isTouch, setIsTouch] = useState(false);
|
|
210
|
-
const [isMobile, setIsMobile] = useState(false);
|
|
211
|
-
useEffect(() => {
|
|
212
|
-
const subs: Array<() => void> = [];
|
|
213
|
-
const watch = (query: string, set: (v: boolean) => void) => {
|
|
214
|
-
const mq = window.matchMedia(query);
|
|
215
|
-
set(mq.matches);
|
|
216
|
-
const onChange = (e: MediaQueryListEvent) => set(e.matches);
|
|
217
|
-
mq.addEventListener('change', onChange);
|
|
218
|
-
subs.push(() => mq.removeEventListener('change', onChange));
|
|
219
|
-
};
|
|
220
|
-
watch('(prefers-reduced-motion: reduce)', setReducedMotion);
|
|
221
|
-
watch('(hover: none)', setIsTouch);
|
|
222
|
-
watch('(max-width: 767px)', setIsMobile);
|
|
223
|
-
return () => subs.forEach(fn => fn());
|
|
224
|
-
}, []);
|
|
225
|
-
|
|
226
|
-
// ---- overflow measurement ---------------------------------------------------
|
|
227
|
-
// `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
|
|
228
|
-
const [overflows, setOverflows] = useState(false);
|
|
229
|
-
const singleCopyWidthRef = useRef(0);
|
|
230
|
-
const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
|
|
231
|
-
|
|
232
|
-
const measure = useCallback(() => {
|
|
233
|
-
const scroller = scrollerRef.current;
|
|
234
|
-
const track = trackRef.current;
|
|
235
|
-
if (!scroller || !track) return;
|
|
236
|
-
// When clones are rendered the track is 2× one copy (+ one joining gap).
|
|
237
|
-
const copies = track.dataset.copies === '2' ? 2 : 1;
|
|
238
|
-
const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
|
|
239
|
-
singleCopyWidthRef.current = singleCopy;
|
|
240
|
-
setOverflows(singleCopy > scroller.clientWidth + 1);
|
|
241
|
-
}, []);
|
|
242
|
-
|
|
243
|
-
useEffect(() => {
|
|
244
|
-
measure();
|
|
245
|
-
const scroller = scrollerRef.current;
|
|
246
|
-
const track = trackRef.current;
|
|
247
|
-
if (!scroller || !track || typeof ResizeObserver === 'undefined') return;
|
|
248
|
-
const ro = new ResizeObserver(measure);
|
|
249
|
-
ro.observe(scroller);
|
|
250
|
-
ro.observe(track);
|
|
251
|
-
return () => ro.disconnect();
|
|
252
|
-
}, [measure, items.length, marqueeActive, isMobile]);
|
|
253
|
-
|
|
254
|
-
// ---- pause-reason set + suppress timestamps ---------------------------------
|
|
255
|
-
// (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
|
|
256
|
-
const nearViewportRef = useRef(true);
|
|
257
|
-
const chevronSuppressUntilRef = useRef(0);
|
|
258
|
-
const userScrollSuppressUntilRef = useRef(0);
|
|
259
|
-
|
|
260
|
-
// Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
|
|
261
|
-
// should stop again when the strip scrolls far off-screen.
|
|
262
|
-
useEffect(() => {
|
|
263
|
-
const el = wrapperRef.current;
|
|
264
|
-
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
265
|
-
const io = new IntersectionObserver(
|
|
266
|
-
entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
|
|
267
|
-
{ rootMargin: '200px' },
|
|
268
|
-
);
|
|
269
|
-
io.observe(el);
|
|
270
|
-
return () => io.disconnect();
|
|
271
|
-
}, []);
|
|
272
|
-
|
|
273
|
-
// ---- marquee rAF engine ------------------------------------------------------
|
|
274
|
-
// Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
|
|
275
|
-
// scrollLeft back each frame returns a rounded value, so sub-pixel increments
|
|
276
|
-
// (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
|
|
277
|
-
// ran measurably slower than the configured speed because of it.
|
|
278
|
-
//
|
|
279
|
-
// Chevron GLIDE also runs through this engine (glideRemainingRef): browser
|
|
280
|
-
// `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
|
|
281
|
-
// seam warp mid-animation made it lunge a full copy-width to catch up, warp
|
|
282
|
-
// again, and oscillate — rapid chevron clicks left every card flickering.
|
|
283
|
-
// The rAF tween consumes a signed remaining-distance instead, wrapping
|
|
284
|
-
// modulo the copy width each frame, so warps are invisible to it.
|
|
285
|
-
const marqueePosRef = useRef(0);
|
|
286
|
-
const glideRemainingRef = useRef(0);
|
|
287
|
-
useEffect(() => {
|
|
288
|
-
if (!marqueeActive) return;
|
|
289
|
-
const scroller = scrollerRef.current;
|
|
290
|
-
if (!scroller) return;
|
|
291
|
-
let raf = 0;
|
|
292
|
-
let last = performance.now();
|
|
293
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
294
|
-
const wrap = (pos: number) => {
|
|
295
|
-
const half = singleCopyWidthRef.current;
|
|
296
|
-
if (half <= 0) return pos;
|
|
297
|
-
while (pos >= half) pos -= half;
|
|
298
|
-
while (pos < 0) pos += half;
|
|
299
|
-
return pos;
|
|
300
|
-
};
|
|
301
|
-
const tick = (now: number) => {
|
|
302
|
-
const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
|
|
303
|
-
last = now;
|
|
304
|
-
const paused =
|
|
305
|
-
(pauseOnHover && activeKeyRef.current !== null) ||
|
|
306
|
-
!nearViewportRef.current ||
|
|
307
|
-
document.visibilityState === 'hidden' ||
|
|
308
|
-
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
309
|
-
const glide = glideRemainingRef.current;
|
|
310
|
-
if (glide !== 0) {
|
|
311
|
-
// Ease-out toward the chevron target; runs even while "paused"
|
|
312
|
-
// (the chevron click itself sets the suppress window).
|
|
313
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
314
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
315
|
-
}
|
|
316
|
-
const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
|
|
317
|
-
const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
|
|
318
|
-
glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
|
|
319
|
-
marqueePosRef.current = wrap(marqueePosRef.current + step);
|
|
320
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
321
|
-
} else if (!paused) {
|
|
322
|
-
// Resync after external movement (user scroll, seam warp).
|
|
323
|
-
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
324
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
325
|
-
}
|
|
326
|
-
marqueePosRef.current = wrap(marqueePosRef.current + autoScrollSpeed * dt);
|
|
327
|
-
scroller.scrollLeft = marqueePosRef.current;
|
|
328
|
-
} else {
|
|
329
|
-
marqueePosRef.current = scroller.scrollLeft;
|
|
330
|
-
}
|
|
331
|
-
raf = requestAnimationFrame(tick);
|
|
332
|
-
};
|
|
333
|
-
raf = requestAnimationFrame(tick);
|
|
334
|
-
return () => cancelAnimationFrame(raf);
|
|
335
|
-
}, [marqueeActive, autoScrollSpeed, pauseOnHover]);
|
|
336
|
-
|
|
337
|
-
// ---- hover / overlay state -----------------------------------------------------
|
|
338
|
-
// activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
|
|
339
|
-
// a clone near the wrap seam works too). It only drives per-card `active`
|
|
340
|
-
// state — any heavier behavior (players, overlays) is the card's concern.
|
|
341
|
-
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
342
|
-
// Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is
|
|
343
|
-
// hovered (activeKey set) — hovering strip whitespace/heading does not
|
|
344
|
-
// pause, and leaving a card resumes immediately.
|
|
345
|
-
const activeKeyRef = useRef<string | null>(null);
|
|
346
|
-
const activate = useCallback((key: string) => {
|
|
347
|
-
activeKeyRef.current = key;
|
|
348
|
-
setActiveKey(key);
|
|
349
|
-
}, []);
|
|
350
|
-
const deactivate = useCallback((key: string) => {
|
|
351
|
-
// Ref write hoisted OUT of the state updater: React re-invokes queued
|
|
352
|
-
// updaters at render time, so an impure updater could re-null the ref
|
|
353
|
-
// AFTER a same-batch activate(B) already set it (leave(A)+enter(B) in one
|
|
354
|
-
// dispatch when the pointer jumps directly between cells) — stranding the
|
|
355
|
-
// rAF pause check while a card is visibly hovered.
|
|
356
|
-
if (activeKeyRef.current === key) activeKeyRef.current = null;
|
|
357
|
-
setActiveKey(current => (current === key ? null : current));
|
|
358
|
-
}, []);
|
|
359
|
-
|
|
360
|
-
// ---- shared card-mount gate (by ITEM index, across both copies) -------------
|
|
361
|
-
// A card and its clone show identical content half a copy-width apart. If
|
|
362
|
-
// each card gated its own expensive content (e.g. video players), a seam warp
|
|
363
|
-
// instantly swapped the visible copy for cards whose content was NOT mounted
|
|
364
|
-
// yet — every wrap crossing flashed placeholders. Mounting by INDEX (near in
|
|
365
|
-
// EITHER copy → both copies mount) makes the warp pixel-identical. Opt-in:
|
|
366
|
-
// cards that never register via ctx.rootRef incur no per-card observation.
|
|
367
|
-
const [mountedIdx, setMountedIdx] = useState<ReadonlySet<number>>(() => new Set());
|
|
368
|
-
const cardObserverRef = useRef<IntersectionObserver | null>(null);
|
|
369
|
-
const cardElIdxRef = useRef(new Map<Element, number>());
|
|
370
|
-
const cardElNearRef = useRef(new Map<Element, boolean>());
|
|
371
|
-
useEffect(() => {
|
|
372
|
-
if (typeof IntersectionObserver === 'undefined') return;
|
|
373
|
-
const io = new IntersectionObserver(entries => {
|
|
374
|
-
for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting);
|
|
375
|
-
const next = new Set<number>();
|
|
376
|
-
cardElNearRef.current.forEach((near, el) => {
|
|
377
|
-
if (!near) return;
|
|
378
|
-
const idx = cardElIdxRef.current.get(el);
|
|
379
|
-
if (idx !== undefined) next.add(idx);
|
|
380
|
-
});
|
|
381
|
-
setMountedIdx(prev => {
|
|
382
|
-
if (prev.size === next.size) {
|
|
383
|
-
let same = true;
|
|
384
|
-
next.forEach(i => { if (!prev.has(i)) same = false; });
|
|
385
|
-
if (same) return prev;
|
|
386
|
-
}
|
|
387
|
-
return next;
|
|
388
|
-
});
|
|
389
|
-
}, { rootMargin: '500px' });
|
|
390
|
-
cardObserverRef.current = io;
|
|
391
|
-
cardElIdxRef.current.forEach((_idx, el) => io.observe(el));
|
|
392
|
-
return () => { io.disconnect(); cardObserverRef.current = null; };
|
|
393
|
-
}, []);
|
|
394
|
-
// Stable per-index ref callbacks (a fresh closure per render would detach/
|
|
395
|
-
// re-attach the observer every render). React 19 ref-cleanup unregisters.
|
|
396
|
-
const cardRefFnsRef = useRef(new Map<number, (el: HTMLDivElement | null) => (() => void) | undefined>());
|
|
397
|
-
const getCardRef = useCallback((idx: number) => {
|
|
398
|
-
let fn = cardRefFnsRef.current.get(idx);
|
|
399
|
-
if (!fn) {
|
|
400
|
-
fn = (el: HTMLDivElement | null) => {
|
|
401
|
-
if (!el) return undefined;
|
|
402
|
-
cardElIdxRef.current.set(el, idx);
|
|
403
|
-
cardObserverRef.current?.observe(el);
|
|
404
|
-
return () => {
|
|
405
|
-
cardElIdxRef.current.delete(el);
|
|
406
|
-
cardElNearRef.current.delete(el);
|
|
407
|
-
cardObserverRef.current?.unobserve(el);
|
|
408
|
-
};
|
|
409
|
-
};
|
|
410
|
-
cardRefFnsRef.current.set(idx, fn);
|
|
411
|
-
}
|
|
412
|
-
return fn;
|
|
413
|
-
}, []);
|
|
414
|
-
|
|
415
|
-
// ---- manual navigation -----------------------------------------------------------
|
|
416
|
-
const scrollByCard = useCallback((dir: 1 | -1) => {
|
|
417
|
-
const scroller = scrollerRef.current;
|
|
418
|
-
const track = trackRef.current;
|
|
419
|
-
if (!scroller || !track) return;
|
|
420
|
-
const firstCard = track.firstElementChild as HTMLElement | null;
|
|
421
|
-
const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
|
|
422
|
-
chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
|
|
423
|
-
if (marqueeActiveRef.current) {
|
|
424
|
-
// Wrap-aware glide via the rAF engine (see engine comment) — clicks
|
|
425
|
-
// accumulate distance instead of racing browser smooth-scroll targets.
|
|
426
|
-
glideRemainingRef.current += dir * step;
|
|
427
|
-
} else {
|
|
428
|
-
// No clones/seam without the marquee — native smooth scroll is safe.
|
|
429
|
-
scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
|
|
430
|
-
}
|
|
431
|
-
}, []);
|
|
432
|
-
|
|
433
|
-
const onUserScrollIntent = useCallback(() => {
|
|
434
|
-
userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
|
|
435
|
-
}, []);
|
|
436
|
-
|
|
437
|
-
// Never-ending strip: warp across the clone seam on EVERY scroll (manual
|
|
438
|
-
// wheel/drag/chevron included — the rAF only wraps while the marquee runs,
|
|
439
|
-
// so without this a user scrolling during a pause/suppress window hits the
|
|
440
|
-
// physical track edges). Both copies render identical content, so a ±half
|
|
441
|
-
// jump is pixel-invisible. Backward warp only when arriving AT the left
|
|
442
|
-
// edge from the right (never on initial rest at 0).
|
|
443
|
-
const lastScrollLeftRef = useRef(0);
|
|
444
|
-
const marqueeActiveRef = useRef(false);
|
|
445
|
-
marqueeActiveRef.current = marqueeActive;
|
|
446
|
-
const onSeamWarp = useCallback(() => {
|
|
447
|
-
const scroller = scrollerRef.current;
|
|
448
|
-
if (!scroller || !marqueeActiveRef.current) return;
|
|
449
|
-
const half = singleCopyWidthRef.current;
|
|
450
|
-
if (half <= 0) return;
|
|
451
|
-
let sl = scroller.scrollLeft;
|
|
452
|
-
if (sl >= half) {
|
|
453
|
-
sl -= half;
|
|
454
|
-
scroller.scrollLeft = sl;
|
|
455
|
-
marqueePosRef.current = sl;
|
|
456
|
-
} else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
|
|
457
|
-
sl += half;
|
|
458
|
-
scroller.scrollLeft = sl;
|
|
459
|
-
marqueePosRef.current = sl;
|
|
460
|
-
}
|
|
461
|
-
lastScrollLeftRef.current = sl;
|
|
462
|
-
}, []);
|
|
463
|
-
|
|
464
|
-
// ---- render ----------------------------------------------------------------
|
|
465
|
-
|
|
466
|
-
const renderItem = (item: unknown, ctx: CardStripRenderCtx): React.ReactNode => {
|
|
467
|
-
if (!childrenMode) {
|
|
468
|
-
// Seam assertion (see normalization comment above).
|
|
469
|
-
return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);
|
|
470
|
-
}
|
|
471
|
-
// Managed cell — the ONLY place cell width / activation / clone a11y are
|
|
472
|
-
// encoded for children mode.
|
|
473
|
-
return (
|
|
474
|
-
// OUTER cell: width + activation handlers (never inert — an inert cell
|
|
475
|
-
// would retarget pointer events to the track and defeat hover-pause on
|
|
476
|
-
// clones).
|
|
477
|
-
<div
|
|
478
|
-
className="shrink-0 self-stretch"
|
|
479
|
-
style={{ width: ctx.isMobile ? cardWidthMobile : cardWidthDesktop, maxWidth: STRIP_CELL_MAX_WIDTH }}
|
|
480
|
-
onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
|
|
481
|
-
onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
|
|
482
|
-
// Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
|
|
483
|
-
onFocus={() => ctx.onActivate(ctx.cardKey)}
|
|
484
|
-
onBlur={e => {
|
|
485
|
-
// Intra-cell focus moves must not blip the pause off.
|
|
486
|
-
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
487
|
-
ctx.onDeactivate(ctx.cardKey);
|
|
488
|
-
}}
|
|
489
|
-
>
|
|
490
|
-
{/* INNER: clone a11y + uniform stretch — cards untouched. `inert` on
|
|
491
|
-
the clone strips anchor focusability without modifying any card
|
|
492
|
-
(WCAG aria-hidden-focus); clone content is pointer/focus-dead by
|
|
493
|
-
design while the outer cell still hover-pauses. */}
|
|
494
|
-
<div
|
|
495
|
-
className="h-full [&>*]:h-full [&>a>*]:h-full"
|
|
496
|
-
aria-hidden={ctx.isClone || undefined}
|
|
497
|
-
inert={ctx.isClone || undefined}
|
|
498
|
-
>
|
|
499
|
-
{item as React.ReactNode}
|
|
500
|
-
</div>
|
|
501
|
-
</div>
|
|
502
|
-
);
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
if (items.length === 0) return null;
|
|
506
|
-
|
|
507
|
-
const copies = marqueeActive ? 2 : 1;
|
|
508
|
-
|
|
509
|
-
return (
|
|
510
|
-
<div
|
|
511
|
-
ref={node => { wrapperRef.current = node; rootRef?.(node); }}
|
|
512
|
-
className={cn('flex flex-col gap-6 w-full min-w-0', className)}
|
|
513
|
-
>
|
|
514
|
-
{showTitle && title && (
|
|
515
|
-
<h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
|
|
516
|
-
)}
|
|
517
|
-
{headerSlot}
|
|
518
|
-
|
|
519
|
-
<div className="relative">
|
|
520
|
-
<div
|
|
521
|
-
ref={scrollerRef}
|
|
522
|
-
className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
523
|
-
onWheel={onUserScrollIntent}
|
|
524
|
-
onTouchStart={onUserScrollIntent}
|
|
525
|
-
onPointerDown={onUserScrollIntent}
|
|
526
|
-
onScroll={onSeamWarp}
|
|
527
|
-
>
|
|
528
|
-
<div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
|
|
529
|
-
{Array.from({ length: copies }, (_, copyIndex) =>
|
|
530
|
-
items.map((item, i) => {
|
|
531
|
-
const key = `${getItemKey(item, i)}__${copyIndex}__${i}`;
|
|
532
|
-
const isClone = copyIndex === 1;
|
|
533
|
-
return (
|
|
534
|
-
// Clone copy a11y (aria-hidden + no focusable descendants —
|
|
535
|
-
// WCAG) is the card's job in render-prop mode (via
|
|
536
|
-
// ctx.isClone) and the managed cell's job in children mode.
|
|
537
|
-
// The engine itself adds no wrapper here in render-prop mode
|
|
538
|
-
// so the card root stays the track's direct child.
|
|
539
|
-
<React.Fragment key={key}>
|
|
540
|
-
{renderItem(item, {
|
|
541
|
-
index: i,
|
|
542
|
-
cardKey: key,
|
|
543
|
-
isClone,
|
|
544
|
-
isTouch,
|
|
545
|
-
isMobile,
|
|
546
|
-
active: activeKey === key,
|
|
547
|
-
onActivate: activate,
|
|
548
|
-
onDeactivate: deactivate,
|
|
549
|
-
mounted: mountedIdx.has(i),
|
|
550
|
-
rootRef: getCardRef(i),
|
|
551
|
-
})}
|
|
552
|
-
</React.Fragment>
|
|
553
|
-
);
|
|
554
|
-
}),
|
|
555
|
-
)}
|
|
556
|
-
</div>
|
|
557
|
-
</div>
|
|
558
|
-
|
|
559
|
-
{showChevrons && overflows && (
|
|
560
|
-
<>
|
|
561
|
-
{/* Shared design-system Button: outline surface (ods-card + border,
|
|
562
|
-
the Figma button-icon look) + the square `icon` size, which owns
|
|
563
|
-
the centered 24px glyph. Icon-only → aria-label required. */}
|
|
564
|
-
<Button
|
|
565
|
-
type="button"
|
|
566
|
-
variant="outline"
|
|
567
|
-
size="icon"
|
|
568
|
-
aria-label={prevLabel}
|
|
569
|
-
onClick={() => scrollByCard(-1)}
|
|
570
|
-
className="absolute left-2 top-1/2 -translate-y-1/2 z-10"
|
|
571
|
-
leftIcon={<Chevron02LeftIcon />}
|
|
572
|
-
/>
|
|
573
|
-
<Button
|
|
574
|
-
type="button"
|
|
575
|
-
variant="outline"
|
|
576
|
-
size="icon"
|
|
577
|
-
aria-label={nextLabel}
|
|
578
|
-
onClick={() => scrollByCard(1)}
|
|
579
|
-
className="absolute right-2 top-1/2 -translate-y-1/2 z-10"
|
|
580
|
-
leftIcon={<Chevron02RightIcon />}
|
|
581
|
-
/>
|
|
582
|
-
</>
|
|
583
|
-
)}
|
|
584
|
-
</div>
|
|
585
|
-
</div>
|
|
586
|
-
);
|
|
587
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
-
import { CardsStrip } from '../components/features/cards-strip'
|
|
3
|
-
import { BlogCard, CaseStudyCard, CustomerInterviewCard } from '../components/chat/entity-cards'
|
|
4
|
-
import { blogPostSummary, caseStudy, customerInterview } from './__fixtures__/chat-cards'
|
|
5
|
-
|
|
6
|
-
// All stories exercise the ORGANIC children mode: real entity cards passed as
|
|
7
|
-
// plain JSX children with zero strip-side registration. The render-prop mode
|
|
8
|
-
// is regression-tested by VideoBitesStrip.stories.tsx.
|
|
9
|
-
//
|
|
10
|
-
// Usage guidance: pass `autoScroll={false}` for chevron-style entity strips
|
|
11
|
-
// (Figma 3905:77388) — the engine default stays `true` for the bites
|
|
12
|
-
// marquee contract, so don't let the marquee-on default propagate silently.
|
|
13
|
-
|
|
14
|
-
const caseStudies = Array.from({ length: 7 }, (_, i) => ({
|
|
15
|
-
...caseStudy,
|
|
16
|
-
id: i + 1,
|
|
17
|
-
slug: `${caseStudy.slug}-${i}`,
|
|
18
|
-
title: `${caseStudy.title} (${i + 1})`,
|
|
19
|
-
}))
|
|
20
|
-
|
|
21
|
-
const meta: Meta<typeof CardsStrip> = {
|
|
22
|
-
title: 'Features/CardsStrip',
|
|
23
|
-
component: CardsStrip,
|
|
24
|
-
parameters: { layout: 'fullscreen' },
|
|
25
|
-
decorators: [
|
|
26
|
-
Story => (
|
|
27
|
-
<div className="bg-ods-bg p-8 min-h-[560px]">
|
|
28
|
-
<Story />
|
|
29
|
-
</div>
|
|
30
|
-
),
|
|
31
|
-
],
|
|
32
|
-
}
|
|
33
|
-
export default meta
|
|
34
|
-
|
|
35
|
-
type Story = StoryObj<typeof CardsStrip>
|
|
36
|
-
|
|
37
|
-
/** Figma 3905:77388: chevron-nav strip of uniform 400px case-study cards —
|
|
38
|
-
* `autoScroll={false}`, chevrons appear only when content overflows. */
|
|
39
|
-
export const CaseStudiesChevrons: Story = {
|
|
40
|
-
render: () => (
|
|
41
|
-
<CardsStrip title="Case Studies" autoScroll={false}>
|
|
42
|
-
{caseStudies.map(study => (
|
|
43
|
-
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
44
|
-
))}
|
|
45
|
-
</CardsStrip>
|
|
46
|
-
),
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Any card type coexists with zero strip config — the managed cell's
|
|
50
|
-
* stretch keeps mixed card types row-aligned (verify heights match). */
|
|
51
|
-
export const MixedCardTypes: Story = {
|
|
52
|
-
render: () => (
|
|
53
|
-
<CardsStrip title="Latest Content" autoScroll={false}>
|
|
54
|
-
<CaseStudyCard key="cs" study={caseStudy} href={`/case-studies/${caseStudy.slug}`} />
|
|
55
|
-
<BlogCard key="blog" post={blogPostSummary} href={`/blog/${blogPostSummary.slug}`} />
|
|
56
|
-
<CustomerInterviewCard
|
|
57
|
-
key="interview"
|
|
58
|
-
interview={customerInterview}
|
|
59
|
-
href={`/interviews/${customerInterview.slug}`}
|
|
60
|
-
/>
|
|
61
|
-
<CaseStudyCard key="cs2" study={caseStudies[1]} href={`/case-studies/${caseStudies[1].slug}`} />
|
|
62
|
-
<BlogCard key="blog2" post={blogPostSummary} href={`/blog/${blogPostSummary.slug}-2`} />
|
|
63
|
-
</CardsStrip>
|
|
64
|
-
),
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Marquee opt-in (engine default). Accepted clone behaviors: the clone
|
|
68
|
-
* copy's inner cell is `aria-hidden` + `inert` (content pointer/focus-dead,
|
|
69
|
-
* links unclickable — WCAG aria-hidden-focus), while the OUTER cell still
|
|
70
|
-
* hover-pauses the marquee; keyboard focus on original cards pauses too. */
|
|
71
|
-
export const MarqueeOptIn: Story = {
|
|
72
|
-
render: () => (
|
|
73
|
-
<CardsStrip title="Case Studies">
|
|
74
|
-
{caseStudies.map(study => (
|
|
75
|
-
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
76
|
-
))}
|
|
77
|
-
</CardsStrip>
|
|
78
|
-
),
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Two cards — no overflow, so no clones, no marquee, no chevrons. */
|
|
82
|
-
export const TwoItemsNoOverflow: Story = {
|
|
83
|
-
render: () => (
|
|
84
|
-
<CardsStrip title="Case Studies" autoScroll={false}>
|
|
85
|
-
{caseStudies.slice(0, 2).map(study => (
|
|
86
|
-
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
87
|
-
))}
|
|
88
|
-
</CardsStrip>
|
|
89
|
-
),
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Narrow cells via cardWidthDesktop/Mobile overrides. */
|
|
93
|
-
export const CustomCellWidth: Story = {
|
|
94
|
-
render: () => (
|
|
95
|
-
<CardsStrip title="Compact Row" autoScroll={false} cardWidthDesktop={320} cardWidthMobile={280}>
|
|
96
|
-
{caseStudies.map(study => (
|
|
97
|
-
<CaseStudyCard key={study.slug} study={study} href={`/case-studies/${study.slug}`} />
|
|
98
|
-
))}
|
|
99
|
-
</CardsStrip>
|
|
100
|
-
),
|
|
101
|
-
}
|