@flamingo-stack/openframe-frontend-core 0.0.447 → 0.0.448-snapshot.20260718005900
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-FR6NVZVM.cjs → chunk-47AMTR2S.cjs} +11 -11
- package/dist/{chunk-FR6NVZVM.cjs.map → chunk-47AMTR2S.cjs.map} +1 -1
- package/dist/{chunk-G7QSLGMI.js → chunk-4IMXFDRI.js} +2 -2
- package/dist/{chunk-ITJMUH4Q.cjs → chunk-4S4RR7XC.cjs} +31 -31
- package/dist/{chunk-ITJMUH4Q.cjs.map → chunk-4S4RR7XC.cjs.map} +1 -1
- package/dist/{chunk-YCSNDUDE.js → chunk-5SJGFLL2.js} +138 -43
- package/dist/chunk-5SJGFLL2.js.map +1 -0
- package/dist/{chunk-XUS75SQW.cjs → chunk-6TS4AIDX.cjs} +15 -15
- package/dist/{chunk-XUS75SQW.cjs.map → chunk-6TS4AIDX.cjs.map} +1 -1
- package/dist/{chunk-EZMM2OMP.cjs → chunk-AZKVNRZP.cjs} +26 -26
- package/dist/{chunk-EZMM2OMP.cjs.map → chunk-AZKVNRZP.cjs.map} +1 -1
- package/dist/{chunk-3GBLPPSW.js → chunk-BSBWBQ7M.js} +4 -4
- package/dist/{chunk-E6HKLQ3Q.cjs → chunk-DJP5HYFG.cjs} +7 -7
- package/dist/{chunk-E6HKLQ3Q.cjs.map → chunk-DJP5HYFG.cjs.map} +1 -1
- package/dist/{chunk-PF6CKZIH.cjs → chunk-DRKQNXSZ.cjs} +5 -5
- package/dist/{chunk-PF6CKZIH.cjs.map → chunk-DRKQNXSZ.cjs.map} +1 -1
- package/dist/{chunk-S7VMUDUB.cjs → chunk-EOFENGSZ.cjs} +9 -9
- package/dist/{chunk-S7VMUDUB.cjs.map → chunk-EOFENGSZ.cjs.map} +1 -1
- package/dist/{chunk-RNXMP7GB.cjs → chunk-H56AEHFC.cjs} +138 -43
- package/dist/chunk-H56AEHFC.cjs.map +1 -0
- package/dist/{chunk-5BJJRPDH.js → chunk-HQ7S6JG5.js} +2 -2
- package/dist/{chunk-TMZYFYSS.js → chunk-JAASDJOF.js} +4 -4
- package/dist/{chunk-2PNWAAU4.cjs → chunk-KKJRBMG3.cjs} +59 -59
- package/dist/{chunk-2PNWAAU4.cjs.map → chunk-KKJRBMG3.cjs.map} +1 -1
- package/dist/{chunk-KYLOPUIH.cjs → chunk-LQTX7EJS.cjs} +26 -26
- package/dist/{chunk-KYLOPUIH.cjs.map → chunk-LQTX7EJS.cjs.map} +1 -1
- package/dist/{chunk-4ONE3HQP.js → chunk-LWT6G75Q.js} +2 -2
- package/dist/{chunk-SHC5NFOH.js → chunk-MWRFPLNB.js} +2 -2
- package/dist/{chunk-ZYB3BYBA.js → chunk-NPBTNWRA.js} +2 -2
- package/dist/{chunk-6TAIXSF5.js → chunk-UNQVTGVG.js} +2 -2
- package/dist/{chunk-WMOF3WQT.js → chunk-VSWFYFIG.js} +4 -4
- package/dist/{chunk-WLU37UZO.cjs → chunk-WYPROXL4.cjs} +62 -62
- package/dist/{chunk-WLU37UZO.cjs.map → chunk-WYPROXL4.cjs.map} +1 -1
- package/dist/{chunk-X4XHW3OV.cjs → chunk-X754WDWO.cjs} +37 -37
- package/dist/{chunk-X4XHW3OV.cjs.map → chunk-X754WDWO.cjs.map} +1 -1
- package/dist/{chunk-D2NLNBLH.js → chunk-XLFAFH2B.js} +2 -2
- package/dist/{chunk-ZQVEB6KT.js → chunk-YSNMV53O.js} +4 -4
- 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/chat/types/message.types.d.ts +13 -0
- package/dist/components/chat/types/message.types.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts +14 -4
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +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/cards-strip.d.ts +2 -1
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +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 +129 -129
- package/dist/components/index.js +9 -9
- 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 -2
- package/dist/index.js +1 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/types/message.types.ts +13 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +301 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +35 -0
- package/src/components/chat/utils/history-merge.ts +157 -14
- package/src/components/chat/utils/process-historical-messages.ts +45 -6
- package/src/components/features/cards-strip.tsx +126 -40
- package/src/stories/CardsStrip.stories.tsx +5 -3
- package/dist/chunk-RNXMP7GB.cjs.map +0 -1
- package/dist/chunk-YCSNDUDE.js.map +0 -1
- /package/dist/{chunk-G7QSLGMI.js.map → chunk-4IMXFDRI.js.map} +0 -0
- /package/dist/{chunk-3GBLPPSW.js.map → chunk-BSBWBQ7M.js.map} +0 -0
- /package/dist/{chunk-5BJJRPDH.js.map → chunk-HQ7S6JG5.js.map} +0 -0
- /package/dist/{chunk-TMZYFYSS.js.map → chunk-JAASDJOF.js.map} +0 -0
- /package/dist/{chunk-4ONE3HQP.js.map → chunk-LWT6G75Q.js.map} +0 -0
- /package/dist/{chunk-SHC5NFOH.js.map → chunk-MWRFPLNB.js.map} +0 -0
- /package/dist/{chunk-ZYB3BYBA.js.map → chunk-NPBTNWRA.js.map} +0 -0
- /package/dist/{chunk-6TAIXSF5.js.map → chunk-UNQVTGVG.js.map} +0 -0
- /package/dist/{chunk-WMOF3WQT.js.map → chunk-VSWFYFIG.js.map} +0 -0
- /package/dist/{chunk-D2NLNBLH.js.map → chunk-XLFAFH2B.js.map} +0 -0
- /package/dist/{chunk-ZQVEB6KT.js.map → chunk-YSNMV53O.js.map} +0 -0
|
@@ -47,6 +47,7 @@ function pushStandaloneMessages(
|
|
|
47
47
|
name: data.text,
|
|
48
48
|
authorType: 'system',
|
|
49
49
|
timestamp: new Date(msg.createdAt),
|
|
50
|
+
...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
|
|
50
51
|
})
|
|
51
52
|
}
|
|
52
53
|
})
|
|
@@ -99,6 +100,9 @@ export function processHistoricalMessages(
|
|
|
99
100
|
let currentAssistantId: string | null = null
|
|
100
101
|
let currentAssistantTimestamp: Date | null = null
|
|
101
102
|
let lastAssistantId: string | null = null
|
|
103
|
+
// MAX persisted seq across the rows grouped into the current assistant turn
|
|
104
|
+
// — carried onto the flushed message's streamSeq for per-role merge coverage.
|
|
105
|
+
let currentAssistantStreamSeq: number | undefined
|
|
102
106
|
|
|
103
107
|
/**
|
|
104
108
|
* Flush the current assistant message to processedMessages.
|
|
@@ -116,12 +120,21 @@ export function processHistoricalMessages(
|
|
|
116
120
|
authorType: assistantType as AuthorType,
|
|
117
121
|
timestamp: currentAssistantTimestamp || new Date(),
|
|
118
122
|
avatar: assistantAvatar,
|
|
123
|
+
...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
|
|
119
124
|
})
|
|
120
125
|
accumulator.resetSegments()
|
|
121
|
-
currentAssistantId = null
|
|
122
|
-
currentAssistantTimestamp = null
|
|
123
|
-
lastAssistantId = null
|
|
124
126
|
}
|
|
127
|
+
// Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
|
|
128
|
+
// assistant turn whose only data was a filtered/escalated approval renders
|
|
129
|
+
// nothing, so `hasContent()` is false and nothing is pushed). Left inside
|
|
130
|
+
// the push-block, a stale id/timestamp and (worse) a stale
|
|
131
|
+
// `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
|
|
132
|
+
// stays false so it keeps the old id/timestamp, and `Math.max` inflates its
|
|
133
|
+
// streamSeq — which then over-covers synthetics in the history merge.
|
|
134
|
+
currentAssistantId = null
|
|
135
|
+
currentAssistantTimestamp = null
|
|
136
|
+
lastAssistantId = null
|
|
137
|
+
currentAssistantStreamSeq = undefined
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
messages.forEach((msg, index) => {
|
|
@@ -171,6 +184,7 @@ export function processHistoricalMessages(
|
|
|
171
184
|
authorType: userAuthorType,
|
|
172
185
|
timestamp: new Date(msg.createdAt),
|
|
173
186
|
...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
|
|
187
|
+
...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
|
|
174
188
|
})
|
|
175
189
|
}
|
|
176
190
|
})
|
|
@@ -180,6 +194,12 @@ export function processHistoricalMessages(
|
|
|
180
194
|
currentAssistantTimestamp = new Date(msg.createdAt)
|
|
181
195
|
}
|
|
182
196
|
lastAssistantId = msg.id
|
|
197
|
+
if (typeof msg.lastChunkStreamSeq === 'number') {
|
|
198
|
+
currentAssistantStreamSeq =
|
|
199
|
+
currentAssistantStreamSeq === undefined
|
|
200
|
+
? msg.lastChunkStreamSeq
|
|
201
|
+
: Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
|
|
202
|
+
}
|
|
183
203
|
|
|
184
204
|
messageDataArray.forEach((data) => {
|
|
185
205
|
processMessageData(
|
|
@@ -501,6 +521,9 @@ export function processHistoricalMessagesWithErrors(
|
|
|
501
521
|
let currentAssistantId: string | null = null
|
|
502
522
|
let currentAssistantTimestamp: Date | null = null
|
|
503
523
|
let lastAssistantId: string | null = null
|
|
524
|
+
// MAX persisted seq across the rows grouped into the current assistant turn
|
|
525
|
+
// — carried onto the flushed message's streamSeq for per-role merge coverage.
|
|
526
|
+
let currentAssistantStreamSeq: number | undefined
|
|
504
527
|
|
|
505
528
|
const flushAssistantMessage = () => {
|
|
506
529
|
const idToUse = lastAssistantId || currentAssistantId
|
|
@@ -514,12 +537,21 @@ export function processHistoricalMessagesWithErrors(
|
|
|
514
537
|
authorType: assistantType as AuthorType,
|
|
515
538
|
timestamp: currentAssistantTimestamp || new Date(),
|
|
516
539
|
avatar: assistantAvatar,
|
|
540
|
+
...(currentAssistantStreamSeq !== undefined ? { streamSeq: currentAssistantStreamSeq } : {}),
|
|
517
541
|
})
|
|
518
542
|
accumulator.resetSegments()
|
|
519
|
-
currentAssistantId = null
|
|
520
|
-
currentAssistantTimestamp = null
|
|
521
|
-
lastAssistantId = null
|
|
522
543
|
}
|
|
544
|
+
// Reset grouping identity + seq UNCONDITIONALLY — even on an EMPTY flush (an
|
|
545
|
+
// assistant turn whose only data was a filtered/escalated approval renders
|
|
546
|
+
// nothing, so `hasContent()` is false and nothing is pushed). Left inside
|
|
547
|
+
// the push-block, a stale id/timestamp and (worse) a stale
|
|
548
|
+
// `currentAssistantStreamSeq` bleed into the NEXT assistant turn: `!currentAssistantId`
|
|
549
|
+
// stays false so it keeps the old id/timestamp, and `Math.max` inflates its
|
|
550
|
+
// streamSeq — which then over-covers synthetics in the history merge.
|
|
551
|
+
currentAssistantId = null
|
|
552
|
+
currentAssistantTimestamp = null
|
|
553
|
+
lastAssistantId = null
|
|
554
|
+
currentAssistantStreamSeq = undefined
|
|
523
555
|
}
|
|
524
556
|
|
|
525
557
|
messages.forEach((msg, index) => {
|
|
@@ -568,6 +600,7 @@ export function processHistoricalMessagesWithErrors(
|
|
|
568
600
|
authorType: userAuthorType,
|
|
569
601
|
timestamp: new Date(msg.createdAt),
|
|
570
602
|
...(contextItems && contextItems.length > 0 ? { contextItems } : {}),
|
|
603
|
+
...(typeof msg.lastChunkStreamSeq === 'number' ? { streamSeq: msg.lastChunkStreamSeq } : {}),
|
|
571
604
|
})
|
|
572
605
|
}
|
|
573
606
|
})
|
|
@@ -577,6 +610,12 @@ export function processHistoricalMessagesWithErrors(
|
|
|
577
610
|
currentAssistantTimestamp = new Date(msg.createdAt)
|
|
578
611
|
}
|
|
579
612
|
lastAssistantId = msg.id
|
|
613
|
+
if (typeof msg.lastChunkStreamSeq === 'number') {
|
|
614
|
+
currentAssistantStreamSeq =
|
|
615
|
+
currentAssistantStreamSeq === undefined
|
|
616
|
+
? msg.lastChunkStreamSeq
|
|
617
|
+
: Math.max(currentAssistantStreamSeq, msg.lastChunkStreamSeq)
|
|
618
|
+
}
|
|
580
619
|
|
|
581
620
|
messageDataArray.forEach((data) => {
|
|
582
621
|
processMessageData(
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* MODE B — children (organic): ANY card components as plain JSX children.
|
|
19
19
|
* Each child is auto-wrapped in a managed cell (fixed width, hover/keyboard
|
|
20
|
-
* marquee-pause, clone `aria-hidden` +
|
|
20
|
+
* marquee-pause, clone `aria-hidden` + focus-suppressed but still CLICKABLE,
|
|
21
|
+
* row-height stretch).
|
|
21
22
|
* Adding a new card type requires ZERO strip code. Contract:
|
|
22
23
|
* (a) pass an ARRAY of children (`{rows.map(...)}`) — `Children.toArray`
|
|
23
24
|
* does NOT flatten through a single <Fragment> child (it would become
|
|
@@ -146,6 +147,122 @@ export const STRIP_CELL_MAX_WIDTH = '90vw';
|
|
|
146
147
|
|
|
147
148
|
const EMPTY_CHILDREN: ReadonlyArray<React.ReactNode> = [];
|
|
148
149
|
|
|
150
|
+
// =============================================================================
|
|
151
|
+
// Children-mode managed cell
|
|
152
|
+
// =============================================================================
|
|
153
|
+
|
|
154
|
+
// Every focusable a clone might contain — matched so it can be pulled out of the
|
|
155
|
+
// keyboard tab order (see useSuppressCloneFocus). `[contenteditable]:not(
|
|
156
|
+
// [contenteditable="false"])` catches the empty/`"true"`/`"plaintext-only"`
|
|
157
|
+
// editable forms; `summary` is focusable inside a <details>.
|
|
158
|
+
const CLONE_FOCUSABLE_SELECTOR =
|
|
159
|
+
'a[href],button,input,select,textarea,iframe,summary,audio[controls],video[controls],[tabindex],[contenteditable]:not([contenteditable="false"])';
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Keep a clone cell's content pointer-CLICKABLE while taking it out of the
|
|
163
|
+
* accessibility tree and the keyboard tab order.
|
|
164
|
+
*
|
|
165
|
+
* `inert` (the previous approach) does all three at once: a11y-hidden,
|
|
166
|
+
* un-focusable AND un-clickable. That third effect was the "news marquee stops
|
|
167
|
+
* being clickable after you scroll it for a while" bug — the 2-copy endless
|
|
168
|
+
* loop ALWAYS paints clone cards inside the viewport near the copy seam, so a
|
|
169
|
+
* user who manually scrolls and parks there is looking at real-looking cards
|
|
170
|
+
* that silently swallow every click. Clones must therefore stay clickable (a
|
|
171
|
+
* click on ANY visible card must open its link — mouse, ⌘-click, middle-click
|
|
172
|
+
* and context-menu all handled natively by the clone's own anchor).
|
|
173
|
+
*
|
|
174
|
+
* The a11y contract is still met without `inert`: the cell keeps `aria-hidden`
|
|
175
|
+
* (out of the a11y tree) and every focusable descendant is forced to
|
|
176
|
+
* `tabindex="-1"` (out of the tab order → no duplicate tab stops, no
|
|
177
|
+
* aria-hidden-focus violation). Re-applied on subtree mutation. This is the
|
|
178
|
+
* standard loop-clone treatment used by Swiper/Splide.
|
|
179
|
+
*/
|
|
180
|
+
function useSuppressCloneFocus(active: boolean) {
|
|
181
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const root = ref.current;
|
|
184
|
+
if (!root || !active) return;
|
|
185
|
+
const suppress = () => {
|
|
186
|
+
root.querySelectorAll<HTMLElement>(CLONE_FOCUSABLE_SELECTOR).forEach(el => {
|
|
187
|
+
// Only stamp the ones not already stamped — avoids touching the DOM (and
|
|
188
|
+
// clobbering an intentional tabindex) on every mutation.
|
|
189
|
+
if (el.getAttribute('tabindex') !== '-1') el.setAttribute('tabindex', '-1');
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
suppress();
|
|
193
|
+
// Watch structure AND the attributes that can turn a descendant tabbable
|
|
194
|
+
// (an anchor gaining `href`, React restoring `tabindex="0"`, an element
|
|
195
|
+
// becoming editable, media gaining `controls`). The `!== '-1'` guard above
|
|
196
|
+
// makes our own tabindex writes a no-op, so the observer never loops.
|
|
197
|
+
const mo = new MutationObserver(suppress);
|
|
198
|
+
mo.observe(root, {
|
|
199
|
+
childList: true,
|
|
200
|
+
subtree: true,
|
|
201
|
+
attributes: true,
|
|
202
|
+
attributeFilter: ['href', 'tabindex', 'contenteditable', 'controls'],
|
|
203
|
+
});
|
|
204
|
+
return () => mo.disconnect();
|
|
205
|
+
}, [active]);
|
|
206
|
+
return ref;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Children-mode cell: fixed width + hover/keyboard activation (wired here, never
|
|
211
|
+
* on the card — the single-place activation invariant). The OUTER cell is never
|
|
212
|
+
* inert/aria-hidden — an inert cell would retarget its pointer events to the
|
|
213
|
+
* track and defeat the clone hover-pause; the INNER wrapper carries the clone
|
|
214
|
+
* a11y treatment.
|
|
215
|
+
*/
|
|
216
|
+
function ManagedCell({
|
|
217
|
+
item,
|
|
218
|
+
ctx,
|
|
219
|
+
cardWidthMobile,
|
|
220
|
+
cardWidthDesktop,
|
|
221
|
+
}: {
|
|
222
|
+
item: unknown;
|
|
223
|
+
ctx: CardStripRenderCtx;
|
|
224
|
+
cardWidthMobile: number;
|
|
225
|
+
cardWidthDesktop: number;
|
|
226
|
+
}) {
|
|
227
|
+
// Per-child DESKTOP cell-width hint: heterogeneous card designs need
|
|
228
|
+
// heterogeneous cells (wide horizontal cards vs 400px vertical cards —
|
|
229
|
+
// variable widths are native to the engine, exactly like bite ratios). Read
|
|
230
|
+
// from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps the
|
|
231
|
+
// strip-level width (wide cards stack vertically below md anyway).
|
|
232
|
+
const hintedWidth = React.isValidElement(item)
|
|
233
|
+
? (item.props as Record<string, unknown>)['data-strip-cell-width']
|
|
234
|
+
: undefined;
|
|
235
|
+
const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
|
|
236
|
+
const cloneRef = useSuppressCloneFocus(ctx.isClone);
|
|
237
|
+
return (
|
|
238
|
+
<div
|
|
239
|
+
className="shrink-0 self-stretch"
|
|
240
|
+
style={{ width: ctx.isMobile ? cardWidthMobile : desktopWidth, maxWidth: STRIP_CELL_MAX_WIDTH }}
|
|
241
|
+
data-strip-card-key={ctx.cardKey}
|
|
242
|
+
onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
|
|
243
|
+
onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
|
|
244
|
+
// Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
|
|
245
|
+
onFocus={() => ctx.onActivate(ctx.cardKey)}
|
|
246
|
+
onBlur={e => {
|
|
247
|
+
// Intra-cell focus moves must not blip the pause off.
|
|
248
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
249
|
+
ctx.onDeactivate(ctx.cardKey);
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
{/* INNER: clone a11y — `aria-hidden` + focusable descendants forced out of
|
|
253
|
+
the tab order (useSuppressCloneFocus), but pointer events preserved so
|
|
254
|
+
a visible clone card stays clickable. Cards untouched. */}
|
|
255
|
+
<div
|
|
256
|
+
ref={cloneRef}
|
|
257
|
+
className="h-full [&>*]:h-full [&>a>*]:h-full"
|
|
258
|
+
aria-hidden={ctx.isClone || undefined}
|
|
259
|
+
>
|
|
260
|
+
{item as React.ReactNode}
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
149
266
|
// =============================================================================
|
|
150
267
|
// Component
|
|
151
268
|
// =============================================================================
|
|
@@ -570,47 +687,16 @@ export function CardsStrip<T = unknown>(props: CardsStripProps<T>): React.ReactE
|
|
|
570
687
|
// Seam assertion (see normalization comment above).
|
|
571
688
|
return (props.renderCard as (item: unknown, ctx: CardStripRenderCtx) => React.ReactNode)(item, ctx);
|
|
572
689
|
}
|
|
573
|
-
// Per-child DESKTOP cell-width hint: heterogeneous card designs need
|
|
574
|
-
// heterogeneous cells (wide horizontal cards vs 400px vertical cards —
|
|
575
|
-
// variable widths are native to the engine, exactly like bite ratios).
|
|
576
|
-
// Read from the child ELEMENT's `data-strip-cell-width` prop; mobile keeps
|
|
577
|
-
// the strip-level width (wide cards stack vertically below md anyway).
|
|
578
|
-
const hintedWidth = React.isValidElement(item)
|
|
579
|
-
? (item.props as Record<string, unknown>)['data-strip-cell-width']
|
|
580
|
-
: undefined;
|
|
581
|
-
const desktopWidth = typeof hintedWidth === 'number' ? hintedWidth : cardWidthDesktop;
|
|
582
690
|
// Managed cell — the ONLY place cell width / activation / clone a11y are
|
|
583
|
-
// encoded for children mode
|
|
691
|
+
// encoded for children mode (extracted to a component so the clone
|
|
692
|
+
// focus-suppression hook can run per cell; see ManagedCell).
|
|
584
693
|
return (
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
data-strip-card-key={ctx.cardKey}
|
|
592
|
-
onPointerEnter={ctx.isTouch ? undefined : () => ctx.onActivate(ctx.cardKey)}
|
|
593
|
-
onPointerLeave={ctx.isTouch ? undefined : () => ctx.onDeactivate(ctx.cardKey)}
|
|
594
|
-
// Keyboard pause parity (focus bubbles from the card's anchor; WCAG 2.2.2).
|
|
595
|
-
onFocus={() => ctx.onActivate(ctx.cardKey)}
|
|
596
|
-
onBlur={e => {
|
|
597
|
-
// Intra-cell focus moves must not blip the pause off.
|
|
598
|
-
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
599
|
-
ctx.onDeactivate(ctx.cardKey);
|
|
600
|
-
}}
|
|
601
|
-
>
|
|
602
|
-
{/* INNER: clone a11y + uniform stretch — cards untouched. `inert` on
|
|
603
|
-
the clone strips anchor focusability without modifying any card
|
|
604
|
-
(WCAG aria-hidden-focus); clone content is pointer/focus-dead by
|
|
605
|
-
design while the outer cell still hover-pauses. */}
|
|
606
|
-
<div
|
|
607
|
-
className="h-full [&>*]:h-full [&>a>*]:h-full"
|
|
608
|
-
aria-hidden={ctx.isClone || undefined}
|
|
609
|
-
inert={ctx.isClone || undefined}
|
|
610
|
-
>
|
|
611
|
-
{item as React.ReactNode}
|
|
612
|
-
</div>
|
|
613
|
-
</div>
|
|
694
|
+
<ManagedCell
|
|
695
|
+
item={item}
|
|
696
|
+
ctx={ctx}
|
|
697
|
+
cardWidthMobile={cardWidthMobile}
|
|
698
|
+
cardWidthDesktop={cardWidthDesktop}
|
|
699
|
+
/>
|
|
614
700
|
);
|
|
615
701
|
};
|
|
616
702
|
|
|
@@ -65,9 +65,11 @@ export const MixedCardTypes: Story = {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Marquee opt-in (engine default). Accepted clone behaviors: the clone
|
|
68
|
-
* copy's inner cell is `aria-hidden` +
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* copy's inner cell is `aria-hidden` + focus-suppressed (every focusable
|
|
69
|
+
* descendant forced to `tabindex="-1"`) yet still pointer-CLICKABLE — a click
|
|
70
|
+
* on a visible clone opens its link, since the endless loop always paints
|
|
71
|
+
* clone cards in the viewport near the seam. The OUTER cell still hover-pauses
|
|
72
|
+
* the marquee; keyboard focus on original cards pauses too. */
|
|
71
73
|
export const MarqueeOptIn: Story = {
|
|
72
74
|
render: () => (
|
|
73
75
|
<CardsStrip title="Case Studies">
|