@vectoriox/iox-ui 5.84.0 → 5.85.0
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/actions-core/index.d.ts +18 -2
- package/composite-core/index.d.ts +93 -15
- package/design-core/index.d.ts +566 -4
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs +18 -1
- package/fesm2022/vectoriox-iox-ui-actions-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs +307 -39
- package/fesm2022/vectoriox-iox-ui-composite-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-design-core.mjs +680 -15
- package/fesm2022/vectoriox-iox-ui-design-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs +35 -4
- package/fesm2022/vectoriox-iox-ui-generation-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +101 -5
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui.mjs +285 -12
- package/fesm2022/vectoriox-iox-ui.mjs.map +1 -1
- package/generation-core/index.d.ts +13 -0
- package/index.d.ts +143 -42
- package/package.json +1 -1
- package/scene-core/index.d.ts +32 -4
- package/src/actions-core/dialog-motion.ts +25 -0
- package/src/composite-core/header.ts +25 -0
- package/src/composite-core/public-api.ts +1 -0
- package/src/composite-core/slider-runtime.ts +82 -0
- package/src/composite-core/slider.ts +194 -27
- package/src/composite-core/structure.ts +14 -9
- package/src/design-core/flavor-spec.generated.ts +596 -9
- package/src/design-core/header-behaviour.ts +76 -0
- package/src/design-core/public-api.ts +1 -0
- package/src/design-core/theme-color.ts +21 -5
- package/src/generation-core/compose-site.ts +8 -0
- package/src/generation-core/node-baselines.generated.ts +17 -3
- package/src/generation-core/patterns.ts +2 -0
- package/src/generation-core/site-spec.generated.ts +8 -0
- package/src/scene-core/scene.ts +18 -0
- package/src/scene-core/scroll-driver.ts +83 -6
- package/styles/iox-design.css +232 -1
- package/styles/iox-forms.css +5 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a site's header behaves as the page scrolls (premium components T2, M4). Two traits on the header's ROOT (the
|
|
3
|
+
* Navigation bar, generation-core `NavRole.Root`) — edited in its "Header" section, stamped by the composer from a
|
|
4
|
+
* `nav.*` pattern's `header` — and ONE answer every renderer applies: the attributes below, which the shared CSS
|
|
5
|
+
* (`iox-design.css`) and the header runtime (`HeaderRuntime`, main entry) read. A header with neither trait renders
|
|
6
|
+
* exactly as before (it scrolls away with the page, its divider line as its look draws it).
|
|
7
|
+
*
|
|
8
|
+
* Framework-free: the composer (generation-core) and both renderers read it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export enum HeaderBehaviour {
|
|
12
|
+
/** It scrolls away with the page (the default; what every header saved before M4 does). */
|
|
13
|
+
Scrolls = 'scrolls',
|
|
14
|
+
/** It stays at the top while the page scrolls under it. */
|
|
15
|
+
Stays = 'stays',
|
|
16
|
+
/** It steps aside while the page scrolls down and comes back the moment it scrolls up. */
|
|
17
|
+
Hides = 'hides',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** What separates the header from the page once the page has moved under it (Emil 99: an edge, not a divider). */
|
|
21
|
+
export enum HeaderEdge {
|
|
22
|
+
/** A soft fade of the header's own ground over the content under it. */
|
|
23
|
+
Fade = 'fade',
|
|
24
|
+
/** A hairline in the header's border colour. */
|
|
25
|
+
Line = 'line',
|
|
26
|
+
None = 'none',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The traits, stored in the root's props (no default: a plain Container saves neither). */
|
|
30
|
+
export enum HeaderTrait {
|
|
31
|
+
Behaviour = 'headerBehaviour',
|
|
32
|
+
Edge = 'headerEdge',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export enum HeaderAttribute {
|
|
36
|
+
/** `stays` | `hides` on a header that stays at the top. Absent: it scrolls away. */
|
|
37
|
+
Behaviour = 'data-iox-header',
|
|
38
|
+
/** `fade` | `line` | `none`: the edge it shows once the page has scrolled. Absent: its look's own divider stays. */
|
|
39
|
+
Edge = 'data-iox-header-edge',
|
|
40
|
+
/** Written by the header runtime while a `hides` header has stepped aside. */
|
|
41
|
+
Hidden = 'data-iox-header-hidden',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a composed header gets (the `nav.*` patterns' `header`, the palette's Navigation bar). */
|
|
45
|
+
export const COMPOSED_HEADER: Readonly<{ behaviour: HeaderBehaviour; edge: HeaderEdge }> = {
|
|
46
|
+
behaviour: HeaderBehaviour.Hides,
|
|
47
|
+
edge: HeaderEdge.Fade,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const BEHAVIOURS = new Set<string>(Object.values(HeaderBehaviour));
|
|
51
|
+
const EDGES = new Set<string>(Object.values(HeaderEdge));
|
|
52
|
+
|
|
53
|
+
/** A stored behaviour, or Scrolls for anything else. */
|
|
54
|
+
export function headerBehaviourOf(value: unknown): HeaderBehaviour {
|
|
55
|
+
return typeof value === 'string' && BEHAVIOURS.has(value) ? value as HeaderBehaviour : HeaderBehaviour.Scrolls;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A stored edge, or null (none chosen: the look's divider stays as it is). */
|
|
59
|
+
export function headerEdgeOf(value: unknown): HeaderEdge | null {
|
|
60
|
+
return typeof value === 'string' && EDGES.has(value) ? value as HeaderEdge : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when the header keeps its place at the top of the screen (it stays, or it hides and comes back). */
|
|
64
|
+
export function headerStaysAtTop(behaviour: unknown): boolean {
|
|
65
|
+
return headerBehaviourOf(behaviour) !== HeaderBehaviour.Scrolls;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The attributes a header renders, from its two trait values — `{}` for a header that keeps today's behaviour. */
|
|
69
|
+
export function headerAttributes(behaviour: unknown, edge: unknown): Record<string, string> {
|
|
70
|
+
const out: Record<string, string> = {};
|
|
71
|
+
const b = headerBehaviourOf(behaviour);
|
|
72
|
+
if (b !== HeaderBehaviour.Scrolls) out[HeaderAttribute.Behaviour] = b;
|
|
73
|
+
const e = headerEdgeOf(edge);
|
|
74
|
+
if (e) out[HeaderAttribute.Edge] = e;
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
@@ -13,6 +13,7 @@ export * from './design-token.model';
|
|
|
13
13
|
export * from './css-names';
|
|
14
14
|
export * from './token-css';
|
|
15
15
|
export * from './theme-color';
|
|
16
|
+
export * from './header-behaviour';
|
|
16
17
|
export * from './flavor';
|
|
17
18
|
export * from './element-styles';
|
|
18
19
|
export * from './adopt';
|
|
@@ -3,6 +3,8 @@ import { DesignTokenLike } from './token-css';
|
|
|
3
3
|
|
|
4
4
|
/** The token the browser's own chrome takes its colour from: the page's ground. */
|
|
5
5
|
export const THEME_COLOR_TOKEN = 'bg';
|
|
6
|
+
/** …or the header's ground, when a header stays at the top of the screen (premium components T2, M4). */
|
|
7
|
+
export const HEADER_THEME_COLOR_TOKEN = 'nav-bg';
|
|
6
8
|
|
|
7
9
|
const VAR_REF = /^var\(\s*--iox-([\w-]+)\s*(?:,\s*([^()]+))?\)$/;
|
|
8
10
|
/** A colour the browser's chrome can wear: opaque hex, rgb() or hsl() — never a gradient, a mix or a translucent one. */
|
|
@@ -20,22 +22,36 @@ function resolveIn(map: ReadonlyMap<string, string>, value: string | undefined,
|
|
|
20
22
|
return resolveIn(map, map.get(ref[1]) ?? ref[2]?.trim(), depth + 1);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
function colourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean): string | undefined {
|
|
25
|
+
function colourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean, token: string): string | undefined {
|
|
24
26
|
const map = new Map(rows.map(r => [r.name, dark ? (r.values?.dark ?? r.value) : r.value] as [string, string]));
|
|
25
|
-
const colour = resolveIn(map, map.get(
|
|
27
|
+
const colour = resolveIn(map, map.get(token));
|
|
26
28
|
return colour && SOLID_COLOUR.test(colour) ? colour : undefined;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
export interface ThemeColorOptions {
|
|
32
|
+
/** A header stays at the top of the screen (it stays, or hides and comes back): the browser's chrome meets the
|
|
33
|
+
* HEADER, so it takes the header's ground when that is a solid colour (a translucent one falls back to the page's). */
|
|
34
|
+
header?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function themeColourFor(rows: ReadonlyArray<DesignTokenLike>, dark: boolean, options: ThemeColorOptions): string | undefined {
|
|
38
|
+
return (options.header ? colourFor(rows, dark, HEADER_THEME_COLOR_TOKEN) : undefined) ?? colourFor(rows, dark, THEME_COLOR_TOKEN);
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* The `theme-color` the browser's chrome takes per colour scheme (premium components G15 — mobile-native M10): the
|
|
31
43
|
* page's ground in each mode the site shows, resolved through the org's rows. A light or dark site answers its one
|
|
32
44
|
* mode; `auto` answers both (the engine writes them with `media="(prefers-color-scheme: …)"`). A value that is not a
|
|
33
45
|
* solid colour answers nothing — the browser keeps its own.
|
|
34
46
|
*/
|
|
35
|
-
export function themeColors(
|
|
47
|
+
export function themeColors(
|
|
48
|
+
rows: ReadonlyArray<DesignTokenLike> | null | undefined,
|
|
49
|
+
mode: DesignMode | string | null | undefined = 'auto',
|
|
50
|
+
options: ThemeColorOptions = {},
|
|
51
|
+
): ThemeColors {
|
|
36
52
|
const list = rows ?? [];
|
|
37
53
|
const out: ThemeColors = {};
|
|
38
|
-
if (mode !== 'dark') { const light =
|
|
39
|
-
if (mode !== 'light') { const dark =
|
|
54
|
+
if (mode !== 'dark') { const light = themeColourFor(list, false, options); if (light) out.light = light; }
|
|
55
|
+
if (mode !== 'light') { const dark = themeColourFor(list, true, options); if (dark) out.dark = dark; }
|
|
40
56
|
return out;
|
|
41
57
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
AccordionAutoBinds, CompositeNodeSpec, findByRole, LIGHTBOX_LABEL, SLIDE_TO_ROW, SLIDE_TYPE, buildTabsRecipe, tabLabelNodeOf,
|
|
8
8
|
tabPanelImageOf, tabPanelTextOf, tabsListOf, tabsPanels, tabsPanelsOf, tabsTriggers, TABS_CONTENT_LIMIT, TABS_IMAGE_BIND_PROP, TABS_LABEL_BIND_PROP,
|
|
9
9
|
TABS_TEXT_BIND_PROP, TABS_WORDS, TabsAutoBinds, BadgeTone, BADGE_TONES, buildSegmentedRecipe, CardWords, ITEM_OPEN_STATE, segmentedOptions,
|
|
10
|
+
setHeaderBehaviour,
|
|
10
11
|
} from '@vectoriox/iox-ui/composite-core';
|
|
11
12
|
|
|
12
13
|
import { EntranceMark, EntrancePart, planEntrances } from './entrances';
|
|
@@ -1210,6 +1211,12 @@ export function composeSite(plan: SitePlan, context: ComposeContext): ComposedSi
|
|
|
1210
1211
|
const shape = patternById(plan.globals[slot])?.phoneMenu;
|
|
1211
1212
|
if (shape) globals = planNavShape(globals, shape as NavShape, {}, slot).globals;
|
|
1212
1213
|
}
|
|
1214
|
+
// Premium M4 (T2): a header pattern's behaviour as the page scrolls — the same edit the Header section makes.
|
|
1215
|
+
for (const slot of Object.values(GlobalSlot)) {
|
|
1216
|
+
const header = patternById(plan.globals[slot])?.header;
|
|
1217
|
+
const root = header ? globals.find(g => g.id === slot) : null;
|
|
1218
|
+
if (header && root) setHeaderBehaviour(root as unknown as CompositeNode, header.behaviour, header.edge ?? null);
|
|
1219
|
+
}
|
|
1213
1220
|
|
|
1214
1221
|
return {
|
|
1215
1222
|
source: clone(plan.source),
|
|
@@ -1302,6 +1309,7 @@ export function composeNavHeader(input: NavHeaderInput, options: NavHeaderOption
|
|
|
1302
1309
|
root.globalPosition = pattern.global;
|
|
1303
1310
|
const shape = pattern.phoneMenu as NavShape | undefined;
|
|
1304
1311
|
const header = shape ? planNavShape([root], shape, { newId: options.newId }, root.id).globals[0] : root;
|
|
1312
|
+
if (pattern.header) setHeaderBehaviour(header as unknown as CompositeNode, pattern.header.behaviour, pattern.header.edge ?? null);
|
|
1305
1313
|
return { header, issues };
|
|
1306
1314
|
}
|
|
1307
1315
|
|
|
@@ -691,7 +691,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
691
691
|
"defaultOn",
|
|
692
692
|
"activateOn",
|
|
693
693
|
"orientation",
|
|
694
|
-
"ariaLabel"
|
|
694
|
+
"ariaLabel",
|
|
695
|
+
"headerBehaviour",
|
|
696
|
+
"headerEdge"
|
|
695
697
|
],
|
|
696
698
|
"styleTraits": [
|
|
697
699
|
"position",
|
|
@@ -3114,6 +3116,10 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
3114
3116
|
"slideToClickedSlide": false,
|
|
3115
3117
|
"effect": "slide",
|
|
3116
3118
|
"speed": 300,
|
|
3119
|
+
"scrollPlays": false,
|
|
3120
|
+
"controlsPlacement": "under",
|
|
3121
|
+
"enableCaption": false,
|
|
3122
|
+
"enableCount": false,
|
|
3117
3123
|
"enableNavigation": false,
|
|
3118
3124
|
"navigationColor": "#ffffff",
|
|
3119
3125
|
"navigationSize": 44,
|
|
@@ -3221,7 +3227,11 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
3221
3227
|
"__effectsLabel",
|
|
3222
3228
|
"effect",
|
|
3223
3229
|
"speed",
|
|
3230
|
+
"scrollPlays",
|
|
3224
3231
|
"__navLabel",
|
|
3232
|
+
"controlsPlacement",
|
|
3233
|
+
"enableCaption",
|
|
3234
|
+
"enableCount",
|
|
3225
3235
|
"enableNavigation",
|
|
3226
3236
|
"navigationColor",
|
|
3227
3237
|
"navigationSize",
|
|
@@ -3326,7 +3336,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
3326
3336
|
"container": true
|
|
3327
3337
|
},
|
|
3328
3338
|
"SliderSlide": {
|
|
3329
|
-
"props": {
|
|
3339
|
+
"props": {
|
|
3340
|
+
"caption": ""
|
|
3341
|
+
},
|
|
3330
3342
|
"styleProps": {
|
|
3331
3343
|
"position": "static",
|
|
3332
3344
|
"top": "auto",
|
|
@@ -3405,7 +3417,9 @@ export const NODE_BASELINES: Readonly<Record<string, NodeBaseline>> = {
|
|
|
3405
3417
|
"transitionDelay": "0ms",
|
|
3406
3418
|
"transitionTimingFunction": "ease"
|
|
3407
3419
|
},
|
|
3408
|
-
"traits": [
|
|
3420
|
+
"traits": [
|
|
3421
|
+
"caption"
|
|
3422
|
+
],
|
|
3409
3423
|
"styleTraits": [
|
|
3410
3424
|
"position",
|
|
3411
3425
|
"top",
|
|
@@ -153,6 +153,8 @@ export interface SitePattern {
|
|
|
153
153
|
readonly global?: `${PatternGlobalPosition}`;
|
|
154
154
|
/** A navigation pattern's phone shape (CC1): `planNavShape` adds it after the pattern is stamped. */
|
|
155
155
|
readonly phoneMenu?: 'drawer' | 'bar' | 'both';
|
|
156
|
+
/** A navigation pattern's behaviour as the page scrolls (premium M4, T2): design-core HeaderBehaviour / HeaderEdge. */
|
|
157
|
+
readonly header?: { readonly behaviour: 'scrolls' | 'stays' | 'hides'; readonly edge?: 'fade' | 'line' | 'none' };
|
|
156
158
|
/** SC6: a Scene section — held while the page scrolls, moved by the scroll. Only at How animated Expressive, at
|
|
157
159
|
* most one per page and on at most two pages (`validateSitePlan`); it never takes the look's entrances. */
|
|
158
160
|
readonly scene?: boolean;
|
|
@@ -1148,6 +1148,10 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
1148
1148
|
"label": "Navigation bar",
|
|
1149
1149
|
"global": "before",
|
|
1150
1150
|
"phoneMenu": "drawer",
|
|
1151
|
+
"header": {
|
|
1152
|
+
"behaviour": "hides",
|
|
1153
|
+
"edge": "fade"
|
|
1154
|
+
},
|
|
1151
1155
|
"intent": "The brand on one side, the pages and one call to action on the other — the default header.",
|
|
1152
1156
|
"node": {
|
|
1153
1157
|
"t": "Container",
|
|
@@ -1217,6 +1221,10 @@ export const SPEC_SITE_PATTERNS = {
|
|
|
1217
1221
|
"label": "Navigation — the name centred",
|
|
1218
1222
|
"global": "before",
|
|
1219
1223
|
"phoneMenu": "drawer",
|
|
1224
|
+
"header": {
|
|
1225
|
+
"behaviour": "hides",
|
|
1226
|
+
"edge": "fade"
|
|
1227
|
+
},
|
|
1220
1228
|
"intent": "The name centred above a centred row of pages — when the name is the brand.",
|
|
1221
1229
|
"node": {
|
|
1222
1230
|
"t": "Container",
|
package/src/scene-core/scene.ts
CHANGED
|
@@ -80,6 +80,24 @@ export enum SceneAttribute {
|
|
|
80
80
|
Root = 'data-iox-scene',
|
|
81
81
|
Tracks = 'data-iox-tracks',
|
|
82
82
|
PhoneTracks = 'data-iox-tracks-phone',
|
|
83
|
+
/** A root's stage that is not a Scene's `scene.stage` part: the slider a scroll plays (premium M4). */
|
|
84
|
+
Stage = 'data-iox-scene-stage',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A SLIDER THE SCROLL PLAYS (premium components M4 — M1.3's held gallery). Its progress rests on each picture and changes
|
|
89
|
+
* only over the middle share of each step, so every picture arrives and stays a moment while the page holds it: `p` is
|
|
90
|
+
* the scroll's progress through the runway (0–1), `n` the slides; the answer is Swiper's progress (0–1).
|
|
91
|
+
*/
|
|
92
|
+
export const SLIDER_HOLD_SHARE = 0.2;
|
|
93
|
+
/** Page px of scroll per picture, as a share of the screen's height (the runway the renderers reserve). */
|
|
94
|
+
export const SLIDER_SCROLL_STEP_SCREENS = 0.55;
|
|
95
|
+
export function heldProgress(p: number, n: number): number {
|
|
96
|
+
if (!(n >= 2) || !Number.isFinite(p)) return 0;
|
|
97
|
+
const s = clamp01(p) * (n - 1);
|
|
98
|
+
const i = Math.min(n - 2, Math.floor(s));
|
|
99
|
+
const f = clamp01((s - i - SLIDER_HOLD_SHARE) / (1 - 2 * SLIDER_HOLD_SHARE));
|
|
100
|
+
return round((i + smoothstep(f)) / (n - 1));
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
/** At or under this stage width a layer plays its phone tracks. The `mobile` breakpoint's width. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SceneRole } from '@vectoriox/iox-ui/composite-core';
|
|
2
2
|
import { FramePlayer, normalizeFrameSequence, VideoAttribute } from '@vectoriox/iox-ui/media-core';
|
|
3
3
|
|
|
4
|
-
import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
|
|
4
|
+
import { heldProgress, normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, scenePlayed, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
|
|
5
5
|
import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -24,9 +24,32 @@ const SETTLED_PX = 0.25;
|
|
|
24
24
|
const MAX_FRAME_SECONDS = 0.1;
|
|
25
25
|
/** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
|
|
26
26
|
const WAKING_FRAME_SECONDS = 1 / 60;
|
|
27
|
-
const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}`;
|
|
27
|
+
const STAGE_SELECTOR = `.iox-role-${SceneRole.Stage.replace(/\./g, '-')}, [${SceneAttribute.Stage}]`;
|
|
28
28
|
const TRANSFORM_WILL_CHANGE = 'transform, opacity';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Dispatched (bubbling) by something the driver plays that arrived after the driver last looked — a Swiper booted on a
|
|
32
|
+
* slider's stage. Never a DOM `scroll`: nothing that follows the driver may dispatch one (SC2).
|
|
33
|
+
*/
|
|
34
|
+
export const SCENE_WAKE_EVENT = 'iox-scene-wake';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ONE driver per root (premium M4 — §0 "To verify first"). The live site attaches a driver per runtime instance (the
|
|
38
|
+
* page's and the globals'); an instance that scanned a container holding the other's Scenes would drive them a second
|
|
39
|
+
* time, two damped values fighting over one layer. A root is held by the first driver that adds it until that driver
|
|
40
|
+
* lets it go.
|
|
41
|
+
*/
|
|
42
|
+
const HELD = new WeakMap<HTMLElement, ScrollDriver>();
|
|
43
|
+
|
|
44
|
+
/** The slider a stage carries when the scroll plays it: a booted Swiper (it sets `el.swiper`), read by shape. */
|
|
45
|
+
interface PlayedSlider { slides?: ArrayLike<unknown>; setProgress?: (progress: number, speed?: number) => void; destroyed?: boolean }
|
|
46
|
+
function sliderOf(stage: HTMLElement | null): PlayedSlider | null {
|
|
47
|
+
// The stage is the runway's child: the `.swiper` itself (both engine paths), or the canvas's shell around it.
|
|
48
|
+
const holder = (stage?.classList.contains('swiper') ? stage : stage?.querySelector('.swiper')) as (HTMLElement & { swiper?: PlayedSlider }) | null | undefined;
|
|
49
|
+
const swiper = holder?.swiper;
|
|
50
|
+
return swiper && typeof swiper.setProgress === 'function' && !swiper.destroyed ? swiper : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
/** One frame of the damping: frame-rate independent, so 60 Hz and 120 Hz settle in the same time. */
|
|
31
54
|
export function dampToward(current: number, target: number, seconds: number, rate: number = SCENE_DAMPING): number {
|
|
32
55
|
return current + (target - current) * (1 - Math.exp(-seconds * rate));
|
|
@@ -86,6 +109,8 @@ interface Scene {
|
|
|
86
109
|
/** Page px and the stage's own width, measured when something resizes and never per frame. */
|
|
87
110
|
height: number;
|
|
88
111
|
travel: number;
|
|
112
|
+
/** Page px from the top of the screen at which the stage holds: 0 for a Scene, mid-screen for a played slider. */
|
|
113
|
+
stick: number;
|
|
89
114
|
phone: boolean;
|
|
90
115
|
inlineSign: 1 | -1;
|
|
91
116
|
/** No frame written yet: the first one lands exactly where the scroll is, with no glide. */
|
|
@@ -105,20 +130,28 @@ export class ScrollDriver {
|
|
|
105
130
|
private frame = 0;
|
|
106
131
|
private lastTime = 0;
|
|
107
132
|
private readonly wakeUp = (): void => this.wake();
|
|
133
|
+
private readonly onWakeEvent = (e: Event): void => {
|
|
134
|
+
const scene = [...this.scenes.values()].find(s => s.root.contains(e.target as Node));
|
|
135
|
+
if (scene) { scene.fresh = true; this.wake(); }
|
|
136
|
+
};
|
|
108
137
|
|
|
109
138
|
constructor(private readonly options: ScrollDriverOptions = {}) {
|
|
110
139
|
this.host = options.host ?? window;
|
|
111
140
|
// In capture: a scroll does not bubble, and the canvas's Lenis scrolls an element, not the window.
|
|
112
141
|
this.host.addEventListener('scroll', this.wakeUp, { capture: true, passive: true });
|
|
113
142
|
this.host.addEventListener('resize', this.wakeUp, { passive: true });
|
|
143
|
+
this.host.addEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
|
|
114
144
|
const Observer = (this.host as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
|
|
115
145
|
this.resize = Observer ? new Observer(() => this.remeasureAll()) : null;
|
|
116
146
|
}
|
|
117
147
|
|
|
118
|
-
/** Drive a Scene's root. Idempotent: adding a root again re-reads it. */
|
|
119
|
-
add(root: HTMLElement):
|
|
148
|
+
/** Drive a Scene's root. Idempotent: adding a root again re-reads it. False when another driver holds it. */
|
|
149
|
+
add(root: HTMLElement): boolean {
|
|
150
|
+
const holder = HELD.get(root);
|
|
151
|
+
if (holder && holder !== this) return false;
|
|
120
152
|
this.remove(root);
|
|
121
|
-
|
|
153
|
+
HELD.set(root, this);
|
|
154
|
+
const scene: Scene = { root, stage: null, layers: [], progress: 0, target: 0, height: 0, travel: 0, stick: 0, phone: false, inlineSign: 1, fresh: true, mutations: null };
|
|
122
155
|
this.scenes.set(root, scene);
|
|
123
156
|
this.resize?.observe(root);
|
|
124
157
|
if (this.options.observe) {
|
|
@@ -129,11 +162,18 @@ export class ScrollDriver {
|
|
|
129
162
|
}
|
|
130
163
|
}
|
|
131
164
|
this.readScene(scene);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** True when this driver holds `root`. */
|
|
169
|
+
holds(root: HTMLElement): boolean {
|
|
170
|
+
return this.scenes.has(root);
|
|
132
171
|
}
|
|
133
172
|
|
|
134
173
|
remove(root: HTMLElement): void {
|
|
135
174
|
const scene = this.scenes.get(root);
|
|
136
175
|
if (!scene) return;
|
|
176
|
+
if (HELD.get(root) === this) HELD.delete(root);
|
|
137
177
|
scene.mutations?.disconnect();
|
|
138
178
|
this.resize?.unobserve(root);
|
|
139
179
|
if (scene.stage) scene.stage.style.removeProperty('translate');
|
|
@@ -198,6 +238,7 @@ export class ScrollDriver {
|
|
|
198
238
|
this.frame = 0;
|
|
199
239
|
this.host.removeEventListener('scroll', this.wakeUp, { capture: true });
|
|
200
240
|
this.host.removeEventListener('resize', this.wakeUp);
|
|
241
|
+
this.host.removeEventListener(SCENE_WAKE_EVENT, this.onWakeEvent, { capture: true });
|
|
201
242
|
for (const root of [...this.scenes.keys()]) this.remove(root);
|
|
202
243
|
this.resize?.disconnect();
|
|
203
244
|
}
|
|
@@ -250,6 +291,9 @@ export class ScrollDriver {
|
|
|
250
291
|
private measure(scene: Scene): void {
|
|
251
292
|
scene.height = scene.root.offsetHeight;
|
|
252
293
|
scene.travel = Math.max(0, scene.height - (scene.stage?.offsetHeight ?? scene.height));
|
|
294
|
+
// Where the stage holds: its sticky top (a played slider holds mid-screen; a Scene's stage at 0). Read when
|
|
295
|
+
// something resizes, never per frame.
|
|
296
|
+
scene.stick = scene.stage ? (parseFloat(this.host.getComputedStyle(scene.stage).top) || 0) : 0;
|
|
253
297
|
scene.phone = (scene.stage?.offsetWidth ?? Infinity) <= SCENE_PHONE_MAX_WIDTH;
|
|
254
298
|
scene.inlineSign = scene.stage && this.host.getComputedStyle(scene.stage).direction === 'rtl' ? -1 : 1;
|
|
255
299
|
for (const layer of scene.layers) layer.stale = true;
|
|
@@ -281,11 +325,14 @@ export class ScrollDriver {
|
|
|
281
325
|
const rect = scene.root.getBoundingClientRect();
|
|
282
326
|
const origin = this.options.originOf?.(scene.root)?.getBoundingClientRect().top ?? 0;
|
|
283
327
|
const zoom = scene.height ? rect.height / scene.height : 1;
|
|
284
|
-
const { local, progress } = sceneScrollState(rect.top, origin, zoom, scene.travel);
|
|
328
|
+
const { local, progress } = sceneScrollState(rect.top, origin + scene.stick * zoom, zoom, scene.travel);
|
|
285
329
|
return { scene, local, target: progress };
|
|
286
330
|
}
|
|
287
331
|
|
|
288
332
|
private write(scene: Scene): void {
|
|
333
|
+
// A slider the scroll plays (M4): the damped moment, held on each picture.
|
|
334
|
+
const slider = sliderOf(scene.stage);
|
|
335
|
+
if (slider) slider.setProgress!(heldProgress(scene.progress, slider.slides?.length ?? 0), 0);
|
|
289
336
|
for (const layer of scene.layers) {
|
|
290
337
|
const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
|
|
291
338
|
this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
|
|
@@ -359,3 +406,33 @@ export function attachScenes(container: HTMLElement, options: ScrollDriverOption
|
|
|
359
406
|
roots.forEach(root => driver.add(root));
|
|
360
407
|
return driver;
|
|
361
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The Scene roots that belong to `elements` (each, and what it holds) — what one runtime instance owns: the page's nodes,
|
|
412
|
+
* or the globals'. Never a whole container that also holds another instance's Scenes (premium M4: the globals' instance
|
|
413
|
+
* scanned the app root and drove the page's Scenes a second time).
|
|
414
|
+
*/
|
|
415
|
+
export function sceneRootsIn(elements: Iterable<Element>): HTMLElement[] {
|
|
416
|
+
const out = new Set<HTMLElement>();
|
|
417
|
+
for (const el of elements) {
|
|
418
|
+
if ((el as HTMLElement).hasAttribute?.(SceneAttribute.Root)) out.add(el as HTMLElement);
|
|
419
|
+
// A wrapper the node renders around itself (a played slider's runway around its `.swiper`) is the node's own.
|
|
420
|
+
const parent = el.parentElement;
|
|
421
|
+
if (parent?.hasAttribute(SceneAttribute.Root) && parent.children.length === 1) out.add(parent);
|
|
422
|
+
el.querySelectorAll?.<HTMLElement>(`[${SceneAttribute.Root}]`).forEach(root => out.add(root));
|
|
423
|
+
}
|
|
424
|
+
return [...out];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The live site's door for a runtime that owns some nodes: their Scenes on one driver (null when none, or under reduced
|
|
428
|
+
* motion — the page's own CSS then lets each Scene flow). A root another driver holds stays with it. */
|
|
429
|
+
export function attachSceneRoots(roots: ReadonlyArray<HTMLElement>, options: ScrollDriverOptions = {}): ScrollDriver | null {
|
|
430
|
+
if (!roots.length) return null;
|
|
431
|
+
const host = options.host ?? window;
|
|
432
|
+
if (host.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return null;
|
|
433
|
+
const driver = new ScrollDriver(options);
|
|
434
|
+
const held = roots.filter(root => driver.add(root));
|
|
435
|
+
if (held.length) return driver;
|
|
436
|
+
driver.destroy();
|
|
437
|
+
return null;
|
|
438
|
+
}
|