@djangocfg/ui-core 2.1.559 → 2.1.561

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/ui-core",
3
- "version": "2.1.559",
3
+ "version": "2.1.561",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -130,7 +130,7 @@
130
130
  "check:contrast": "node scripts/check-preset-contrast.mjs"
131
131
  },
132
132
  "peerDependencies": {
133
- "@djangocfg/i18n": "^2.1.559",
133
+ "@djangocfg/i18n": "^2.1.561",
134
134
  "consola": "^3.4.2",
135
135
  "lucide-react": "^0.545.0",
136
136
  "moment": "^2.30.1",
@@ -206,9 +206,9 @@
206
206
  "vaul": "1.1.2"
207
207
  },
208
208
  "devDependencies": {
209
- "@djangocfg/eslint-config": "^2.1.559",
210
- "@djangocfg/i18n": "^2.1.559",
211
- "@djangocfg/typescript-config": "^2.1.559",
209
+ "@djangocfg/eslint-config": "^2.1.561",
210
+ "@djangocfg/i18n": "^2.1.561",
211
+ "@djangocfg/typescript-config": "^2.1.561",
212
212
  "@storybook/react-vite": "^10.5.0",
213
213
  "@types/node": "^24.13.3",
214
214
  "@types/react": "19.2.15",
@@ -66,6 +66,8 @@ export {
66
66
  parseAsJson,
67
67
  // useRouter (composite facade)
68
68
  useRouter,
69
+ // useScrollToTop (covers the routes Next's own scroll handler skips)
70
+ useScrollToTop,
69
71
  } from './router';
70
72
  export type {
71
73
  RouterAdapter,
@@ -93,4 +95,5 @@ export type {
93
95
  QueryParser,
94
96
  QueryParserBuilder,
95
97
  UseRouterReturn,
98
+ UseScrollToTopOptions,
96
99
  } from './router';
@@ -22,12 +22,17 @@ export type {
22
22
  export {
23
23
  useLocation,
24
24
  useLocationProperty,
25
+ patchHistoryOnce,
25
26
  NAVIGATE_EVENT,
26
27
  PUSH_STATE_EVENT,
27
28
  REPLACE_STATE_EVENT,
28
29
  } from './useLocation';
29
30
  export type { LocationSnapshot } from './useLocation';
30
31
 
32
+ // useScrollToTop (fills the gaps in Next's own scroll restoration)
33
+ export { useScrollToTop } from './useScrollToTop';
34
+ export type { UseScrollToTopOptions } from './useScrollToTop';
35
+
31
36
  // useNavigate
32
37
  export { useNavigate } from './useNavigate';
33
38
  export type { NavigateOptions, UseNavigateReturn } from './useNavigate';
@@ -54,7 +54,16 @@ const SSR_SNAPSHOT: LocationSnapshot = Object.freeze({
54
54
  // (us or them) has installed a patch, the marker stays until full reload.
55
55
  const PATCH_KEY = Symbol.for('djc.router.historyPatched');
56
56
 
57
- function patchHistoryOnce(): void {
57
+ /**
58
+ * Installs the `pushState`/`replaceState` patch that makes SPA navigations
59
+ * observable, exactly once per document.
60
+ *
61
+ * Exported because `useLocation` is no longer the only subscriber: a hook may
62
+ * listen for `NAVIGATE_EVENT` without reading the location through
63
+ * `useSyncExternalStore` (see `useScrollToTop`), and without this call it
64
+ * would subscribe to an event nothing dispatches — silently doing nothing.
65
+ */
66
+ export function patchHistoryOnce(): void {
58
67
  if (typeof window === 'undefined') return;
59
68
  const w = window as Window & { [PATCH_KEY]?: true };
60
69
  if (w[PATCH_KEY]) return;
@@ -0,0 +1,120 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useScrollToTop — land at the top of the page after a forward navigation.
5
+ *
6
+ * WHY THIS EXISTS AT ALL — Next.js App Router already scrolls, sometimes
7
+ *
8
+ * `layout-router.tsx` walks the navigated segment looking for an element to
9
+ * scroll to, and **gives up silently** in two cases that are ordinary in our
10
+ * apps:
11
+ *
12
+ * 1. It takes the segment's first DOM node and skips it when the node is
13
+ * `position: sticky|fixed` OR when `getBoundingClientRect()` is all
14
+ * zeroes — then moves to `nextElementSibling`, and `return`s outright
15
+ * when there is none. A page whose root is `display: contents` (a width
16
+ * wrapper), or whose first child renders `null`, `<script type=
17
+ * "application/ld+json">`, or any other zero-box node, therefore gets no
18
+ * scroll at all.
19
+ * 2. Having found a node, it exits early when that node's top edge is
20
+ * ALREADY within the viewport. Land mid-page on a long route whose next
21
+ * route starts with a tall block, and the check passes while the visitor
22
+ * is looking at the middle of the new page.
23
+ *
24
+ * Both are position-dependent, which is why the bug reads as intermittent:
25
+ * clicking from the top of a page (what an automated run usually does) hits
26
+ * neither branch, so the symptom only shows up when a person scrolls first.
27
+ *
28
+ * WHAT IT DELIBERATELY DOES NOT DO
29
+ *
30
+ * - **Back/forward are left alone.** The browser restores the previous scroll
31
+ * position on a `popstate`, and overriding that is a worse bug than the one
32
+ * being fixed. Only `pushState`/`replaceState` navigations scroll.
33
+ * - **`#hash` links are left alone.** A URL naming an anchor has already said
34
+ * where it wants to land.
35
+ * - **Query-only changes are left alone.** Filters, pagination and tab state
36
+ * write the query string; yanking the visitor to the top as they tick a
37
+ * checkbox is the complaint this hook would otherwise cause. Only a
38
+ * `pathname` change scrolls.
39
+ *
40
+ * It is idempotent with Next's own handler: when Next succeeds we are already
41
+ * at the top and the write is a no-op.
42
+ *
43
+ * @example
44
+ * // Once, at the app root — `BaseApp` already does this for Next.js hosts.
45
+ * useScrollToTop();
46
+ */
47
+
48
+ import { useEffect, useRef } from 'react';
49
+
50
+ import { NAVIGATE_EVENT, patchHistoryOnce } from './useLocation';
51
+
52
+ export interface UseScrollToTopOptions {
53
+ /** Turn the behaviour off without unmounting the host. Default: true. */
54
+ enabled?: boolean;
55
+ /**
56
+ * Scroll animation. Default `'auto'` (instant), matching a hard navigation.
57
+ * `'smooth'` animates a full page height on every route change, which reads
58
+ * as lag rather than polish.
59
+ */
60
+ behavior?: ScrollBehavior;
61
+ }
62
+
63
+ /**
64
+ * Scrolls the window to the top after each forward navigation to a new path.
65
+ * Mount ONCE per app — a second mount just writes `scrollTo(0, 0)` twice.
66
+ */
67
+ export function useScrollToTop({
68
+ enabled = true,
69
+ behavior = 'auto',
70
+ }: UseScrollToTopOptions = {}): void {
71
+ /**
72
+ * The path we last saw. Seeded on mount rather than from `''`, so the first
73
+ * paint does not count as a navigation — that would fight the browser's own
74
+ * restoration on a reload.
75
+ */
76
+ const lastPathRef = useRef<string | null>(null);
77
+
78
+ useEffect(() => {
79
+ if (!enabled || typeof window === 'undefined') return;
80
+
81
+ // `pushState`/`replaceState` do not emit an event on their own. Without
82
+ // this the listener below is subscribed to something nothing dispatches.
83
+ patchHistoryOnce();
84
+ lastPathRef.current = window.location.pathname;
85
+
86
+ /*
87
+ * NAVIGATE_EVENT only, and that is what keeps back/forward working: the
88
+ * patch in `useLocation` dispatches it from `pushState`/`replaceState`
89
+ * exclusively, so a `popstate` never reaches this handler and the
90
+ * browser's restored position is never overwritten. Subscribing to
91
+ * `popstate` here — or to `hashchange` — would undo exactly that.
92
+ */
93
+ const onNavigate = () => {
94
+ const { pathname, hash } = window.location;
95
+ const changedPath = pathname !== lastPathRef.current;
96
+ lastPathRef.current = pathname;
97
+
98
+ // Query-only or state-only writes: the visitor stays where they are.
99
+ if (!changedPath) return;
100
+ // The URL names an anchor; it has already said where to land.
101
+ if (hash) return;
102
+
103
+ /*
104
+ * Deferred by one frame, and this is load-bearing. Our navigate event
105
+ * fires from a microtask right after `pushState`, while the destination
106
+ * is still the OLD document — React has not committed the new segment
107
+ * yet. Scrolling then is correct but immediately undone: Next's own
108
+ * handler runs on the commit that follows and may scroll back down to
109
+ * an element it found mid-page. Running after paint means we write
110
+ * last, over a document whose real height is known.
111
+ */
112
+ requestAnimationFrame(() => {
113
+ window.scrollTo({ top: 0, left: 0, behavior });
114
+ });
115
+ };
116
+
117
+ window.addEventListener(NAVIGATE_EVENT, onNavigate);
118
+ return () => window.removeEventListener(NAVIGATE_EVENT, onNavigate);
119
+ }, [enabled, behavior]);
120
+ }