@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
|
|
3
|
+
import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSS font shorthand for Pretext measurement.
|
|
7
|
+
*
|
|
8
|
+
* Must match the actually rendered font, otherwise the balanced width will be
|
|
9
|
+
* off. When `font` is omitted, the component reads `getComputedStyle()` on the
|
|
10
|
+
* element after mount and falls back to {@link DEFAULT_BALANCED_FONT} on the
|
|
11
|
+
* server.
|
|
12
|
+
*/
|
|
13
|
+
export type BalancedFont = string;
|
|
14
|
+
|
|
15
|
+
export interface BalancedTextOwnProps {
|
|
16
|
+
/** Text to balance. Required as a string — pretext measures runs of glyphs, not React trees. */
|
|
17
|
+
children: string;
|
|
18
|
+
/** CSS font shorthand (`'16px Inter, sans-serif'`). When omitted, reads `getComputedStyle`. */
|
|
19
|
+
font?: BalancedFont;
|
|
20
|
+
/**
|
|
21
|
+
* Hard cap on width in px. Balanced width never exceeds this. When omitted,
|
|
22
|
+
* the parent container's measured `clientWidth` is used. `maxLines` cannot
|
|
23
|
+
* make the text wider than this.
|
|
24
|
+
* @default 600
|
|
25
|
+
*/
|
|
26
|
+
maxWidth?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Cap on number of lines. When set, the balanced width is the *narrowest*
|
|
29
|
+
* width that still keeps ≤ `maxLines` lines (instead of preserving the
|
|
30
|
+
* natural line count at `maxWidth`). Useful for headings.
|
|
31
|
+
*/
|
|
32
|
+
maxLines?: number;
|
|
33
|
+
/** Element to render as. @default 'span' */
|
|
34
|
+
as?: ElementType;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Props for {@link BalancedText}. */
|
|
38
|
+
export type BalancedTextProps = BalancedTextOwnProps &
|
|
39
|
+
Omit<ComponentPropsWithoutRef<'span'>, keyof BalancedTextOwnProps | 'children'> & {
|
|
40
|
+
children: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Fallback used on SSR and when `font` is not provided + measurement hasn't run. */
|
|
44
|
+
export const DEFAULT_BALANCED_FONT: BalancedFont =
|
|
45
|
+
'16px ui-sans-serif, system-ui, -apple-system, sans-serif';
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_BALANCED_MAX_WIDTH = 600;
|
|
48
|
+
|
|
49
|
+
/** Internal: BalancedText render shape (used by subcomponents). */
|
|
50
|
+
export interface InternalRenderProps {
|
|
51
|
+
Tag: ElementType;
|
|
52
|
+
balancedWidth: number;
|
|
53
|
+
hasMeasured: boolean;
|
|
54
|
+
text: string;
|
|
55
|
+
className?: string;
|
|
56
|
+
style?: React.CSSProperties;
|
|
57
|
+
rest: Record<string, unknown>;
|
|
58
|
+
textNode: ReactNode;
|
|
59
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -187,6 +187,8 @@ export { CircularProgress, CircularProgressCombined, CircularProgressIndicator,
|
|
|
187
187
|
export type { CircularProgressProps } from './data/circular-progress';
|
|
188
188
|
export { RelativeTimeCard, relativeTimeCardVariants } from './data/relative-time-card';
|
|
189
189
|
export type { RelativeTimeCardProps } from './data/relative-time-card';
|
|
190
|
+
export { BalancedText, DEFAULT_BALANCED_FONT, DEFAULT_BALANCED_MAX_WIDTH } from './data/BalancedText';
|
|
191
|
+
export type { BalancedTextProps, BalancedFont } from './data/BalancedText';
|
|
190
192
|
|
|
191
193
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
194
|
// Chart Components
|
package/src/hooks/dom/index.ts
CHANGED
|
@@ -12,5 +12,7 @@ export {
|
|
|
12
12
|
export type { ScrollSnapshot, ScrollDirection, ScrollTarget } from './useScroll';
|
|
13
13
|
export { useLayoutEffect } from './useLayoutEffect';
|
|
14
14
|
export { useSize } from './useSize';
|
|
15
|
+
export { useResizeObserver } from './useResizeObserver';
|
|
16
|
+
export type { Size } from './useResizeObserver';
|
|
15
17
|
export { useFormReset } from './useFormReset';
|
|
16
18
|
export type { UseFormResetParams } from './useFormReset';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Shared ResizeObserver store with refcounting.
|
|
4
|
+
//
|
|
5
|
+
// Why a shared observer (vs one per `useSize`/`useResizeObserver` call):
|
|
6
|
+
// - A single ResizeObserver instance is cheaper than N when many
|
|
7
|
+
// components subscribe (charts, sparklines, masonry items, …).
|
|
8
|
+
// - Avoids "ResizeObserver loop completed with undelivered notifications"
|
|
9
|
+
// warnings that pile up when many tiny observers fire in the same frame.
|
|
10
|
+
//
|
|
11
|
+
// The store keeps one global RO; per-element listener sets are reference
|
|
12
|
+
// counted so the observer detaches from elements once nobody listens.
|
|
13
|
+
//
|
|
14
|
+
// Inspired by the pattern at activity-graph.tsx:180-188 in jalcoui.
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import { useLayoutEffect } from './useLayoutEffect';
|
|
20
|
+
|
|
21
|
+
export interface Size {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Listener = (size: Size) => void;
|
|
27
|
+
|
|
28
|
+
interface Entry {
|
|
29
|
+
listeners: Set<Listener>;
|
|
30
|
+
last: Size;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let sharedObserver: ResizeObserver | null = null;
|
|
34
|
+
const entries = new WeakMap<Element, Entry>();
|
|
35
|
+
// Mirror keyset so we can resolve elements from RO entries.
|
|
36
|
+
const elementByEntry = new WeakMap<Element, Entry>();
|
|
37
|
+
|
|
38
|
+
function getObserver(): ResizeObserver {
|
|
39
|
+
if (sharedObserver) return sharedObserver;
|
|
40
|
+
sharedObserver = new ResizeObserver((roEntries) => {
|
|
41
|
+
for (const roEntry of roEntries) {
|
|
42
|
+
const el = roEntry.target;
|
|
43
|
+
const entry = elementByEntry.get(el);
|
|
44
|
+
if (!entry) continue;
|
|
45
|
+
|
|
46
|
+
let width: number;
|
|
47
|
+
let height: number;
|
|
48
|
+
const box = (roEntry as ResizeObserverEntry).borderBoxSize;
|
|
49
|
+
if (box) {
|
|
50
|
+
const b = Array.isArray(box) ? box[0] : box;
|
|
51
|
+
width = b.inlineSize;
|
|
52
|
+
height = b.blockSize;
|
|
53
|
+
} else {
|
|
54
|
+
width = (el as HTMLElement).offsetWidth;
|
|
55
|
+
height = (el as HTMLElement).offsetHeight;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
entry.last = { width, height };
|
|
59
|
+
for (const listener of entry.listeners) listener(entry.last);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
return sharedObserver;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function subscribe(element: Element, listener: Listener): () => void {
|
|
66
|
+
const observer = getObserver();
|
|
67
|
+
let entry = entries.get(element);
|
|
68
|
+
if (!entry) {
|
|
69
|
+
entry = {
|
|
70
|
+
listeners: new Set<Listener>(),
|
|
71
|
+
last: {
|
|
72
|
+
width: (element as HTMLElement).offsetWidth ?? 0,
|
|
73
|
+
height: (element as HTMLElement).offsetHeight ?? 0,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
entries.set(element, entry);
|
|
77
|
+
elementByEntry.set(element, entry);
|
|
78
|
+
observer.observe(element);
|
|
79
|
+
}
|
|
80
|
+
entry.listeners.add(listener);
|
|
81
|
+
// Deliver current size synchronously so consumers don't render with 0.
|
|
82
|
+
listener(entry.last);
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
const e = entries.get(element);
|
|
86
|
+
if (!e) return;
|
|
87
|
+
e.listeners.delete(listener);
|
|
88
|
+
if (e.listeners.size === 0) {
|
|
89
|
+
observer.unobserve(element);
|
|
90
|
+
entries.delete(element);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Observe an element's border-box size via a shared ResizeObserver.
|
|
97
|
+
*
|
|
98
|
+
* Returns `{ width: 0, height: 0 }` until the first measurement lands.
|
|
99
|
+
* Pass either a ref or a callback-style ref result.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const ref = React.useRef<HTMLDivElement>(null);
|
|
103
|
+
* const { width, height } = useResizeObserver(ref);
|
|
104
|
+
*/
|
|
105
|
+
export function useResizeObserver<T extends Element = Element>(
|
|
106
|
+
ref: React.RefObject<T | null>,
|
|
107
|
+
): Size {
|
|
108
|
+
const [size, setSize] = React.useState<Size>({ width: 0, height: 0 });
|
|
109
|
+
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
const el = ref.current;
|
|
112
|
+
if (!el) return;
|
|
113
|
+
return subscribe(el, setSize);
|
|
114
|
+
}, [ref]);
|
|
115
|
+
|
|
116
|
+
return size;
|
|
117
|
+
}
|
package/src/lib/index.ts
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Quantize a numeric value into a discrete intensity bucket. Used by
|
|
4
|
+
// heatmaps (activity-graph), gauges, and any thermal-style visualization
|
|
5
|
+
// that needs N colored steps.
|
|
6
|
+
//
|
|
7
|
+
// `thresholds` are upper bounds in ascending order, expressed as fractions
|
|
8
|
+
// of `max` or as absolute values — caller picks. The returned bucket index
|
|
9
|
+
// is in `[0, thresholds.length]`.
|
|
10
|
+
//
|
|
11
|
+
// Inspired by `getIntensity` at activity-graph.tsx:72-80.
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param value Current value.
|
|
15
|
+
* @param thresholds Ascending upper bounds. The returned bucket is the
|
|
16
|
+
* index of the first threshold `value` does not exceed,
|
|
17
|
+
* or `thresholds.length` if it exceeds them all.
|
|
18
|
+
* @returns Bucket index in `[0, thresholds.length]`. `0` for non-positive
|
|
19
|
+
* values (treated as "empty").
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* getIntensity(0, [0.25, 0.5, 0.75]) // 0 (empty)
|
|
23
|
+
* getIntensity(0.1, [0.25, 0.5, 0.75]) // 1
|
|
24
|
+
* getIntensity(0.5, [0.25, 0.5, 0.75]) // 2
|
|
25
|
+
* getIntensity(0.8, [0.25, 0.5, 0.75]) // 3
|
|
26
|
+
* getIntensity(1.0, [0.25, 0.5, 0.75]) // 3
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // Quantize commits/day into 4 buckets relative to the busiest day:
|
|
30
|
+
* const max = Math.max(...counts);
|
|
31
|
+
* const ratio = count / max;
|
|
32
|
+
* const step = getIntensity(ratio, [0.25, 0.5, 0.75]); // 0..3
|
|
33
|
+
*/
|
|
34
|
+
export function getIntensity(value: number, thresholds: number[]): number {
|
|
35
|
+
if (!(value > 0)) return 0;
|
|
36
|
+
for (let i = 0; i < thresholds.length; i++) {
|
|
37
|
+
if (value <= thresholds[i]) return i + 1;
|
|
38
|
+
}
|
|
39
|
+
return thresholds.length;
|
|
40
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
usePretext,
|
|
5
|
+
usePretextWithSegments,
|
|
6
|
+
usePretextLayout,
|
|
7
|
+
usePretextLines,
|
|
8
|
+
useShrinkwrap,
|
|
9
|
+
useBalancedWidth,
|
|
10
|
+
} from './use-pretext';
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
PreparedText,
|
|
14
|
+
PreparedTextWithSegments,
|
|
15
|
+
LayoutResult,
|
|
16
|
+
LayoutLinesResult,
|
|
17
|
+
PrepareOptions,
|
|
18
|
+
} from './use-pretext';
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// Local mirror of the @chenglou/pretext public API. The runtime dependency
|
|
4
|
+
// is declared `optional` (see ui-tools/package.json), so consumers that
|
|
5
|
+
// never use BalancedText / pretext-powered tools don't pay for it. To keep
|
|
6
|
+
// `pnpm check` green without the dependency installed, we redeclare just
|
|
7
|
+
// the types we touch — no `import type from '@chenglou/pretext'`.
|
|
8
|
+
//
|
|
9
|
+
// Mirrors the public surface as of @chenglou/pretext 0.0.7. If the upstream
|
|
10
|
+
// types drift, update here.
|
|
11
|
+
|
|
12
|
+
export interface PrepareOptions {
|
|
13
|
+
whiteSpace?: 'normal' | 'pre' | 'pre-wrap' | 'pre-line';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Opaque handles — internal shape doesn't matter to consumers of the hooks.
|
|
17
|
+
// We brand them so the two flavours don't get accidentally swapped.
|
|
18
|
+
export interface PreparedText {
|
|
19
|
+
readonly __brand: 'PreparedText';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PreparedTextWithSegments {
|
|
23
|
+
readonly __brand: 'PreparedTextWithSegments';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LayoutResult {
|
|
27
|
+
lineCount: number;
|
|
28
|
+
height: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LayoutLine {
|
|
32
|
+
text: string;
|
|
33
|
+
width: number;
|
|
34
|
+
start: number;
|
|
35
|
+
end: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LayoutLinesResult {
|
|
39
|
+
lineCount: number;
|
|
40
|
+
height: number;
|
|
41
|
+
lines: LayoutLine[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WalkLineRange {
|
|
45
|
+
width: number;
|
|
46
|
+
start: number;
|
|
47
|
+
end: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PretextModule {
|
|
51
|
+
prepare(text: string, font: string, options?: PrepareOptions): PreparedText;
|
|
52
|
+
prepareWithSegments(
|
|
53
|
+
text: string,
|
|
54
|
+
font: string,
|
|
55
|
+
options?: PrepareOptions,
|
|
56
|
+
): PreparedTextWithSegments;
|
|
57
|
+
layout(
|
|
58
|
+
prepared: PreparedText,
|
|
59
|
+
maxWidth: number,
|
|
60
|
+
lineHeight: number,
|
|
61
|
+
): LayoutResult;
|
|
62
|
+
layoutWithLines(
|
|
63
|
+
prepared: PreparedTextWithSegments,
|
|
64
|
+
maxWidth: number,
|
|
65
|
+
lineHeight: number,
|
|
66
|
+
): LayoutLinesResult;
|
|
67
|
+
walkLineRanges(
|
|
68
|
+
prepared: PreparedTextWithSegments,
|
|
69
|
+
maxWidth: number,
|
|
70
|
+
visit: (line: WalkLineRange) => void,
|
|
71
|
+
): void;
|
|
72
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Adapted from jalcoui (MIT) — github.com/jal-co/ui
|
|
2
|
+
//
|
|
3
|
+
// React hooks wrapping @chenglou/pretext for DOM-free text measurement.
|
|
4
|
+
// Provides prepare/layout lifecycle management, shrinkwrap search,
|
|
5
|
+
// and line-balanced width computation.
|
|
6
|
+
//
|
|
7
|
+
// Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
|
|
8
|
+
//
|
|
9
|
+
// `@chenglou/pretext` is declared as an optionalDependency in
|
|
10
|
+
// `packages/ui-tools/package.json` — consumers that never use BalancedText
|
|
11
|
+
// (or other pretext-based tools) can install ui-tools without it. The
|
|
12
|
+
// `getPretext()` helper uses a CommonJS `require()` so the optional
|
|
13
|
+
// dependency is resolved lazily at first use; if it is missing, the
|
|
14
|
+
// `require` throws at call site, never at module-load.
|
|
15
|
+
|
|
16
|
+
'use client';
|
|
17
|
+
|
|
18
|
+
import * as React from 'react';
|
|
19
|
+
import type {
|
|
20
|
+
PreparedText,
|
|
21
|
+
PreparedTextWithSegments,
|
|
22
|
+
LayoutResult,
|
|
23
|
+
LayoutLinesResult,
|
|
24
|
+
PrepareOptions,
|
|
25
|
+
PretextModule,
|
|
26
|
+
} from './pretext.types';
|
|
27
|
+
|
|
28
|
+
export type {
|
|
29
|
+
PreparedText,
|
|
30
|
+
PreparedTextWithSegments,
|
|
31
|
+
LayoutResult,
|
|
32
|
+
LayoutLinesResult,
|
|
33
|
+
PrepareOptions,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isBrowser = typeof window !== 'undefined';
|
|
37
|
+
|
|
38
|
+
let cachedPretext: PretextModule | null = null;
|
|
39
|
+
|
|
40
|
+
function getPretext(): PretextModule {
|
|
41
|
+
if (cachedPretext) return cachedPretext;
|
|
42
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
43
|
+
cachedPretext = require('@chenglou/pretext') as PretextModule;
|
|
44
|
+
return cachedPretext;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const EMPTY_LAYOUT: LayoutResult = { lineCount: 0, height: 0 };
|
|
48
|
+
const EMPTY_LINES: LayoutLinesResult = { lineCount: 0, height: 0, lines: [] };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Prepare text for Pretext measurement. Runs `prepare()` once and caches
|
|
52
|
+
* the result until `text`, `font`, or `options` change.
|
|
53
|
+
*
|
|
54
|
+
* The returned handle is opaque — pass it to `usePretextLayout`.
|
|
55
|
+
*/
|
|
56
|
+
export function usePretext(
|
|
57
|
+
text: string,
|
|
58
|
+
font: string,
|
|
59
|
+
options?: PrepareOptions,
|
|
60
|
+
): PreparedText | null {
|
|
61
|
+
const whiteSpace = options?.whiteSpace ?? 'normal';
|
|
62
|
+
|
|
63
|
+
return React.useMemo(() => {
|
|
64
|
+
if (!isBrowser) return null;
|
|
65
|
+
return getPretext().prepare(text, font, options);
|
|
66
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
67
|
+
}, [text, font, whiteSpace]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Prepare text with segment data for advanced layout (line-by-line rendering,
|
|
72
|
+
* shrinkwrap, balancing). Same as `usePretext` but returns the richer handle.
|
|
73
|
+
*/
|
|
74
|
+
export function usePretextWithSegments(
|
|
75
|
+
text: string,
|
|
76
|
+
font: string,
|
|
77
|
+
options?: PrepareOptions,
|
|
78
|
+
): PreparedTextWithSegments | null {
|
|
79
|
+
const whiteSpace = options?.whiteSpace ?? 'normal';
|
|
80
|
+
|
|
81
|
+
return React.useMemo(() => {
|
|
82
|
+
if (!isBrowser) return null;
|
|
83
|
+
return getPretext().prepareWithSegments(text, font, options);
|
|
84
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
85
|
+
}, [text, font, whiteSpace]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Layout prepared text at a given width and line height. Pure arithmetic —
|
|
90
|
+
* no DOM reads. Returns line count and total height.
|
|
91
|
+
*
|
|
92
|
+
* Re-runs on every `maxWidth` or `lineHeight` change (~0.0002ms).
|
|
93
|
+
*/
|
|
94
|
+
export function usePretextLayout(
|
|
95
|
+
prepared: PreparedText | null,
|
|
96
|
+
maxWidth: number,
|
|
97
|
+
lineHeight: number,
|
|
98
|
+
): LayoutResult {
|
|
99
|
+
return React.useMemo(() => {
|
|
100
|
+
if (!prepared) return EMPTY_LAYOUT;
|
|
101
|
+
return getPretext().layout(prepared, maxWidth, lineHeight);
|
|
102
|
+
}, [prepared, maxWidth, lineHeight]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Layout prepared text and return full line data (text, width, cursors).
|
|
107
|
+
* Heavier than `usePretextLayout` — use when you need per-line info
|
|
108
|
+
* for custom rendering.
|
|
109
|
+
*/
|
|
110
|
+
export function usePretextLines(
|
|
111
|
+
prepared: PreparedTextWithSegments | null,
|
|
112
|
+
maxWidth: number,
|
|
113
|
+
lineHeight: number,
|
|
114
|
+
): LayoutLinesResult {
|
|
115
|
+
return React.useMemo(() => {
|
|
116
|
+
if (!prepared) return EMPTY_LINES;
|
|
117
|
+
return getPretext().layoutWithLines(prepared, maxWidth, lineHeight);
|
|
118
|
+
}, [prepared, maxWidth, lineHeight]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Find the tightest width that produces the same line count as `maxWidth`.
|
|
123
|
+
* Binary-searches widths using `walkLineRanges` — no DOM measurement.
|
|
124
|
+
*
|
|
125
|
+
* Returns the shrinkwrapped width in pixels.
|
|
126
|
+
*/
|
|
127
|
+
export function useShrinkwrap(
|
|
128
|
+
prepared: PreparedTextWithSegments | null,
|
|
129
|
+
maxWidth: number,
|
|
130
|
+
): number {
|
|
131
|
+
return React.useMemo(() => {
|
|
132
|
+
if (!prepared || maxWidth <= 0) return 0;
|
|
133
|
+
|
|
134
|
+
const { walkLineRanges } = getPretext();
|
|
135
|
+
|
|
136
|
+
let baseLineCount = 0;
|
|
137
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
138
|
+
baseLineCount++;
|
|
139
|
+
});
|
|
140
|
+
if (baseLineCount <= 1) {
|
|
141
|
+
let singleLineWidth = 0;
|
|
142
|
+
walkLineRanges(prepared, maxWidth, (line) => {
|
|
143
|
+
singleLineWidth = line.width;
|
|
144
|
+
});
|
|
145
|
+
return Math.ceil(singleLineWidth) || 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let lo = 1;
|
|
149
|
+
let hi = Math.ceil(maxWidth);
|
|
150
|
+
|
|
151
|
+
while (lo < hi) {
|
|
152
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
153
|
+
let midLineCount = 0;
|
|
154
|
+
walkLineRanges(prepared, mid, () => {
|
|
155
|
+
midLineCount++;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (midLineCount <= baseLineCount) {
|
|
159
|
+
hi = mid;
|
|
160
|
+
} else {
|
|
161
|
+
lo = mid + 1;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return lo;
|
|
166
|
+
}, [prepared, maxWidth]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Find the width where all lines are roughly equal length (balanced text).
|
|
171
|
+
* Binary-searches for the narrowest width that keeps the same line count
|
|
172
|
+
* as `maxWidth`, then returns that width.
|
|
173
|
+
*
|
|
174
|
+
* CSS `text-wrap: balance` only works up to ~6 lines and is inconsistent
|
|
175
|
+
* cross-browser. This works on any length and is deterministic.
|
|
176
|
+
*/
|
|
177
|
+
export function useBalancedWidth(
|
|
178
|
+
prepared: PreparedTextWithSegments | null,
|
|
179
|
+
maxWidth: number,
|
|
180
|
+
): number {
|
|
181
|
+
return React.useMemo(() => {
|
|
182
|
+
if (!prepared || maxWidth <= 0) return 0;
|
|
183
|
+
|
|
184
|
+
const { walkLineRanges } = getPretext();
|
|
185
|
+
|
|
186
|
+
let baseLineCount = 0;
|
|
187
|
+
walkLineRanges(prepared, maxWidth, () => {
|
|
188
|
+
baseLineCount++;
|
|
189
|
+
});
|
|
190
|
+
if (baseLineCount <= 1) return maxWidth;
|
|
191
|
+
|
|
192
|
+
let lo = 1;
|
|
193
|
+
let hi = Math.ceil(maxWidth);
|
|
194
|
+
|
|
195
|
+
while (lo < hi) {
|
|
196
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
197
|
+
let midLineCount = 0;
|
|
198
|
+
walkLineRanges(prepared, mid, () => {
|
|
199
|
+
midLineCount++;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (midLineCount <= baseLineCount) {
|
|
203
|
+
hi = mid;
|
|
204
|
+
} else {
|
|
205
|
+
lo = mid + 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lo;
|
|
210
|
+
}, [prepared, maxWidth]);
|
|
211
|
+
}
|
package/src/styles/README.md
CHANGED
|
@@ -6,7 +6,8 @@ CSS architecture for `@djangocfg/ui-core` — **Tailwind v4** with semantic toke
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
styles/
|
|
9
|
-
├──
|
|
9
|
+
├── full.css # Golden path (recommended) — Tailwind + tokens + base + utilities, cascade-layer-safe
|
|
10
|
+
├── index.css # Plain entry (no Tailwind, unlayered) — you own layer ordering
|
|
10
11
|
├── theme.css # Imports tokens.css → animations → light → dark
|
|
11
12
|
├── base.css # Resets + `*` border-color + body bg/color
|
|
12
13
|
├── utilities.css # Custom utilities (.glass-*, .step, animations)
|
|
@@ -155,18 +156,55 @@ Each requires a **non-transparent parent** (something for the blur to chew on).
|
|
|
155
156
|
|
|
156
157
|
## App setup
|
|
157
158
|
|
|
158
|
-
|
|
159
|
+
### Golden path (recommended) — one import, layer-safe
|
|
159
160
|
|
|
160
161
|
```css
|
|
161
|
-
/*
|
|
162
|
-
|
|
162
|
+
/* Single line. Imports Tailwind + tokens + base + utilities in the
|
|
163
|
+
correct cascade layers. Put it FIRST; other package CSS after. */
|
|
164
|
+
@import "@djangocfg/ui-core/styles/full";
|
|
163
165
|
|
|
164
|
-
/* 2. Other package styles after */
|
|
165
166
|
@import "@djangocfg/layouts/styles";
|
|
166
167
|
@import "@djangocfg/ui-tools/styles";
|
|
168
|
+
```
|
|
167
169
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
+
`…/styles/full` (`full.css`) pins ui-core's base resets to `@layer base`
|
|
171
|
+
and its custom utilities to `@layer utilities` via `@import "…" layer(name)`.
|
|
172
|
+
A `layer()`-qualified import is folded into that layer **regardless of
|
|
173
|
+
import order or build tool**, so you cannot get the ordering wrong, and
|
|
174
|
+
you do not need to import `tailwindcss` yourself.
|
|
175
|
+
|
|
176
|
+
### The cascade-layer rule (why ordering matters)
|
|
177
|
+
|
|
178
|
+
Tailwind v4 emits its utilities inside `@layer utilities`. **Unlayered
|
|
179
|
+
CSS beats any layered rule** in the cascade. So if a package's base
|
|
180
|
+
resets (here `* { border-color }` and the `body` background/font rules in
|
|
181
|
+
`base.css`) are emitted *unlayered*, they sit above `@layer utilities`
|
|
182
|
+
and silently defeat layout utilities (`gap`, `space-y`, `divide`, `flex`,
|
|
183
|
+
`border`, `padding`). Colors usually survive because they flow through
|
|
184
|
+
CSS vars (no cascade conflict), so the breakage is invisible in Chrome
|
|
185
|
+
but shows up in stricter engines (WKWebView).
|
|
186
|
+
|
|
187
|
+
Whether a *plain*, unlayered `@import` lands in a layer depends on its
|
|
188
|
+
position relative to `@import "tailwindcss"` **and** on the build tool
|
|
189
|
+
(Vite vs Next.js resolve `@import` differently). `full.css` removes that
|
|
190
|
+
dependency by binding each file to a layer explicitly. **Use `…/styles/full`
|
|
191
|
+
and this whole class of bug cannot occur.**
|
|
192
|
+
|
|
193
|
+
### Manual ordering (only if you manage layers yourself)
|
|
194
|
+
|
|
195
|
+
The plain `@djangocfg/ui-core/styles` entry imports `theme.css` +
|
|
196
|
+
`sources.css` + `base.css` + `utilities.css` **unlayered** (it does not
|
|
197
|
+
import Tailwind). If you use it, you own the layer ordering. The Next.js
|
|
198
|
+
demo does this and works because PostCSS folds the trailing
|
|
199
|
+
`@import "tailwindcss"` such that the resets still end up benign — but a
|
|
200
|
+
Vite consumer that puts `@import "tailwindcss"` last hit exactly the bug
|
|
201
|
+
above. If you must hand-order, import `tailwindcss` **first**:
|
|
202
|
+
|
|
203
|
+
```css
|
|
204
|
+
@import "tailwindcss"; /* establishes the layers first */
|
|
205
|
+
@import "@djangocfg/ui-core/styles";
|
|
206
|
+
@import "@djangocfg/layouts/styles";
|
|
207
|
+
@import "@djangocfg/ui-tools/styles";
|
|
170
208
|
```
|
|
171
209
|
|
|
172
210
|
> **No `@plugin "tailwindcss-animate"` needed** in v4 — the keyframes ship via `theme/animations.css`. Use `tw-animate-css` instead if you need extra utilities.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @djangocfg/ui-core — golden-path style entry (cascade-layer-safe)
|
|
3
|
+
*
|
|
4
|
+
* Import THIS file (`@djangocfg/ui-core/styles/full`) as the SINGLE,
|
|
5
|
+
* FIRST style import in a consumer app and Tailwind cascade-layer
|
|
6
|
+
* ordering cannot go wrong — regardless of build tool (Vite or Next.js)
|
|
7
|
+
* or where the consumer's own `@import "tailwindcss"` ends up.
|
|
8
|
+
*
|
|
9
|
+
* Why this file exists — the cascade-layer trap it removes
|
|
10
|
+
* --------------------------------------------------------
|
|
11
|
+
* Tailwind v4 emits its utilities inside `@layer utilities`. Any CSS
|
|
12
|
+
* that lands OUTSIDE a cascade layer beats every layered rule, because
|
|
13
|
+
* unlayered styles always win over layered ones in the cascade. So if a
|
|
14
|
+
* package's base resets (e.g. ui-core's `* { border-color }` and the
|
|
15
|
+
* `body` background/font rules in base.css) are emitted unlayered, they
|
|
16
|
+
* sit above `@layer utilities` and silently defeat layout utilities
|
|
17
|
+
* (gap, space-y, divide, flex, border, padding). Colors usually survive
|
|
18
|
+
* because they resolve through CSS vars, which have no cascade conflict,
|
|
19
|
+
* so the breakage is invisible until a stricter engine (WKWebView) shows
|
|
20
|
+
* it. The plain `@djangocfg/ui-core/styles` entry imports base.css and
|
|
21
|
+
* utilities.css unlayered, which means their position relative to
|
|
22
|
+
* `@import "tailwindcss"` decides whether they get folded into a layer —
|
|
23
|
+
* a footgun for consumers.
|
|
24
|
+
*
|
|
25
|
+
* This entry removes the footgun by binding each package file to an
|
|
26
|
+
* explicit cascade layer at import time via `@import "..." layer(name)`.
|
|
27
|
+
* A `layer()`-qualified import is folded into that named layer no matter
|
|
28
|
+
* the import order or build tool, so the consumer can put their own
|
|
29
|
+
* `@import "tailwindcss"` anywhere (or omit it — this file imports it
|
|
30
|
+
* first) and the resets stay in `@layer base`, the custom utilities stay
|
|
31
|
+
* in `@layer utilities`, both correctly under Tailwind's own emissions.
|
|
32
|
+
*
|
|
33
|
+
* theme.css and sources.css are intentionally NOT layered: `@theme`
|
|
34
|
+
* token blocks and the `:root` / `.dark` CSS variables must stay global
|
|
35
|
+
* so Tailwind collects the tokens and the variables apply everywhere,
|
|
36
|
+
* and `@source` is a build directive, not a style rule.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/* Tailwind v4 core first — establishes the properties/theme/base/components/utilities layer order. */
|
|
40
|
+
@import "tailwindcss";
|
|
41
|
+
|
|
42
|
+
/* Theme tokens + CSS variables — must stay global (unlayered) so Tailwind reads @theme and :root/.dark apply everywhere. */
|
|
43
|
+
@import "./theme.css";
|
|
44
|
+
|
|
45
|
+
/* @source directives for monorepo class detection — build directive, not a style rule. */
|
|
46
|
+
@import "./sources.css";
|
|
47
|
+
|
|
48
|
+
/* Base resets pinned to @layer base so they never escape above Tailwind's utilities. */
|
|
49
|
+
@import "./base.css" layer(base);
|
|
50
|
+
|
|
51
|
+
/* Custom utilities pinned to @layer utilities to sit alongside Tailwind's own. */
|
|
52
|
+
@import "./utilities.css" layer(utilities);
|