@djangocfg/ui-core 2.1.454 → 2.1.455

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.454",
3
+ "version": "2.1.455",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -119,7 +119,7 @@
119
119
  "check": "tsc --noEmit"
120
120
  },
121
121
  "peerDependencies": {
122
- "@djangocfg/i18n": "^2.1.454",
122
+ "@djangocfg/i18n": "^2.1.455",
123
123
  "consola": "^3.4.2",
124
124
  "lucide-react": "^0.545.0",
125
125
  "moment": "^2.30.1",
@@ -197,8 +197,8 @@
197
197
  "@chenglou/pretext": "*"
198
198
  },
199
199
  "devDependencies": {
200
- "@djangocfg/i18n": "^2.1.454",
201
- "@djangocfg/typescript-config": "^2.1.454",
200
+ "@djangocfg/i18n": "^2.1.455",
201
+ "@djangocfg/typescript-config": "^2.1.455",
202
202
  "@types/node": "^25.2.3",
203
203
  "@types/react": "^19.2.15",
204
204
  "@types/react-dom": "^19.2.3",
@@ -53,6 +53,8 @@ export {
53
53
  useIsActive,
54
54
  // useQueryState (typed single-key URL state)
55
55
  useQueryState,
56
+ // useHashState (useState-style URL fragment binding; adapter-bypassing)
57
+ useHashState,
56
58
  // Parsers
57
59
  parseAsString,
58
60
  parseAsInteger,
@@ -86,6 +88,8 @@ export type {
86
88
  UseIsActiveOptions,
87
89
  UseQueryStateOptions,
88
90
  QueryStateUpdater,
91
+ UseHashStateOptions,
92
+ HashStateUpdater,
89
93
  QueryParser,
90
94
  QueryParserBuilder,
91
95
  UseRouterReturn,
@@ -14,6 +14,7 @@ Framework-agnostic navigation primitives for `@djangocfg/ui-core`. Built on plai
14
14
  | `useSmartLink` | Click + keyboard handlers that turn any element into a proper link (cmd-click, middle-click, Enter, Space). |
15
15
  | `useIsActive` | `boolean` for "current pathname matches this href" — for nav-item highlighting. |
16
16
  | `useQueryState` | Typed `useState`-style hook bound to ONE URL key (with parsers + `clearOnDefault`). |
17
+ | `useHashState` | `useState`-style hook bound to the URL fragment (`#…`). Writes bypass the adapter — see [`useHashState`](#usehashstate-url-fragment) below. |
17
18
  | `useLocationProperty` | Subscribe to ONE derived field of `window.location` (avoids re-renders on unrelated fields). |
18
19
  | `useRouter` | Convenience facade composing the above. |
19
20
  | `RouterAdapterProvider` | Swap the navigation backend (e.g. Next.js's router). |
@@ -122,6 +123,29 @@ setPage(null); // clears the key from the URL
122
123
 
123
124
  Parsers: `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsIsoDate`, `parseAsStringEnum([...])`, `parseAsArrayOf(item)`, `parseAsJson<T>()`. Each has `.withDefault(value)`. Build your own by satisfying the `QueryParser<T>` interface — it's three functions (`parse`, `serialize`, `eq`).
124
125
 
126
+ ## `useHashState` (URL fragment)
127
+
128
+ `useState`-style binding to `window.location.hash`. The value is the fragment **without** the leading `#` (`''` when absent); setting `''`/`null` strips it.
129
+
130
+ ```tsx
131
+ import { useHashState } from '@djangocfg/ui-core/hooks';
132
+
133
+ const [hash, setHash] = useHashState();
134
+ setHash('settings/account'); // → …/private#settings/account (pushState)
135
+ setHash('overview', { replace: true }); // no new history entry
136
+ setHash((h) => h || 'top'); // functional updater
137
+ setHash(null); // strip the fragment → …/private
138
+ ```
139
+
140
+ **Why it bypasses the adapter (and `useQueryState` does not).** The fragment is the one part of the URL that must NOT go through `navigate()` / `next.push()`:
141
+
142
+ - The hash is invisible to the server, so a `#hash` change needs no RSC refetch / loader / prefetch — routing it through the framework router is wasteful.
143
+ - Worse, it's **wrong** under Next.js App Router: `router.push('/path#b')` where `/path` equals the current pathname resolves the target *relative to the current URL including its existing hash*, so pushing `…#b` while the URL already reads `…#a` yields a doubled `…#a#b` (compounded by `next-intl` locale prefixing).
144
+
145
+ So `useHashState` writes via `history.pushState` / `replaceState` directly, against the **absolute** URL. Reads stay reactive through the same patched-history observation as every other router hook, so the round-trip works in Next / Wails / Vite / plain React alike.
146
+
147
+ Query state is the opposite: `?key=value` IS server-visible, so `useQueryParams` / `useQueryState` deliberately go **through** the adapter — a query change must drive Next's router to refetch server components and re-run loaders. Don't "fix" them to use the History API.
148
+
125
149
  ## How `useLocation` knows about `pushState`
126
150
 
127
151
  Browsers don't fire `popstate` for programmatic `pushState` / `replaceState`. We monkey-patch both methods once (idempotent, module-level guard) on first mount and dispatch a custom `'djc:navigate'` event after each call. Anyone calling history APIs anywhere in the page will trigger an update — including the consumer's own router, third-party scripts, and our default adapter.
@@ -72,6 +72,14 @@ export type {
72
72
  QueryStateUpdater,
73
73
  } from './useQueryState';
74
74
 
75
+ // useHashState (useState-style binding to the URL fragment; bypasses the
76
+ // adapter so `#hash` writes are absolute — avoids Next.js hash doubling)
77
+ export { useHashState } from './useHashState';
78
+ export type {
79
+ UseHashStateOptions,
80
+ HashStateUpdater,
81
+ } from './useHashState';
82
+
75
83
  // Parsers (typed marshalling between URL strings and JS values)
76
84
  export {
77
85
  parseAsString,
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useHashState — `useState`-style hook bound to `window.location.hash`.
5
+ *
6
+ * WHY A DEDICATED HOOK (and why it bypasses the router adapter):
7
+ * The URL fragment (`#…`) is the one part of the URL that must NOT go through
8
+ * the router adapter's `navigate()` / `next.push()`. Under Next.js App Router
9
+ * — especially with `next-intl` locale prefixing — `router.push('/path#hash')`
10
+ * where `/path` equals the current pathname resolves the target *relative to
11
+ * the current URL, including its existing hash*. Pushing `…#b` while the URL
12
+ * already reads `…#a` yields a doubled `…#a#b`. A `#hash` change also never
13
+ * needs an RSC refetch / loader / prefetch, so routing it through the
14
+ * framework router is both wrong (the doubling) and wasteful.
15
+ *
16
+ * This hook therefore writes the hash via `history.pushState` / `replaceState`
17
+ * directly, always against the *absolute* URL (`pathname + search + #hash`).
18
+ * Reads stay reactive through `useLocationProperty` — which observes the same
19
+ * patched history methods (see `useLocation.ts`), so the round-trip works in
20
+ * every host (Next.js, Wails, Vite, plain React) with no adapter involved.
21
+ *
22
+ * SHAPE:
23
+ * The value is the fragment WITHOUT the leading `#`. `''` means "no hash".
24
+ * Setting `''` or `null` removes the fragment (and the trailing `#`) entirely.
25
+ *
26
+ * @example
27
+ * const [hash, setHash] = useHashState();
28
+ * setHash('settings/account'); // → …/private#settings/account (pushState)
29
+ * setHash('overview', { replace: true }); // no new history entry
30
+ * setHash((h) => h || 'top'); // functional updater
31
+ * setHash(null); // strip the fragment → …/private
32
+ *
33
+ * @example // deep-linkable dialog section
34
+ * const [section, setSection] = useHashState(); // e.g. "settings/usage"
35
+ */
36
+
37
+ import { useCallback } from 'react';
38
+
39
+ import { useLocationProperty } from './useLocation';
40
+
41
+ export interface UseHashStateOptions {
42
+ /** Use `replaceState` instead of `pushState` for writes. Default: false. */
43
+ replace?: boolean;
44
+ }
45
+
46
+ export type HashStateUpdater =
47
+ | string
48
+ | null
49
+ | ((current: string) => string | null);
50
+
51
+ /** Strip a single leading '#'. `'#a/b'` → `'a/b'`; `''`/`'#'` → `''`. */
52
+ function stripHash(raw: string): string {
53
+ if (!raw) return '';
54
+ return raw.startsWith('#') ? raw.slice(1) : raw;
55
+ }
56
+
57
+ /** Current pathname + search, WITHOUT the fragment. SSR-safe. */
58
+ function baseUrl(): string {
59
+ if (typeof window === 'undefined') return '/';
60
+ const { pathname, search } = window.location;
61
+ return `${pathname}${search}`;
62
+ }
63
+
64
+ /**
65
+ * `useState`-style binding to the URL fragment. Returns `[hash, setHash]` where
66
+ * `hash` is the fragment without its leading `#` (`''` when absent).
67
+ *
68
+ * Writes go straight to the History API (never the router adapter) so the hash
69
+ * is set absolutely — see the module doc for why that matters under Next.js.
70
+ */
71
+ export function useHashState(
72
+ options: UseHashStateOptions = {},
73
+ ): [string, (next: HashStateUpdater, opts?: UseHashStateOptions) => void] {
74
+ const value = useLocationProperty(
75
+ () => stripHash(window.location.hash),
76
+ () => '',
77
+ );
78
+
79
+ const setValue = useCallback(
80
+ (next: HashStateUpdater, callOpts?: UseHashStateOptions) => {
81
+ if (typeof window === 'undefined') return;
82
+
83
+ const current = stripHash(window.location.hash);
84
+ const resolved =
85
+ typeof next === 'function'
86
+ ? (next as (current: string) => string | null)(current)
87
+ : next;
88
+
89
+ const nextHash = stripHash(resolved ?? '');
90
+ if (nextHash === current) return; // no-op: don't churn history
91
+
92
+ const url = nextHash ? `${baseUrl()}#${nextHash}` : baseUrl();
93
+ const replace = callOpts?.replace ?? options.replace ?? false;
94
+
95
+ // Preserve any existing history.state (Next stores routing metadata there).
96
+ if (replace) {
97
+ window.history.replaceState(window.history.state, '', url);
98
+ } else {
99
+ window.history.pushState(window.history.state, '', url);
100
+ }
101
+ },
102
+ [options.replace],
103
+ );
104
+
105
+ return [value, setValue];
106
+ }