@djangocfg/layouts 2.1.514 → 2.1.516
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": "@djangocfg/layouts",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.516",
|
|
4
4
|
"description": "Simple, straightforward layout components for Next.js - import and use with props",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"layouts",
|
|
@@ -89,12 +89,12 @@
|
|
|
89
89
|
"check": "tsc --noEmit"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@djangocfg/analytics": "^2.1.
|
|
93
|
-
"@djangocfg/api": "^2.1.
|
|
94
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
95
|
-
"@djangocfg/devtools": "^2.1.
|
|
96
|
-
"@djangocfg/i18n": "^2.1.
|
|
97
|
-
"@djangocfg/ui-core": "^2.1.
|
|
92
|
+
"@djangocfg/analytics": "^2.1.516",
|
|
93
|
+
"@djangocfg/api": "^2.1.516",
|
|
94
|
+
"@djangocfg/centrifugo": "^2.1.516",
|
|
95
|
+
"@djangocfg/devtools": "^2.1.516",
|
|
96
|
+
"@djangocfg/i18n": "^2.1.516",
|
|
97
|
+
"@djangocfg/ui-core": "^2.1.516",
|
|
98
98
|
"@hookform/resolvers": "^5.2.2",
|
|
99
99
|
"consola": "^3.4.2",
|
|
100
100
|
"lucide-react": "^0.545.0",
|
|
@@ -124,14 +124,14 @@
|
|
|
124
124
|
"uuid": "^11.1.1"
|
|
125
125
|
},
|
|
126
126
|
"devDependencies": {
|
|
127
|
-
"@djangocfg/analytics": "^2.1.
|
|
128
|
-
"@djangocfg/api": "^2.1.
|
|
129
|
-
"@djangocfg/centrifugo": "^2.1.
|
|
130
|
-
"@djangocfg/devtools": "^2.1.
|
|
131
|
-
"@djangocfg/i18n": "^2.1.
|
|
132
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
133
|
-
"@djangocfg/ui-core": "^2.1.
|
|
134
|
-
"@djangocfg/ui-tools": "^2.1.
|
|
127
|
+
"@djangocfg/analytics": "^2.1.516",
|
|
128
|
+
"@djangocfg/api": "^2.1.516",
|
|
129
|
+
"@djangocfg/centrifugo": "^2.1.516",
|
|
130
|
+
"@djangocfg/devtools": "^2.1.516",
|
|
131
|
+
"@djangocfg/i18n": "^2.1.516",
|
|
132
|
+
"@djangocfg/typescript-config": "^2.1.516",
|
|
133
|
+
"@djangocfg/ui-core": "^2.1.516",
|
|
134
|
+
"@djangocfg/ui-tools": "^2.1.516",
|
|
135
135
|
"@types/node": "^25.9.5",
|
|
136
136
|
"@types/react": "19.2.15",
|
|
137
137
|
"@types/react-dom": "19.2.3",
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
|
|
5
|
+
import { useScroll } from '@djangocfg/ui-core/hooks';
|
|
6
|
+
|
|
5
7
|
export interface UseNavbarScrollOptions {
|
|
6
8
|
/** Slide navbar up on scroll-down, back on scroll-up. @default false */
|
|
7
9
|
hideNavOnScroll?: boolean;
|
|
@@ -18,44 +20,129 @@ export interface UseNavbarScrollReturn {
|
|
|
18
20
|
scrolled: boolean;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* How much accumulated movement in one direction it takes to flip the navbar.
|
|
25
|
+
*
|
|
26
|
+
* The whole point: react to *intent*, not to every pixel. A raw
|
|
27
|
+
* `direction === 'down' → hide` flickers on a trackpad's jittery micro-scrolls
|
|
28
|
+
* and on Safari's momentum tail, where the delta's sign wobbles frame to frame.
|
|
29
|
+
* We integrate movement instead and only commit once it clears this gate, then
|
|
30
|
+
* reset — so one deliberate flick hides, but noise around zero does nothing.
|
|
31
|
+
*/
|
|
32
|
+
const DIRECTION_COMMIT_PX = 8;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Navbar scroll behaviour, built on ui-core's shared `useScroll` store.
|
|
36
|
+
*
|
|
37
|
+
* We deliberately do NOT attach our own scroll listener: `useScroll` already
|
|
38
|
+
* runs a single rAF-throttled, `useSyncExternalStore`-backed listener per
|
|
39
|
+
* target for the whole page (its own JSDoc points at this hook as the intended
|
|
40
|
+
* consumer — "for cumulative direction, build on top of this"). Here we add the
|
|
41
|
+
* navbar-specific *policy* on top of that raw position:
|
|
42
|
+
*
|
|
43
|
+
* - a commit threshold so micro-scroll / momentum jitter can't flip the bar
|
|
44
|
+
* - overscroll clamping so Safari rubber-banding at the edges doesn't twitch
|
|
45
|
+
* - "always visible near the top" and "don't hide if there's nothing to
|
|
46
|
+
* reclaim"
|
|
47
|
+
* - `prefers-reduced-motion` opt-out of the hide (the slide, not the fade)
|
|
48
|
+
*
|
|
49
|
+
* All of that lives in refs and only touches React state when the derived
|
|
50
|
+
* boolean actually flips, so a page of scrolling costs at most a couple of
|
|
51
|
+
* renders.
|
|
52
|
+
*/
|
|
21
53
|
export function useNavbarScroll(options: UseNavbarScrollOptions = {}): UseNavbarScrollReturn {
|
|
22
54
|
const { hideNavOnScroll = false, transparent = false, transparentThreshold = 40 } = options;
|
|
23
55
|
|
|
56
|
+
const active = hideNavOnScroll || transparent;
|
|
57
|
+
// Subscribe to the shared store only when the feature is on. The `y` this
|
|
58
|
+
// returns is already rAF-coalesced; we run cheap arithmetic on each change.
|
|
59
|
+
const { y } = useScroll(active ? undefined : NEVER_TARGET);
|
|
60
|
+
|
|
24
61
|
const [hidden, setHidden] = useState(false);
|
|
25
62
|
const [scrolled, setScrolled] = useState(false);
|
|
26
|
-
|
|
63
|
+
|
|
64
|
+
// Cumulative-direction state — refs so integrating never forces a render.
|
|
65
|
+
const lastY = useRef(0);
|
|
66
|
+
const accum = useRef(0);
|
|
67
|
+
const hiddenRef = useRef(false);
|
|
68
|
+
const scrolledRef = useRef(false);
|
|
27
69
|
|
|
28
70
|
useEffect(() => {
|
|
29
|
-
if (!
|
|
71
|
+
if (!active) {
|
|
72
|
+
// Feature off → make sure we don't leave the bar stuck hidden/opaque.
|
|
73
|
+
hiddenRef.current = false;
|
|
74
|
+
scrolledRef.current = false;
|
|
75
|
+
accum.current = 0;
|
|
30
76
|
setHidden(false);
|
|
31
77
|
setScrolled(false);
|
|
32
78
|
return;
|
|
33
79
|
}
|
|
34
80
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
81
|
+
// Respect the user's motion preference: if they've asked for reduced
|
|
82
|
+
// motion, never yank the bar out of view — it can be disorienting. The
|
|
83
|
+
// transparency fade (opacity, not a translate) is still fine.
|
|
84
|
+
const prefersReducedMotion =
|
|
85
|
+
typeof window.matchMedia === 'function' &&
|
|
86
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
87
|
+
const hideEnabled = hideNavOnScroll && !prefersReducedMotion;
|
|
38
88
|
|
|
39
|
-
|
|
40
|
-
|
|
89
|
+
// iOS Safari rubber-banding reports negative scrollY at the top and values
|
|
90
|
+
// past maxScroll at the bottom while the page bounces back; feeding those
|
|
91
|
+
// into the integrator makes the bar twitch on every overscroll pull.
|
|
92
|
+
const doc = document.documentElement;
|
|
93
|
+
const maxScroll = Math.max(0, doc.scrollHeight - window.innerHeight);
|
|
94
|
+
const clampedY = Math.min(Math.max(0, y), maxScroll);
|
|
95
|
+
|
|
96
|
+
if (transparent) {
|
|
97
|
+
const next = clampedY >= transparentThreshold;
|
|
98
|
+
if (scrolledRef.current !== next) {
|
|
99
|
+
scrolledRef.current = next;
|
|
100
|
+
setScrolled(next);
|
|
41
101
|
}
|
|
102
|
+
}
|
|
42
103
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
if (hideEnabled) {
|
|
105
|
+
// Near the top there's nowhere to hide *to*, and the header is usually
|
|
106
|
+
// part of the hero — always show. Same when the page can't scroll a full
|
|
107
|
+
// viewport (nothing to reclaim by hiding).
|
|
108
|
+
if (clampedY <= transparentThreshold || maxScroll <= 0) {
|
|
109
|
+
accum.current = 0;
|
|
110
|
+
if (hiddenRef.current) {
|
|
111
|
+
hiddenRef.current = false;
|
|
48
112
|
setHidden(false);
|
|
49
113
|
}
|
|
50
|
-
}
|
|
114
|
+
} else {
|
|
115
|
+
const delta = clampedY - lastY.current;
|
|
116
|
+
// Reset the integrator whenever direction reverses, so a flick the
|
|
117
|
+
// other way takes full effect immediately instead of first paying off
|
|
118
|
+
// the debt built up going the previous way.
|
|
119
|
+
if ((delta > 0 && accum.current < 0) || (delta < 0 && accum.current > 0)) {
|
|
120
|
+
accum.current = 0;
|
|
121
|
+
}
|
|
122
|
+
accum.current += delta;
|
|
51
123
|
|
|
52
|
-
|
|
53
|
-
|
|
124
|
+
if (accum.current >= DIRECTION_COMMIT_PX && !hiddenRef.current) {
|
|
125
|
+
hiddenRef.current = true;
|
|
126
|
+
setHidden(true);
|
|
127
|
+
accum.current = 0;
|
|
128
|
+
} else if (accum.current <= -DIRECTION_COMMIT_PX && hiddenRef.current) {
|
|
129
|
+
hiddenRef.current = false;
|
|
130
|
+
setHidden(false);
|
|
131
|
+
accum.current = 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
54
135
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return () => window.removeEventListener('scroll', handleScroll);
|
|
58
|
-
}, [hideNavOnScroll, transparent, transparentThreshold]);
|
|
136
|
+
lastY.current = clampedY;
|
|
137
|
+
}, [y, active, hideNavOnScroll, transparent, transparentThreshold]);
|
|
59
138
|
|
|
60
139
|
return { hidden, scrolled };
|
|
61
140
|
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A stable ref target whose `.current` is always null, so `useScroll` resolves
|
|
144
|
+
* to "no target" and never subscribes when the feature is off. Module-level so
|
|
145
|
+
* its identity is stable across renders (a fresh object each render would make
|
|
146
|
+
* `useScroll`'s `useCallback` deps churn).
|
|
147
|
+
*/
|
|
148
|
+
const NEVER_TARGET = { current: null } as const;
|
|
@@ -3,9 +3,22 @@
|
|
|
3
3
|
import { type RefObject, useEffect } from 'react';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Measures navbar geometry into CSS custom properties on `:root` so the rest of
|
|
7
|
+
* the page can lay itself out against the real bar — without prop-drilling a
|
|
8
|
+
* pixel number through React or hardcoding a magic height.
|
|
9
|
+
*
|
|
10
|
+
* Writes:
|
|
11
|
+
* --public-navbar-height the bar's own rendered height (px)
|
|
12
|
+
* --public-navbar-mobile-drawer-top y where the mobile drawer should start
|
|
8
13
|
* --public-navbar-mobile-drawer-max-height
|
|
14
|
+
* --dvh 1% of the *stable* visual-viewport height
|
|
15
|
+
*
|
|
16
|
+
* Why `--dvh` and not just the CSS `dvh` unit: a length written in `dvh` with a
|
|
17
|
+
* CSS transition on it re-animates on every step of the mobile browser chrome
|
|
18
|
+
* collapsing/expanding — a jittery, stuttering result. This var is updated from
|
|
19
|
+
* `visualViewport` only on real resize (chrome settled), so heights built on
|
|
20
|
+
* `calc(var(--dvh) * 100)` stay smooth. Sections that don't animate can keep
|
|
21
|
+
* using the native `dvh` unit directly.
|
|
9
22
|
*
|
|
10
23
|
* @param navOuterRef - ref on the outermost navbar wrapper div
|
|
11
24
|
* @param deps - values that should re-trigger observer setup (position, variant, containerClassName)
|
|
@@ -15,31 +28,62 @@ export function useNavbarViewportVars(
|
|
|
15
28
|
deps: readonly unknown[],
|
|
16
29
|
): void {
|
|
17
30
|
useEffect(() => {
|
|
18
|
-
const
|
|
19
|
-
|
|
31
|
+
const root = document.documentElement;
|
|
32
|
+
const vv = window.visualViewport;
|
|
33
|
+
|
|
34
|
+
// Navbar height + the mobile-drawer anchoring. Depends on the navbar's own
|
|
35
|
+
// box, so it belongs on the ResizeObserver + scroll path (a hide-on-scroll
|
|
36
|
+
// bar's `rect.bottom` moves as it slides).
|
|
37
|
+
const updateNav = () => {
|
|
20
38
|
const navEl = navOuterRef.current;
|
|
21
39
|
if (!navEl) return;
|
|
22
40
|
|
|
23
41
|
const rect = navEl.getBoundingClientRect();
|
|
42
|
+
|
|
43
|
+
// The bar's intrinsic height (not affected by a hide translate — that's
|
|
44
|
+
// why we read `height`, not `bottom - top` off a possibly-translated
|
|
45
|
+
// box). offsetHeight is layout height, stable across the slide.
|
|
46
|
+
const navHeight = Math.round(navEl.offsetHeight);
|
|
47
|
+
root.style.setProperty('--public-navbar-height', `${navHeight}px`);
|
|
48
|
+
|
|
24
49
|
const top = Math.max(0, Math.round(rect.bottom + 8));
|
|
25
50
|
const maxHeight = Math.max(240, window.innerHeight - top - 12);
|
|
26
|
-
|
|
27
51
|
root.style.setProperty('--public-navbar-mobile-drawer-top', `${top}px`);
|
|
28
52
|
root.style.setProperty('--public-navbar-mobile-drawer-max-height', `${maxHeight}px`);
|
|
29
53
|
};
|
|
30
54
|
|
|
31
|
-
|
|
55
|
+
// Stable viewport-height unit. `visualViewport.height` excludes the mobile
|
|
56
|
+
// browser chrome and (with `interactive-widget=resizes-content`) the
|
|
57
|
+
// on-screen keyboard, which is exactly what we want a full-height section
|
|
58
|
+
// to fit into. Updated only on resize so animated heights don't stutter.
|
|
59
|
+
const updateDvh = () => {
|
|
60
|
+
const h = vv?.height ?? window.innerHeight;
|
|
61
|
+
root.style.setProperty('--dvh', `${h / 100}px`);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
updateNav();
|
|
65
|
+
updateDvh();
|
|
66
|
+
|
|
32
67
|
const navEl = navOuterRef.current;
|
|
33
|
-
const observer = navEl ? new ResizeObserver(
|
|
68
|
+
const observer = navEl ? new ResizeObserver(updateNav) : null;
|
|
34
69
|
if (navEl && observer) observer.observe(navEl);
|
|
35
|
-
|
|
36
|
-
|
|
70
|
+
|
|
71
|
+
// Nav geometry tracks scroll (the bar slides) + window resize.
|
|
72
|
+
window.addEventListener('resize', updateNav);
|
|
73
|
+
window.addEventListener('scroll', updateNav, { passive: true });
|
|
74
|
+
|
|
75
|
+
// `--dvh` intentionally does NOT listen to scroll: reacting to the mobile
|
|
76
|
+
// chrome mid-collapse is the jitter we're avoiding. Only settled resizes.
|
|
77
|
+
window.addEventListener('resize', updateDvh);
|
|
78
|
+
vv?.addEventListener('resize', updateDvh);
|
|
37
79
|
|
|
38
80
|
return () => {
|
|
39
81
|
if (navEl && observer) observer.unobserve(navEl);
|
|
40
82
|
observer?.disconnect();
|
|
41
|
-
window.removeEventListener('resize',
|
|
42
|
-
window.removeEventListener('scroll',
|
|
83
|
+
window.removeEventListener('resize', updateNav);
|
|
84
|
+
window.removeEventListener('scroll', updateNav);
|
|
85
|
+
window.removeEventListener('resize', updateDvh);
|
|
86
|
+
vv?.removeEventListener('resize', updateDvh);
|
|
43
87
|
};
|
|
44
88
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
45
89
|
}, deps);
|
|
@@ -291,7 +291,14 @@ function NavbarShellRaw(props: NavbarShellProps) {
|
|
|
291
291
|
// Stay above the mobile drawer backdrop (z-[998]) and drawer (z-[1000])
|
|
292
292
|
// so the brand + close button remain visible and clickable.
|
|
293
293
|
mobileMenuOpen ? 'z-[1001]' : 'z-50',
|
|
294
|
-
|
|
294
|
+
// Slide timing tuned for a "settled" feel, not a slow drift: a short 220ms
|
|
295
|
+
// with a gentle ease-out (decelerate into place) reads as the bar snapping
|
|
296
|
+
// to attention on scroll-up, while `motion-reduce:transition-none` honours
|
|
297
|
+
// the OS setting (the hook also refuses to set `hidden` under reduced
|
|
298
|
+
// motion, so the bar simply stays put). `will-change` promotes it to its
|
|
299
|
+
// own layer so the transform never repaints the page behind it.
|
|
300
|
+
hideNavOnScroll &&
|
|
301
|
+
'transition-transform duration-[220ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none',
|
|
295
302
|
hideNavOnScroll && hidden && !mobileMenuOpen && '-translate-y-full',
|
|
296
303
|
);
|
|
297
304
|
|