@uniweb/runtime 1.4.5 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ssr.js +36 -2
- package/dist/ssr.js.map +1 -1
- package/package.json +4 -4
- package/src/components/BlockRenderer.jsx +14 -0
- package/src/components/Layout.jsx +8 -2
- package/src/ssr-renderer.js +16 -2
- package/src/sticky-top.js +48 -5
- package/src/under-header.js +83 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
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.6.
|
|
40
|
-
"@uniweb/theming": "~1.
|
|
39
|
+
"@uniweb/core": "~1.6.6",
|
|
40
|
+
"@uniweb/theming": "~1.3.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.
|
|
47
|
+
"@uniweb/build": "1.17.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -13,6 +13,7 @@ import Background from './Background.jsx'
|
|
|
13
13
|
import { sectionStickyProps, sectionScrollMargin } from '../sticky.js'
|
|
14
14
|
import { noteBodySection } from '../sticky-top.js'
|
|
15
15
|
import { noteSectionWrapper } from '../sticky-check.js'
|
|
16
|
+
import { openingUnderHeader, underHeaderProps } from '../under-header.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Valid color contexts
|
|
@@ -173,6 +174,14 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
|
|
|
173
174
|
// On a page whose layout has a sticky area, a link to this section lands below it.
|
|
174
175
|
const margin = sectionScrollMargin(block, stickySelf)
|
|
175
176
|
if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
|
|
177
|
+
// The page's opening section, when the header lies over it: the header's height to clear, the
|
|
178
|
+
// declared one until the browser measures it (`../under-header.js`). Mirrored in `ssr-renderer.js`.
|
|
179
|
+
const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
|
|
180
|
+
if (overlay) {
|
|
181
|
+
const under = underHeaderProps(overlay)
|
|
182
|
+
wrapperProps.style = { ...wrapperProps.style, ...under.style }
|
|
183
|
+
wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
|
|
184
|
+
}
|
|
176
185
|
// A body section's element, for the measurement of what is stuck over the body (`../sticky-top.js`);
|
|
177
186
|
// in dev, every section's, so `uniweb dev`'s sticky check knows a component's outermost element.
|
|
178
187
|
const bodySection = stickySelf === 'section' || stickySelf === true
|
|
@@ -202,6 +211,11 @@ export default function BlockRenderer({ block, as = 'section', stickySelf = fals
|
|
|
202
211
|
// and let the engine background show through.
|
|
203
212
|
block.hasBackground = hasBackground
|
|
204
213
|
|
|
214
|
+
// `background: 'self'` is everything behind the content: the theme leaves this wrapper
|
|
215
|
+
// unpainted too, and the component paints its context's surface where it wants it
|
|
216
|
+
// (`@uniweb/theming`'s section rule). Mirrored in `ssr-renderer.js`.
|
|
217
|
+
if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
|
|
218
|
+
|
|
205
219
|
// Determine wrapper element:
|
|
206
220
|
// - Explicit as prop (not 'section') → use as prop directly
|
|
207
221
|
// - Component.as → use component's declared tag (e.g., Header.as = 'header')
|
|
@@ -35,6 +35,7 @@ import { useEffect } from 'react'
|
|
|
35
35
|
import Blocks from './Blocks.jsx'
|
|
36
36
|
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from '../area-wrappers.js'
|
|
37
37
|
import { layoutStickiness, zoneProps } from '../sticky.js'
|
|
38
|
+
import { headerOverlay, overlayBoxProps } from '../under-header.js'
|
|
38
39
|
import { warnUnmovableSticky } from '../sticky-check.js'
|
|
39
40
|
import { watchStickyTop, bodySectionOf } from '../sticky-top.js'
|
|
40
41
|
// The built-in layout — one copy, which the prerender renders too (`../default-layout.js`).
|
|
@@ -95,9 +96,14 @@ export default function Layout({ page, website }) {
|
|
|
95
96
|
// it is lifted above the areas that scroll under it.
|
|
96
97
|
const sticky = layoutStickiness(areas)
|
|
97
98
|
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
99
|
+
// A page that opens with a section under the header has the header lie over it: the header's box
|
|
100
|
+
// takes no room in the flow (`../under-header.js`). In a foundation's layout that box is the area
|
|
101
|
+
// wrapper; in the built-in one, the zone around it.
|
|
102
|
+
const overlay = headerOverlay(page)
|
|
98
103
|
|
|
99
104
|
const wrap = (region, children) => {
|
|
100
|
-
|
|
105
|
+
let props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
|
|
106
|
+
if (overlay && RemoteLayout && region === 'header') props = overlayBoxProps(props)
|
|
101
107
|
return props ? <div {...props}>{children}</div> : children
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -143,7 +149,7 @@ export default function Layout({ page, website }) {
|
|
|
143
149
|
<DefaultLayout
|
|
144
150
|
body={bodyElement}
|
|
145
151
|
{...areaElements}
|
|
146
|
-
zones={{ header: zone('header'), footer: zone('footer') }}
|
|
152
|
+
zones={{ header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') }}
|
|
147
153
|
/>
|
|
148
154
|
)
|
|
149
155
|
}
|
package/src/ssr-renderer.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
} from './wire-foundation.js'
|
|
35
35
|
import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperProps } from './area-wrappers.js'
|
|
36
36
|
import { layoutStickiness, sectionStickyProps, sectionScrollMargin, zoneProps } from './sticky.js'
|
|
37
|
+
import { headerOverlay, overlayBoxProps, openingUnderHeader, underHeaderProps } from './under-header.js'
|
|
37
38
|
import { DefaultLayout } from './default-layout.js'
|
|
38
39
|
import { renderAppearanceBootScript } from './appearance.js'
|
|
39
40
|
import { documentTitle } from './document-title.js'
|
|
@@ -185,6 +186,14 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
|
|
|
185
186
|
// On a page whose layout has a sticky area, a link to this section lands below it.
|
|
186
187
|
const margin = sectionScrollMargin(block, stickySelf)
|
|
187
188
|
if (margin) wrapperProps.style = { ...wrapperProps.style, ...margin }
|
|
189
|
+
// The page's opening section, when the header lies over it: the header's height to clear
|
|
190
|
+
// (`under-header.js`). Mirrors `BlockRenderer.jsx`.
|
|
191
|
+
const overlay = stickySelf === 'section' || stickySelf === true ? openingUnderHeader(block) : null
|
|
192
|
+
if (overlay) {
|
|
193
|
+
const under = underHeaderProps(overlay)
|
|
194
|
+
wrapperProps.style = { ...wrapperProps.style, ...under.style }
|
|
195
|
+
wrapperProps['data-uw-under-header'] = under['data-uw-under-header']
|
|
196
|
+
}
|
|
188
197
|
|
|
189
198
|
// Merge Component.className (static classes declared on the component function)
|
|
190
199
|
const componentClassName = Component.className
|
|
@@ -197,6 +206,8 @@ export function renderBlock(block, { as = 'section', stickySelf = false } = {})
|
|
|
197
206
|
// Check if component handles its own background
|
|
198
207
|
const hasBackground = background?.mode && meta?.background !== 'self'
|
|
199
208
|
block.hasBackground = hasBackground
|
|
209
|
+
// `background: 'self'` leaves the wrapper unpainted by the theme too (`BlockRenderer.jsx`).
|
|
210
|
+
if (meta?.background === 'self') wrapperProps['data-uw-background'] = 'self'
|
|
200
211
|
|
|
201
212
|
// Determine wrapper element:
|
|
202
213
|
// - Explicit as (not 'section') → use as prop directly
|
|
@@ -261,8 +272,11 @@ export function renderLayout(page, website) {
|
|
|
261
272
|
// above the areas that scroll under it.
|
|
262
273
|
const sticky = layoutStickiness(areas)
|
|
263
274
|
const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers, sticky)
|
|
275
|
+
// Mirror Layout.jsx: the header lies over a page's opening section under it (`under-header.js`).
|
|
276
|
+
const overlay = headerOverlay(page)
|
|
264
277
|
const wrapArea = (name, element) => {
|
|
265
|
-
|
|
278
|
+
let props = areaWrapperProps(name, transitions, layers, RemoteLayout ? sticky[name] : null)
|
|
279
|
+
if (overlay && RemoteLayout && name === 'header') props = overlayBoxProps(props)
|
|
266
280
|
return props ? React.createElement('div', props, element) : element
|
|
267
281
|
}
|
|
268
282
|
|
|
@@ -288,7 +302,7 @@ export function renderLayout(page, website) {
|
|
|
288
302
|
return React.createElement(DefaultLayout, {
|
|
289
303
|
body: bodyElement,
|
|
290
304
|
...areaElements,
|
|
291
|
-
zones: { header: zone('header'), footer: zone('footer') },
|
|
305
|
+
zones: { header: overlay ? overlayBoxProps(zone('header')) : zone('header'), footer: zone('footer') },
|
|
292
306
|
})
|
|
293
307
|
}
|
|
294
308
|
|
package/src/sticky-top.js
CHANGED
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
* reaches its `top` plus its height; the variable is the furthest any of them reaches. A rule of
|
|
17
17
|
* position, so it reads no meaning into an area's name (`area-wrappers.js` refuses to for the
|
|
18
18
|
* same reason).
|
|
19
|
+
*
|
|
20
|
+
* ⭐ A HEADER OVER THE OPENING SECTION (`under-header.js`) takes no room in the flow, so its box is
|
|
21
|
+
* as tall as nothing: what it reaches is what it holds, the runtime's own wrappers inside it. Its
|
|
22
|
+
* height is published too, as `--uw-header-measured`, which the opening section's
|
|
23
|
+
* `--uw-header-height` reads in place of the height the header type declared for the first paint.
|
|
19
24
|
*/
|
|
20
25
|
|
|
26
|
+
import { MEASURED_HEADER_HEIGHT } from './under-header.js'
|
|
27
|
+
|
|
21
28
|
const VARIABLE = '--uw-sticky-top'
|
|
22
29
|
const FOLLOWING = 4 // Node.DOCUMENT_POSITION_FOLLOWING
|
|
23
30
|
|
|
@@ -40,6 +47,34 @@ export function bodySectionOf(block) {
|
|
|
40
47
|
return (block && bodySections.get(block)) || null
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* How tall a box is drawn: its own height — or, for the header's box over the page's opening
|
|
52
|
+
* section (`data-uw-header="over"`, `under-header.js`), which takes no room in the flow, how far what
|
|
53
|
+
* it holds reaches below its top. What it holds is the runtime's own elements (the area's wrapper,
|
|
54
|
+
* the sections' wrappers), never a guess at a foundation's markup.
|
|
55
|
+
*
|
|
56
|
+
* @param {Element} box
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
function drawnHeight(box) {
|
|
60
|
+
const own = box.getBoundingClientRect()
|
|
61
|
+
if (box.getAttribute('data-uw-header') !== 'over') return own.height
|
|
62
|
+
let bottom = own.top
|
|
63
|
+
for (const child of box.children) bottom = Math.max(bottom, child.getBoundingClientRect().bottom)
|
|
64
|
+
return bottom - own.top
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* How tall the header lying over the page's opening section is, in px — null on a page with none.
|
|
69
|
+
*
|
|
70
|
+
* @param {Document} doc
|
|
71
|
+
* @returns {number|null}
|
|
72
|
+
*/
|
|
73
|
+
export function measureHeaderOverlay(doc) {
|
|
74
|
+
const box = doc.querySelector('[data-uw-header="over"]')
|
|
75
|
+
return box ? Math.round(drawnHeight(box) * 100) / 100 : null
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
/**
|
|
44
79
|
* How far down the viewport the stuck chrome over the body reaches, in px — 0 when none does.
|
|
45
80
|
*
|
|
@@ -57,7 +92,7 @@ export function measureStickyTop(doc, body) {
|
|
|
57
92
|
const own = box.getBoundingClientRect()
|
|
58
93
|
if (own.right <= over.left || own.left >= over.right) continue // beside the body: a rail
|
|
59
94
|
const top = parseFloat(doc.defaultView.getComputedStyle(box).top) || 0
|
|
60
|
-
reach = Math.max(reach, top +
|
|
95
|
+
reach = Math.max(reach, top + drawnHeight(box))
|
|
61
96
|
}
|
|
62
97
|
return Math.round(reach * 100) / 100
|
|
63
98
|
}
|
|
@@ -78,13 +113,19 @@ export function watchStickyTop(doc, body) {
|
|
|
78
113
|
const reach = measureStickyTop(doc, body)
|
|
79
114
|
if (reach > 0) root.style.setProperty(VARIABLE, `${reach}px`)
|
|
80
115
|
else root.style.removeProperty(VARIABLE)
|
|
116
|
+
// The header over the opening section: what the section's `--uw-header-height` reads once known.
|
|
117
|
+
const header = measureHeaderOverlay(doc)
|
|
118
|
+
if (header != null) root.style.setProperty(MEASURED_HEADER_HEIGHT, `${header}px`)
|
|
119
|
+
else root.style.removeProperty(MEASURED_HEADER_HEIGHT)
|
|
120
|
+
}
|
|
121
|
+
const forget = () => {
|
|
122
|
+
root.style.removeProperty(VARIABLE)
|
|
123
|
+
root.style.removeProperty(MEASURED_HEADER_HEIGHT)
|
|
81
124
|
}
|
|
82
125
|
apply()
|
|
83
126
|
|
|
84
127
|
const View = doc.defaultView
|
|
85
|
-
if (!body || typeof View?.ResizeObserver !== 'function')
|
|
86
|
-
return () => root.style.removeProperty(VARIABLE)
|
|
87
|
-
}
|
|
128
|
+
if (!body || typeof View?.ResizeObserver !== 'function') return forget
|
|
88
129
|
let frame = 0
|
|
89
130
|
const observer = new View.ResizeObserver(() => {
|
|
90
131
|
View.cancelAnimationFrame(frame)
|
|
@@ -92,9 +133,11 @@ export function watchStickyTop(doc, body) {
|
|
|
92
133
|
})
|
|
93
134
|
observer.observe(body)
|
|
94
135
|
for (const box of doc.querySelectorAll('[data-uw-sticky="area"]')) observer.observe(box)
|
|
136
|
+
// A box over the opening section keeps no height of its own to change: what it holds does.
|
|
137
|
+
for (const box of doc.querySelectorAll('[data-uw-header="over"]')) for (const child of box.children) observer.observe(child)
|
|
95
138
|
return () => {
|
|
96
139
|
View.cancelAnimationFrame(frame)
|
|
97
140
|
observer.disconnect()
|
|
98
|
-
|
|
141
|
+
forget()
|
|
99
142
|
}
|
|
100
143
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header laid over a page's opening section — `underHeader`, a setting of the section.
|
|
3
|
+
*
|
|
4
|
+
* A page that opens with a section that can sit under the header (`underHeader: true`: its type's
|
|
5
|
+
* default in `meta.js`, or its author's) has the header lie over it. The header's box takes no room
|
|
6
|
+
* in the flow, so the section starts at the top of the window and its picture reaches up behind a
|
|
7
|
+
* see-through header; the header keeps its own height inside that box, and stays in view if it
|
|
8
|
+
* sticks. Pure, so both renderers (`components/Layout.jsx`, `ssr-renderer.js`) build the same page,
|
|
9
|
+
* and the browser's measurement (`sticky-top.js`) reads the same boxes.
|
|
10
|
+
*
|
|
11
|
+
* ⭐ THE RUNTIME DOES IT, AS IT DOES `sticky`, because a component cannot move the box the runtime
|
|
12
|
+
* puts it in. Foundations that floated a header from inside its own section took the bar out of
|
|
13
|
+
* the flow (`absolute`, `fixed`), which left the box the runtime measures with no height: no
|
|
14
|
+
* `--uw-sticky-top`, and a link to a section landing under the bar (measured 2026-11-06 on a site's
|
|
15
|
+
* store page, in Chromium and WebKit). Five sites solved it five ways.
|
|
16
|
+
*
|
|
17
|
+
* The opening section keeps its content clear of the header by `--uw-header-height`, which the
|
|
18
|
+
* runtime gives its wrapper: the header type's `height` on the first paint, which no prerender can
|
|
19
|
+
* measure, then what the browser measures. The header reads `getNextBlockInfo().underHeader` to
|
|
20
|
+
* paint itself see-through, and its box says `data-uw-header="over"` for a stylesheet.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The header's height, on the opening section's wrapper: what its content is padded by. */
|
|
24
|
+
export const HEADER_HEIGHT = '--uw-header-height'
|
|
25
|
+
|
|
26
|
+
/** What the browser measured, on the root, which the opening section's value defers to. */
|
|
27
|
+
export const MEASURED_HEADER_HEIGHT = '--uw-header-measured'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Whether the page's header lies over its opening section, and how tall the header is before the
|
|
31
|
+
* browser measures it: the `height` its sections' types declare, else `0px` until measured.
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} page - its blocks' components initialized, as both renderers do before rendering
|
|
34
|
+
* @returns {{ height: string }|null}
|
|
35
|
+
*/
|
|
36
|
+
export function headerOverlay(page) {
|
|
37
|
+
const first = page?.getBodyBlocks?.()?.[0]
|
|
38
|
+
if (!first?.underHeader) return null
|
|
39
|
+
const header = page.getLayoutAreas?.()?.header
|
|
40
|
+
if (!Array.isArray(header) || header.length === 0) return null
|
|
41
|
+
const heights = header.map((block) => block?.height).filter(Boolean)
|
|
42
|
+
const height = heights.length === 0 ? '0px' : heights.length === 1 ? heights[0] : `calc(${heights.join(' + ')})`
|
|
43
|
+
return { height }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The props of the header's box — the built-in layout's zone, or a foundation layout's area
|
|
48
|
+
* wrapper — when it lies over the opening section: it takes no room in the flow, and what it holds
|
|
49
|
+
* keeps its height. Whatever else the box has (its layer, its stickiness) stays.
|
|
50
|
+
*
|
|
51
|
+
* @param {Object|null} props - the box's props without the overlay
|
|
52
|
+
* @returns {Object}
|
|
53
|
+
*/
|
|
54
|
+
export function overlayBoxProps(props) {
|
|
55
|
+
return { ...(props || {}), style: { ...(props?.style || {}), height: 0, minHeight: 0 }, 'data-uw-header': 'over' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the opening section's wrapper adds when the header lies over it: the header's height, the
|
|
60
|
+
* declared one until the browser has measured it.
|
|
61
|
+
*
|
|
62
|
+
* @param {{ height: string }} overlay - `headerOverlay(page)`
|
|
63
|
+
* @returns {{ style: Object, 'data-uw-under-header': string }}
|
|
64
|
+
*/
|
|
65
|
+
export function underHeaderProps(overlay) {
|
|
66
|
+
return {
|
|
67
|
+
style: { [HEADER_HEIGHT]: `var(${MEASURED_HEADER_HEIGHT}, ${overlay.height})` },
|
|
68
|
+
'data-uw-under-header': '',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether `block` is its page's opening section under the header — what its wrapper asks before it
|
|
74
|
+
* adds `underHeaderProps`.
|
|
75
|
+
*
|
|
76
|
+
* @param {Object} block - a body section's block, its component initialized
|
|
77
|
+
* @returns {{ height: string }|null} the page's overlay, when it opens with this section
|
|
78
|
+
*/
|
|
79
|
+
export function openingUnderHeader(block) {
|
|
80
|
+
const page = block?.page
|
|
81
|
+
if (!page || page.getBodyBlocks?.()?.[0] !== block) return null
|
|
82
|
+
return headerOverlay(page)
|
|
83
|
+
}
|