@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 +4 -4
- package/src/components/overlay/alert-dialog/index.tsx +1 -1
- package/src/components/overlay/dialog/index.tsx +1 -1
- package/src/components/overlay/drawer/index.tsx +1 -1
- package/src/components/overlay/sheet/index.tsx +1 -1
- package/src/hooks/index.ts +4 -0
- package/src/hooks/router/README.md +24 -0
- package/src/hooks/router/index.ts +8 -0
- package/src/hooks/router/useHashState.ts +106 -0
- package/src/styles/README.md +3 -2
- package/src/styles/base.css +4 -4
- package/src/styles/utilities/overlay.css +15 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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.
|
|
201
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/styles/README.md
CHANGED
|
@@ -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
|
|
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.
|
package/src/styles/base.css
CHANGED
|
@@ -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
|
|
23
|
-
* / sheet
|
|
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.
|
|
26
|
-
--overlay-blur:
|
|
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
|
|
2
|
+
* Modal backdrop frosting — the blur behind dialogs, drawers, sheets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
20
|
-
|
|
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
|
}
|