@uniweb/runtime 1.1.11 → 1.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.3.1",
40
- "@uniweb/theming": "~1.2.0"
39
+ "@uniweb/theming": "~1.2.0",
40
+ "@uniweb/core": "~1.4.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.5.2"
47
+ "@uniweb/build": "1.6.2"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -29,6 +29,8 @@
29
29
  * the hydrated SPA aligned.
30
30
  */
31
31
 
32
+ import { stickyStyle } from './sticky.js'
33
+
32
34
  // Namespace so generated names can't collide with `view-transition-name`s a
33
35
  // foundation sets inside its own component CSS. The prefix also guarantees a
34
36
  // valid CSS <custom-ident> (starts with a letter).
@@ -66,7 +68,8 @@ export function resolveLayoutTransitions(areaNames, explicit) {
66
68
  * The stacking layer of each area's wrapper.
67
69
  *
68
70
  * Default: every area except the body gets `1`, and the body gets nothing —
69
- * content is the backdrop, chrome is above it. That is the whole of what the
71
+ * content is the backdrop, chrome is above it — and a sticky area gets one more
72
+ * than every area that does not stick (below). That is the whole of what the
70
73
  * framework claims to know, and it is deliberately not more.
71
74
  *
72
75
  * The body is left unstacked rather than pinned to `0` on purpose. A layer
@@ -103,17 +106,36 @@ export function resolveLayoutTransitions(areaNames, explicit) {
103
106
  * whole — a foundation could set `layers: { header: 0 }` on the default layout
104
107
  * and measurably nothing happened.
105
108
  *
109
+ * ⭐ The one ordering the framework does know is a sticky area's (`sticky.js` — any of its sections
110
+ * is). It stays in view while the rest of the page scrolls under it, so it is lifted above every
111
+ * area that does not stick: the runtime made it stick, so it owes it the layer. Left equal to them,
112
+ * a footer taller than the space below a stuck header paints over the header at the bottom of the
113
+ * page, since it comes later in the DOM (measured 2026-10-19: a 521px footer on a 480px-tall
114
+ * phone). That is a fact about the box, not a reading of the area's name, and the layout's
115
+ * `layers` still rank a sticky area it names.
116
+ *
106
117
  * @param {string[]} areaNames - Names of the areas rendered for this page (excludes `body`).
107
118
  * @param {Object|false|null|undefined} explicit - `layoutMeta.layers`.
119
+ * @param {Object} [sticky] - region → its stickiness, or null (`layoutStickiness`).
108
120
  * @returns {Object} region → z-index. Empty when the layout opts out.
109
121
  */
110
- export function resolveLayoutLayers(areaNames, explicit) {
122
+ export function resolveLayoutLayers(areaNames, explicit, sticky = null) {
111
123
  if (explicit === false) return {}
112
124
 
113
- const defaults = {}
114
- for (const name of areaNames) defaults[name] = 1
125
+ const layers = {}
126
+ for (const name of areaNames) layers[name] = 1
127
+ if (explicit) Object.assign(layers, explicit)
115
128
 
116
- return explicit ? { ...defaults, ...explicit } : defaults
129
+ const lifted = areaNames.filter((name) => sticky?.[name] && !(explicit && name in explicit))
130
+ if (lifted.length > 0) {
131
+ const under = Object.entries(layers)
132
+ .filter(([name, layer]) => !sticky?.[name] && Number.isFinite(layer))
133
+ .map(([, layer]) => layer)
134
+ const above = Math.max(0, ...under) + 1
135
+ for (const name of lifted) layers[name] = above
136
+ }
137
+
138
+ return layers
117
139
  }
118
140
 
119
141
  /**
@@ -132,19 +154,26 @@ export function resolveLayoutLayers(areaNames, explicit) {
132
154
  * absolutely-positioned descendant on the page, and the ordering does not need
133
155
  * it.
134
156
  *
157
+ * A sticky area (`sticky.js` — any of its sections is) is `position: sticky` in place of
158
+ * `relative`, keeping its layer and its transition name. Only a foundation's layout passes one: the
159
+ * built-in layout's area wrapper sits in its zone, as tall as itself, so the zone sticks instead.
160
+ *
135
161
  * @param {string} region - Area name, or `body`.
136
162
  * @param {Object|null} transitions - region → view-transition-name.
137
163
  * @param {Object} layers - region → z-index.
164
+ * @param {{ top: string }|null} [sticky] - the area's stickiness, when its box is the one that sticks
138
165
  * @returns {Object|null} Inline style object, or null for no wrapper.
139
166
  */
140
- export function areaWrapperStyle(region, transitions, layers) {
167
+ export function areaWrapperStyle(region, transitions, layers, sticky = null) {
141
168
  const style = {}
142
169
 
143
170
  const name = transitions?.[region]
144
171
  if (name) style.viewTransitionName = name
145
172
 
146
173
  const layer = layers?.[region]
147
- if (layer != null) {
174
+ if (sticky) {
175
+ Object.assign(style, stickyStyle(sticky.top, layer))
176
+ } else if (layer != null) {
148
177
  style.position = 'relative'
149
178
  style.zIndex = layer
150
179
  }
@@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react'
10
10
  import { sectionDomId } from '@uniweb/core/section-id'
11
11
  import { prepareProps, getComponentMeta } from '../prepare-props.js'
12
12
  import Background from './Background.jsx'
13
+ import { sectionStickyStyle } from '../sticky.js'
13
14
 
14
15
  /**
15
16
  * Valid color contexts
@@ -78,8 +79,9 @@ const getWrapperProps = (block) => {
78
79
  * @param {Object} props
79
80
  * @param {Block} props.block - Block instance to render
80
81
  * @param {string|null} props.as - Wrapper element tag for section mode, or null/false for bare mode
82
+ * @param {boolean} [props.stickySelf] - whether a sticky section's own wrapper sticks (in the body)
81
83
  */
82
- export default function BlockRenderer({ block, as = 'section' }) {
84
+ export default function BlockRenderer({ block, as = 'section', stickySelf = false }) {
83
85
  const Component = block.initComponent()
84
86
 
85
87
  // Entity-aware data resolution via EntityStore
@@ -159,6 +161,10 @@ export default function BlockRenderer({ block, as = 'section' }) {
159
161
  // Section mode — full treatment with wrapper, context classes, background.
160
162
  const { background, ...wrapperProps } = getWrapperProps(block)
161
163
 
164
+ // In the body, a sticky section's wrapper is the box that stays in view (`../sticky.js`).
165
+ const sticky = stickySelf ? sectionStickyStyle(block) : null
166
+ if (sticky) wrapperProps.style = { ...wrapperProps.style, ...sticky }
167
+
162
168
  // Merge Component.className (static classes declared on the component function)
163
169
  // Order: context-{theme} + block.state.className + Component.className
164
170
  const componentClassName = Component.className
@@ -16,12 +16,18 @@ import BlockRenderer from './BlockRenderer.jsx'
16
16
  * @param {Object} props
17
17
  * @param {Block[]} props.blocks - Array of Block instances to render
18
18
  */
19
- export default function Blocks({ blocks }) {
19
+ /**
20
+ * @param {Object} props
21
+ * @param {Block[]} props.blocks
22
+ * @param {boolean} [props.stickySelf] - a sticky section's own wrapper is the box that sticks:
23
+ * true in the body; an area's sections leave it to the area's box (`../sticky.js`)
24
+ */
25
+ export default function Blocks({ blocks, stickySelf = false }) {
20
26
  if (!blocks || blocks.length === 0) return null
21
27
 
22
28
  return blocks.map((block, index) => (
23
29
  <React.Fragment key={block.id || index}>
24
- <BlockRenderer block={block} />
30
+ <BlockRenderer block={block} stickySelf={stickySelf} />
25
31
  </React.Fragment>
26
32
  ))
27
33
  }
@@ -31,8 +31,11 @@
31
31
  * - header, footer, left, right, ...: Pre-rendered area React elements
32
32
  */
33
33
 
34
+ import { useEffect } from 'react'
34
35
  import Blocks from './Blocks.jsx'
35
36
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperStyle } from '../area-wrappers.js'
37
+ import { layoutStickiness, stickyStyle } from '../sticky.js'
38
+ import { warnUnmovableSticky } from '../sticky-check.js'
36
39
  // The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
37
40
  import { DefaultLayout } from '../default-layout.js'
38
41
 
@@ -88,20 +91,32 @@ export default function Layout({ page, website }) {
88
91
  const transitions = website.viewTransitions
89
92
  ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
90
93
  : null
91
- const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers)
94
+ // An area stays in view when any of its sections is sticky (`../sticky.js`): in a foundation's
95
+ // layout its wrapper sticks; in the built-in one, the zone around the wrapper does. Either way
96
+ // it is lifted above the areas that scroll under it.
97
+ const sticky = layoutStickiness(areas)
98
+ const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
92
99
 
93
100
  const wrap = (region, children) => {
94
- const style = areaWrapperStyle(region, transitions, layers)
101
+ const style = areaWrapperStyle(region, transitions, layers, RemoteLayout ? sticky[region] : null)
95
102
  return style ? <div style={style}>{children}</div> : children
96
103
  }
97
104
 
98
- const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} />) : null
105
+ // A body section's own wrapper is the box that sticks for it; an area's sections leave that to
106
+ // the area's box.
107
+ const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf />) : null
99
108
 
100
109
  const areaElements = {}
101
110
  for (const [name, blocks] of Object.entries(areas)) {
102
111
  areaElements[name] = wrap(name, <Blocks blocks={blocks} />)
103
112
  }
104
113
 
114
+ // `uniweb dev` names a sticky element that cannot move — a component's own `sticky`, or an area
115
+ // a layout wraps in an element of its own. Dropped from a published page's runtime.
116
+ useEffect(() => {
117
+ if (import.meta.env?.DEV) return warnUnmovableSticky()
118
+ }, [page])
119
+
105
120
  // Use foundation's custom Layout if provided
106
121
  if (RemoteLayout) {
107
122
  const params = mergeParams(page.getLayoutParams(), layoutMeta?.defaults)
@@ -118,11 +133,13 @@ export default function Layout({ page, website }) {
118
133
  )
119
134
  }
120
135
 
121
- // Default layout
136
+ // Default layout — a sticky area's zone is the box that sticks.
137
+ const zone = (name) => (sticky[name] ? stickyStyle(sticky[name].top, layers?.[name]) : null)
122
138
  return (
123
139
  <DefaultLayout
124
140
  body={bodyElement}
125
141
  {...areaElements}
142
+ zones={{ header: zone('header'), footer: zone('footer') }}
126
143
  />
127
144
  )
128
145
  }
@@ -19,6 +19,11 @@
19
19
  * the ordering, those numbers were a second mechanism for it, and the older one won — a positioned
20
20
  * element here becomes a stacking context that seals the area's own layer inside it. Measured
21
21
  * 2026-07-31: `layers: { header: 0 }` on the default layout changed nothing.
22
+ *
23
+ * ⭐ A ZONE IS THE BOX THAT STICKS. When a section of the header (or footer) area is sticky, the
24
+ * runtime hands this layout the zone's style (`zones`): its parent is the page column, so it can
25
+ * stay in view, where the area wrapper inside it, as tall as itself, cannot (`sticky.js`). The
26
+ * style carries the area's layer, which the sticky zone then holds.
22
27
  */
23
28
 
24
29
  import React from 'react'
@@ -27,15 +32,20 @@ const COLUMN = { display: 'flex', flexDirection: 'column', minHeight: '100vh' }
27
32
  const GROW = { flex: 1 }
28
33
 
29
34
  /**
30
- * @param {{ header?: React.ReactNode, body?: React.ReactNode, footer?: React.ReactNode }} props
35
+ * @param {Object} props
36
+ * @param {React.ReactNode} [props.header]
37
+ * @param {React.ReactNode} [props.body]
38
+ * @param {React.ReactNode} [props.footer]
39
+ * @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's style, when it sticks
31
40
  * @returns {React.ReactElement}
32
41
  */
33
- export function DefaultLayout({ header, body, footer }) {
42
+ export function DefaultLayout({ header, body, footer, zones }) {
43
+ const zone = (name) => (zones?.[name] ? { style: zones[name] } : null)
34
44
  return React.createElement(
35
45
  'div',
36
46
  { style: COLUMN },
37
- header && React.createElement('header', null, header),
47
+ header && React.createElement('header', zone('header'), header),
38
48
  body && React.createElement('main', { style: GROW }, body),
39
- footer && React.createElement('footer', null, footer),
49
+ footer && React.createElement('footer', zone('footer'), footer),
40
50
  )
41
51
  }
@@ -31,6 +31,7 @@ import {
31
31
  ensureThemeCss,
32
32
  } from './wire-foundation.js'
33
33
  import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperStyle } from './area-wrappers.js'
34
+ import { layoutStickiness, sectionStickyStyle, stickyStyle } from './sticky.js'
34
35
  import { DefaultLayout } from './default-layout.js'
35
36
  import { renderAppearanceBootScript } from './appearance.js'
36
37
  import { documentTitle } from './document-title.js'
@@ -223,9 +224,10 @@ export function renderBackground(background) {
223
224
  * @param {Block} block - Block instance to render
224
225
  * @param {Object} [options]
225
226
  * @param {string|null} [options.as='section'] - Wrapper element tag, or null/false for bare mode
227
+ * @param {boolean} [options.stickySelf] - whether a sticky section's own wrapper sticks (in the body)
226
228
  * @returns {React.ReactElement}
227
229
  */
228
- export function renderBlock(block, { as = 'section' } = {}) {
230
+ export function renderBlock(block, { as = 'section', stickySelf = false } = {}) {
229
231
  const Component = block.initComponent()
230
232
 
231
233
  if (!Component) {
@@ -265,6 +267,10 @@ export function renderBlock(block, { as = 'section' } = {}) {
265
267
  // Section mode: full treatment with wrapper, context classes, background.
266
268
  const { background, ...wrapperProps } = getWrapperProps(block)
267
269
 
270
+ // In the body, a sticky section's wrapper is the box that stays in view (`sticky.js`).
271
+ const sticky = stickySelf ? sectionStickyStyle(block) : null
272
+ if (sticky) wrapperProps.style = { ...wrapperProps.style, ...sticky }
273
+
268
274
  // Merge Component.className (static classes declared on the component function)
269
275
  const componentClassName = Component.className
270
276
  if (componentClassName) {
@@ -300,11 +306,11 @@ export function renderBlock(block, { as = 'section' } = {}) {
300
306
  /**
301
307
  * Render an array of blocks for SSR.
302
308
  */
303
- export function renderBlocks(blocks) {
309
+ export function renderBlocks(blocks, { stickySelf = false } = {}) {
304
310
  if (!blocks || blocks.length === 0) return null
305
311
  return blocks.map((block, index) =>
306
312
  React.createElement(React.Fragment, { key: block.id || index },
307
- renderBlock(block)
313
+ renderBlock(block, { stickySelf })
308
314
  )
309
315
  )
310
316
  }
@@ -321,6 +327,12 @@ export function renderLayout(page, website) {
321
327
  const bodyBlocks = page.getBodyBlocks()
322
328
  const areas = page.getLayoutAreas()
323
329
 
330
+ // Mirror Layout.jsx: every block's component initialized before anything renders, so each
331
+ // section's `sticky` — its type's default included — is known before the areas' layers are.
332
+ for (const blocks of [bodyBlocks, ...Object.values(areas)]) {
333
+ for (const block of blocks || []) block.initComponent()
334
+ }
335
+
324
336
  // Mirror Layout.jsx: wrap body + each area in a thin div carrying its
325
337
  // view-transition-name, so the prerendered HTML matches what the SPA hydrates
326
338
  // and the browser can animate regions independently on client navigation.
@@ -328,13 +340,18 @@ export function renderLayout(page, website) {
328
340
  const transitions = website.viewTransitions
329
341
  ? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
330
342
  : null
331
- const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers)
343
+
344
+ // Mirror Layout.jsx: an area stays in view when any of its sections is sticky — in a
345
+ // foundation's layout its wrapper sticks, in the built-in one the zone around it — and is lifted
346
+ // above the areas that scroll under it.
347
+ const sticky = layoutStickiness(areas)
348
+ const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
332
349
  const wrapArea = (name, element) => {
333
- const style = areaWrapperStyle(name, transitions, layers)
350
+ const style = areaWrapperStyle(name, transitions, layers, RemoteLayout ? sticky[name] : null)
334
351
  return style ? React.createElement('div', { style }, element) : element
335
352
  }
336
353
 
337
- const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks)) : null
354
+ const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf: true })) : null
338
355
  const areaElements = {}
339
356
  for (const [name, blocks] of Object.entries(areas)) {
340
357
  areaElements[name] = wrapArea(name, renderBlocks(blocks))
@@ -352,7 +369,12 @@ export function renderLayout(page, website) {
352
369
  // The built-in layout — the browser's own copy, not a mirror of it (`default-layout.js`).
353
370
  // ⛔ Until 2026-10-07 this wrote its own, with no full-height column, so the prerendered
354
371
  // page differed from the one the browser renders over it.
355
- return React.createElement(DefaultLayout, { body: bodyElement, ...areaElements })
372
+ const zone = (name) => (sticky[name] ? stickyStyle(sticky[name].top, layers?.[name]) : null)
373
+ return React.createElement(DefaultLayout, {
374
+ body: bodyElement,
375
+ ...areaElements,
376
+ zones: { header: zone('header'), footer: zone('footer') },
377
+ })
356
378
  }
357
379
 
358
380
  // ============================================================================
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `uniweb dev`'s warning about a sticky element that cannot move — browser-only, run by
3
+ * `components/Layout.jsx` behind `import.meta.env.DEV`, so a published page's runtime drops it.
4
+ *
5
+ * `position: sticky` keeps an element inside its parent's box. When the parent is no taller than
6
+ * the element (no wider, for one that sticks sideways), the element has no room and scrolls away
7
+ * with the page, and nothing says so. The two ways it happens:
8
+ *
9
+ * - a component's own `sticky top-0`: a section's markup sits in boxes as tall as itself, so it
10
+ * never moves. The section's `sticky: true` asks the runtime to make sticky the box that can
11
+ * (`sticky.js`).
12
+ * - a layout that wraps a sticky area in an element of its own, which confines the area's box.
13
+ * The layout makes its element sticky when `page.isSticky(area)`.
14
+ *
15
+ * Table parts are left out: a sticky table header cell sticks within its table, though its row is
16
+ * no taller than it is.
17
+ */
18
+
19
+ const warned = new WeakSet()
20
+
21
+ /** The nearest ancestor that draws a box — `display: contents` draws none. */
22
+ function boxParent(el) {
23
+ let parent = el.parentElement
24
+ while (parent && getComputedStyle(parent).display === 'contents') parent = parent.parentElement
25
+ return parent
26
+ }
27
+
28
+ /**
29
+ * Whether a sticky element has room to move in the direction it sticks.
30
+ *
31
+ * @param {Element} el
32
+ * @returns {boolean}
33
+ */
34
+ export function canStick(el) {
35
+ const style = getComputedStyle(el)
36
+ if (style.display.startsWith('table')) return true
37
+ const parent = boxParent(el)
38
+ if (!parent) return true
39
+ const own = el.getBoundingClientRect()
40
+ const room = parent.getBoundingClientRect()
41
+ const vertical = style.top !== 'auto' || style.bottom !== 'auto'
42
+ const horizontal = style.left !== 'auto' || style.right !== 'auto'
43
+ return (vertical && room.height > own.height + 0.5) || (horizontal && room.width > own.width + 0.5)
44
+ }
45
+
46
+ /**
47
+ * Warn, once per element, about each sticky element on the page that cannot move.
48
+ *
49
+ * @param {Document} [doc]
50
+ * @returns {() => void} cancels a check not yet run
51
+ */
52
+ export function warnUnmovableSticky(doc = globalThis.document) {
53
+ if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
54
+ const frame = requestAnimationFrame(() => {
55
+ for (const el of doc.body.querySelectorAll('*')) {
56
+ if (warned.has(el) || getComputedStyle(el).position !== 'sticky' || canStick(el)) continue
57
+ warned.add(el)
58
+ console.warn(
59
+ '[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
60
+ 'scrolls away with the page. To keep a section in view, give the section `sticky: true` ' +
61
+ '(its type may default it in meta.js) — no box a component draws can stick. A layout that ' +
62
+ 'wraps an area in an element of its own makes that element sticky when `page.isSticky(area)`.',
63
+ el,
64
+ )
65
+ }
66
+ })
67
+ return () => cancelAnimationFrame(frame)
68
+ }
package/src/sticky.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Where a section's `sticky` setting lands — pure, so the browser's renderer
3
+ * (`components/Layout.jsx`, `BlockRenderer.jsx`) and the prerender's (`ssr-renderer.js`) make the
4
+ * same boxes sticky, and a prerendered page does not change when the browser renders over it.
5
+ *
6
+ * ⭐ NO BOX A COMPONENT DRAWS CAN STICK. `position: sticky` moves an element only within its
7
+ * parent's box, and a component's markup sits in boxes as tall as itself: the section's wrapper,
8
+ * the layout area's wrapper (`area-wrappers.js`), the built-in layout's zone (`default-layout.js`).
9
+ * So a section asks — `sticky:`, one of a section's own settings (`@uniweb/schemas/section`),
10
+ * defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
11
+ *
12
+ * - in the body, the section's own wrapper, whose parent, the body, spans every section;
13
+ * - in any other area, the area's box when any of its sections is sticky: the zone, in the
14
+ * built-in layout; the area wrapper, in a foundation's layout. That box takes the area's
15
+ * layer, and a sticky area's layer is above every area that scrolls under it
16
+ * (`resolveLayoutLayers`).
17
+ *
18
+ * ⛔ A layout that wraps an area in an element of its own confines the area wrapper, as the zone
19
+ * does in the built-in layout. That element is the layout's, so the layout makes it sticky, when
20
+ * `page.isSticky(area)`.
21
+ *
22
+ * ⛔ Until 2026-10-19 there was no setting, and a header's own `sticky top-0` — the starter
23
+ * templates' — scrolled away with the page.
24
+ */
25
+
26
+ /** The very top of the viewport — a sticky box with no `top` of its own. */
27
+ export const STICKY_TOP = '0px'
28
+
29
+ /**
30
+ * The style that keeps a box in view.
31
+ *
32
+ * @param {string|null} [top] - how far below the viewport's top, a CSS length
33
+ * @param {number|null} [zIndex] - its layer, when it has one
34
+ * @returns {{ position: 'sticky', top: string, zIndex?: number }}
35
+ */
36
+ export function stickyStyle(top, zIndex) {
37
+ const style = { position: 'sticky', top: top || STICKY_TOP }
38
+ if (zIndex != null) style.zIndex = zIndex
39
+ return style
40
+ }
41
+
42
+ /**
43
+ * Whether an area stays in view — when any of its sections is sticky — and how far below the
44
+ * top: the first sticky section's.
45
+ *
46
+ * @param {Array<Object>|null|undefined} blocks - the area's blocks, their components initialized
47
+ * @returns {{ top: string }|null}
48
+ */
49
+ export function areaStickiness(blocks) {
50
+ const first = (blocks || []).find((block) => block?.sticky)
51
+ return first ? { top: first.stickyTop || STICKY_TOP } : null
52
+ }
53
+
54
+ /**
55
+ * Each layout area's stickiness, read from its sections — what both renderers compute before the
56
+ * areas' layers, since a sticky area is lifted above the rest (`resolveLayoutLayers`).
57
+ *
58
+ * @param {Object<string, Array<Object>>} areas - area name → its blocks, their components initialized
59
+ * @returns {Object<string, { top: string }|null>}
60
+ */
61
+ export function layoutStickiness(areas) {
62
+ const sticky = {}
63
+ for (const [name, blocks] of Object.entries(areas || {})) sticky[name] = areaStickiness(blocks)
64
+ return sticky
65
+ }
66
+
67
+ /**
68
+ * The style a sticky section's own wrapper takes, in the body — or null. Layer 1, so it paints
69
+ * over the sections after it, which are positioned only when they have a background.
70
+ *
71
+ * @param {Object} block - its component initialized
72
+ * @returns {Object|null}
73
+ */
74
+ export function sectionStickyStyle(block) {
75
+ return block?.sticky ? stickyStyle(block.stickyTop, 1) : null
76
+ }