@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/dist/ssr.js +55 -16
- package/dist/ssr.js.map +1 -1
- package/package.json +4 -4
- package/src/area-wrappers.js +36 -7
- package/src/components/BlockRenderer.jsx +7 -1
- package/src/components/Blocks.jsx +8 -2
- package/src/components/Layout.jsx +21 -4
- package/src/default-layout.js +14 -4
- package/src/ssr-renderer.js +29 -7
- package/src/sticky-check.js +68 -0
- package/src/sticky.js +76 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "1.1.
|
|
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/
|
|
40
|
-
"@uniweb/
|
|
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.
|
|
47
|
+
"@uniweb/build": "1.6.2"
|
|
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)
|
|
115
128
|
|
|
116
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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 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 {
|
|
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',
|
|
47
|
+
header && React.createElement('header', zone('header'), header),
|
|
38
48
|
body && React.createElement('main', { style: GROW }, body),
|
|
39
|
-
footer && React.createElement('footer',
|
|
49
|
+
footer && React.createElement('footer', zone('footer'), footer),
|
|
40
50
|
)
|
|
41
51
|
}
|
package/src/ssr-renderer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|