@djangocfg/ui-core 2.1.453 → 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.453",
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.453",
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.453",
201
- "@djangocfg/typescript-config": "^2.1.453",
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",
@@ -19,7 +19,7 @@ const AlertDialogOverlay = React.forwardRef<
19
19
  >(({ className, style, ...props }, ref) => (
20
20
  <AlertDialogPrimitive.Overlay
21
21
  className={cn(
22
- "fixed inset-0 z-600 overlay-backdrop data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ "fixed inset-0 z-600 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
23
  className
24
24
  )}
25
25
  style={style}
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
22
22
  <DialogPrimitive.Overlay
23
23
  ref={ref}
24
24
  className={cn(
25
- "fixed inset-0 z-600 overlay-backdrop data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ "fixed inset-0 z-600 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
26
  className
27
27
  )}
28
28
  style={style}
@@ -50,7 +50,7 @@ const DrawerOverlay = React.forwardRef<
50
50
  >(({ className, style, ...props }, ref) => (
51
51
  <DrawerPrimitive.Overlay
52
52
  ref={ref}
53
- className={cn("fixed inset-0 z-500 overlay-backdrop transition-opacity duration-200", className)}
53
+ className={cn("fixed inset-0 z-500 bg-overlay transition-opacity duration-200", className)}
54
54
  style={style}
55
55
  {...props}
56
56
  />
@@ -22,7 +22,7 @@ const SheetOverlay = React.forwardRef<
22
22
  >(({ className, style, ...props }, ref) => (
23
23
  <SheetPrimitive.Overlay
24
24
  className={cn(
25
- "fixed inset-0 z-200 overlay-backdrop data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ "fixed inset-0 z-200 bg-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
26
26
  className
27
27
  )}
28
28
  style={style}
@@ -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
+ }
@@ -11,7 +11,7 @@ styles/
11
11
  ├── theme.css # Imports tokens.css → animations → light → dark
12
12
  ├── base.css # Resets + `*` border-color + body bg/color + radius scale + native focus-outline reset
13
13
  ├── utilities.css # Custom utilities entry — imports utilities/*
14
- │ └── utilities/ # display · divider · controls · step · animations · glass · marquee
14
+ │ └── utilities/ # display · divider · overlay · controls · step · animations · glass · marquee
15
15
  ├── sources.css # @source directives for monorepo class detection
16
16
  ├── palette/ # JS-readable color access (Canvas/SVG/Mermaid)
17
17
  └── theme/
@@ -70,7 +70,7 @@ This makes opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve thro
70
70
  | `bg-destructive` / `text-destructive-foreground` | `--destructive` | Error / delete filled controls |
71
71
  | `border-border` | `--border` | **Outlines only** — card frames, control/input edges. *Not* for separators (see Presets § Border vs divider) |
72
72
  | `border-divider` / `.divider-b` / `bg-divider` | `--divider` | **All separators** — columns, header `border-b`, list rows, resize handles. A soft hairline (translucent on dark themes) so the chrome never reads as a heavy grid |
73
- | `bg-overlay` | `--overlay` | Modal scrim / backdrop behind dialogs, drawers, sheets — black scrim in both themes, the token owns the opacity |
73
+ | `bg-overlay` | `--overlay` | Modal scrim / backdrop behind dialogs, drawers, sheets — black scrim in both themes (the token owns the opacity), **plus** a `backdrop-filter` frost via `--overlay-blur` (`utilities/overlay.css` augments this same class) |
74
74
  | `bg-input` | `--input` | Input **fill** — a notch off the panel so fields read as real controls (not flush holes). The input *border* uses `--border`, not `--input` |
75
75
  | `ring-ring` | `--ring` | Focus rings, selected outlines — **blue** (system-accent feel), independent of the cyan brand |
76
76
 
@@ -273,6 +273,7 @@ Some classes are authored as **plain CSS** in `utilities/*.css` (not Tailwind ut
273
273
  | Class | File | Why plain CSS |
274
274
  |---|---|---|
275
275
  | `.divider-b` / `.divider-t` | `utilities/divider.css` | Hairline via `--divider` |
276
+ | `.bg-overlay` (frost) | `utilities/overlay.css` | Adds `backdrop-filter: blur(var(--overlay-blur))` onto Tailwind's own `bg-overlay` so modal backdrops frost the page. Tune via `--overlay-blur` (base.css, default 2px; `0px` disables). Attached to `.bg-overlay` — not a new class — so overlays and host forks that render `bg-overlay` all get it |
276
277
  | `.rounded-control` | `utilities/controls.css` | Shared control radius |
277
278
 
278
279
  > **JIT-scan gotcha.** Tailwind's content scan covers `ui-core/src` but **not always every consumer package** (e.g. the `layouts` package source isn't always scanned by Storybook/apps). A *new* arbitrary Tailwind class used **only** in a consumer (`border-zinc-500/25`, `border-foreground/[0.12]`, `rounded-[var(--x)]`) then produces **no CSS rule** and silently falls back to the global `* { border-color: var(--border) }` — the class is in the DOM but the computed color is wrong. **Fix pattern:** for any token-driven visual that consumers need, add a plain `.class` in `ui-core/styles/utilities/*` and `@import` it, then use that class downstream. Don't invent new Tailwind classes in the `layouts` package.
@@ -19,11 +19,11 @@
19
19
  --radius-dialog: 1rem;
20
20
  --radius-tooltip: 0.375rem;
21
21
 
22
- /* Modal backdrop frosting — how hard the `.overlay-backdrop` (dialog / drawer
23
- * / sheet scrim) blurs the page behind it. Theme-agnostic (same both modes);
22
+ /* Modal backdrop frosting — how hard the `.bg-overlay` scrim (dialog / drawer
23
+ * / sheet) blurs the page behind it. Theme-agnostic (same both modes);
24
24
  * a preset can override for a heavier/lighter frost, or set `0px` to disable
25
- * the blur while keeping the `--overlay` dimming scrim. 8px = a light frost. */
26
- --overlay-blur: 8px;
25
+ * the blur while keeping the `--overlay` dimming scrim. 2px = a subtle frost. */
26
+ --overlay-blur: 2px;
27
27
 
28
28
  /* Typography tokens — overridable per preset */
29
29
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -1,24 +1,24 @@
1
1
  /**
2
- * Modal backdrop utility — the scrim behind dialogs, drawers, sheets.
2
+ * Modal backdrop frosting — the blur behind dialogs, drawers, sheets.
3
3
  *
4
- * One class owns BOTH layers of a modal backdrop:
5
- * 1. the dimming scrim → `--overlay` (a themed `hsl(… / α)` token)
6
- * 2. a light frosting → `backdrop-filter: blur(var(--overlay-blur))`
4
+ * The dimming scrim is Tailwind's `bg-overlay` (`--overlay`, a themed
5
+ * `hsl(… / α)` token). Blur can't live in that color token (`--overlay` is a
6
+ * color, blur is a filter), so we attach `backdrop-filter` to the SAME
7
+ * `.bg-overlay` class every overlay already renders — no extra class, no
8
+ * component churn, and host forks of Dialog that render `bg-overlay` directly
9
+ * get the frost for free.
7
10
  *
8
- * Blur can't live in the color token (`--overlay` is a color, blur is a
9
- * filter), so it rides its own `--overlay-blur` variable override it per
10
- * theme/preset to make the backdrop crisper or softer, or set it to `0` to
11
- * disable frosting entirely without touching the scrim color.
11
+ * Blur strength rides its own `--overlay-blur` variable (default in base.css)
12
+ * override it per theme/preset to make the backdrop crisper or softer, or set
13
+ * it to `0px` to disable frosting entirely without touching the scrim color.
12
14
  *
13
- * Plain CSS class (NOT a JIT-scanned Tailwind utility) so it compiles
14
- * regardless of which package's source the Tailwind content scan covers —
15
- * same rationale as `divider.css`. Used by dialog / alert-dialog / drawer /
16
- * sheet overlays.
15
+ * Plain CSS (NOT a JIT-scanned Tailwind utility) so it compiles regardless of
16
+ * which package's source the Tailwind content scan covers — same rationale as
17
+ * `divider.css`.
17
18
  */
18
19
 
19
- .overlay-backdrop {
20
- background-color: var(--overlay);
21
- /* `--overlay-blur` defaults in base.css; a 0 value cleanly no-ops the blur. */
20
+ .bg-overlay {
21
+ /* `--overlay-blur` defaults in base.css; a 0px value cleanly no-ops the blur. */
22
22
  backdrop-filter: blur(var(--overlay-blur, 0px));
23
23
  -webkit-backdrop-filter: blur(var(--overlay-blur, 0px));
24
24
  }