@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.1.12",
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.3.2",
40
- "@uniweb/theming": "~1.2.0"
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.6.1"
47
+ "@uniweb/build": "1.7.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -29,6 +29,8 @@
29
29
  * the hydrated SPA aligned.
30
30
  */
31
31
 
32
+ import { stickyStyle } from './sticky.js'
33
+
32
34
  // Namespace so generated names can't collide with `view-transition-name`s a
33
35
  // foundation sets inside its own component CSS. The prefix also guarantees a
34
36
  // valid CSS <custom-ident> (starts with a letter).
@@ -66,7 +68,8 @@ export function resolveLayoutTransitions(areaNames, explicit) {
66
68
  * The stacking layer of each area's wrapper.
67
69
  *
68
70
  * Default: every area except the body gets `1`, and the body gets nothing —
69
- * content is the backdrop, chrome is above it. That is the whole of what the
71
+ * content is the backdrop, chrome is above it — and a sticky area gets one more
72
+ * than every area that does not stick (below). That is the whole of what the
70
73
  * framework claims to know, and it is deliberately not more.
71
74
  *
72
75
  * The body is left unstacked rather than pinned to `0` on purpose. A layer
@@ -103,17 +106,36 @@ export function resolveLayoutTransitions(areaNames, explicit) {
103
106
  * whole — a foundation could set `layers: { header: 0 }` on the default layout
104
107
  * and measurably nothing happened.
105
108
  *
109
+ * ⭐ The one ordering the framework does know is a sticky area's (`sticky.js` — any of its sections
110
+ * is). It stays in view while the rest of the page scrolls under it, so it is lifted above every
111
+ * area that does not stick: the runtime made it stick, so it owes it the layer. Left equal to them,
112
+ * a footer taller than the space below a stuck header paints over the header at the bottom of the
113
+ * page, since it comes later in the DOM (measured 2026-10-19: a 521px footer on a 480px-tall
114
+ * phone). That is a fact about the box, not a reading of the area's name, and the layout's
115
+ * `layers` still rank a sticky area it names.
116
+ *
106
117
  * @param {string[]} areaNames - Names of the areas rendered for this page (excludes `body`).
107
118
  * @param {Object|false|null|undefined} explicit - `layoutMeta.layers`.
119
+ * @param {Object} [sticky] - region → its stickiness, or null (`layoutStickiness`).
108
120
  * @returns {Object} region → z-index. Empty when the layout opts out.
109
121
  */
110
- export function resolveLayoutLayers(areaNames, explicit) {
122
+ export function resolveLayoutLayers(areaNames, explicit, sticky = null) {
111
123
  if (explicit === false) return {}
112
124
 
113
- const defaults = {}
114
- for (const name of areaNames) defaults[name] = 1
125
+ const layers = {}
126
+ for (const name of areaNames) layers[name] = 1
127
+ if (explicit) Object.assign(layers, explicit)
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 explicit ? { ...defaults, ...explicit } : defaults
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 (layer != null) {
174
+ if (sticky) {
175
+ Object.assign(style, stickyStyle(sticky.top, layer))
176
+ } else if (layer != null) {
148
177
  style.position = 'relative'
149
178
  style.zIndex = layer
150
179
  }
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
- export default function Blocks({ blocks }) {
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, areaWrapperStyle } from '../area-wrappers.js'
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
- const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers)
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 style = areaWrapperStyle(region, transitions, layers)
95
- return style ? <div style={style}>{children}</div> : children
102
+ const props = areaWrapperProps(region, transitions, layers, RemoteLayout ? sticky[region] : null)
103
+ return props ? <div {...props}>{children}</div> : children
96
104
  }
97
105
 
98
- const bodyElement = bodyBlocks ? wrap('body', <Blocks blocks={bodyBlocks} />) : null
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
  }
@@ -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 {{ header?: React.ReactNode, body?: React.ReactNode, footer?: React.ReactNode }} props
35
+ * @param {Object} props
36
+ * @param {React.ReactNode} [props.header]
37
+ * @param {React.ReactNode} [props.body]
38
+ * @param {React.ReactNode} [props.footer]
39
+ * @param {{ header?: Object, footer?: Object }} [props.zones] - a zone's 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
  }
@@ -30,7 +30,8 @@ import {
30
30
  hydrateDataStore,
31
31
  ensureThemeCss,
32
32
  } from './wire-foundation.js'
33
- import { resolveLayoutTransitions, resolveLayoutLayers, areaWrapperStyle } from './area-wrappers.js'
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
- const layers = resolveLayoutLayers(areaNames, layoutMeta?.layers)
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 style = areaWrapperStyle(name, transitions, layers)
334
- return style ? React.createElement('div', { style }, element) : element
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
- return React.createElement(DefaultLayout, { body: bodyElement, ...areaElements })
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
+ }