@uniweb/runtime 1.1.12 → 1.1.14
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 +72 -18
- package/dist/ssr.js.map +1 -1
- package/package.json +4 -4
- package/src/area-wrappers.js +53 -7
- package/src/components/BlockRenderer.jsx +14 -1
- package/src/components/Blocks.jsx +8 -2
- package/src/components/Layout.jsx +28 -6
- package/src/components/PageRenderer.jsx +1 -1
- package/src/default-layout.js +13 -4
- package/src/ssr-renderer.js +36 -10
- package/src/sticky-check.js +110 -0
- package/src/sticky-top.js +100 -0
- package/src/sticky.js +124 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
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.
|
|
40
|
-
"@uniweb/theming": "~1.2.
|
|
39
|
+
"@uniweb/core": "~1.4.0",
|
|
40
|
+
"@uniweb/theming": "~1.2.1"
|
|
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.
|
|
47
|
+
"@uniweb/build": "1.7.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/area-wrappers.js
CHANGED
|
@@ -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
|
|
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
|
|
114
|
-
for (const name of areaNames)
|
|
125
|
+
const layers = {}
|
|
126
|
+
for (const name of areaNames) layers[name] = 1
|
|
127
|
+
if (explicit) Object.assign(layers, explicit)
|
|
128
|
+
|
|
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
|
+
}
|
|
115
137
|
|
|
116
|
-
return
|
|
138
|
+
return layers
|
|
117
139
|
}
|
|
118
140
|
|
|
119
141
|
/**
|
|
@@ -132,22 +154,46 @@ 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 (
|
|
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
|
}
|
|
151
180
|
|
|
152
181
|
return Object.keys(style).length > 0 ? style : null
|
|
153
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,6 +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 { sectionStickyProps } from '../sticky.js'
|
|
14
|
+
import { noteBodySection } from '../sticky-top.js'
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Valid color contexts
|
|
@@ -78,8 +80,10 @@ const getWrapperProps = (block) => {
|
|
|
78
80
|
* @param {Object} props
|
|
79
81
|
* @param {Block} props.block - Block instance to render
|
|
80
82
|
* @param {string|null} props.as - Wrapper element tag for section mode, or null/false for bare mode
|
|
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'`)
|
|
81
85
|
*/
|
|
82
|
-
export default function BlockRenderer({ block, as = 'section' }) {
|
|
86
|
+
export default function BlockRenderer({ block, as = 'section', stickySelf = false }) {
|
|
83
87
|
const Component = block.initComponent()
|
|
84
88
|
|
|
85
89
|
// Entity-aware data resolution via EntityStore
|
|
@@ -159,6 +163,15 @@ export default function BlockRenderer({ block, as = 'section' }) {
|
|
|
159
163
|
// Section mode — full treatment with wrapper, context classes, background.
|
|
160
164
|
const { background, ...wrapperProps } = getWrapperProps(block)
|
|
161
165
|
|
|
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
|
+
// A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`).
|
|
173
|
+
if (stickySelf === 'section' || stickySelf === true) wrapperProps.ref = (element) => noteBodySection(block, element)
|
|
174
|
+
|
|
162
175
|
// Merge Component.className (static classes declared on the component function)
|
|
163
176
|
// Order: context-{theme} + block.state.className + Component.className
|
|
164
177
|
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
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} props
|
|
21
|
+
* @param {Block[]} props.blocks
|
|
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
|
+
*/
|
|
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,12 @@
|
|
|
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
|
-
import { resolveLayoutTransitions, resolveLayoutLayers,
|
|
36
|
+
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
|
|
37
|
+
import { layoutStickiness, zoneProps } from '../sticky.js'
|
|
38
|
+
import { warnUnmovableSticky } from '../sticky-check.js'
|
|
39
|
+
import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
|
|
36
40
|
// The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
|
|
37
41
|
import { DefaultLayout } from '../default-layout.js'
|
|
38
42
|
|
|
@@ -88,20 +92,36 @@ export default function Layout({ page, website }) {
|
|
|
88
92
|
const transitions = website.viewTransitions
|
|
89
93
|
? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
|
|
90
94
|
: null
|
|
91
|
-
|
|
95
|
+
// An area stays in view when any of its sections is sticky (`../sticky.js`): in a foundation's
|
|
96
|
+
// layout its wrapper sticks; in the built-in one, the zone around the wrapper does. Either way
|
|
97
|
+
// it is lifted above the areas that scroll under it.
|
|
98
|
+
const sticky = layoutStickiness(areas)
|
|
99
|
+
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
92
100
|
|
|
93
101
|
const wrap = (region, children) => {
|
|
94
|
-
const
|
|
95
|
-
return
|
|
102
|
+
const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
|
|
103
|
+
return props ? <div {...props}>{children}</div> : children
|
|
96
104
|
}
|
|
97
105
|
|
|
98
|
-
|
|
106
|
+
// A body section's own wrapper is the box that sticks for it; an area's sections leave that to
|
|
107
|
+
// the area's box.
|
|
108
|
+
const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} stickySelf="section" />) : null
|
|
99
109
|
|
|
100
110
|
const areaElements = {}
|
|
101
111
|
for (const [name, blocks] of Object.entries(areas)) {
|
|
102
112
|
areaElements[name] = wrap(name, <Blocks blocks={blocks} />)
|
|
103
113
|
}
|
|
104
114
|
|
|
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.
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (import.meta.env?.DEV) return warnUnmovableSticky()
|
|
123
|
+
}, [page])
|
|
124
|
+
|
|
105
125
|
// Use foundation's custom Layout if provided
|
|
106
126
|
if (RemoteLayout) {
|
|
107
127
|
const params = mergeParams(page.getLayoutParams(), layoutMeta?.defaults)
|
|
@@ -118,11 +138,13 @@ export default function Layout({ page, website }) {
|
|
|
118
138
|
)
|
|
119
139
|
}
|
|
120
140
|
|
|
121
|
-
// Default layout
|
|
141
|
+
// Default layout — a sticky area's zone is the box that sticks.
|
|
142
|
+
const zone = (name) => zoneProps(sticky[name], layers?.[name])
|
|
122
143
|
return (
|
|
123
144
|
<DefaultLayout
|
|
124
145
|
body={bodyElement}
|
|
125
146
|
{...areaElements}
|
|
147
|
+
zones={{ header: zone('header'), footer: zone('footer') }}
|
|
126
148
|
/>
|
|
127
149
|
)
|
|
128
150
|
}
|
|
@@ -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
|
@@ -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 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.
|
|
22
27
|
*/
|
|
23
28
|
|
|
24
29
|
import React from 'react'
|
|
@@ -27,15 +32,19 @@ const COLUMN = { display: 'flex', flexDirection: 'column', minHeight: '100vh' }
|
|
|
27
32
|
const GROW = { flex: 1 }
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
|
-
* @param {
|
|
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 props, 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 }) {
|
|
34
43
|
return React.createElement(
|
|
35
44
|
'div',
|
|
36
45
|
{ style: COLUMN },
|
|
37
|
-
header && React.createElement('header', null, header),
|
|
46
|
+
header && React.createElement('header', zones?.header || null, header),
|
|
38
47
|
body && React.createElement('main', { style: GROW }, body),
|
|
39
|
-
footer && React.createElement('footer', null, footer),
|
|
48
|
+
footer && React.createElement('footer', zones?.footer || null, footer),
|
|
40
49
|
)
|
|
41
50
|
}
|
package/src/ssr-renderer.js
CHANGED
|
@@ -30,7 +30,8 @@ import {
|
|
|
30
30
|
hydrateDataStore,
|
|
31
31
|
ensureThemeCss,
|
|
32
32
|
} from './wire-foundation.js'
|
|
33
|
-
import { resolveLayoutTransitions, resolveLayoutLayers,
|
|
33
|
+
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
|
|
34
|
+
import { layoutStickiness, sectionStickyProps, zoneProps } 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,11 @@ 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 {'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'`)
|
|
226
229
|
* @returns {React.ReactElement}
|
|
227
230
|
*/
|
|
228
|
-
export function renderBlock(block, { as = 'section' } = {}) {
|
|
231
|
+
export function renderBlock(block, { as = 'section', stickySelf = false } = {}) {
|
|
229
232
|
const Component = block.initComponent()
|
|
230
233
|
|
|
231
234
|
if (!Component) {
|
|
@@ -265,6 +268,13 @@ export function renderBlock(block, { as = 'section' } = {}) {
|
|
|
265
268
|
// Section mode: full treatment with wrapper, context classes, background.
|
|
266
269
|
const { background, ...wrapperProps } = getWrapperProps(block)
|
|
267
270
|
|
|
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
|
+
|
|
268
278
|
// Merge Component.className (static classes declared on the component function)
|
|
269
279
|
const componentClassName = Component.className
|
|
270
280
|
if (componentClassName) {
|
|
@@ -300,11 +310,11 @@ export function renderBlock(block, { as = 'section' } = {}) {
|
|
|
300
310
|
/**
|
|
301
311
|
* Render an array of blocks for SSR.
|
|
302
312
|
*/
|
|
303
|
-
export function renderBlocks(blocks) {
|
|
313
|
+
export function renderBlocks(blocks, { stickySelf = false } = {}) {
|
|
304
314
|
if (!blocks || blocks.length === 0) return null
|
|
305
315
|
return blocks.map((block, index) =>
|
|
306
316
|
React.createElement(React.Fragment, { key: block.id || index },
|
|
307
|
-
renderBlock(block)
|
|
317
|
+
renderBlock(block, { stickySelf })
|
|
308
318
|
)
|
|
309
319
|
)
|
|
310
320
|
}
|
|
@@ -321,6 +331,12 @@ export function renderLayout(page, website) {
|
|
|
321
331
|
const bodyBlocks = page.getBodyBlocks()
|
|
322
332
|
const areas = page.getLayoutAreas()
|
|
323
333
|
|
|
334
|
+
// Mirror Layout.jsx: every block's component initialized before anything renders, so each
|
|
335
|
+
// section's `sticky` — its type's default included — is known before the areas' layers are.
|
|
336
|
+
for (const blocks of [bodyBlocks, ...Object.values(areas)]) {
|
|
337
|
+
for (const block of blocks || []) block.initComponent()
|
|
338
|
+
}
|
|
339
|
+
|
|
324
340
|
// Mirror Layout.jsx: wrap body + each area in a thin div carrying its
|
|
325
341
|
// view-transition-name, so the prerendered HTML matches what the SPA hydrates
|
|
326
342
|
// and the browser can animate regions independently on client navigation.
|
|
@@ -328,13 +344,18 @@ export function renderLayout(page, website) {
|
|
|
328
344
|
const transitions = website.viewTransitions
|
|
329
345
|
? resolveLayoutTransitions(areaNames, layoutMeta?.transitions)
|
|
330
346
|
: null
|
|
331
|
-
|
|
347
|
+
|
|
348
|
+
// Mirror Layout.jsx: an area stays in view when any of its sections is sticky — in a
|
|
349
|
+
// foundation's layout its wrapper sticks, in the built-in one the zone around it — and is lifted
|
|
350
|
+
// above the areas that scroll under it.
|
|
351
|
+
const sticky = layoutStickiness(areas)
|
|
352
|
+
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
332
353
|
const wrapArea = (name, element) => {
|
|
333
|
-
const
|
|
334
|
-
return
|
|
354
|
+
const props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
|
|
355
|
+
return props ? React.createElement('div', props, element) : element
|
|
335
356
|
}
|
|
336
357
|
|
|
337
|
-
const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks)) : null
|
|
358
|
+
const bodyElement = bodyBlocks ? wrapArea('body', renderBlocks(bodyBlocks, { stickySelf: 'section' })) : null
|
|
338
359
|
const areaElements = {}
|
|
339
360
|
for (const [name, blocks] of Object.entries(areas)) {
|
|
340
361
|
areaElements[name] = wrapArea(name, renderBlocks(blocks))
|
|
@@ -352,7 +373,12 @@ export function renderLayout(page, website) {
|
|
|
352
373
|
// The built-in layout — the browser's own copy, not a mirror of it (`default-layout.js`).
|
|
353
374
|
// ⛔ Until 2026-10-07 this wrote its own, with no full-height column, so the prerendered
|
|
354
375
|
// page differed from the one the browser renders over it.
|
|
355
|
-
|
|
376
|
+
const zone = (name) => zoneProps(sticky[name], layers?.[name])
|
|
377
|
+
return React.createElement(DefaultLayout, {
|
|
378
|
+
body: bodyElement,
|
|
379
|
+
...areaElements,
|
|
380
|
+
zones: { header: zone('header'), footer: zone('footer') },
|
|
381
|
+
})
|
|
356
382
|
}
|
|
357
383
|
|
|
358
384
|
// ============================================================================
|
|
@@ -439,7 +465,7 @@ export function initPrerender(content, foundation, extensionsOrOptions, maybeOpt
|
|
|
439
465
|
const blockList = blocks || from?.childBlocks || []
|
|
440
466
|
return blockList.map((childBlock, index) =>
|
|
441
467
|
React.createElement(React.Fragment, { key: childBlock.id || index },
|
|
442
|
-
renderBlock(childBlock, { as: wrapAs || null })
|
|
468
|
+
renderBlock(childBlock, { as: wrapAs || null, stickySelf: wrapAs ? 'child' : false })
|
|
443
469
|
)
|
|
444
470
|
)
|
|
445
471
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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. 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.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const warned = new WeakSet()
|
|
27
|
+
|
|
28
|
+
/** The nearest ancestor that draws a box — `display: contents` draws none. */
|
|
29
|
+
function boxParent(el) {
|
|
30
|
+
let parent = el.parentElement
|
|
31
|
+
while (parent && getComputedStyle(parent).display === 'contents') parent = parent.parentElement
|
|
32
|
+
return parent
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether a sticky element has room to move in the direction it sticks.
|
|
37
|
+
*
|
|
38
|
+
* @param {Element} el
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function canStick(el) {
|
|
42
|
+
const style = getComputedStyle(el)
|
|
43
|
+
if (style.display.startsWith('table')) return true
|
|
44
|
+
const parent = boxParent(el)
|
|
45
|
+
if (!parent) return true
|
|
46
|
+
const own = el.getBoundingClientRect()
|
|
47
|
+
const room = parent.getBoundingClientRect()
|
|
48
|
+
const vertical = style.top !== 'auto' || style.bottom !== 'auto'
|
|
49
|
+
const horizontal = style.left !== 'auto' || style.right !== 'auto'
|
|
50
|
+
return (vertical && room.height > own.height + 0.5) || (horizontal && room.width > own.width + 0.5)
|
|
51
|
+
}
|
|
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
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Warn, once per element, about each sticky element on the page that cannot move.
|
|
77
|
+
*
|
|
78
|
+
* @param {Document} [doc]
|
|
79
|
+
* @returns {() => void} cancels a check not yet run
|
|
80
|
+
*/
|
|
81
|
+
export function warnUnmovableSticky(doc = globalThis.document) {
|
|
82
|
+
if (!doc?.body || typeof requestAnimationFrame !== 'function') return () => {}
|
|
83
|
+
const frame = requestAnimationFrame(() => {
|
|
84
|
+
for (const el of doc.body.querySelectorAll('*')) {
|
|
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
|
|
99
|
+
warned.add(el)
|
|
100
|
+
console.warn(
|
|
101
|
+
'[uniweb] This element is sticky but cannot move: its parent is no taller than it is, so it ' +
|
|
102
|
+
'scrolls away with the page. To keep a section in view, give the section `sticky: true` ' +
|
|
103
|
+
'(its type may default it in meta.js) — no box a component draws can stick. A layout that ' +
|
|
104
|
+
'wraps an area in an element of its own makes that element sticky when `page.isSticky(area)`.',
|
|
105
|
+
el,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
return () => cancelAnimationFrame(frame)
|
|
110
|
+
}
|
|
@@ -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
|
+
}
|