@uniweb/kit 0.12.3 → 0.13.1
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 +2 -2
- package/src/hooks/index.js +11 -2
- package/src/hooks/useReadingDepth.js +115 -0
- package/src/index.js +3 -3
- package/src/hooks/useScrollDepth.js +0 -97
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Standard component library for Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"shiki": "^3.0.0",
|
|
45
45
|
"tailwind-merge": "^3.6.0",
|
|
46
46
|
"temml": "^0.13.2",
|
|
47
|
-
"@uniweb/core": "^0.10.1",
|
|
48
47
|
"@uniweb/semantic-parser": "^1.2.3",
|
|
48
|
+
"@uniweb/core": "^0.11.0",
|
|
49
49
|
"@uniweb/scene": "^0.1.3"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
package/src/hooks/index.js
CHANGED
|
@@ -41,7 +41,16 @@ export { useFormValues, valueAt } from './useFormValues.js'
|
|
|
41
41
|
|
|
42
42
|
// Site tracking — one event stream.
|
|
43
43
|
// `block.track(name, data)` is the common case and needs no hook; these cover
|
|
44
|
-
// events with no block in hand, the consent gate
|
|
44
|
+
// events with no block in hand, and the consent gate a banner sets.
|
|
45
|
+
//
|
|
46
|
+
// ⛔ `useScrollDepth` was here until 2026-08-19 and is RETIRED, not moved. It
|
|
47
|
+
// measured the DOCUMENT, so two long sections on one page reported the same
|
|
48
|
+
// number and any section added above silently rebased the series — and it had
|
|
49
|
+
// zero callers, so it never emitted anywhere. On pages composed of sections,
|
|
50
|
+
// `section_view` is the same signal with names attached. The case it looked
|
|
51
|
+
// like it served is `useReadingDepth` below, which measures one element.
|
|
45
52
|
export { useTracker } from './useTracker.js'
|
|
46
53
|
export { useTrackingConsent } from './useTrackingConsent.js'
|
|
47
|
-
|
|
54
|
+
// Reading depth through ONE long section — the foundation is the only party
|
|
55
|
+
// that knows a section is long-form, which is why it is opt-in and lives here.
|
|
56
|
+
export { useReadingDepth } from './useReadingDepth.js'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useReadingDepth — how far through ONE long section a visitor actually read.
|
|
3
|
+
*
|
|
4
|
+
* ```jsx
|
|
5
|
+
* export default function Article({ content, block }) {
|
|
6
|
+
* const ref = useRef(null)
|
|
7
|
+
* useReadingDepth({ ref, block })
|
|
8
|
+
* return <article ref={ref}>{…}</article>
|
|
9
|
+
* }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* ⭐ **Why this is kit's and not the runtime's.** The runtime emits what the
|
|
13
|
+
* SITE OWNER buys — it cannot know that one section is five thousand words of
|
|
14
|
+
* prose and another is a row of logos. **Only the foundation knows a section is
|
|
15
|
+
* long-form reading**, which is exactly the criterion for living here: declared
|
|
16
|
+
* by the component that has the knowledge, and tree-shaken to nothing for every
|
|
17
|
+
* foundation that never asks.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ **This is NOT page scroll depth, and the difference is the whole point.**
|
|
20
|
+
* A page-scoped version measures the document, so on a page holding two long
|
|
21
|
+
* articles both would report the same number, and adding any section above
|
|
22
|
+
* silently rebases the series. This measures **the element's own box**, so it
|
|
23
|
+
* keeps meaning the same thing when the page around it changes.
|
|
24
|
+
*
|
|
25
|
+
* ⚖️ **It complements `section_view` rather than duplicating it.** That event
|
|
26
|
+
* fires once, when the section becomes half visible — it says the reader
|
|
27
|
+
* *arrived*. This says how far they got, which for a long article is the only
|
|
28
|
+
* question worth asking.
|
|
29
|
+
*
|
|
30
|
+
* ⛔ **No guard is needed at the call site.** With no tracking destination the
|
|
31
|
+
* report is a silent no-op — and the listener is never attached either, so an
|
|
32
|
+
* unconfigured site pays nothing for the call.
|
|
33
|
+
*
|
|
34
|
+
* @module @uniweb/kit/hooks/useReadingDepth
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { useEffect } from 'react'
|
|
38
|
+
import { getUniweb } from '@uniweb/core'
|
|
39
|
+
|
|
40
|
+
const MILESTONES = [25, 50, 75, 100]
|
|
41
|
+
const THROTTLE_MS = 200
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How far the viewport has advanced through this element, 0-100.
|
|
45
|
+
*
|
|
46
|
+
* Measured against the element's own box: 0 when its top first reaches the
|
|
47
|
+
* bottom of the viewport, 100 once its bottom has. An element shorter than the
|
|
48
|
+
* viewport reports 100 as soon as it is fully on screen, which is correct —
|
|
49
|
+
* there was nothing further to read.
|
|
50
|
+
*/
|
|
51
|
+
function depthOf(el) {
|
|
52
|
+
const rect = el.getBoundingClientRect()
|
|
53
|
+
if (rect.height <= 0) return 0
|
|
54
|
+
const seen = window.innerHeight - rect.top
|
|
55
|
+
return Math.max(0, Math.min(100, Math.round((seen / rect.height) * 100)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {Object} options
|
|
60
|
+
* @param {{current: HTMLElement|null}} options.ref - the element to measure
|
|
61
|
+
* @param {Object} [options.block] - report through the block, so the section
|
|
62
|
+
* type, its instance id and the page path ride along for free
|
|
63
|
+
* @param {string} [options.event='read_depth']
|
|
64
|
+
*/
|
|
65
|
+
export function useReadingDepth({ ref, block, event = 'read_depth' } = {}) {
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const el = ref?.current
|
|
68
|
+
const tracking = getUniweb()?.tracking
|
|
69
|
+
// ⛔ `isEnabled()`, NOT `arms()`. A foundation's own events are never gated
|
|
70
|
+
// by the site's `tracking.emit` or a host's list — the registry is open, and
|
|
71
|
+
// a client-side allowlist over them would export one collector's policy to
|
|
72
|
+
// every host. `emit` decides what the RUNTIME arms; this is not that.
|
|
73
|
+
if (!el || !tracking?.isEnabled?.()) return
|
|
74
|
+
|
|
75
|
+
const reported = new Set()
|
|
76
|
+
let lastCheck = 0
|
|
77
|
+
|
|
78
|
+
const report = (depth) => {
|
|
79
|
+
// Prefer the block: it attaches `section`, `section_id` and `path`, so an
|
|
80
|
+
// article's depth is attributable to the section it was read in.
|
|
81
|
+
if (block?.track) block.track(event, { depth })
|
|
82
|
+
else tracking.track(event, { depth })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const onScroll = () => {
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
if (now - lastCheck < THROTTLE_MS) return
|
|
88
|
+
lastCheck = now
|
|
89
|
+
|
|
90
|
+
const depth = depthOf(el)
|
|
91
|
+
for (const milestone of MILESTONES) {
|
|
92
|
+
if (depth >= milestone && !reported.has(milestone)) {
|
|
93
|
+
reported.add(milestone)
|
|
94
|
+
report(milestone)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onScroll() // an element already fully read at mount reports immediately
|
|
100
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
101
|
+
// Resize changes the element's height and the viewport at once, so a
|
|
102
|
+
// milestone can be crossed without any scrolling at all.
|
|
103
|
+
window.addEventListener('resize', onScroll, { passive: true })
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
window.removeEventListener('scroll', onScroll)
|
|
107
|
+
window.removeEventListener('resize', onScroll)
|
|
108
|
+
}
|
|
109
|
+
// The reported set is a local of this effect, so a re-run IS the reset —
|
|
110
|
+
// no ref to remember to clear, which is how the retired page-level hook
|
|
111
|
+
// shipped a bug where every page after the first reported nothing.
|
|
112
|
+
}, [ref, block, event])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default useReadingDepth
|
package/src/index.js
CHANGED
|
@@ -95,11 +95,11 @@ export {
|
|
|
95
95
|
useFormValues,
|
|
96
96
|
valueAt,
|
|
97
97
|
// Site tracking. `block.track(name, data)` is the common case and needs no
|
|
98
|
-
// hook — these are for events with no block in hand
|
|
99
|
-
// banner sets
|
|
98
|
+
// hook — these are for events with no block in hand and the consent gate a
|
|
99
|
+
// banner sets. Scroll depth is the runtime's now; see hooks/index.js.
|
|
100
100
|
useTracker,
|
|
101
101
|
useTrackingConsent,
|
|
102
|
-
|
|
102
|
+
useReadingDepth
|
|
103
103
|
} from './hooks/index.js'
|
|
104
104
|
|
|
105
105
|
// ============================================================================
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useScrollDepth — report how far down the page a visitor got.
|
|
3
|
-
*
|
|
4
|
-
* ```jsx
|
|
5
|
-
* useScrollDepth() // 25 / 50 / 75 / 100, once each per page
|
|
6
|
-
* ```
|
|
7
|
-
*
|
|
8
|
-
* ⭐ **Opt-in, and in kit rather than the runtime, on purpose.** The runtime
|
|
9
|
-
* auto-emits only what requires runtime privilege — a page view needs the
|
|
10
|
-
* router, so nothing else can emit it. Scroll depth needs nothing but the
|
|
11
|
-
* window, so it is a foundation's choice and lives where tree-shaking can drop
|
|
12
|
-
* it for the foundations that never call it.
|
|
13
|
-
*
|
|
14
|
-
* *(It previously lived in `@uniweb/runtime` as an unexported, unreachable file
|
|
15
|
-
* that reported to a metric baked into the transport. The milestones are now an
|
|
16
|
-
* ordinary event, which is both smaller and more general.)*
|
|
17
|
-
*
|
|
18
|
-
* ⛔ **No guard is needed.** With no tracking destination the report is a
|
|
19
|
-
* silent no-op — but the scroll listener is skipped too, so an unconfigured
|
|
20
|
-
* site pays nothing for the call.
|
|
21
|
-
*
|
|
22
|
-
* @module @uniweb/kit/hooks/useScrollDepth
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import { useEffect, useRef } from 'react'
|
|
26
|
-
import { getUniweb } from '@uniweb/core'
|
|
27
|
-
import { useRouting } from './useRouting.js'
|
|
28
|
-
|
|
29
|
-
const MILESTONES = [25, 50, 75, 100]
|
|
30
|
-
|
|
31
|
-
/** @returns {number} 0-100; 100 when the page fits in the viewport */
|
|
32
|
-
function getScrollDepth() {
|
|
33
|
-
const scrollTop = window.scrollY
|
|
34
|
-
const docHeight = document.documentElement.scrollHeight - window.innerHeight
|
|
35
|
-
if (docHeight <= 0) return 100
|
|
36
|
-
return Math.min(100, Math.round((scrollTop / docHeight) * 100))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* @param {Object} [options]
|
|
41
|
-
* @param {boolean} [options.enabled=true]
|
|
42
|
-
* @param {number} [options.throttleMs=200]
|
|
43
|
-
* @param {string} [options.event='scroll_depth'] - override the event name
|
|
44
|
-
*/
|
|
45
|
-
export function useScrollDepth(options = {}) {
|
|
46
|
-
const { enabled = true, throttleMs = 200, event = 'scroll_depth' } = options
|
|
47
|
-
|
|
48
|
-
const lastCheck = useRef(0)
|
|
49
|
-
const reported = useRef(new Set())
|
|
50
|
-
|
|
51
|
-
// ⛔ **Keyed on the path, or "once each per page" is not true.** The milestone
|
|
52
|
-
// set lives in a ref, so without this the effect runs once at mount and the
|
|
53
|
-
// set is never cleared again — a second page reports nothing at all. That bites
|
|
54
|
-
// exactly where this hook is most naturally called: once, in a layout
|
|
55
|
-
// component, which persists across SPA navigation (`PageRenderer` does not
|
|
56
|
-
// remount — the router declares a single catch-all route). Calling it in every
|
|
57
|
-
// section would have hidden the bug and multiplied the events instead.
|
|
58
|
-
//
|
|
59
|
-
// ⭐ `pathname` rather than a route object, deliberately: it is the same
|
|
60
|
-
// boundary `usePageView` emits on, so a `scroll_depth` always pairs with the
|
|
61
|
-
// `page_view` it belongs to. `useRouting` returns a default location when there
|
|
62
|
-
// is no Router, so this stays SSG-safe.
|
|
63
|
-
const { useLocation } = useRouting()
|
|
64
|
-
const { pathname } = useLocation()
|
|
65
|
-
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
const tracking = getUniweb()?.tracking
|
|
68
|
-
// Skip the listener entirely when there is nowhere to report — the calls
|
|
69
|
-
// would be no-ops, but the scroll handler would still run on every frame.
|
|
70
|
-
if (!enabled || !tracking?.isEnabled?.()) return
|
|
71
|
-
|
|
72
|
-
reported.current.clear()
|
|
73
|
-
// Reset the throttle too: a navigation within the throttle window would
|
|
74
|
-
// otherwise make the immediate check below return early and miss the fold.
|
|
75
|
-
lastCheck.current = 0
|
|
76
|
-
|
|
77
|
-
const handleScroll = () => {
|
|
78
|
-
const now = Date.now()
|
|
79
|
-
if (now - lastCheck.current < throttleMs) return
|
|
80
|
-
lastCheck.current = now
|
|
81
|
-
|
|
82
|
-
const depth = getScrollDepth()
|
|
83
|
-
for (const milestone of MILESTONES) {
|
|
84
|
-
if (depth >= milestone && !reported.current.has(milestone)) {
|
|
85
|
-
reported.current.add(milestone)
|
|
86
|
-
tracking.track(event, { depth: milestone })
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
handleScroll() // a page that fits the viewport is already at 100
|
|
92
|
-
window.addEventListener('scroll', handleScroll, { passive: true })
|
|
93
|
-
return () => window.removeEventListener('scroll', handleScroll)
|
|
94
|
-
}, [enabled, throttleMs, event, pathname])
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export default useScrollDepth
|