@djangocfg/ui-core 2.1.418 → 2.1.420
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/README.md +85 -249
- package/package.json +13 -4
- package/src/components/data/BalancedText/BalancedText.tsx +74 -0
- package/src/components/data/BalancedText/README.md +29 -0
- package/src/components/data/BalancedText/hooks/useMaxLinesWidth.ts +79 -0
- package/src/components/data/BalancedText/hooks/useMeasuredFont.ts +35 -0
- package/src/components/data/BalancedText/index.ts +5 -0
- package/src/components/data/BalancedText/types.ts +59 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/dom/index.ts +2 -0
- package/src/hooks/dom/useResizeObserver.ts +117 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/intensity.ts +40 -0
- package/src/lib/pretext/index.ts +18 -0
- package/src/lib/pretext/pretext.types.ts +72 -0
- package/src/lib/pretext/use-pretext.ts +211 -0
- package/src/styles/README.md +45 -7
- package/src/styles/full.css +52 -0
- package/src/styles/index.css +22 -13
- package/src/styles/theme/tokens.css +6 -0
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
# @djangocfg/ui-core
|
|
8
8
|
|
|
9
|
-
Framework-agnostic React UI library: 70+ components on
|
|
9
|
+
Framework-agnostic React UI library: 70+ shadcn/Radix components on Tailwind v4, semantic theme tokens, palette hooks for Canvas/SVG, plus a router-adapter system that lets the same `<Link>` / `<Sidebar>` / `<SSRPagination>` work under Next.js, Vite, Electron, Wails, or plain React.
|
|
10
10
|
|
|
11
|
-
**Part of [DjangoCFG](https://djangocfg.com)
|
|
11
|
+
**Part of [DjangoCFG](https://djangocfg.com). [Live demo](https://djangocfg.com/demo/).**
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -16,287 +16,123 @@ Framework-agnostic React UI library: 70+ components on Radix + Tailwind v4, plus
|
|
|
16
16
|
pnpm add @djangocfg/ui-core
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Next.js apps should import from [`@djangocfg/ui-nextjs`](../ui-nextjs) instead — it re-exports everything here plus Next-specific bindings.
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
import { useIsMobile, useNavigate, useQueryParams } from '@djangocfg/ui-core/hooks';
|
|
24
|
-
import { cn } from '@djangocfg/ui-core/lib';
|
|
25
|
-
import { UiProviders } from '@djangocfg/ui-core/providers';
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Root providers
|
|
29
|
-
|
|
30
|
-
Mount `<UiProviders>` once at the top of your app — it bundles the overlay
|
|
31
|
-
and imperative services every ui-core / ui-tools component expects:
|
|
32
|
-
|
|
33
|
-
```tsx
|
|
34
|
-
import { UiProviders } from '@djangocfg/ui-core/providers';
|
|
21
|
+
Import styles once at the app root — use the golden path, which pins everything
|
|
22
|
+
to the right cascade layers so import order can't break layout utilities:
|
|
35
23
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
```css
|
|
25
|
+
/* FIRST style import in your app entry CSS */
|
|
26
|
+
@import "@djangocfg/ui-core/styles/full"; /* Tailwind v4 + tokens + base + utilities, layer-safe */
|
|
39
27
|
```
|
|
40
28
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
Opt out per-service: `<UiProviders noToaster noDialogService>`.
|
|
47
|
-
|
|
48
|
-
**Do not nest a second `<TooltipProvider>` inside library components** —
|
|
49
|
-
that creates a separate context scope and breaks the tooltip↔provider link.
|
|
29
|
+
The plain `@djangocfg/ui-core/styles` entry does NOT import Tailwind and emits
|
|
30
|
+
its CSS unlayered — if you use it you own the layer ordering (import
|
|
31
|
+
`tailwindcss` first). See `src/styles/README.md` § App setup for why. Prefer
|
|
32
|
+
`…/styles/full`.
|
|
50
33
|
|
|
51
|
-
##
|
|
52
|
-
|
|
53
|
-
Organized in `components/` by category — everything re-exported from the root barrel.
|
|
54
|
-
|
|
55
|
-
| Category | Examples |
|
|
56
|
-
|---|---|
|
|
57
|
-
| **Forms** | `Button`, `ButtonLink`, `ButtonGroup`, `Input`, `Textarea`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `Label`, `Form`, `Field`, `InputOTP`, `PhoneInput`, `InputGroup`, `DownloadButton` |
|
|
58
|
-
| **Select** | `Select`, `Combobox`, `ComboboxAsync`, `MultiSelect`, `MultiSelectPro`, `MultiSelectProAsync`, `CountrySelect`, `LanguageSelect` (all support icons + badges; `Select` accepts empty-string values; `ComboboxAsync` is the single-select counterpart to `MultiSelectProAsync` for server-side typeahead) |
|
|
59
|
-
| **Overlay** | `Dialog`, `AlertDialog`, `Sheet`, `Drawer`, `Popover`, `Tooltip`, `HoverCard`, `ResponsiveSheet`, `SidePanel` |
|
|
60
|
-
| **Navigation** | `Link`, `Breadcrumb`, `BreadcrumbNavigation`, `Pagination`, `StaticPagination`, `SSRPagination`, `Sidebar` (full shadcn primitives), `Tabs`, `Accordion`, `Collapsible`, `Command`, `DropdownMenu`, `ContextMenu`, `Menubar`, `NavigationMenu`, `MenuBuilder` (declarative data-driven menu) |
|
|
61
|
-
| **Theme** | `ThemeProvider`, `ThemeToggle`, `ForceTheme`, `ThemeOverride`, `useThemeContext`, `useForcedTheme` — framework-agnostic theme runtime |
|
|
62
|
-
| **Layout** | `Card`, `Section`, `Sticky`, `ScrollArea`, `Resizable`, `Separator`, `Skeleton`, `AspectRatio` |
|
|
63
|
-
| **Data** | `Table`, `Badge`, `Avatar`, `Progress`, `Carousel`, `Calendar`, `DatePicker`, `DateRangePicker`, `Toggle`, `ToggleGroup`, `Chart*` |
|
|
64
|
-
| **Feedback** | `Alert`, `Spinner`, `Empty`, `Preloader`, `Toaster` (Sonner) |
|
|
65
|
-
| **Boundary** | `Boundary` (React error boundary with `silent`/`inline`/`card`/`fullscreen` variants, `resetKeys`, custom `fallback`) |
|
|
66
|
-
| **Specialized** | `Kbd`, `CopyButton`, `CopyField`, `TokenIcon`, `Item`, `Portal`, `ImageWithFallback`, `Flag`, `LanguageFlag` |
|
|
67
|
-
| **Effects** | `GlowBackground` |
|
|
68
|
-
|
|
69
|
-
### Boundary
|
|
34
|
+
## Quick start
|
|
70
35
|
|
|
71
36
|
```tsx
|
|
72
|
-
import {
|
|
37
|
+
import { UiProviders, Button, Card } from '@djangocfg/ui-core';
|
|
73
38
|
|
|
74
|
-
<
|
|
75
|
-
<
|
|
39
|
+
<UiProviders>
|
|
40
|
+
<Card><Button>Hello</Button></Card>
|
|
41
|
+
</UiProviders>
|
|
76
42
|
```
|
|
77
43
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Full docs and patterns: [`src/components/boundary/README.md`](./src/components/boundary/README.md).
|
|
81
|
-
|
|
82
|
-
For automatic backend reporting use `MonitorBoundary` from `@djangocfg/layouts`.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
> Pagination, breadcrumb and sidebar live here (not in ui-nextjs). `SSRPagination` reads URL state through `useLocation` + `useQueryParams`, so it works under any router adapter.
|
|
44
|
+
`<UiProviders>` mounts Tooltip / Dialog / Toast / Theme provider in the right order. **Mount it once at the root** — library components (and everything in `@djangocfg/ui-tools`) trust it to be there and never nest their own (a second `TooltipProvider` is the canonical "Tooltip must be used within TooltipProvider" trap).
|
|
86
45
|
|
|
87
|
-
##
|
|
46
|
+
## Catalogue
|
|
88
47
|
|
|
89
|
-
|
|
90
|
-
import { useIsMobile, useMediaQuery, useShortcutModLabel } from '@djangocfg/ui-core/hooks';
|
|
91
|
-
import { useNavigate, useLocation, useQueryParams, useRouter, useIsActive } from '@djangocfg/ui-core/hooks/router';
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
| Group | Hooks |
|
|
48
|
+
| Group | Examples |
|
|
95
49
|
|---|---|
|
|
96
|
-
|
|
|
97
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
100
|
-
|
|
|
101
|
-
|
|
|
102
|
-
|
|
|
103
|
-
|
|
|
104
|
-
|
|
|
105
|
-
|
|
|
106
|
-
|
|
50
|
+
| `components/data/` | Avatar · Badge · Card · Table · BalancedText · Skeleton |
|
|
51
|
+
| `components/forms/` | Button · Input · Textarea · Select · Switch · Checkbox · Slider · Form |
|
|
52
|
+
| `components/feedback/` | Alert · Toast · Banner · Progress · Spinner |
|
|
53
|
+
| `components/overlay/` | Dialog · Drawer · Popover · Tooltip · HoverCard · Sheet · ContextMenu · DropdownMenu |
|
|
54
|
+
| `components/navigation/` | Sidebar · Tabs · Breadcrumb · Pagination · NavigationMenu · Command |
|
|
55
|
+
| `components/layout/` | Container · Grid · Stack · Separator · ScrollArea · Sticky |
|
|
56
|
+
| `components/select/` | Combobox · MultiSelect |
|
|
57
|
+
| `components/effects/` | Glass · Marquee · Backdrop |
|
|
58
|
+
| `components/specialized/` | Accordion · Collapsible · Toggle · Calendar · DatePicker |
|
|
59
|
+
| `components/boundary/` | ErrorBoundary |
|
|
60
|
+
|
|
61
|
+
Imports stay flat — group folders are organisational.
|
|
62
|
+
|
|
63
|
+
## Hooks (`/hooks`)
|
|
64
|
+
|
|
65
|
+
| Topic | Hooks |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `dom/` | `useSize` · `useResizeObserver` · `useMeasure` · `useMutationObserver` · `useIntersection` |
|
|
68
|
+
| `device/` | `useIsMobile` · `useMediaQuery` · `useOnline` · `useViewportSize` · `useOrientation` |
|
|
69
|
+
| `state/` | `useLocalStorage` · `useSessionStorage` · `useToggle` · `useCounter` · `useDebouncedValue` |
|
|
70
|
+
| `events/` | `useEventListener` · `useClickOutside` · `useKeyPress` · `useFocusWithin` |
|
|
71
|
+
| `theme/` | `useTheme` · `useResolvedTheme` · `useThemePreset` |
|
|
72
|
+
| `feedback/` | `useToast` · `useDialog` · `useClipboard` · `useConfirm` |
|
|
73
|
+
| `hotkey/` | `useHotkey` (single key + chord) |
|
|
74
|
+
| `audio/` | `useBeep` · `useSpeak` (Web Speech) |
|
|
75
|
+
| `tabs/` | `useCrossTab` (BroadcastChannel coordination) |
|
|
76
|
+
| `media/` | `useMediaPermissions` · `useUserMedia` · `useDevices` |
|
|
77
|
+
| `time/` | `useNow` · `useInterval` · `useTimeout` |
|
|
78
|
+
| `router/` | `useLink` · `useRouter` (router-adapter consumer) |
|
|
79
|
+
|
|
80
|
+
## Lib utilities (`/lib`)
|
|
81
|
+
|
|
82
|
+
- `cn(...)` — `clsx` + `tailwind-merge` shortcut
|
|
83
|
+
- `getIntensity(value, thresholds)` — quantise values into discrete bins (heatmaps, gauges)
|
|
84
|
+
- `createLogger()` — leveled console logger
|
|
85
|
+
- `dialog-service` — imperative `confirm()` / `alert()` / `prompt()` returning promises
|
|
86
|
+
- `persist` — typed localStorage / sessionStorage hooks
|
|
87
|
+
- `pretext` (subpath `@djangocfg/ui-core/lib/pretext`) — DOM-free text measurement via [@chenglou/pretext](https://github.com/chenglou/pretext); powers `<BalancedText>` and is the primitive for non-CSS line balancing
|
|
107
88
|
|
|
108
89
|
## Router adapters
|
|
109
90
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
```tsx
|
|
113
|
-
// Next.js host (e.g. apps/demo)
|
|
114
|
-
import { LinkProvider } from '@djangocfg/ui-core/components';
|
|
115
|
-
import NextLink from 'next/link';
|
|
116
|
-
|
|
117
|
-
<LinkProvider value={NextLink}>{children}</LinkProvider>
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
When no adapter is mounted, `<Link>` falls back to a plain `<a>` and routes clicks through the History API. `@djangocfg/layouts/BaseApp` mounts the Next adapters by default.
|
|
121
|
-
|
|
122
|
-
## Hotkeys
|
|
123
|
-
|
|
124
|
-
```tsx
|
|
125
|
-
import { useHotkey, formatHotkey, useHotkeyChord } from '@djangocfg/ui-core/hooks';
|
|
126
|
-
|
|
127
|
-
// Smart defaults — auto-detects modifiers vs bare keys:
|
|
128
|
-
useHotkey('mod+k', openPalette); // ⌘K everywhere, incl. inputs; preventDefault auto
|
|
129
|
-
useHotkey('/', focusSearch); // bare key — skipped inside inputs
|
|
130
|
-
useHotkey('escape', closeModal); // always fires in inputs (blur / close pattern)
|
|
131
|
-
useHotkey('?', openHelp, { description: 'Show shortcuts' }); // registers in cheat-sheet
|
|
132
|
-
|
|
133
|
-
// Linear-style chords:
|
|
134
|
-
useHotkeyChord(['g', 't'], () => router.push('/tasks'));
|
|
135
|
-
|
|
136
|
-
// OS-aware tooltips:
|
|
137
|
-
formatHotkey('mod+/') // → '⌘/' on Mac, 'Ctrl+/' elsewhere
|
|
138
|
-
formatHotkey('g t') // → 'G then T'
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
Built on `react-hotkeys-hook` with an opinionated policy: modifier-combos and `escape` fire inside `<input>` / `<textarea>` / `[contenteditable]` by default; bare keys don't, so they won't hijack typing. Override per-call with `inInput: true | false`.
|
|
142
|
-
|
|
143
|
-
`useHotkeyHelp()` reads a module-level registry populated by every `useHotkey(..., { description })` call — drop it into a `?` cheat-sheet dialog with two lines.
|
|
144
|
-
|
|
145
|
-
## Audio
|
|
146
|
-
|
|
147
|
-
Notification sounds with Safari unlock, persisted mute, and a side-channel for native hosts.
|
|
148
|
-
|
|
149
|
-
```tsx
|
|
150
|
-
import { useNotificationSounds, useSoundEffect } from '@djangocfg/ui-core/hooks';
|
|
151
|
-
|
|
152
|
-
// Map event → URL + persisted mute + per-event toggles
|
|
153
|
-
type Event = 'received' | 'mention' | 'error';
|
|
154
|
-
const sounds = useNotificationSounds<Event>({
|
|
155
|
-
storageKey: 'myapp.audio',
|
|
156
|
-
sounds: { received: '/sfx/r.mp3', mention: '/sfx/m.mp3', error: '/sfx/e.mp3' },
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
onMessage = (m) => sounds.play('received');
|
|
160
|
-
toggle = () => sounds.toggleMute();
|
|
161
|
-
sounds.muted // boolean, persisted
|
|
162
|
-
sounds.isSilent // true if no sounds wired or silenced
|
|
163
|
-
|
|
164
|
-
// One-shot single asset (no persistence, no toggles)
|
|
165
|
-
const ding = useSoundEffect('/sfx/ding.mp3');
|
|
166
|
-
<Button onClick={() => { ding.play(); submit(); }} />
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
**Native-host bridge.** When a wrapper (Electron / Wails / Tauri) plays sounds outside the browser, set `silenced: true` and pipe `onSoundEvent` to your bridge — web playback stays silent while the backend gets the trigger:
|
|
170
|
-
|
|
171
|
-
```tsx
|
|
172
|
-
useNotificationSounds({
|
|
173
|
-
storageKey: 'cmdop.audio',
|
|
174
|
-
silenced: true,
|
|
175
|
-
onSoundEvent: (event) => window.go.playSound(event),
|
|
176
|
-
});
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
**Per-event volume scale.** Master volume is multiplied by an optional per-event factor so different sounds can be balanced without juggling source files:
|
|
180
|
-
|
|
181
|
-
```tsx
|
|
182
|
-
useNotificationSounds<Event>({
|
|
183
|
-
storageKey: 'myapp.audio',
|
|
184
|
-
sounds: { received: '/r.mp3', error: '/e.mp3', mention: '/m.mp3' },
|
|
185
|
-
eventVolumes: {
|
|
186
|
-
error: 0.25, // soft ack — destructive UI is the loud signal
|
|
187
|
-
mention: 1, // personal — louder than baseline
|
|
188
|
-
received: 0.7,
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
The bus respects `prefers-reduced-motion`, `prefers-reduced-data`, and `visibilityState === 'hidden'` by default (each is an opt-out). Multi-component sync: same `storageKey` from different surfaces shares the same store.
|
|
194
|
-
|
|
195
|
-
Lower level: `createSoundBus<E>({ sounds, getMuted, getVolume, isEnabled })` exposes the raw bus if you need to wire your own React state. `getVolume(event)` receives the event so you can scale per-fire.
|
|
196
|
-
|
|
197
|
-
For an opinionated, fully-wired example with bundled audio assets see `useChatAudio` in [`@djangocfg/ui-tools`](../../ui-tools/src/tools/Chat/README.md#audio) — it inlines six notification mp3s into the lazy chat chunk as `data:`-URLs so consumers need no asset setup.
|
|
198
|
-
|
|
199
|
-
## Cross-tab coordination
|
|
200
|
-
|
|
201
|
-
```tsx
|
|
202
|
-
import { useActiveTab, useIsTabLeader } from '@djangocfg/ui-core/hooks';
|
|
203
|
-
|
|
204
|
-
const { isActive, isLeader, tabId } = useActiveTab();
|
|
205
|
-
// isActive — this tab has user focus (visibilitychange + focus/blur)
|
|
206
|
-
// isLeader — elected leader among open tabs (stable; oldest tab wins)
|
|
207
|
-
// tabId — stable per-tab id (sessionStorage, survives in-tab reload)
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
Single shared coordinator over `BroadcastChannel` — leader election runs locally on every tab from the same peer-set view, no server. Use the leader flag to dedupe side-effects across tabs: only the leader mutates `document.title` / favicon, holds the websocket, plays a sound; followers stay silent but still read state. Zustand-backed, SSR-safe, single-tab fallback when `BroadcastChannel` is unavailable.
|
|
211
|
-
|
|
212
|
-
## Schema-driven configurators
|
|
213
|
-
|
|
214
|
-
`@djangocfg/ui-core/lib` exports a portable JSON Schema 7 subset (`CustomJsonSchema7`, `CustomJsonUiSchema7`, `CustomJsonUiGroup`, `CustomJsonUiDisabledWhenRule`) for packages that ship configurator schemas without taking a runtime dependency on RJSF.
|
|
215
|
-
|
|
216
|
-
```tsx
|
|
217
|
-
import type { CustomJsonSchema7, CustomJsonUiSchema7 } from '@djangocfg/ui-core/lib';
|
|
218
|
-
```
|
|
219
|
-
|
|
220
|
-
`@djangocfg/ui-tools/json-form` accepts these directly (it's a union with `RJSFSchema`).
|
|
91
|
+
The router-aware components (`Sidebar`, `Link`, `SSRPagination`) read the active router via `RouterAdapterProvider`. Ship the adapter that matches your host:
|
|
221
92
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
dialog.confirm({ title: 'Delete?', description: 'This is permanent.' });
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
## Logger
|
|
231
|
-
|
|
232
|
-
```tsx
|
|
233
|
-
import { createLogger } from '@djangocfg/ui-core/lib';
|
|
234
|
-
const log = createLogger('MyComponent');
|
|
235
|
-
log.info('user logged in', { userId: 123 });
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
## Styles & Theming
|
|
239
|
-
|
|
240
|
-
```css
|
|
241
|
-
/* In your app's globals.css — import BEFORE @import "tailwindcss" */
|
|
242
|
-
@import '@djangocfg/ui-nextjs/styles'; /* re-exports ui-core styles */
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
### Semantic color tokens
|
|
93
|
+
| Adapter | Source |
|
|
94
|
+
|---|---|
|
|
95
|
+
| Next.js App Router | `@djangocfg/ui-nextjs` (auto-wired) |
|
|
96
|
+
| Vite / SPA (`react-router`) | `@djangocfg/ui-core/adapters/react-router` |
|
|
97
|
+
| Plain `<a>` fallback | default (no adapter) |
|
|
246
98
|
|
|
247
|
-
|
|
248
|
-
**Never use raw Tailwind color-scale classes** (`amber-500`, `green-100`) in components — use
|
|
249
|
-
semantic tokens that adapt to both light and dark themes automatically.
|
|
99
|
+
## Theming (`/styles`)
|
|
250
100
|
|
|
251
|
-
|
|
101
|
+
Tailwind v4 with semantic tokens, not raw color scales:
|
|
252
102
|
|
|
253
103
|
```tsx
|
|
254
|
-
|
|
255
|
-
<
|
|
256
|
-
|
|
257
|
-
<Icon className="text-warning" />
|
|
258
|
-
<span>You're on a preview plan.</span>
|
|
259
|
-
</div>
|
|
104
|
+
<Card className="bg-card border-border text-foreground" />
|
|
105
|
+
<Button className="bg-primary text-primary-foreground" />
|
|
106
|
+
<Alert className="bg-warning-background text-warning-foreground border-warning-border" />
|
|
260
107
|
```
|
|
261
108
|
|
|
262
|
-
|
|
109
|
+
Tokens live in `:root` / `.dark` as fully-wrapped CSS colors; `@theme inline` exposes them as `--color-X` references, so opacity modifiers (`bg-card/40`, `border-foreground/20`) resolve via `color-mix` for **every** semantic token.
|
|
263
110
|
|
|
264
|
-
|
|
265
|
-
|---|---|
|
|
266
|
-
| `bg-*-background` | Banner fill |
|
|
267
|
-
| `text-*-foreground` | Readable body text |
|
|
268
|
-
| `border-*-border` | Border ring |
|
|
269
|
-
| `text-*` / `bg-*` | Icon / accent color |
|
|
270
|
-
|
|
271
|
-
#### Code surface
|
|
111
|
+
`@custom-variant dark (&:where(.dark, .dark *))` binds the `dark:` variant to the `.dark` class on `<html>` (not `prefers-color-scheme`) — every theme-switcher in this monorepo toggles that class.
|
|
272
112
|
|
|
273
|
-
|
|
274
|
-
`bg-code-inline` · `text-code-inline-foreground` — for inline `<code>` chips.
|
|
113
|
+
**Programmatic theme colors** for Canvas / SVG / Mermaid:
|
|
275
114
|
|
|
276
|
-
|
|
115
|
+
```ts
|
|
116
|
+
import { useThemeColor, alpha, useStylePresets } from '@djangocfg/ui-core/styles/palette';
|
|
277
117
|
|
|
278
|
-
|
|
279
|
-
|
|
118
|
+
const primary = useThemeColor('primary'); // #00d9ff (hex, not oklch)
|
|
119
|
+
const dim = alpha(primary, 0.15); // 'rgba(0, 217, 255, 0.15)'
|
|
120
|
+
const { success, warning, danger } = useStylePresets();
|
|
280
121
|
```
|
|
281
122
|
|
|
282
|
-
|
|
283
|
-
changes before syncing CSS to consumers. See `src/styles/README.md` for the full token reference
|
|
284
|
-
and the manual sync workflow for local package development.
|
|
123
|
+
Always hex-strings — `color-mix(...)` / `oklch(...)` syntax is rejected by Canvas2D fillStyle.
|
|
285
124
|
|
|
286
|
-
|
|
125
|
+
[Live playground](https://djangocfg.com/demo/) covers all tokens, presets, and dark-mode pairs.
|
|
287
126
|
|
|
288
|
-
|
|
289
|
-
import { useThemePalette, alpha } from '@djangocfg/ui-core/styles/palette';
|
|
127
|
+
## Maintenance rule
|
|
290
128
|
|
|
291
|
-
|
|
292
|
-
ctx.fillStyle = alpha(palette.primary, 0.3); // hex → rgba, updates on theme switch
|
|
293
|
-
```
|
|
129
|
+
After any change to components or hooks — update this README and bump the package patch version. Consumers pin to npm versions; surface drift in this file is the canonical changelog.
|
|
294
130
|
|
|
295
131
|
## Requirements
|
|
296
132
|
|
|
297
|
-
- React
|
|
298
|
-
- Tailwind CSS
|
|
133
|
+
- React 18 or 19
|
|
134
|
+
- Tailwind CSS v4 (host imports `@djangocfg/ui-core/styles`)
|
|
299
135
|
|
|
300
|
-
|
|
136
|
+
## License
|
|
301
137
|
|
|
302
|
-
|
|
138
|
+
MIT — © djangocfg.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-core",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.420",
|
|
4
4
|
"description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-components",
|
|
@@ -63,6 +63,11 @@
|
|
|
63
63
|
"import": "./src/lib/dialog-service/index.ts",
|
|
64
64
|
"require": "./src/lib/dialog-service/index.ts"
|
|
65
65
|
},
|
|
66
|
+
"./lib/pretext": {
|
|
67
|
+
"types": "./src/lib/pretext/index.ts",
|
|
68
|
+
"import": "./src/lib/pretext/index.ts",
|
|
69
|
+
"require": "./src/lib/pretext/index.ts"
|
|
70
|
+
},
|
|
66
71
|
"./providers": {
|
|
67
72
|
"types": "./src/providers/index.ts",
|
|
68
73
|
"import": "./src/providers/index.ts",
|
|
@@ -74,6 +79,7 @@
|
|
|
74
79
|
"require": "./src/utils/index.ts"
|
|
75
80
|
},
|
|
76
81
|
"./styles": "./src/styles/index.css",
|
|
82
|
+
"./styles/full": "./src/styles/full.css",
|
|
77
83
|
"./styles/globals": "./src/styles/globals.css",
|
|
78
84
|
"./styles/theme": "./src/styles/theme.css",
|
|
79
85
|
"./styles/base": "./src/styles/base.css",
|
|
@@ -100,7 +106,7 @@
|
|
|
100
106
|
"check": "tsc --noEmit"
|
|
101
107
|
},
|
|
102
108
|
"peerDependencies": {
|
|
103
|
-
"@djangocfg/i18n": "^2.1.
|
|
109
|
+
"@djangocfg/i18n": "^2.1.420",
|
|
104
110
|
"consola": "^3.4.2",
|
|
105
111
|
"lucide-react": "^0.545.0",
|
|
106
112
|
"moment": "^2.30.1",
|
|
@@ -170,9 +176,12 @@
|
|
|
170
176
|
"tailwind-merge": "^3.3.1",
|
|
171
177
|
"vaul": "1.1.2"
|
|
172
178
|
},
|
|
179
|
+
"optionalDependencies": {
|
|
180
|
+
"@chenglou/pretext": "*"
|
|
181
|
+
},
|
|
173
182
|
"devDependencies": {
|
|
174
|
-
"@djangocfg/i18n": "^2.1.
|
|
175
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
183
|
+
"@djangocfg/i18n": "^2.1.420",
|
|
184
|
+
"@djangocfg/typescript-config": "^2.1.420",
|
|
176
185
|
"@types/node": "^25.2.3",
|
|
177
186
|
"@types/react": "^19.2.15",
|
|
178
187
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Pretext-powered balanced text. CSS `text-wrap: balance` only works up to
|
|
4
|
+
// ~6 lines and is inconsistent cross-browser; this is deterministic and
|
|
5
|
+
// scales to any length. On SSR / before measurement we fall back to the
|
|
6
|
+
// CSS rule so the first paint is never visibly off — once pretext has
|
|
7
|
+
// measured a width, the inline `maxWidth` style takes over.
|
|
8
|
+
//
|
|
9
|
+
// Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
|
|
10
|
+
|
|
11
|
+
'use client';
|
|
12
|
+
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import { cn } from '../../../lib/utils';
|
|
15
|
+
import {
|
|
16
|
+
usePretextWithSegments,
|
|
17
|
+
useBalancedWidth,
|
|
18
|
+
} from '../../../lib/pretext';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_BALANCED_FONT,
|
|
21
|
+
DEFAULT_BALANCED_MAX_WIDTH,
|
|
22
|
+
type BalancedTextProps,
|
|
23
|
+
} from './types';
|
|
24
|
+
import { useMeasuredFont } from './hooks/useMeasuredFont';
|
|
25
|
+
import { useMaxLinesWidth } from './hooks/useMaxLinesWidth';
|
|
26
|
+
|
|
27
|
+
function BalancedTextImpl({
|
|
28
|
+
children,
|
|
29
|
+
font,
|
|
30
|
+
maxWidth = DEFAULT_BALANCED_MAX_WIDTH,
|
|
31
|
+
maxLines,
|
|
32
|
+
as,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
...rest
|
|
36
|
+
}: BalancedTextProps) {
|
|
37
|
+
const Tag = (as ?? 'span') as React.ElementType;
|
|
38
|
+
const ref = React.useRef<HTMLElement | null>(null);
|
|
39
|
+
|
|
40
|
+
// Resolve the font: explicit prop wins; otherwise read computed style after mount.
|
|
41
|
+
const measuredFont = useMeasuredFont(ref, font);
|
|
42
|
+
const effectiveFont = font ?? measuredFont ?? DEFAULT_BALANCED_FONT;
|
|
43
|
+
|
|
44
|
+
const prepared = usePretextWithSegments(children, effectiveFont);
|
|
45
|
+
// Balanced width that preserves the natural line count at maxWidth.
|
|
46
|
+
const balancedAtMax = useBalancedWidth(prepared, maxWidth);
|
|
47
|
+
// If maxLines is set, search the narrower width that yields ≤ maxLines lines.
|
|
48
|
+
const lineCappedWidth = useMaxLinesWidth(prepared, maxWidth, maxLines);
|
|
49
|
+
|
|
50
|
+
const hasMeasured = balancedAtMax > 0 || lineCappedWidth > 0;
|
|
51
|
+
const finalWidth = lineCappedWidth || balancedAtMax;
|
|
52
|
+
|
|
53
|
+
// SSR / pre-measurement: rely on CSS `text-wrap: balance`. After measurement,
|
|
54
|
+
// the inline `maxWidth` provides a deterministic balanced layout.
|
|
55
|
+
const inlineStyle: React.CSSProperties = hasMeasured
|
|
56
|
+
? { maxWidth: finalWidth, ...style }
|
|
57
|
+
: { textWrap: 'balance', ...style };
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Tag
|
|
61
|
+
ref={ref}
|
|
62
|
+
data-slot="balanced-text"
|
|
63
|
+
data-measured={hasMeasured ? 'true' : 'false'}
|
|
64
|
+
className={cn(className)}
|
|
65
|
+
style={inlineStyle}
|
|
66
|
+
{...rest}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</Tag>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const BalancedText = React.memo(BalancedTextImpl);
|
|
74
|
+
BalancedText.displayName = 'BalancedText';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# BalancedText
|
|
2
|
+
|
|
3
|
+
Deterministic line-balancing for headings and short paragraphs. Wraps `@chenglou/pretext`'s `useBalancedWidth` to compute the narrowest container width that preserves the natural line count (or `maxLines`).
|
|
4
|
+
|
|
5
|
+
CSS `text-wrap: balance` is used as the SSR fallback — the balanced width takes over after mount.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { BalancedText } from '@djangocfg/ui-core';
|
|
9
|
+
|
|
10
|
+
<BalancedText as="h1" maxLines={2}>
|
|
11
|
+
A balanced heading that never produces an orphan word
|
|
12
|
+
</BalancedText>
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Props
|
|
16
|
+
|
|
17
|
+
| Prop | Type | Default | Description |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| `children` | `string` | — | Text to balance. **Must be a plain string** — pretext measures glyphs, not React trees. |
|
|
20
|
+
| `font` | `string` | computed style | CSS font shorthand for measurement (e.g. `'16px Inter, sans-serif'`). |
|
|
21
|
+
| `maxWidth` | `number` | parent `clientWidth` or `600` | Hard cap on container width in px. |
|
|
22
|
+
| `maxLines` | `number` | — | Cap on rendered lines. Forces the narrowest width that keeps line count ≤ `maxLines`. |
|
|
23
|
+
| `as` | `ElementType` | `'span'` | Element to render as. |
|
|
24
|
+
|
|
25
|
+
Storybook: `apps/storybook/stories/ui-core/data/BalancedText.stories.tsx`
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Adapted from jalcoui (MIT). Powered by [`@chenglou/pretext`](https://github.com/chenglou/pretext) (optional peer dependency).
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// When `maxLines` is set we want the narrowest width that produces ≤ maxLines.
|
|
4
|
+
// `useBalancedWidth` only preserves the *natural* line count at maxWidth — it
|
|
5
|
+
// won't push the text into fewer lines than the user typed by accident.
|
|
6
|
+
// This hook complements it: it walks line counts and binary-searches for a
|
|
7
|
+
// width ≤ maxWidth that yields exactly `min(natural, maxLines)` lines.
|
|
8
|
+
|
|
9
|
+
'use client';
|
|
10
|
+
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import type { PreparedTextWithSegments } from '../../../../lib/pretext';
|
|
13
|
+
|
|
14
|
+
// We replicate the structure of `useBalancedWidth` so that pretext stays an
|
|
15
|
+
// optionalDependency: import the runtime lazily via require, mirror the
|
|
16
|
+
// minimal `walkLineRanges` surface.
|
|
17
|
+
interface WalkLineRange {
|
|
18
|
+
width: number;
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}
|
|
22
|
+
interface PretextWalker {
|
|
23
|
+
walkLineRanges(
|
|
24
|
+
prepared: PreparedTextWithSegments,
|
|
25
|
+
maxWidth: number,
|
|
26
|
+
visit: (line: WalkLineRange) => void,
|
|
27
|
+
): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let cached: PretextWalker | null = null;
|
|
31
|
+
function getPretext(): PretextWalker {
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
34
|
+
cached = require('@chenglou/pretext') as PretextWalker;
|
|
35
|
+
return cached;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useMaxLinesWidth(
|
|
39
|
+
prepared: PreparedTextWithSegments | null,
|
|
40
|
+
maxWidth: number,
|
|
41
|
+
maxLines: number | undefined,
|
|
42
|
+
): number {
|
|
43
|
+
return React.useMemo(() => {
|
|
44
|
+
if (!prepared || !maxLines || maxLines <= 0 || maxWidth <= 0) return 0;
|
|
45
|
+
|
|
46
|
+
const { walkLineRanges } = getPretext();
|
|
47
|
+
|
|
48
|
+
// Natural line count at maxWidth. If it's already within the cap, defer
|
|
49
|
+
// to `useBalancedWidth` (returning 0 means "don't use my value").
|
|
50
|
+
let naturalCount = 0;
|
|
51
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
52
|
+
naturalCount++;
|
|
53
|
+
});
|
|
54
|
+
if (naturalCount <= maxLines) return 0;
|
|
55
|
+
|
|
56
|
+
// Binary search: find the narrowest width that produces ≤ maxLines lines.
|
|
57
|
+
// Wider width → fewer lines, narrower width → more lines.
|
|
58
|
+
let lo = 1;
|
|
59
|
+
let hi = Math.ceil(maxWidth);
|
|
60
|
+
let best = hi;
|
|
61
|
+
|
|
62
|
+
while (lo <= hi) {
|
|
63
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
64
|
+
let count = 0;
|
|
65
|
+
walkLineRanges(prepared, mid, () => {
|
|
66
|
+
count++;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (count <= maxLines) {
|
|
70
|
+
best = mid;
|
|
71
|
+
hi = mid - 1; // try narrower
|
|
72
|
+
} else {
|
|
73
|
+
lo = mid + 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return best;
|
|
78
|
+
}, [prepared, maxWidth, maxLines]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Read the host element's computed font shorthand after mount so the caller
|
|
4
|
+
// doesn't have to duplicate the design tokens that style the element.
|
|
5
|
+
// Returns `null` until the effect has run (SSR + first render), at which point
|
|
6
|
+
// the BalancedText component falls back to a literal default font string.
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
|
|
12
|
+
export function useMeasuredFont(
|
|
13
|
+
ref: React.RefObject<HTMLElement | null>,
|
|
14
|
+
explicitFont: string | undefined,
|
|
15
|
+
): string | null {
|
|
16
|
+
const [font, setFont] = React.useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (explicitFont) return; // caller wins; skip measurement
|
|
20
|
+
const el = ref.current;
|
|
21
|
+
if (!el) return;
|
|
22
|
+
const cs = getComputedStyle(el);
|
|
23
|
+
// `font` shorthand is the most compact; some browsers return '' when
|
|
24
|
+
// individual properties were set separately — fall back to a manual build.
|
|
25
|
+
const shorthand = cs.font;
|
|
26
|
+
if (shorthand && shorthand.trim() !== '') {
|
|
27
|
+
setFont(shorthand);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const composed = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} / ${cs.lineHeight} ${cs.fontFamily}`;
|
|
31
|
+
setFont(composed);
|
|
32
|
+
}, [ref, explicitFont]);
|
|
33
|
+
|
|
34
|
+
return font;
|
|
35
|
+
}
|