@uniweb/runtime 1.1.13 → 1.1.15
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/ssr.js +35 -11
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/area-wrappers.js +17 -0
- package/src/components/BlockRenderer.jsx +15 -5
- package/src/components/Blocks.jsx +2 -2
- package/src/components/Layout.jsx +13 -8
- package/src/components/PageRenderer.jsx +1 -1
- package/src/default-layout.js +6 -7
- package/src/ssr-renderer.js +18 -11
- package/src/sticky-check.js +44 -2
- package/src/sticky-top.js +100 -0
- package/src/sticky.js +81 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.15",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=22.12"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/theming": "~1.2.
|
|
39
|
+
"@uniweb/theming": "~1.2.1",
|
|
40
40
|
"@uniweb/core": "~1.4.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -44,7 +44,7 @@
|
|
|
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.
|
|
47
|
+
"@uniweb/build": "1.7.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/area-wrappers.js
CHANGED
|
@@ -180,3 +180,20 @@ export function areaWrapperStyle(region, transitions, layers, sticky = null) {
|
|
|
180
180
|
|
|
181
181
|
return Object.keys(style).length > 0 ? style : null
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The props for one area's wrapper element — its style, and `data-uw-sticky="area"` when it is
|
|
186
|
+
* the box that sticks (`sticky.js`) — or `null` when the region needs no wrapper. Both renderers
|
|
187
|
+
* build the wrapper from this, so the prerendered page and the browser's agree.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} region - Area name, or `body`.
|
|
190
|
+
* @param {Object|null} transitions - region → view-transition-name.
|
|
191
|
+
* @param {Object} layers - region → z-index.
|
|
192
|
+
* @param {{ top: string }|null} [sticky] - the area's stickiness, when its box is the one that sticks
|
|
193
|
+
* @returns {{ style: Object, 'data-uw-sticky'?: 'area' }|null}
|
|
194
|
+
*/
|
|
195
|
+
export function areaWrapperProps(region, transitions, layers, sticky = null) {
|
|
196
|
+
const style = areaWrapperStyle(region, transitions, layers, sticky)
|
|
197
|
+
if (!style) return null
|
|
198
|
+
return sticky ? { style, 'data-uw-sticky': 'area' } : { style }
|
|
199
|
+
}
|
|
@@ -10,7 +10,8 @@ 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 {
|
|
13
|
+
import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
|
|
14
|
+
import { noteBodySection } from '../sticky-top.js'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Valid color contexts
|
|
@@ -79,7 +80,8 @@ const getWrapperProps = (block) => {
|
|
|
79
80
|
* @param {Object} props
|
|
80
81
|
* @param {Block} props.block - Block instance to render
|
|
81
82
|
* @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
|
|
83
|
+
* @param {'section'|'child'|boolean} [props.stickySelf] - whether a sticky section's own wrapper is
|
|
84
|
+
* the box that sticks: a body section's (`'section'`), or a child section's (`'child'`)
|
|
83
85
|
*/
|
|
84
86
|
export default function BlockRenderer({ block, as = 'section', stickySelf = false }) {
|
|
85
87
|
const Component = block.initComponent()
|
|
@@ -161,9 +163,17 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
|
|
|
161
163
|
// Section mode — full treatment with wrapper, context classes, background.
|
|
162
164
|
const { background, ...wrapperProps } = getWrapperProps(block)
|
|
163
165
|
|
|
164
|
-
//
|
|
165
|
-
const sticky =
|
|
166
|
-
if (sticky)
|
|
166
|
+
// A body section's wrapper, or a child section's, is the box that stays in view (`../sticky.js`).
|
|
167
|
+
const sticky = sectionStickyProps(block, stickySelf)
|
|
168
|
+
if (sticky) {
|
|
169
|
+
wrapperProps.style = { ...wrapperProps.style, ...sticky.style }
|
|
170
|
+
wrapperProps['data-uw-sticky'] = sticky['data-uw-sticky']
|
|
171
|
+
}
|
|
172
|
+
// On a page whose layout has a sticky area, a link to this section lands below it.
|
|
173
|
+
const margin = sectionScrollMargin(block, stickySelf)
|
|
174
|
+
if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
|
|
175
|
+
// A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`).
|
|
176
|
+
if (stickySelf === 'section' || stickySelf === true) wrapperProps.ref = (element) => noteBodySection(block, element)
|
|
167
177
|
|
|
168
178
|
// Merge Component.className (static classes declared on the component function)
|
|
169
179
|
// Order: context-{theme} + block.state.className + Component.className
|
|
@@ -19,8 +19,8 @@ import BlockRenderer from './BlockRenderer.jsx'
|
|
|
19
19
|
/**
|
|
20
20
|
* @param {Object} props
|
|
21
21
|
* @param {Block[]} props.blocks
|
|
22
|
-
* @param {
|
|
23
|
-
*
|
|
22
|
+
* @param {'section'|false} [props.stickySelf] - a sticky section's own wrapper is the box that
|
|
23
|
+
* sticks: `'section'` in the body; an area's sections leave it to the area's box (`../sticky.js`)
|
|
24
24
|
*/
|
|
25
25
|
export default function Blocks({ blocks, stickySelf = false }) {
|
|
26
26
|
if (!blocks || blocks.length === 0) return null
|
|
@@ -33,9 +33,10 @@
|
|
|
33
33
|
|
|
34
34
|
import { useEffect } from 'react'
|
|
35
35
|
import Blocks from './Blocks.jsx'
|
|
36
|
-
import { resolveLayoutTransitions, resolveLayoutLayers,
|
|
37
|
-
import { layoutStickiness,
|
|
36
|
+
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
|
|
37
|
+
import { layoutStickiness, zoneProps } from '../sticky.js'
|
|
38
38
|
import { warnUnmovableSticky } from '../sticky-check.js'
|
|
39
|
+
import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
|
|
39
40
|
// The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
|
|
40
41
|
import { DefaultLayout } from '../default-layout.js'
|
|
41
42
|
|
|
@@ -98,21 +99,25 @@ export default function Layout({ page, website }) {
|
|
|
98
99
|
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
99
100
|
|
|
100
101
|
const wrap = (region, children) => {
|
|
101
|
-
const
|
|
102
|
-
return
|
|
102
|
+
const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
|
|
103
|
+
return props ? <div {...props}>{children}</div> : children
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
// A body section's own wrapper is the box that sticks for it; an area's sections leave that to
|
|
106
107
|
// the area's box.
|
|
107
|
-
const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf />) : null
|
|
108
|
+
const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf="section" />) : null
|
|
108
109
|
|
|
109
110
|
const areaElements = {}
|
|
110
111
|
for (const [name, blocks] of Object.entries(areas)) {
|
|
111
112
|
areaElements[name] = wrap(name, <Blocks blocks={blocks} />)
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
//
|
|
115
|
-
//
|
|
115
|
+
// How far down the stuck chrome reaches — `--uw-sticky-top`, where a sticky section with no
|
|
116
|
+
// `top` of its own stays, and where kit's anchors land (`../sticky-top.js`).
|
|
117
|
+
useEffect(() => watchStickyTop(document, bodySectionOf(bodyBlocks?.[0])), [page])
|
|
118
|
+
|
|
119
|
+
// `uniweb dev` names a sticky element that cannot move — a component's own `sticky`, an area a
|
|
120
|
+
// layout wraps in an element of its own, a box that clips. Dropped from a published page's runtime.
|
|
116
121
|
useEffect(() => {
|
|
117
122
|
if (import.meta.env?.DEV) return warnUnmovableSticky()
|
|
118
123
|
}, [page])
|
|
@@ -134,7 +139,7 @@ export default function Layout({ page, website }) {
|
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
// Default layout — a sticky area's zone is the box that sticks.
|
|
137
|
-
const zone = (name) => (sticky[name]
|
|
142
|
+
const zone = (name) => zoneProps(sticky[name], layers?.[name])
|
|
138
143
|
return (
|
|
139
144
|
<DefaultLayout
|
|
140
145
|
body={bodyElement}
|
|
@@ -45,7 +45,7 @@ export function ChildBlocks({ blocks, from, wrapAs }) {
|
|
|
45
45
|
|
|
46
46
|
return blockList.map((childBlock, index) => (
|
|
47
47
|
<React.Fragment key={childBlock.id || index}>
|
|
48
|
-
<BlockRenderer block={childBlock} as={wrapAs || null} />
|
|
48
|
+
<BlockRenderer block={childBlock} as={wrapAs || null} stickySelf={wrapAs ? 'child' : false} />
|
|
49
49
|
</React.Fragment>
|
|
50
50
|
))
|
|
51
51
|
}
|
package/src/default-layout.js
CHANGED
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
* 2026-07-31: `layers: { header: 0 }` on the default layout changed nothing.
|
|
22
22
|
*
|
|
23
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
|
|
25
|
-
* stay in view, where the area wrapper inside it, as tall as itself, cannot
|
|
26
|
-
* style carries the area's layer, which the sticky zone then holds.
|
|
24
|
+
* runtime hands this layout the zone's props (`zones`, from `zoneProps`): its parent is the page
|
|
25
|
+
* column, so it can stay in view, where the area wrapper inside it, as tall as itself, cannot
|
|
26
|
+
* (`sticky.js`). The style carries the area's layer, which the sticky zone then holds.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import React from 'react'
|
|
@@ -36,16 +36,15 @@ const GROW = { flex: 1 }
|
|
|
36
36
|
* @param {React.ReactNode} [props.header]
|
|
37
37
|
* @param {React.ReactNode} [props.body]
|
|
38
38
|
* @param {React.ReactNode} [props.footer]
|
|
39
|
-
* @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's
|
|
39
|
+
* @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's props, when it sticks
|
|
40
40
|
* @returns {React.ReactElement}
|
|
41
41
|
*/
|
|
42
42
|
export function DefaultLayout({ header, body, footer, zones }) {
|
|
43
|
-
const zone = (name) => (zones?.[name] ? { style: zones[name] } : null)
|
|
44
43
|
return React.createElement(
|
|
45
44
|
'div',
|
|
46
45
|
{ style: COLUMN },
|
|
47
|
-
header && React.createElement('header',
|
|
46
|
+
header && React.createElement('header', zones?.header || null, header),
|
|
48
47
|
body && React.createElement('main', { style: GROW }, body),
|
|
49
|
-
footer && React.createElement('footer',
|
|
48
|
+
footer && React.createElement('footer', zones?.footer || null, footer),
|
|
50
49
|
)
|
|
51
50
|
}
|
package/src/ssr-renderer.js
CHANGED
|
@@ -30,8 +30,8 @@ import {
|
|
|
30
30
|
hydrateDataStore,
|
|
31
31
|
ensureThemeCss,
|
|
32
32
|
} from './wire-foundation.js'
|
|
33
|
-
import { resolveLayoutTransitions, resolveLayoutLayers,
|
|
34
|
-
import { layoutStickiness,
|
|
33
|
+
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
|
|
34
|
+
import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps } from './sticky.js'
|
|
35
35
|
import { DefaultLayout } from './default-layout.js'
|
|
36
36
|
import { renderAppearanceBootScript } from './appearance.js'
|
|
37
37
|
import { documentTitle } from './document-title.js'
|
|
@@ -224,7 +224,8 @@ export function renderBackground(background) {
|
|
|
224
224
|
* @param {Block} block - Block instance to render
|
|
225
225
|
* @param {Object} [options]
|
|
226
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
|
|
227
|
+
* @param {'section'|'child'|boolean} [options.stickySelf] - whether a sticky section's own wrapper
|
|
228
|
+
* is the box that sticks: a body section's (`'section'`), or a child section's (`'child'`)
|
|
228
229
|
* @returns {React.ReactElement}
|
|
229
230
|
*/
|
|
230
231
|
export function renderBlock(block, { as = 'section', stickySelf = false } = {}) {
|
|
@@ -267,9 +268,15 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
|
|
|
267
268
|
// Section mode: full treatment with wrapper, context classes, background.
|
|
268
269
|
const { background, ...wrapperProps } = getWrapperProps(block)
|
|
269
270
|
|
|
270
|
-
//
|
|
271
|
-
const sticky =
|
|
272
|
-
if (sticky)
|
|
271
|
+
// A body section's wrapper, or a child section's, is the box that stays in view (`sticky.js`).
|
|
272
|
+
const sticky = sectionStickyProps(block, stickySelf)
|
|
273
|
+
if (sticky) {
|
|
274
|
+
wrapperProps.style = { ...wrapperProps.style, ...sticky.style }
|
|
275
|
+
wrapperProps['data-uw-sticky'] = sticky['data-uw-sticky']
|
|
276
|
+
}
|
|
277
|
+
// On a page whose layout has a sticky area, a link to this section lands below it.
|
|
278
|
+
const margin = sectionScrollMargin(block, stickySelf)
|
|
279
|
+
if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
|
|
273
280
|
|
|
274
281
|
// Merge Component.className (static classes declared on the component function)
|
|
275
282
|
const componentClassName = Component.className
|
|
@@ -347,11 +354,11 @@ export function renderLayout(page, website) {
|
|
|
347
354
|
const sticky = layoutStickiness(areas)
|
|
348
355
|
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
349
356
|
const wrapArea = (name, element) => {
|
|
350
|
-
const
|
|
351
|
-
return
|
|
357
|
+
const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
|
|
358
|
+
return props ? React.createElement('div', props, element) : element
|
|
352
359
|
}
|
|
353
360
|
|
|
354
|
-
const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf:
|
|
361
|
+
const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf: 'section' })) : null
|
|
355
362
|
const areaElements = {}
|
|
356
363
|
for (const [name, blocks] of Object.entries(areas)) {
|
|
357
364
|
areaElements[name] = wrapArea(name, renderBlocks(blocks))
|
|
@@ -369,7 +376,7 @@ export function renderLayout(page, website) {
|
|
|
369
376
|
// The built-in layout — the browser's own copy, not a mirror of it (`default-layout.js`).
|
|
370
377
|
// ⛔ Until 2026-10-07 this wrote its own, with no full-height column, so the prerendered
|
|
371
378
|
// page differed from the one the browser renders over it.
|
|
372
|
-
const zone = (name) => (sticky[name]
|
|
379
|
+
const zone = (name) => zoneProps(sticky[name], layers?.[name])
|
|
373
380
|
return React.createElement(DefaultLayout, {
|
|
374
381
|
body: bodyElement,
|
|
375
382
|
...areaElements,
|
|
@@ -461,7 +468,7 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
|
|
|
461
468
|
const blockList = blocks || from?.childBlocks || []
|
|
462
469
|
return blockList.map((childBlock, index) =>
|
|
463
470
|
React.createElement(React.Fragment, { key: childBlock.id || index },
|
|
464
|
-
renderBlock(childBlock, { as: wrapAs || null })
|
|
471
|
+
renderBlock(childBlock, { as: wrapAs || null, stickySelf: wrapAs ? 'child' : false })
|
|
465
472
|
)
|
|
466
473
|
)
|
|
467
474
|
}
|
package/src/sticky-check.js
CHANGED
|
@@ -13,7 +13,14 @@
|
|
|
13
13
|
* The layout makes its element sticky when `page.isSticky(area)`.
|
|
14
14
|
*
|
|
15
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.
|
|
16
|
+
* no taller than it is. So is a child section the runtime made sticky (`data-uw-sticky="child"`):
|
|
17
|
+
* with no room — stacked at one column, or the tallest in its row — it does not stick, by design.
|
|
18
|
+
*
|
|
19
|
+
* And an element that sits in a box that CLIPS — `overflow: hidden`, or `overflow-x: hidden`
|
|
20
|
+
* alone, which makes the box scroll vertically too — sticks to that box in place of the page,
|
|
21
|
+
* and the box does not scroll, so it never moves. Components clip for rounded corners; the
|
|
22
|
+
* warning names `overflow: clip`, which clips and keeps it sticking. A box that really scrolls
|
|
23
|
+
* (a layout's own scrolling `<main>`) is the element's scroller, and is left alone.
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
const warned = new WeakSet()
|
|
@@ -43,6 +50,28 @@ export function canStick(el) {
|
|
|
43
50
|
return (vertical && room.height > own.height + 0.5) || (horizontal && room.width > own.width + 0.5)
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/**
|
|
54
|
+
* The box a sticky element would stick to in place of the page, because it clips its overflow
|
|
55
|
+
* without scrolling — or null. Looks no further than the nearest box with an overflow of its own:
|
|
56
|
+
* one that scrolls is the element's scroller, which is fine. Stops below `<body>`, whose overflow
|
|
57
|
+
* is the viewport's.
|
|
58
|
+
*
|
|
59
|
+
* @param {Element} el
|
|
60
|
+
* @returns {Element|null}
|
|
61
|
+
*/
|
|
62
|
+
export function clippingAncestor(el) {
|
|
63
|
+
const doc = el.ownerDocument
|
|
64
|
+
for (let node = el.parentElement; node && node !== doc?.body && node !== doc?.documentElement; node = node.parentElement) {
|
|
65
|
+
const { overflowX, overflowY } = getComputedStyle(node)
|
|
66
|
+
if (overflowY === 'visible' || overflowY === 'clip') continue
|
|
67
|
+
if (overflowY === 'hidden') return node
|
|
68
|
+
// `auto` / `scroll`: a scroller — unless it is only `overflow-x: hidden`'s side effect.
|
|
69
|
+
const scrolls = node.scrollHeight > node.clientHeight + 1
|
|
70
|
+
return overflowX === 'hidden' && !scrolls ? node : null
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
46
75
|
/**
|
|
47
76
|
* Warn, once per element, about each sticky element on the page that cannot move.
|
|
48
77
|
*
|
|
@@ -53,7 +82,20 @@ export function warnUnmovableSticky(doc = globalThis.document) {
|
|
|
53
82
|
if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
|
|
54
83
|
const frame = requestAnimationFrame(() => {
|
|
55
84
|
for (const el of doc.body.querySelectorAll('*')) {
|
|
56
|
-
if (warned.has(el) || getComputedStyle(el).position !== 'sticky'
|
|
85
|
+
if (warned.has(el) || getComputedStyle(el).position !== 'sticky') continue
|
|
86
|
+
const clip = clippingAncestor(el)
|
|
87
|
+
if (clip) {
|
|
88
|
+
warned.add(el)
|
|
89
|
+
console.warn(
|
|
90
|
+
'[uniweb] This element is sticky but sits in a box that clips its overflow (`overflow: hidden`, ' +
|
|
91
|
+
'or `overflow-x: hidden`), so it sticks to that box, which does not scroll, and never moves. ' +
|
|
92
|
+
'Clip with `overflow: clip` instead: it clips the same and keeps the element sticking.',
|
|
93
|
+
el,
|
|
94
|
+
clip,
|
|
95
|
+
)
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (el.getAttribute('data-uw-sticky') === 'child' || canStick(el)) continue
|
|
57
99
|
warned.add(el)
|
|
58
100
|
console.warn(
|
|
59
101
|
'[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--uw-sticky-top` — how far down the viewport the page's stuck chrome reaches.
|
|
3
|
+
*
|
|
4
|
+
* A sticky section with no `top` of its own stays in view below it (`sticky.js`), and kit's
|
|
5
|
+
* anchors land below it — a heading a contents rail scrolls to, a figure a cross-reference links
|
|
6
|
+
* to. Browser-only, run by `components/Layout.jsx`: only a browser knows a box's height, and the
|
|
7
|
+
* height is all that varies — a header that wraps on a phone, an announcement bar above it.
|
|
8
|
+
*
|
|
9
|
+
* ⭐ A MEASUREMENT THE PRERENDER DOES NOT NEED. The offset matters only once something is stuck,
|
|
10
|
+
* which takes scrolling; by then the browser has rendered and measured. A prerendered page, and
|
|
11
|
+
* one read without scripts, keep the variable's fallback.
|
|
12
|
+
*
|
|
13
|
+
* WHAT COUNTS: each box the runtime made sticky for an area (`data-uw-sticky="area"`) that comes
|
|
14
|
+
* before the body and spans over it — a header — but not one that follows it (a footer that
|
|
15
|
+
* sticks never reaches the top) or sits beside it (a rail stuck at the top covers no content). It
|
|
16
|
+
* reaches its `top` plus its height; the variable is the furthest any of them reaches. A rule of
|
|
17
|
+
* position, so it reads no meaning into an area's name (`area-wrappers.js` refuses to for the
|
|
18
|
+
* same reason).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const VARIABLE = '--uw-sticky-top'
|
|
22
|
+
const FOLLOWING = 4 // Node.DOCUMENT_POSITION_FOLLOWING
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The body's sections, by their Block — noted by `components/BlockRenderer.jsx` — so the
|
|
26
|
+
* measurement finds the body by the block it rendered, never by an id: a header's section and a
|
|
27
|
+
* body's can carry the same one.
|
|
28
|
+
*/
|
|
29
|
+
const bodySections = new WeakMap()
|
|
30
|
+
|
|
31
|
+
/** Note (or forget, with `null`) the element a body section rendered into. */
|
|
32
|
+
export function noteBodySection(block, element) {
|
|
33
|
+
if (!block) return
|
|
34
|
+
if (element) bodySections.set(block, element)
|
|
35
|
+
else bodySections.delete(block)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The element a body section rendered into, or null. */
|
|
39
|
+
export function bodySectionOf(block) {
|
|
40
|
+
return (block && bodySections.get(block)) || null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How far down the viewport the stuck chrome over the body reaches, in px — 0 when none does.
|
|
45
|
+
*
|
|
46
|
+
* @param {Document} doc
|
|
47
|
+
* @param {Element|null} body - the body's first section
|
|
48
|
+
* @returns {number}
|
|
49
|
+
*/
|
|
50
|
+
export function measureStickyTop(doc, body) {
|
|
51
|
+
if (!body) return 0
|
|
52
|
+
const over = body.getBoundingClientRect()
|
|
53
|
+
let reach = 0
|
|
54
|
+
for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) {
|
|
55
|
+
if (box.contains(body)) continue
|
|
56
|
+
if (!(box.compareDocumentPosition(body) & FOLLOWING)) continue // the body comes first: a footer
|
|
57
|
+
const own = box.getBoundingClientRect()
|
|
58
|
+
if (own.right <= over.left || own.left >= over.right) continue // beside the body: a rail
|
|
59
|
+
const top = parseFloat(doc.defaultView.getComputedStyle(box).top) || 0
|
|
60
|
+
reach = Math.max(reach, top + own.height)
|
|
61
|
+
}
|
|
62
|
+
return Math.round(reach * 100) / 100
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keep `--uw-sticky-top` on the root element as the page's stuck chrome changes size, and remove
|
|
67
|
+
* it when nothing over the body sticks.
|
|
68
|
+
*
|
|
69
|
+
* @param {Document} doc
|
|
70
|
+
* @param {Element|null} body - the body's first section
|
|
71
|
+
* @returns {() => void} stops watching, and removes the variable
|
|
72
|
+
*/
|
|
73
|
+
export function watchStickyTop(doc, body) {
|
|
74
|
+
const root = doc?.documentElement
|
|
75
|
+
if (!root) return () => {}
|
|
76
|
+
|
|
77
|
+
const apply = () => {
|
|
78
|
+
const reach = measureStickyTop(doc, body)
|
|
79
|
+
if (reach > 0) root.style.setProperty(VARIABLE, `${reach}px`)
|
|
80
|
+
else root.style.removeProperty(VARIABLE)
|
|
81
|
+
}
|
|
82
|
+
apply()
|
|
83
|
+
|
|
84
|
+
const View = doc.defaultView
|
|
85
|
+
if (!body || typeof View?.ResizeObserver !== 'function') {
|
|
86
|
+
return () => root.style.removeProperty(VARIABLE)
|
|
87
|
+
}
|
|
88
|
+
let frame = 0
|
|
89
|
+
const observer = new View.ResizeObserver(() => {
|
|
90
|
+
View.cancelAnimationFrame(frame)
|
|
91
|
+
frame = View.requestAnimationFrame(apply)
|
|
92
|
+
})
|
|
93
|
+
observer.observe(body)
|
|
94
|
+
for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) observer.observe(box)
|
|
95
|
+
return () => {
|
|
96
|
+
View.cancelAnimationFrame(frame)
|
|
97
|
+
observer.disconnect()
|
|
98
|
+
root.style.removeProperty(VARIABLE)
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/sticky.js
CHANGED
|
@@ -10,22 +10,44 @@
|
|
|
10
10
|
* defaulted by its type's `meta.js` — and the runtime makes sticky the box that can:
|
|
11
11
|
*
|
|
12
12
|
* - in the body, the section's own wrapper, whose parent, the body, spans every section;
|
|
13
|
+
* - a child section rendered as a section (`<ChildBlocks wrapAs>`, kit's `ChildGrid`), its own
|
|
14
|
+
* wrapper too, which then stays in view within the box its parent places it in — in
|
|
15
|
+
* `ChildGrid`, its cell, as tall as the row — and no further. Top-aligned, so a flex or grid
|
|
16
|
+
* item has room. At one column (a phone) a child is its own row and does not stick, which is
|
|
17
|
+
* what a side panel stacked under the content should do;
|
|
13
18
|
* - in any other area, the area's box when any of its sections is sticky: the zone, in the
|
|
14
19
|
* built-in layout; the area wrapper, in a foundation's layout. That box takes the area's
|
|
15
20
|
* layer, and a sticky area's layer is above every area that scrolls under it
|
|
16
21
|
* (`resolveLayoutLayers`).
|
|
17
22
|
*
|
|
23
|
+
* ⭐ A section stays in view BELOW what is stuck at the top. A sticky area sticks at `0px`; a
|
|
24
|
+
* sticky section with no `top` of its own sticks at `--uw-sticky-top`, how far down the page's
|
|
25
|
+
* stuck chrome reaches, which the browser measures (`sticky-top.js`) — so a side panel or a
|
|
26
|
+
* sub-navigation clears a stuck header whatever its height. Unmeasured — a prerendered page
|
|
27
|
+
* before the browser runs, a page with nothing stuck — it is `0px`.
|
|
28
|
+
*
|
|
29
|
+
* Every box this module makes sticky says so, `data-uw-sticky` = `area`, `section` or `child`,
|
|
30
|
+
* for what reads them in the browser: the measurement, and `uniweb dev`'s check
|
|
31
|
+
* (`sticky-check.js`).
|
|
32
|
+
*
|
|
18
33
|
* ⛔ A layout that wraps an area in an element of its own confines the area wrapper, as the zone
|
|
19
34
|
* does in the built-in layout. That element is the layout's, so the layout makes it sticky, when
|
|
20
35
|
* `page.isSticky(area)`.
|
|
21
36
|
*
|
|
22
37
|
* ⛔ 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.
|
|
38
|
+
* templates' — scrolled away with the page. Until 2026-10-20 a child section's setting did
|
|
39
|
+
* nothing, and a sticky section stuck at `0px`, under a stuck header.
|
|
24
40
|
*/
|
|
25
41
|
|
|
26
|
-
/** The very top of the viewport — a sticky
|
|
42
|
+
/** The very top of the viewport — a sticky area with no `top` of its own. */
|
|
27
43
|
export const STICKY_TOP = '0px'
|
|
28
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Where a sticky section with no `top` of its own stays: below what is stuck at the top
|
|
47
|
+
* (`sticky-top.js` measures it), else the very top.
|
|
48
|
+
*/
|
|
49
|
+
export const STICKY_SECTION_TOP = 'var(--uw-sticky-top, 0px)'
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* The style that keeps a box in view.
|
|
31
53
|
*
|
|
@@ -65,12 +87,66 @@ export function layoutStickiness(areas) {
|
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
/**
|
|
68
|
-
* The
|
|
69
|
-
*
|
|
90
|
+
* The props a built-in layout zone takes when its area sticks — or null.
|
|
91
|
+
*
|
|
92
|
+
* @param {{ top: string }|null|undefined} stickiness - the area's (`areaStickiness`)
|
|
93
|
+
* @param {number|null} [layer] - the area's layer (`resolveLayoutLayers`)
|
|
94
|
+
* @returns {{ style: Object, 'data-uw-sticky': 'area' }|null}
|
|
95
|
+
*/
|
|
96
|
+
export function zoneProps(stickiness, layer) {
|
|
97
|
+
return stickiness ? { style: stickyStyle(stickiness.top, layer), 'data-uw-sticky': 'area' } : null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The style a sticky section's own wrapper takes — a body section's, or a child section's — or
|
|
102
|
+
* null. Below what is stuck at the top unless its type says how far down; layer 1, so it paints
|
|
103
|
+
* over the sections after it, which are positioned only when they have a background; and
|
|
104
|
+
* top-aligned, so that as a flex or grid item it is not stretched to the row, which would leave
|
|
105
|
+
* it no room to move (in normal flow `align-self` does nothing).
|
|
70
106
|
*
|
|
71
107
|
* @param {Object} block - its component initialized
|
|
72
108
|
* @returns {Object|null}
|
|
73
109
|
*/
|
|
74
110
|
export function sectionStickyStyle(block) {
|
|
75
|
-
return block?.sticky ? stickyStyle(block.stickyTop, 1) : null
|
|
111
|
+
return block?.sticky ? { ...stickyStyle(block.stickyTop || STICKY_SECTION_TOP, 1), alignSelf: 'start' } : null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether any area of a page's layout sticks — its sections' components initialized, as both
|
|
116
|
+
* renderers do before they render.
|
|
117
|
+
*
|
|
118
|
+
* @param {Object} page
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function hasStickyArea(page) {
|
|
122
|
+
const areas = page?.getLayoutAreas?.() || {}
|
|
123
|
+
return Object.values(areas).some((blocks) => areaStickiness(blocks) !== null)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a link to a section lands — below what is stuck at the top, `--uw-sticky-top` — given to a
|
|
128
|
+
* body section's box, and a child section's, on a page whose layout has a sticky area. A link to
|
|
129
|
+
* `#section-…` (a search result, a contents entry, a pasted deep link) scrolls the section's top
|
|
130
|
+
* edge to the top of the viewport, and without the margin the stuck header covers it — measured
|
|
131
|
+
* 2026-10-20: a section's top at 0px under an 80px header, at 80px with the margin. On other pages
|
|
132
|
+
* the markup is unchanged, and unmeasured the margin is `0px`.
|
|
133
|
+
*
|
|
134
|
+
* @param {Object} block - its component initialized
|
|
135
|
+
* @param {'section'|'child'|boolean} kind - a body section's box, or a child section's; else none
|
|
136
|
+
* @returns {{ scrollMarginTop: string }|null}
|
|
137
|
+
*/
|
|
138
|
+
export function sectionScrollMargin(block, kind) {
|
|
139
|
+
return kind && hasStickyArea(block?.page) ? { scrollMarginTop: STICKY_SECTION_TOP } : null
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a section's wrapper adds when it sticks: its style, and which kind of box it is.
|
|
144
|
+
*
|
|
145
|
+
* @param {Object} block - its component initialized
|
|
146
|
+
* @param {'section'|'child'|boolean} kind - a body section (`true` reads as one), or a child section
|
|
147
|
+
* @returns {{ style: Object, 'data-uw-sticky': string }|null}
|
|
148
|
+
*/
|
|
149
|
+
export function sectionStickyProps(block, kind) {
|
|
150
|
+
const style = kind ? sectionStickyStyle(block) : null
|
|
151
|
+
return style ? { style, 'data-uw-sticky': kind === 'child' ? 'child' : 'section' } : null
|
|
76
152
|
}
|