@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.514",
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.514",
93
- "@djangocfg/api": "^2.1.514",
94
- "@djangocfg/centrifugo": "^2.1.514",
95
- "@djangocfg/devtools": "^2.1.514",
96
- "@djangocfg/i18n": "^2.1.514",
97
- "@djangocfg/ui-core": "^2.1.514",
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.514",
128
- "@djangocfg/api": "^2.1.514",
129
- "@djangocfg/centrifugo": "^2.1.514",
130
- "@djangocfg/devtools": "^2.1.514",
131
- "@djangocfg/i18n": "^2.1.514",
132
- "@djangocfg/typescript-config": "^2.1.514",
133
- "@djangocfg/ui-core": "^2.1.514",
134
- "@djangocfg/ui-tools": "^2.1.514",
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
- const lastScrollY = useRef(0);
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 (!hideNavOnScroll && !transparent) {
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
- const handleScroll = () => {
36
- const currentY = window.scrollY;
37
- const delta = currentY - lastScrollY.current;
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
- if (transparent) {
40
- setScrolled(currentY >= transparentThreshold);
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
- if (hideNavOnScroll) {
44
- if (currentY > transparentThreshold) {
45
- if (delta > 0) setHidden(true);
46
- else if (delta < 0) setHidden(false);
47
- } else {
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
- lastScrollY.current = currentY;
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
- handleScroll();
56
- window.addEventListener('scroll', handleScroll, { passive: true });
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
- * Tracks the bottom edge of the navbar outer element and writes CSS vars:
7
- * --public-navbar-mobile-drawer-top
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 update = () => {
19
- const root = document.documentElement;
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
- update();
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(update) : null;
68
+ const observer = navEl ? new ResizeObserver(updateNav) : null;
34
69
  if (navEl && observer) observer.observe(navEl);
35
- window.addEventListener('resize', update);
36
- window.addEventListener('scroll', update, { passive: true });
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', update);
42
- window.removeEventListener('scroll', update);
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
- hideNavOnScroll && 'transition-transform duration-300 ease-in-out will-change-transform',
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