@djangocfg/ui-core 2.1.560 → 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.
|
|
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.
|
|
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.
|
|
210
|
-
"@djangocfg/i18n": "^2.1.
|
|
211
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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",
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|