@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 CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  # @djangocfg/ui-core
8
8
 
9
- Framework-agnostic React UI library: 70+ components on Radix + Tailwind v4, plus a router-adapter system that lets the same `<Link>` / `<Sidebar>` / `<SSRPagination>` work under Next.js, Vite, Electron, Wails, or plain React.
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).** **[Live demo](https://djangocfg.com/demo/)**.
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
- ## Imports
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
- ```tsx
22
- import { Button, Card, Sidebar, SSRPagination } from '@djangocfg/ui-core/components';
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
- export function Root({ children }: { children: React.ReactNode }) {
37
- return <UiProviders>{children}</UiProviders>;
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
- Includes:
42
- - `<TooltipProvider>` (Radix tooltip root, `delayDuration={100}` by default)
43
- - `<DialogProvider>` (installs `window.dialog.*` + renders active dialogs)
44
- - `<Toaster>` (Sonner toast portal)
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
- ## Components
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 { Boundary, useBoundary } from '@djangocfg/ui-core';
37
+ import { UiProviders, Button, Card } from '@djangocfg/ui-core';
73
38
 
74
- <Boundary variant="silent"><ChatLauncher /></Boundary>
75
- <Boundary variant="card" resetKeys={[pathname]}><Panel /></Boundary>
39
+ <UiProviders>
40
+ <Card><Button>Hello</Button></Card>
41
+ </UiProviders>
76
42
  ```
77
43
 
78
- Variants: `silent` / `inline` / `card` / `fullscreen`. Plus `useBoundary()` hook for async errors, `resetKeys`, `onReset`, custom `fallback` / `FallbackComponent`, and safe re-render guarantees.
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
- ## Hooks
46
+ ## Catalogue
88
47
 
89
- ```tsx
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
- | **Router** (adapter-driven) | `useNavigate`, `useLocation`, `useLocationProperty`, `useQueryParams`, `useQueryState` (typed), `useRouter`, `useUrlBuilder`, `useSmartLink`, `useIsActive`, `useBackOrFallback` + parsers (`parseAsInteger`, `parseAsBoolean`, …) |
97
- | **Media** | `useIsMobile`, `useIsPhone`, `useIsTabletOrBelow`, `useMediaQuery` |
98
- | **Device** | `useDeviceDetect`, `useBrowserDetect`, `useShortcutModLabel` |
99
- | **State** | `useDebounce`, `useDebouncedCallback`, `useCountdown`, `useImageLoader`, `useMounted` |
100
- | **DOM** | `useEventListener` |
101
- | **Theme** | `useResolvedTheme` (light/dark detection), `useThemeColor`, `useThemePalette` (palette-aware hex colors for Canvas/SVG) |
102
- | **Hotkey** | `useHotkey` (smart `inInput` + `preventDefault` policy), `useHotkeyChord` (sequences), `formatHotkey('mod+k') → ⌘K`, `useHotkeyHelp` (auto cheat-sheet) |
103
- | **Audio** | `createSoundBus`, `useNotificationSounds`, `useAudioPrefs`, `useSoundEffect` Safari unlock, mute persist, per-event toggles + volume scale, native-host bridge |
104
- | **Tabs** | `useActiveTab`, `useIsTabActive`, `useIsTabLeader` cross-tab focus + leader election via BroadcastChannel |
105
- | **Feedback** | `useToast`, `toast` (Sonner) |
106
- | **Debug** | `useDebugTools` |
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
- `<Link>`, `<Sidebar>`, `<SSRPagination>` and friends are wired through a small adapter system, so the same component renders correctly under any router.
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
- ## Dialog service
223
-
224
- ```tsx
225
- import { DialogProvider, useDialog, dialog } from '@djangocfg/ui-core/lib/dialog-service';
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
- All colors are CSS custom properties registered in `@theme` so Tailwind generates utility classes.
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
- #### Status surfaces banners and alerts
101
+ Tailwind v4 with semantic tokens, not raw color scales:
252
102
 
253
103
  ```tsx
254
- {/* Warning */}
255
- <div className="flex gap-2 rounded-md border border-warning-border/40
256
- bg-warning-background px-3 py-2 text-xs text-warning-foreground">
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
- Each status (`warning` · `success` · `destructive` · `info`) exposes four classes:
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
- | Class | Role |
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
- `bg-code` · `text-code-foreground` · `border-code-border` used by PrettyCode, MarkdownMessage code fences.
274
- `bg-code-inline` · `text-code-inline-foreground` — for inline `<code>` chips.
113
+ **Programmatic theme colors** for Canvas / SVG / Mermaid:
275
114
 
276
- ### Playground
115
+ ```ts
116
+ import { useThemeColor, alpha, useStylePresets } from '@djangocfg/ui-core/styles/palette';
277
117
 
278
- ```bash
279
- pnpm playground # opens component explorer with live theme preview
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
- The **Theme / Tokens** story shows every semantic token in both themes — use it to validate
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
- ### Programmatic colors (Canvas / SVG)
125
+ [Live playground](https://djangocfg.com/demo/) covers all tokens, presets, and dark-mode pairs.
287
126
 
288
- ```tsx
289
- import { useThemePalette, alpha } from '@djangocfg/ui-core/styles/palette';
127
+ ## Maintenance rule
290
128
 
291
- const palette = useThemePalette();
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 19
298
- - Tailwind CSS 4
133
+ - React 18 or 19
134
+ - Tailwind CSS v4 (host imports `@djangocfg/ui-core/styles`)
299
135
 
300
- ---
136
+ ## License
301
137
 
302
- **[djangocfg.com](https://djangocfg.com)**
138
+ MIT — © djangocfg.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.418",
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.418",
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.418",
175
- "@djangocfg/typescript-config": "^2.1.418",
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
+ }