@flamingo-stack/openframe-frontend-core 0.0.734 → 0.0.736
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/build/export-map.json +1 -1
- package/dist/{chunk-XKB7YGQM.cjs → chunk-77SD7RXY.cjs} +18 -6
- package/dist/{chunk-FA5SMA7L.cjs → chunk-7V2KP2TB.cjs} +4 -4
- package/dist/{chunk-KSXZABGH.cjs → chunk-BDF3FNOC.cjs} +38 -38
- package/dist/{chunk-3NANWZO3.cjs → chunk-BL5GOCHP.cjs} +14 -14
- package/dist/{chunk-DHIISS4W.cjs → chunk-EJOXXJJB.cjs} +62 -10
- package/dist/{chunk-TLQQP2EL.cjs → chunk-EX2GYIGW.cjs} +6 -6
- package/dist/{chunk-JRC3KXKV.cjs → chunk-FTGVG6FW.cjs} +30 -30
- package/dist/{chunk-4Q34V7MD.cjs → chunk-JGGSRCRD.cjs} +18 -18
- package/dist/{chunk-73ZW3265.cjs → chunk-JM54CNZZ.cjs} +85 -85
- package/dist/{chunk-HBUUCLQM.cjs → chunk-L535OD7V.cjs} +2 -2
- package/dist/{chunk-AOTOOGNX.cjs → chunk-LIH5OQLY.cjs} +3 -3
- package/dist/{chunk-OXJ57643.cjs → chunk-MGW5DO6P.cjs} +76 -17
- package/dist/{chunk-R65UD6BH.cjs → chunk-NONQUGRP.cjs} +14 -14
- package/dist/{chunk-4V672TEI.cjs → chunk-OJUMVOMP.cjs} +408 -355
- package/dist/{chunk-HAWQA5FI.cjs → chunk-OV6BGMEL.cjs} +151 -184
- package/dist/{chunk-5OGT7H2S.cjs → chunk-P6Z3EBPJ.cjs} +15 -15
- package/dist/{chunk-5DDFSGCE.cjs → chunk-SZFA2LQR.cjs} +33 -33
- package/dist/{chunk-EMBH2EJO.cjs → chunk-T27CVQBO.cjs} +65 -65
- package/dist/{chunk-CMUEFHM3.cjs → chunk-UBQSOB3X.cjs} +4 -4
- package/dist/{chunk-L7WJBJU7.cjs → chunk-URX2YTCL.cjs} +72 -72
- package/dist/{chunk-BP6R5WDW.cjs → chunk-VA7OMDZO.cjs} +41 -23
- package/dist/{chunk-D34BLEIR.cjs → chunk-VRNNIV5Y.cjs} +2 -2
- package/dist/{chunk-2OHBH6PS.cjs → chunk-XLSVQJBA.cjs} +44 -44
- package/dist/{chunk-OV7QQHLG.cjs → chunk-ZEX5WU35.cjs} +17 -17
- package/dist/{cloud-tenants-HU7YZD33.cjs → cloud-tenants-M55HMW6F.cjs} +8 -8
- package/dist/components/case-studies/index.cjs +20 -20
- package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/program-card.js +6 -6
- package/dist/components/chat/index.cjs +9 -9
- package/dist/components/contact/index.cjs +9 -9
- package/dist/components/docs/index.cjs +12 -12
- package/dist/components/embeds/index.cjs +9 -9
- package/dist/components/faq/index.cjs +9 -9
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/cards-strip.js +6 -2
- package/dist/components/features/index.cjs +8 -8
- package/dist/components/features/video-bites-strip.d.ts +6 -1
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-bites-strip.js +15 -3
- package/dist/components/help-center-pages/index.cjs +109 -109
- package/dist/components/index.cjs +68 -62
- package/dist/components/meeting-scheduler/index.cjs +35 -35
- package/dist/components/navigation/index.cjs +11 -11
- package/dist/components/onboarding-guides/index.cjs +11 -11
- package/dist/components/pagination.d.ts +9 -1
- package/dist/components/pagination.d.ts.map +1 -1
- package/dist/components/pagination.js +72 -17
- package/dist/components/product-demo/index.cjs +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/tickets/index.cjs +13 -13
- package/dist/components/ui/fade-preview.d.ts +5 -1
- package/dist/components/ui/fade-preview.d.ts.map +1 -1
- package/dist/components/ui/fade-preview.js +18 -1
- package/dist/components/ui/index.cjs +18 -12
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/layout-hold.d.ts +31 -0
- package/dist/components/ui/layout-hold.d.ts.map +1 -0
- package/dist/components/ui/layout-hold.js +25 -0
- package/dist/components/ui/snap-carousel.d.ts.map +1 -1
- package/dist/components/ui/snap-carousel.js +28 -15
- package/dist/components/unified-pagination.d.ts.map +1 -1
- package/dist/components/unified-pagination.js +16 -4
- package/dist/{devices-CKLN7BHY.cjs → devices-WWJZFG6E.cjs} +9 -9
- package/dist/{embeddable-chat-FTAX4SVV.cjs → embeddable-chat-76IJ6TQZ.cjs} +72 -72
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/ui/index.d.ts +1 -0
- package/dist/hooks/ui/index.d.ts.map +1 -1
- package/dist/hooks/ui/index.js +1 -0
- package/dist/hooks/ui/use-resumable-timeout.d.ts +22 -0
- package/dist/hooks/ui/use-resumable-timeout.d.ts.map +1 -0
- package/dist/hooks/ui/use-resumable-timeout.js +26 -0
- package/dist/hooks/use-scenario-player.d.ts +4 -3
- package/dist/hooks/use-scenario-player.d.ts.map +1 -1
- package/dist/hooks/use-scenario-player.js +17 -23
- package/dist/index.cjs +24 -16
- package/dist/{knowledge-NRLHSCC5.cjs → knowledge-F4YFQT3G.cjs} +9 -9
- package/dist/{logs-BR6SLYAF.cjs → logs-GAYEGEZM.cjs} +16 -16
- package/dist/{policies-MQRXBYG2.cjs → policies-ZOVJCJWX.cjs} +12 -12
- package/dist/{software-update-RUGIQP7N.cjs → software-update-H4YE5GMJ.cjs} +11 -11
- package/package.json +1 -1
- package/src/components/chat/entity-cards/program-card.tsx +9 -8
- package/src/components/features/cards-strip.tsx +11 -2
- package/src/components/features/video-bites-strip.tsx +22 -3
- package/src/components/pagination.tsx +117 -21
- package/src/components/ui/fade-preview.tsx +30 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/layout-hold.tsx +55 -0
- package/src/components/ui/snap-carousel.tsx +36 -20
- package/src/components/unified-pagination.tsx +23 -7
- package/src/hooks/ui/index.ts +1 -0
- package/src/hooks/ui/use-resumable-timeout.ts +55 -0
- package/src/hooks/use-scenario-player.ts +28 -32
|
@@ -56,7 +56,9 @@ export interface FadePreviewProps {
|
|
|
56
56
|
* MEASURED to the bottom of item N (at every width). Implies `fixedHeight`, so
|
|
57
57
|
* the block keeps that height loading or loaded, and "Show N more" counts the
|
|
58
58
|
* items past N unless `hiddenCount` says otherwise. Until the list holds N
|
|
59
|
-
* items, `collapsedHeight` (or the height last measured) is kept.
|
|
59
|
+
* items, `collapsedHeight` (or the height last measured) is kept. The first
|
|
60
|
+
* paint (before any measure) is held for N up to 12; add rows to
|
|
61
|
+
* `UNMEASURED_ITEMS_CLASSES` for a longer preview.
|
|
60
62
|
*/
|
|
61
63
|
visibleItems?: number;
|
|
62
64
|
/**
|
|
@@ -80,6 +82,32 @@ const FILL_CLASSES: Record<string, string> = {
|
|
|
80
82
|
lg: 'lg:absolute lg:inset-0',
|
|
81
83
|
};
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Item mode before the first measure (the server's markup, and the page until it
|
|
87
|
+
* hydrates): the items past the first N are hidden by CSS, so the block is its
|
|
88
|
+
* collapsed height in the very first paint. Without it the whole list showed and
|
|
89
|
+
* the measure then cut it, which moved everything under it. Spelled out per N
|
|
90
|
+
* (Tailwind only emits classes it can read, and a `<style>` element is not
|
|
91
|
+
* allowed here: each rewrite relayouts the document), so it covers N up to
|
|
92
|
+
* `FADE_PREVIEW_MAX_UNMEASURED_ITEMS`; a larger N is clipped at the measure, as before.
|
|
93
|
+
*/
|
|
94
|
+
const UNMEASURED_ITEMS_CLASSES: Record<number, string> = {
|
|
95
|
+
1: '[&>*>*:nth-child(n+2)]:hidden',
|
|
96
|
+
2: '[&>*>*:nth-child(n+3)]:hidden',
|
|
97
|
+
3: '[&>*>*:nth-child(n+4)]:hidden',
|
|
98
|
+
4: '[&>*>*:nth-child(n+5)]:hidden',
|
|
99
|
+
5: '[&>*>*:nth-child(n+6)]:hidden',
|
|
100
|
+
6: '[&>*>*:nth-child(n+7)]:hidden',
|
|
101
|
+
7: '[&>*>*:nth-child(n+8)]:hidden',
|
|
102
|
+
8: '[&>*>*:nth-child(n+9)]:hidden',
|
|
103
|
+
9: '[&>*>*:nth-child(n+10)]:hidden',
|
|
104
|
+
10: '[&>*>*:nth-child(n+11)]:hidden',
|
|
105
|
+
11: '[&>*>*:nth-child(n+12)]:hidden',
|
|
106
|
+
12: '[&>*>*:nth-child(n+13)]:hidden',
|
|
107
|
+
};
|
|
108
|
+
/** The largest `visibleItems` the first paint is held for (the table above). */
|
|
109
|
+
export const FADE_PREVIEW_MAX_UNMEASURED_ITEMS = 12;
|
|
110
|
+
|
|
83
111
|
const cssLength = (value: number | string) => (typeof value === 'number' ? `${value}px` : value);
|
|
84
112
|
|
|
85
113
|
export function FadePreview({
|
|
@@ -198,6 +226,7 @@ export function FadePreview({
|
|
|
198
226
|
className={cn(
|
|
199
227
|
'overflow-hidden transition-[max-height,height] duration-500',
|
|
200
228
|
fill && FILL_CLASSES[String(fill)],
|
|
229
|
+
itemMode && itemsHeight == null && !expanded && UNMEASURED_ITEMS_CLASSES[visible],
|
|
201
230
|
)}
|
|
202
231
|
style={{
|
|
203
232
|
transitionTimingFunction: 'cubic-bezier(0.33, 1, 0.68, 1)',
|
|
@@ -64,6 +64,7 @@ export * from './alert';
|
|
|
64
64
|
export * from './badge';
|
|
65
65
|
export * from './ai-generated-badge';
|
|
66
66
|
export * from './fade-preview';
|
|
67
|
+
export * from './layout-hold';
|
|
67
68
|
export * from './progress';
|
|
68
69
|
export * from './release-changelog-section';
|
|
69
70
|
export * from './stacked-rows-panel';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { cn } from '../../utils/cn';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Holding a layout while its content is read, the two ways that are right at
|
|
6
|
+
* EVERY width. A placeholder of a guessed size (a `w-40` bar, a `h-[120px]`
|
|
7
|
+
* box) is only right at the widths it was tuned at: text wraps where the words
|
|
8
|
+
* make it wrap, so the placeholder has to be made of the words, or of the real
|
|
9
|
+
* layout, to wrap where the content will.
|
|
10
|
+
*
|
|
11
|
+
* - `TextHold`: an inline stand-in exactly as wide as the words it is given
|
|
12
|
+
* (same font, same letter spacing, because it IS those words, not shown).
|
|
13
|
+
* `bar` draws it as a pulsing grey bar; `blank` only keeps its space.
|
|
14
|
+
* - `LayoutHold`: a block as tall as the real layout rendered inside it, kept
|
|
15
|
+
* invisible and inert: the layout's own height, at any width.
|
|
16
|
+
*
|
|
17
|
+
* Neither is shown or read out; both are removed for assistive technology.
|
|
18
|
+
*/
|
|
19
|
+
export interface TextHoldProps {
|
|
20
|
+
/** The words whose width is held (the content's own, or a stand-in of its length). */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
/** `bar`: a pulsing grey bar of that width. `blank`: the width only. */
|
|
23
|
+
variant?: 'bar' | 'blank';
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function TextHold({ children, variant = 'bar', className }: TextHoldProps) {
|
|
28
|
+
return (
|
|
29
|
+
<span
|
|
30
|
+
aria-hidden
|
|
31
|
+
className={cn(
|
|
32
|
+
variant === 'bar' ? 'animate-pulse rounded bg-ods-border text-transparent' : 'invisible',
|
|
33
|
+
// A hold is never selectable or clickable, whatever it wraps.
|
|
34
|
+
'pointer-events-none select-none',
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LayoutHoldProps {
|
|
44
|
+
/** The real layout, rendered with the data at hand. */
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LayoutHold({ children, className }: LayoutHoldProps) {
|
|
50
|
+
return (
|
|
51
|
+
<div aria-hidden inert className={cn('invisible', className)}>
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { useAutoplay } from '../../hooks/ui/use-autoplay';
|
|
5
5
|
import { useInView } from '../../hooks/ui/use-in-view';
|
|
6
|
+
import { useResumableTimeout } from '../../hooks/ui/use-resumable-timeout';
|
|
6
7
|
import { cn } from '../../utils/cn';
|
|
7
8
|
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
8
9
|
import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
|
|
@@ -39,20 +40,31 @@ export interface SnapCarouselProps<T> {
|
|
|
39
40
|
const GAP_PX = 12;
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* The active dot
|
|
43
|
-
*
|
|
44
|
-
*
|
|
43
|
+
* The active dot's fill: how much of the slide's wait has been run. It starts
|
|
44
|
+
* from where the wait stands (`ranMs`, nothing for a new slide) and runs to
|
|
45
|
+
* full over what is LEFT of it. While the carousel is not advancing it stands
|
|
46
|
+
* still at that point; it never empties because the carousel left the screen.
|
|
45
47
|
*/
|
|
46
|
-
function DotFill({ durationMs }: { durationMs: number }) {
|
|
47
|
-
|
|
48
|
+
function DotFill({ durationMs, ranMs, running }: { durationMs: number; ranMs: number; running: boolean }) {
|
|
49
|
+
// One frame at the starting point, so the browser has it to run from.
|
|
50
|
+
const [going, setGoing] = useState(false);
|
|
48
51
|
useEffect(() => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
if (!running) return undefined;
|
|
53
|
+
const frame = requestAnimationFrame(() => setGoing(true));
|
|
54
|
+
return () => {
|
|
55
|
+
cancelAnimationFrame(frame);
|
|
56
|
+
setGoing(false);
|
|
57
|
+
};
|
|
58
|
+
}, [running]);
|
|
59
|
+
const moving = running && going;
|
|
52
60
|
return (
|
|
53
61
|
<span
|
|
54
|
-
className="absolute inset-0 origin-left bg-ods-text-primary
|
|
55
|
-
style={{
|
|
62
|
+
className="absolute inset-0 origin-left bg-ods-text-primary ease-linear"
|
|
63
|
+
style={{
|
|
64
|
+
transform: `scaleX(${moving ? 1 : durationMs > 0 ? ranMs / durationMs : 0})`,
|
|
65
|
+
transitionProperty: moving ? 'transform' : 'none',
|
|
66
|
+
transitionDuration: `${Math.max(0, durationMs - ranMs)}ms`,
|
|
67
|
+
}}
|
|
56
68
|
/>
|
|
57
69
|
);
|
|
58
70
|
}
|
|
@@ -122,12 +134,15 @@ export function SnapCarousel<T>({
|
|
|
122
134
|
const canAutoAdvance = autoAdvanceMs > 0 && count > 1;
|
|
123
135
|
const playing = auto.playing && canAutoAdvance;
|
|
124
136
|
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
137
|
+
// A new slide starts a whole interval (a swipe or an arrow is followed by a
|
|
138
|
+
// full one); an interruption (off screen, a hold, another tab) only pauses it.
|
|
139
|
+
const ranMs = useResumableTimeout({
|
|
140
|
+
running: playing,
|
|
141
|
+
durationMs: autoAdvanceMs,
|
|
142
|
+
resetKey: index,
|
|
143
|
+
onElapsed: () => goTo(index >= count - 1 ? 0 : index + 1),
|
|
144
|
+
});
|
|
145
|
+
const showsWait = canAutoAdvance && !auto.reducedMotion;
|
|
131
146
|
|
|
132
147
|
const setRefs = useCallback(
|
|
133
148
|
(node: HTMLDivElement | null) => {
|
|
@@ -172,12 +187,13 @@ export function SnapCarousel<T>({
|
|
|
172
187
|
className={cn(
|
|
173
188
|
'relative h-1.5 overflow-hidden rounded-full transition-[width] duration-200 motion-reduce:transition-none',
|
|
174
189
|
dotIndex === index ? 'w-6' : 'w-1.5',
|
|
175
|
-
|
|
190
|
+
// A carousel that never advances by itself marks its slide with a whole dot.
|
|
191
|
+
dotIndex === index && !showsWait ? 'bg-ods-text-primary' : 'bg-ods-border',
|
|
176
192
|
)}
|
|
177
193
|
>
|
|
178
|
-
{dotIndex === index &&
|
|
179
|
-
// Re-keyed per slide so
|
|
180
|
-
<DotFill key={index} durationMs={autoAdvanceMs} />
|
|
194
|
+
{dotIndex === index && showsWait && (
|
|
195
|
+
// Re-keyed per slide so a NEW slide starts from empty; a pause keeps its place.
|
|
196
|
+
<DotFill key={index} durationMs={autoAdvanceMs} ranMs={ranMs} running={playing} />
|
|
181
197
|
)}
|
|
182
198
|
</span>
|
|
183
199
|
))}
|
|
@@ -33,6 +33,17 @@ export function UnifiedPagination({
|
|
|
33
33
|
const searchParams = useSearchParams();
|
|
34
34
|
const pathname = usePathname();
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The address of a page of this list: the current URL with `page` set. Page 1
|
|
38
|
+
* is the list's own address (no `page`), so the first page has one URL.
|
|
39
|
+
*/
|
|
40
|
+
const pageHref = (page: number) => {
|
|
41
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
42
|
+
if (page <= 1) params.delete('page');
|
|
43
|
+
else params.set('page', page.toString());
|
|
44
|
+
return withQuery(pathname, params.toString());
|
|
45
|
+
};
|
|
46
|
+
|
|
36
47
|
const handlePageChange = (page: number) => {
|
|
37
48
|
// Preserve current scroll position
|
|
38
49
|
const currentScrollY = window.scrollY;
|
|
@@ -42,13 +53,12 @@ export function UnifiedPagination({
|
|
|
42
53
|
onPageChange(page);
|
|
43
54
|
}
|
|
44
55
|
|
|
45
|
-
// Update
|
|
46
|
-
// the URL (`syncUrl={false}`)
|
|
47
|
-
//
|
|
56
|
+
// Update the address bar in place (a bookmark, a shared link), unless the
|
|
57
|
+
// caller owns the URL (`syncUrl={false}`): a second writer here would race
|
|
58
|
+
// the caller's own param write. The address is the one the page's link
|
|
59
|
+
// carries (`pageHref`), so a click and a new-tab open land on the same URL.
|
|
48
60
|
if (syncUrl) {
|
|
49
|
-
|
|
50
|
-
params.set('page', page.toString());
|
|
51
|
-
window.history.replaceState(null, '', withQuery(pathname, params.toString()));
|
|
61
|
+
window.history.replaceState(null, '', pageHref(page));
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
// Restore scroll position after a brief delay to allow content to render
|
|
@@ -79,7 +89,13 @@ export function UnifiedPagination({
|
|
|
79
89
|
|
|
80
90
|
return (
|
|
81
91
|
<div className={className}>
|
|
82
|
-
|
|
92
|
+
{/* Real links only while this component owns `?page=`: a caller that owns the URL names its own parameter. */}
|
|
93
|
+
<Pagination
|
|
94
|
+
currentPage={currentPage}
|
|
95
|
+
totalPages={totalPages}
|
|
96
|
+
onPageChange={handlePageChange}
|
|
97
|
+
getPageHref={syncUrl ? pageHref : undefined}
|
|
98
|
+
/>
|
|
83
99
|
</div>
|
|
84
100
|
);
|
|
85
101
|
}
|
package/src/hooks/ui/index.ts
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseResumableTimeoutOptions {
|
|
6
|
+
/** The wait counts down only while this is true. */
|
|
7
|
+
running: boolean;
|
|
8
|
+
/** How long the whole wait is. */
|
|
9
|
+
durationMs: number;
|
|
10
|
+
/** A new value starts the wait over from nothing (the slide changed). */
|
|
11
|
+
resetKey: string | number;
|
|
12
|
+
/** Called once the whole wait has been run. */
|
|
13
|
+
onElapsed: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A wait that PAUSES instead of starting over: `durationMs` of time spent
|
|
18
|
+
* `running`, however many times it is interrupted. A carousel that scrolls off
|
|
19
|
+
* screen half-way through a slide comes back with half a slide left, not a
|
|
20
|
+
* whole one, and the bar that shows the wait picks up where it stopped.
|
|
21
|
+
*
|
|
22
|
+
* Returns how much of the wait had been run at the last stop (0 after a
|
|
23
|
+
* reset), which is what a progress bar draws while it is not running and
|
|
24
|
+
* where it resumes from.
|
|
25
|
+
*/
|
|
26
|
+
export function useResumableTimeout({ running, durationMs, resetKey, onElapsed }: UseResumableTimeoutOptions): number {
|
|
27
|
+
// What has been run so far, for the key it was run under. The ref is the
|
|
28
|
+
// clock's own memory (read when a stretch starts); the state is the same
|
|
29
|
+
// figure for whoever draws it.
|
|
30
|
+
const banked = useRef({ key: resetKey, ms: 0 });
|
|
31
|
+
const [shown, setShown] = useState({ key: resetKey, ms: 0 });
|
|
32
|
+
|
|
33
|
+
const onElapsedRef = useRef(onElapsed);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
onElapsedRef.current = onElapsed;
|
|
36
|
+
}, [onElapsed]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
// A new key: the wait starts over.
|
|
40
|
+
if (banked.current.key !== resetKey) banked.current = { key: resetKey, ms: 0 };
|
|
41
|
+
if (!running) return undefined;
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
const timer = setTimeout(() => onElapsedRef.current(), Math.max(0, durationMs - banked.current.ms));
|
|
44
|
+
return () => {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
// Bank what this stretch ran.
|
|
47
|
+
const ms = Math.min(durationMs, banked.current.ms + (Date.now() - startedAt));
|
|
48
|
+
banked.current = { key: resetKey, ms };
|
|
49
|
+
setShown({ key: resetKey, ms });
|
|
50
|
+
};
|
|
51
|
+
}, [running, durationMs, resetKey]);
|
|
52
|
+
|
|
53
|
+
// Under another key (the slide changed) nothing has been run yet.
|
|
54
|
+
return shown.key === resetKey ? shown.ms : 0;
|
|
55
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback,
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
4
|
import { type AutoplayHoldProps, useAutoplay } from './ui/use-autoplay';
|
|
5
|
+
import { useResumableTimeout } from './ui/use-resumable-timeout';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* How soon the first step follows the moment the demo becomes playable (it
|
|
@@ -78,9 +79,10 @@ export interface UseScenarioPlayerResult {
|
|
|
78
79
|
* not held by the visitor, motion allowed. Under reduced motion it parks on the last step
|
|
79
80
|
* of the current scenario, and every scenario can still be read by choosing it.
|
|
80
81
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* A step is `stepMs` of time spent playing: an interruption pauses it and it
|
|
83
|
+
* resumes with what was left (`useResumableTimeout`, the one clock every timed
|
|
84
|
+
* thing here runs on). At the top of a scenario whose first step is the empty
|
|
85
|
+
* stage, that step is left within `START_MS`.
|
|
84
86
|
*
|
|
85
87
|
* A visitor's own choice (`go`, `setStep`) moves the clock there and lets go of
|
|
86
88
|
* any hold; the rotation carries on from that point.
|
|
@@ -110,34 +112,28 @@ export function useScenarioPlayer({
|
|
|
110
112
|
const [started, setStarted] = useState(false);
|
|
111
113
|
if (playing && !started) setStarted(true);
|
|
112
114
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
atEnd ? holdMs : quickStart ? Math.min(START_MS, stepMs) : stepMs,
|
|
138
|
-
);
|
|
139
|
-
return () => clearTimeout(timer);
|
|
140
|
-
}, [playing, rawStep, lastStep, stepMs, holdMs, scenario, scenarioCount, emptyFirstStep]);
|
|
115
|
+
// ONE clock for everything that waits and moves on (`useResumableTimeout`,
|
|
116
|
+
// the carousel's too): a step is `stepMs` of time spent PLAYING. Interrupted
|
|
117
|
+
// (off screen, held, another tab), it comes back with what was left of the
|
|
118
|
+
// step, never a whole new one and never a skipped one.
|
|
119
|
+
const atEnd = rawStep >= lastStep;
|
|
120
|
+
// Step 0 of a scenario whose first step is the empty stage is left at once:
|
|
121
|
+
// a stage that sits empty for a whole step after a tap reads as not
|
|
122
|
+
// responding. A finished scenario holds its full time: it is there to be read.
|
|
123
|
+
const leavesAtOnce = emptyFirstStep && rawStep === 0 && !atEnd;
|
|
124
|
+
useResumableTimeout({
|
|
125
|
+
running: playing,
|
|
126
|
+
durationMs: atEnd ? holdMs : leavesAtOnce ? Math.min(START_MS, stepMs) : stepMs,
|
|
127
|
+
resetKey: `${scenario}:${Math.min(rawStep, lastStep)}`,
|
|
128
|
+
onElapsed: () => {
|
|
129
|
+
if (atEnd) {
|
|
130
|
+
setScenario(current => (current + 1) % scenarioCount);
|
|
131
|
+
setRawStep(0);
|
|
132
|
+
} else {
|
|
133
|
+
setRawStep(current => Math.min(current, lastStep) + 1);
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
141
137
|
|
|
142
138
|
// A visitor's choice is an instruction to play from there: it lets go of any hold.
|
|
143
139
|
const setStep = useCallback(
|