pretrunc 0.0.0-stage → 0.1.0

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/DESIGN.md +235 -0
  3. package/LICENSE +21 -0
  4. package/README.md +200 -2
  5. package/dist/cut-D7qi2yZl.js +356 -0
  6. package/dist/cut-D7qi2yZl.js.map +1 -0
  7. package/dist/cut.d.ts +75 -0
  8. package/dist/dom-CPL0-qc3.js +52 -0
  9. package/dist/dom-CPL0-qc3.js.map +1 -0
  10. package/dist/dom.d.ts +22 -0
  11. package/dist/dom.js +1 -0
  12. package/dist/face-pICHAjvk.js +68 -0
  13. package/dist/face-pICHAjvk.js.map +1 -0
  14. package/dist/face.d.ts +46 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +3 -0
  17. package/dist/lines-DOj2DQLl.js +334 -0
  18. package/dist/lines-DOj2DQLl.js.map +1 -0
  19. package/dist/lines.d.ts +32 -0
  20. package/dist/lines.js +1 -0
  21. package/dist/metrics-C2jv4JVL.js +49 -0
  22. package/dist/metrics-C2jv4JVL.js.map +1 -0
  23. package/dist/metrics-cli.js +19 -0
  24. package/dist/metrics-cli.js.map +1 -0
  25. package/dist/metrics.d.ts +36 -0
  26. package/dist/metrics.js +73 -0
  27. package/dist/metrics.js.map +1 -0
  28. package/dist/react-CnQfjWyS.js +156 -0
  29. package/dist/react-CnQfjWyS.js.map +1 -0
  30. package/dist/react-lines.d.ts +27 -0
  31. package/dist/react-lines.js +74 -0
  32. package/dist/react-lines.js.map +1 -0
  33. package/dist/react.d.ts +81 -0
  34. package/dist/react.js +1 -0
  35. package/dist/solid-BWC32VMg.js +155 -0
  36. package/dist/solid-BWC32VMg.js.map +1 -0
  37. package/dist/solid-lines.d.ts +27 -0
  38. package/dist/solid-lines.js +90 -0
  39. package/dist/solid-lines.js.map +1 -0
  40. package/dist/solid.d.ts +77 -0
  41. package/dist/solid.js +1 -0
  42. package/package.json +145 -3
  43. package/src/cut.ts +596 -0
  44. package/src/dom.ts +59 -0
  45. package/src/face.ts +112 -0
  46. package/src/index.ts +15 -0
  47. package/src/lines.ts +585 -0
  48. package/src/metrics.ts +106 -0
  49. package/src/react-lines.ts +99 -0
  50. package/src/react.ts +251 -0
  51. package/src/solid-lines.tsx +89 -0
  52. package/src/solid.tsx +225 -0
@@ -0,0 +1,99 @@
1
+ // Written with createElement, not JSX, as react.ts is.
2
+ import {
3
+ createElement,
4
+ type ReactNode,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+ import { type CutOptions, head, type Protect, tail, truncated } from "./cut.ts";
11
+ import { observe } from "./dom.ts";
12
+ import { clamp, prepareLines } from "./lines.ts";
13
+ import { useBox, useProtect } from "./react.ts";
14
+
15
+ export interface ClampProps extends Omit<CutOptions, "tailGraphemes"> {
16
+ readonly text: string;
17
+ /** How many lines to keep. */
18
+ readonly lines: number;
19
+ readonly ellipsis?: string;
20
+ /**
21
+ * Painted after the text whenever it is cut or `expanded`, such as a "more" or
22
+ * "less" button. Its width is observed and kept free on the last line.
23
+ */
24
+ readonly after?: ReactNode;
25
+ /** Show every line, with `after` following the last one, on its own if it doesn't fit. */
26
+ readonly expanded?: boolean;
27
+ /** The room in px. Left out, it is observed from the element. */
28
+ readonly width?: number;
29
+ /** A CSS font shorthand. Left out, it is read from the element's computed style. */
30
+ readonly font?: string;
31
+ /** CSS `letter-spacing` in px. Left out, it is read from the element's computed style. */
32
+ readonly letterSpacing?: number;
33
+ readonly protect?: Protect;
34
+ readonly whiteSpace?: "normal" | "pre-wrap";
35
+ readonly wordBreak?: "normal" | "keep-all";
36
+ readonly className?: string;
37
+ }
38
+
39
+ const box = { display: "inline-block" } as const;
40
+
41
+ /** A paragraph cut to a number of lines at its end, start or middle. */
42
+ export function Clamp(props: ClampProps): ReactNode {
43
+ const b = useBox(props);
44
+ const { font, spacing, fonts } = b;
45
+ const { text, lines, at, ratio, words, trim, ellipsis = "…", whiteSpace, wordBreak } = props;
46
+ const hasAfter = props.after !== undefined;
47
+ // `after` mounts only once the text is cut, so it is observed from its own ref.
48
+ const [after, setAfter] = useState<Element | null>(null);
49
+ const [afterWidth, setAfterWidth] = useState(0);
50
+ useEffect(() => (after ? observe(after, setAfterWidth) : undefined), [after]);
51
+ const protect = useProtect(props.protect);
52
+ const prepared = useMemo(
53
+ () =>
54
+ font === undefined
55
+ ? undefined
56
+ : prepareLines(text, font, {
57
+ ...(protect ? { protect } : {}),
58
+ ...(whiteSpace ? { whiteSpace } : {}),
59
+ ...(wordBreak ? { wordBreak } : {}),
60
+ letterSpacing: spacing,
61
+ }),
62
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- fonts changes when a web font loads, after forget() has dropped the widths the last prepare used
63
+ [text, font, spacing, protect, whiteSpace, wordBreak, fonts],
64
+ );
65
+ // Clamps are few beside a table's rows, so each renders on a width change and clamps
66
+ // only when something it reads has changed.
67
+ const width = useSyncExternalStore(b.subscribe, b.width, () => undefined);
68
+ const within = typeof words === "object" ? words.within : words;
69
+ const c = useMemo(() => {
70
+ if (prepared === undefined || width === undefined) return undefined;
71
+ return clamp(prepared, width, {
72
+ lines,
73
+ ellipsis,
74
+ // `after` is an element here and a width to `clamp`, so it's always given as one.
75
+ after: hasAfter ? afterWidth : 0,
76
+ ...(at === undefined ? {} : { at }),
77
+ ...(ratio === undefined ? {} : { ratio }),
78
+ ...(within === undefined ? {} : { words: typeof within === "number" ? { within } : within }),
79
+ ...(trim === undefined ? {} : { trim }),
80
+ });
81
+ }, [prepared, width, lines, ellipsis, hasAfter, afterWidth, at, ratio, within, trim]);
82
+ const cutNow = c !== undefined && truncated(c);
83
+ const shown =
84
+ props.expanded || prepared === undefined || !cutNow
85
+ ? text
86
+ : prepared.text.slice(0, head(c)) + ellipsis + prepared.text.slice(tail(c));
87
+ return createElement(
88
+ "div",
89
+ {
90
+ ref: b.ref,
91
+ className: props.className,
92
+ style: { overflowWrap: "break-word", whiteSpace: whiteSpace ?? "normal" },
93
+ },
94
+ shown,
95
+ hasAfter && (props.expanded || cutNow)
96
+ ? createElement("span", { ref: setAfter, style: box }, props.after)
97
+ : null,
98
+ );
99
+ }
package/src/react.ts ADDED
@@ -0,0 +1,251 @@
1
+ // Written with createElement, not JSX: every .tsx file here compiles as Solid, so plain
2
+ // .ts keeps the React adapter clear of the Solid compiler without a second JSX setup.
3
+ import {
4
+ createContext,
5
+ createElement,
6
+ type ReactNode,
7
+ useCallback,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ useSyncExternalStore,
14
+ } from "react";
15
+ import {
16
+ cut,
17
+ type Cut,
18
+ type CutOptions,
19
+ head,
20
+ prepare,
21
+ type Prepared,
22
+ type Protect,
23
+ tail,
24
+ truncated,
25
+ } from "./cut.ts";
26
+ import { fontOf, observe, onFonts, spacingOf } from "./dom.ts";
27
+ import { faceOf, widthOf } from "./face.ts";
28
+
29
+ export interface BoxProps {
30
+ /** The room in px. Left out, it is observed from the element. */
31
+ readonly width?: number;
32
+ /** A CSS font shorthand. Left out, it is read from the element's computed style. */
33
+ readonly font?: string;
34
+ /** CSS `letter-spacing` in px. Left out, it is read from the element's computed style. */
35
+ readonly letterSpacing?: number;
36
+ }
37
+
38
+ export interface Box {
39
+ /** Give it the element whose width and font the text follows. */
40
+ readonly ref: (element: Element | null) => void;
41
+ /** The font given, or the element's; undefined until it mounts. */
42
+ readonly font: string | undefined;
43
+ /** The letter spacing given, or the element's, in px. */
44
+ readonly spacing: number;
45
+ /** Changes whenever web fonts load: a dependency wherever text is prepared. */
46
+ readonly fonts: number;
47
+ /** Subscribes to the width, for `useSyncExternalStore`. */
48
+ readonly subscribe: (change: () => void) => () => void;
49
+ /** The width given, or the element's content width; undefined until observed. */
50
+ readonly width: () => number | undefined;
51
+ }
52
+
53
+ export interface FontProviderProps {
54
+ /** A CSS font shorthand that every cut inside is in, unless it's given its own. */
55
+ readonly font?: string;
56
+ /** CSS `letter-spacing` in px for every cut inside, unless it's given its own. */
57
+ readonly letterSpacing?: number;
58
+ readonly children?: ReactNode;
59
+ }
60
+
61
+ interface Provided {
62
+ readonly font: string | undefined;
63
+ readonly letterSpacing: number | undefined;
64
+ }
65
+
66
+ const Fonts = createContext<Provided>({ font: undefined, letterSpacing: undefined });
67
+
68
+ /**
69
+ * Gives every `Truncate`, `Clamp` and `useBox` inside the font and letter spacing their
70
+ * text renders in, once for a list. Each would otherwise read its element's computed
71
+ * style when it mounts: 5,000 rows take 4 to 5 ms of style reads. Cuts follow what's
72
+ * given, so give what the rows really render in.
73
+ */
74
+ export function FontProvider(props: FontProviderProps): ReactNode {
75
+ const { font, letterSpacing } = props;
76
+ const value = useMemo(() => ({ font, letterSpacing }), [font, letterSpacing]);
77
+ return createElement(Fonts, { value }, props.children);
78
+ }
79
+
80
+ /**
81
+ * The width and font text is cut to: the ones given, then a `FontProvider`'s, or an
82
+ * element's. The width is a store, not state, so a component can read what it derives
83
+ * from it with `useSyncExternalStore` and render only when that changes.
84
+ */
85
+ export const useBox = (props: BoxProps): Box => {
86
+ const provided = useContext(Fonts);
87
+ const given = props.font ?? provided.font;
88
+ const givenSpacing = props.letterSpacing ?? provided.letterSpacing;
89
+ const [element, setElement] = useState<Element | null>(null);
90
+ const [computed, setComputed] = useState<{ font: string; spacing: number }>();
91
+ const [fonts, setFonts] = useState(0);
92
+ const observed = useRef<number | undefined>(undefined);
93
+ useEffect(() => onFonts(() => setFonts((n) => n + 1)), []);
94
+ const readFont = given === undefined;
95
+ const readSpacing = givenSpacing === undefined;
96
+ // The style is read once per element as it mounts, before it paints, and only for what
97
+ // wasn't given.
98
+ const ref = useCallback(
99
+ (e: Element | null) => {
100
+ setElement(e);
101
+ if (!e || (!readFont && !readSpacing)) return;
102
+ const style = getComputedStyle(e);
103
+ setComputed({ font: fontOf(e, style), spacing: spacingOf(e, style) });
104
+ },
105
+ [readFont, readSpacing],
106
+ );
107
+ const observing = props.width === undefined;
108
+ const subscribe = useCallback(
109
+ (change: () => void) => {
110
+ if (!element || !observing) return () => {};
111
+ return observe(element, (width) => {
112
+ observed.current = width;
113
+ change();
114
+ });
115
+ },
116
+ [element, observing],
117
+ );
118
+ const width = props.width;
119
+ return {
120
+ ref,
121
+ font: given ?? computed?.font,
122
+ spacing: givenSpacing ?? computed?.spacing ?? 0,
123
+ fonts,
124
+ subscribe,
125
+ width: () => width ?? observed.current,
126
+ };
127
+ };
128
+
129
+ export interface CutProps extends CutOptions, BoxProps {
130
+ readonly text: string;
131
+ readonly protect?: Protect;
132
+ }
133
+
134
+ /**
135
+ * `protect` kept by what it says, not by identity: an inline literal is new on every
136
+ * render, and this returns one value while its patterns, weights and wholes stay the same.
137
+ */
138
+ export const useProtect = (protect: Protect | undefined): Protect | undefined => {
139
+ let key = "";
140
+ if (protect) {
141
+ for (const given of "length" in protect ? protect : [protect]) {
142
+ const {
143
+ pattern,
144
+ weight = 1,
145
+ whole = false,
146
+ } = given instanceof RegExp ? { pattern: given } : given;
147
+ key += `${pattern}${weight}${whole} `;
148
+ }
149
+ }
150
+ // The value from the render that first saw this key, as React's derived state is kept.
151
+ const [kept, setKept] = useState({ key, protect });
152
+ if (kept.key !== key) setKept({ key, protect });
153
+ return kept.key === key ? kept.protect : protect;
154
+ };
155
+
156
+ export interface UseCutProps extends CutProps {
157
+ /**
158
+ * The ellipsis is painted in an element of its own, so it is measured as a run of its
159
+ * own and no kerning across it counts.
160
+ */
161
+ readonly separateEllipsis?: boolean;
162
+ }
163
+
164
+ export interface CutHandle {
165
+ /** Give it the element whose width and font the cut follows. */
166
+ readonly ref: (element: Element | null) => void;
167
+ readonly prepared: Prepared | undefined;
168
+ /** Undefined until the width and font are known. */
169
+ readonly cut: Cut | undefined;
170
+ }
171
+
172
+ const useCutWith = (props: CutProps, apart: boolean): CutHandle => {
173
+ const box = useBox(props);
174
+ const { font, spacing, fonts } = box;
175
+ const { text } = props;
176
+ const protect = useProtect(props.protect);
177
+ const prepared = useMemo(
178
+ () =>
179
+ font === undefined
180
+ ? undefined
181
+ : prepare(text, font, { ...(protect ? { protect } : {}), letterSpacing: spacing }),
182
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- fonts changes when a web font loads, after forget() has dropped the widths the last prepare used
183
+ [text, font, spacing, protect, fonts],
184
+ );
185
+ // The cut is the snapshot: a number, so a resize that doesn't move it renders nothing.
186
+ const snapshot = (): Cut | undefined => {
187
+ const width = box.width();
188
+ if (prepared === undefined || width === undefined || font === undefined) return undefined;
189
+ const ellipsis = props.ellipsis ?? "…";
190
+ return apart && typeof ellipsis === "string"
191
+ ? cut(prepared, width, {
192
+ ...props,
193
+ ellipsis: widthOf(faceOf(font), ellipsis) + spacing * ellipsis.length,
194
+ })
195
+ : cut(prepared, width, props);
196
+ };
197
+ const result = useSyncExternalStore(box.subscribe, snapshot, () => undefined);
198
+ return { ref: box.ref, prepared, cut: result };
199
+ };
200
+
201
+ /** The cut of `props.text` for an element's width and font, or for the ones given. */
202
+ export const useCut = (props: UseCutProps): CutHandle =>
203
+ useCutWith(props, props.separateEllipsis === true);
204
+
205
+ export interface TruncateProps extends CutProps {
206
+ readonly ellipsis?: string;
207
+ /**
208
+ * "kept" (default) paints the kept text, with the whole text in `title` once cut.
209
+ * "full" paints all of it with the cut part collapsed to no width, so copy,
210
+ * find-in-page and screen readers get the whole text.
211
+ */
212
+ readonly render?: "kept" | "full";
213
+ readonly className?: string;
214
+ }
215
+
216
+ // Until the first cut, the browser's own end ellipsis stands in.
217
+ const line = {
218
+ display: "block",
219
+ overflow: "hidden",
220
+ whiteSpace: "nowrap",
221
+ textOverflow: "ellipsis",
222
+ } as const;
223
+ const collapsed = { fontSize: 0 } as const;
224
+ const unselectable = { userSelect: "none", WebkitUserSelect: "none" } as const;
225
+
226
+ /** One line of text, cut to fit its box. */
227
+ export function Truncate(props: TruncateProps): ReactNode {
228
+ const full = props.render === "full";
229
+ const handle = useCutWith(props, full);
230
+ const { prepared: p, cut: c } = handle;
231
+ const ellipsis = props.ellipsis ?? "…";
232
+ const cutNow = p !== undefined && c !== undefined && truncated(c);
233
+ const text = p?.text ?? props.text;
234
+ const kept = cutNow ? text.slice(0, head(c)) : text;
235
+ const rest = cutNow ? text.slice(tail(c)) : "";
236
+ const own = {
237
+ ref: handle.ref,
238
+ className: props.className,
239
+ style: line,
240
+ title: !full && cutNow ? text : undefined,
241
+ };
242
+ if (!full) return createElement("span", own, kept + (cutNow ? ellipsis + rest : ""));
243
+ return createElement(
244
+ "span",
245
+ own,
246
+ kept,
247
+ createElement("span", { style: collapsed }, cutNow ? text.slice(head(c), tail(c)) : ""),
248
+ cutNow ? createElement("span", { "aria-hidden": true, style: unselectable }, ellipsis) : null,
249
+ rest,
250
+ );
251
+ }
@@ -0,0 +1,89 @@
1
+ import type { JSX } from "@solidjs/web";
2
+ import { createMemo, createSignal, onSettled, Show } from "solid-js";
3
+ import { type CutOptions, head, type Protect, tail, truncated } from "./cut.ts";
4
+ import { observe } from "./dom.ts";
5
+ import { clamp, prepareLines } from "./lines.ts";
6
+ import { createBox } from "./solid.tsx";
7
+
8
+ export interface ClampProps extends Omit<CutOptions, "tailGraphemes"> {
9
+ readonly text: string;
10
+ /** How many lines to keep. */
11
+ readonly lines: number;
12
+ readonly ellipsis?: string;
13
+ /**
14
+ * Painted after the text whenever it is cut or `expanded`, such as a "more" or
15
+ * "less" button. Its width is observed and kept free on the last line.
16
+ */
17
+ readonly after?: JSX.Element;
18
+ /** Show every line, with `after` following the last one, on its own if it doesn't fit. */
19
+ readonly expanded?: boolean;
20
+ /** The room in px. Left out, it is observed from the element. */
21
+ readonly width?: number;
22
+ /** A CSS font shorthand. Left out, it is read from the element's computed style. */
23
+ readonly font?: string;
24
+ /** CSS `letter-spacing` in px. Left out, it is read from the element's computed style. */
25
+ readonly letterSpacing?: number;
26
+ readonly protect?: Protect;
27
+ readonly whiteSpace?: "normal" | "pre-wrap";
28
+ readonly wordBreak?: "normal" | "keep-all";
29
+ readonly class?: string;
30
+ }
31
+
32
+ /** A paragraph cut to a number of lines at its end, start or middle. */
33
+ export function Clamp(props: ClampProps): JSX.Element {
34
+ const box = createBox(props);
35
+ const [afterWidth, setAfterWidth] = createSignal(0);
36
+ // `after` mounts only once the text is cut, so it is observed from its own ref.
37
+ let stop: (() => void) | undefined;
38
+ const watch = (element: Element) => {
39
+ stop?.();
40
+ stop = observe(element, setAfterWidth);
41
+ };
42
+ onSettled(() => () => stop?.());
43
+ const prepared = createMemo(() => {
44
+ box.fonts();
45
+ const font = box.font();
46
+ if (font === undefined) return undefined;
47
+ return prepareLines(props.text, font, {
48
+ ...(props.protect ? { protect: props.protect } : {}),
49
+ ...(props.whiteSpace ? { whiteSpace: props.whiteSpace } : {}),
50
+ ...(props.wordBreak ? { wordBreak: props.wordBreak } : {}),
51
+ letterSpacing: box.spacing(),
52
+ });
53
+ });
54
+ const cut = createMemo(() => {
55
+ const p = prepared();
56
+ const width = box.width();
57
+ const font = box.font();
58
+ if (p === undefined || width === undefined || font === undefined) return undefined;
59
+ // `after` is an element here and a width to `clamp`, so it's always given as one.
60
+ return clamp(p, width, {
61
+ ...props,
62
+ after: props.after === undefined ? 0 : afterWidth(),
63
+ });
64
+ });
65
+ const cutNow = () => {
66
+ const c = cut();
67
+ return c !== undefined && truncated(c);
68
+ };
69
+ const text = () => {
70
+ const p = prepared();
71
+ const c = cut();
72
+ if (props.expanded || p === undefined || c === undefined || !truncated(c)) return props.text;
73
+ return p.text.slice(0, head(c)) + (props.ellipsis ?? "…") + p.text.slice(tail(c));
74
+ };
75
+ return (
76
+ <div
77
+ ref={box.ref}
78
+ class={props.class}
79
+ style={{ "overflow-wrap": "break-word", "white-space": props.whiteSpace ?? "normal" }}
80
+ >
81
+ {text()}
82
+ <Show when={props.after !== undefined && (props.expanded || cutNow())}>
83
+ <span ref={watch} style={{ display: "inline-block" }}>
84
+ {props.after}
85
+ </span>
86
+ </Show>
87
+ </div>
88
+ );
89
+ }
package/src/solid.tsx ADDED
@@ -0,0 +1,225 @@
1
+ import type { JSX } from "@solidjs/web";
2
+ import {
3
+ type Accessor,
4
+ createContext,
5
+ createMemo,
6
+ createSignal,
7
+ onSettled,
8
+ Show,
9
+ untrack,
10
+ useContext,
11
+ } from "solid-js";
12
+ import {
13
+ cut,
14
+ type Cut,
15
+ type CutOptions,
16
+ head,
17
+ prepare,
18
+ type Prepared,
19
+ type Protect,
20
+ tail,
21
+ truncated,
22
+ } from "./cut.ts";
23
+ import { fontOf, observe, onFonts, spacingOf } from "./dom.ts";
24
+ import { faceOf, widthOf } from "./face.ts";
25
+
26
+ export interface CutProps extends CutOptions {
27
+ readonly text: string;
28
+ /** The room in px. Left out, it is observed from the element. */
29
+ readonly width?: number;
30
+ /** A CSS font shorthand. Left out, it is read from the element's computed style. */
31
+ readonly font?: string;
32
+ /** CSS `letter-spacing` in px. Left out, it is read from the element's computed style. */
33
+ readonly letterSpacing?: number;
34
+ readonly protect?: Protect;
35
+ }
36
+
37
+ export interface CutHandle {
38
+ /** Give it the element whose width and font the cut follows. */
39
+ readonly ref: (element: Element) => void;
40
+ readonly prepared: Accessor<Prepared | undefined>;
41
+ /** Undefined until the width and font are known. */
42
+ readonly cut: Accessor<Cut | undefined>;
43
+ }
44
+
45
+ export interface Box {
46
+ /** Give it the element whose width and font the text follows. */
47
+ readonly ref: (element: Element) => void;
48
+ /** The width given, or the element's content width; undefined until observed. */
49
+ readonly width: Accessor<number | undefined>;
50
+ /** The font given, or the element's; undefined until it mounts. */
51
+ readonly font: Accessor<string | undefined>;
52
+ /** The letter spacing given, or the element's, in px. */
53
+ readonly spacing: Accessor<number>;
54
+ /** Changes whenever web fonts load: read it where text is prepared. */
55
+ readonly fonts: Accessor<number>;
56
+ }
57
+
58
+ export interface FontProviderProps {
59
+ /** A CSS font shorthand that every cut inside is in, unless it's given its own. */
60
+ readonly font?: string;
61
+ /** CSS `letter-spacing` in px for every cut inside, unless it's given its own. */
62
+ readonly letterSpacing?: number;
63
+ readonly children?: JSX.Element;
64
+ }
65
+
66
+ const Fonts = createContext<FontProviderProps>({});
67
+
68
+ /**
69
+ * Gives every `Truncate`, `Clamp` and `createBox` inside the font and letter spacing
70
+ * their text renders in, once for a list. Each would otherwise read its element's
71
+ * computed style when it mounts: 5,000 rows take 4 to 5 ms of style reads. Cuts follow
72
+ * what's given, so give what the rows really render in.
73
+ */
74
+ export function FontProvider(props: FontProviderProps): JSX.Element {
75
+ return <Fonts value={props}>{props.children}</Fonts>;
76
+ }
77
+
78
+ /**
79
+ * The width and font text is cut to: the ones given, then a `FontProvider`'s, or an
80
+ * element's, observed. Whether to observe the width and read the font is decided once,
81
+ * when it settles.
82
+ */
83
+ export const createBox = (props: {
84
+ readonly width?: number;
85
+ readonly font?: string;
86
+ readonly letterSpacing?: number;
87
+ }): Box => {
88
+ const provided = useContext(Fonts);
89
+ const given = () => props.font ?? provided.font;
90
+ const spacing = () => props.letterSpacing ?? provided.letterSpacing;
91
+ let element: Element | undefined;
92
+ const [observed, setObserved] = createSignal<number>();
93
+ const [computed, setComputed] = createSignal<string>();
94
+ const [spaced, setSpaced] = createSignal(0);
95
+ const [fonts, setFonts] = createSignal(0);
96
+ onSettled(() => {
97
+ const stops = [onFonts(() => setFonts((n) => n + 1))];
98
+ const readFont = untrack(given) === undefined;
99
+ const readSpacing = untrack(spacing) === undefined;
100
+ if (element && (readFont || readSpacing)) {
101
+ const style = getComputedStyle(element);
102
+ if (readFont) setComputed(fontOf(element, style));
103
+ if (readSpacing) setSpaced(spacingOf(element, style));
104
+ }
105
+ if (element && untrack(() => props.width) === undefined) {
106
+ stops.push(observe(element, setObserved));
107
+ }
108
+ return () => {
109
+ for (const stop of stops) stop();
110
+ };
111
+ });
112
+ return {
113
+ ref: (e) => (element = e),
114
+ width: () => props.width ?? observed(),
115
+ font: () => given() ?? computed(),
116
+ spacing: () => spacing() ?? spaced(),
117
+ fonts,
118
+ };
119
+ };
120
+
121
+ export interface CreateCutProps extends CutProps {
122
+ /**
123
+ * The ellipsis is painted in an element of its own, so it is measured as a run of its
124
+ * own and no kerning across it counts. Read once.
125
+ */
126
+ readonly separateEllipsis?: boolean;
127
+ }
128
+
129
+ /** The cut of `props.text` for an element's width and font, or for the ones given. */
130
+ export const createCut = (props: CreateCutProps): CutHandle =>
131
+ cutOf(
132
+ props,
133
+ untrack(() => props.separateEllipsis),
134
+ );
135
+
136
+ const cutOf = (props: CutProps, apart: boolean | undefined): CutHandle => {
137
+ const box = createBox(props);
138
+ const font = box.font;
139
+ const prepared = createMemo(() => {
140
+ box.fonts();
141
+ const f = font();
142
+ if (f === undefined) return undefined;
143
+ return prepare(props.text, f, {
144
+ ...(props.protect ? { protect: props.protect } : {}),
145
+ letterSpacing: box.spacing(),
146
+ });
147
+ });
148
+ const result = createMemo(() => {
149
+ const p = prepared();
150
+ const width = box.width();
151
+ const f = font();
152
+ if (p === undefined || width === undefined || f === undefined) return undefined;
153
+ const ellipsis = props.ellipsis ?? "…";
154
+ return apart && typeof ellipsis === "string"
155
+ ? cut(p, width, {
156
+ ...props,
157
+ ellipsis: widthOf(faceOf(f), ellipsis) + box.spacing() * ellipsis.length,
158
+ })
159
+ : cut(p, width, props);
160
+ });
161
+ return { ref: box.ref, prepared, cut: result };
162
+ };
163
+
164
+ export interface TruncateProps extends CutProps {
165
+ readonly ellipsis?: string;
166
+ /**
167
+ * "kept" (default) paints the kept text, with the whole text in `title` once cut.
168
+ * "full" paints all of it with the cut part collapsed to no width, so copy,
169
+ * find-in-page and screen readers get the whole text. Read once.
170
+ */
171
+ readonly render?: "kept" | "full";
172
+ readonly class?: string;
173
+ }
174
+
175
+ /** One line of text, cut to fit its box. */
176
+ export function Truncate(props: TruncateProps): JSX.Element {
177
+ const full = untrack(() => props.render === "full");
178
+ const handle = cutOf(props, full);
179
+ const ellipsis = () => props.ellipsis ?? "…";
180
+ // Head, cut part, tail and, once cut, the whole text. Undefined until the first cut.
181
+ const parts = createMemo(() => {
182
+ const p = handle.prepared();
183
+ const c = handle.cut();
184
+ if (p === undefined || c === undefined) return undefined;
185
+ const text = p.text;
186
+ return [
187
+ text.slice(0, head(c)),
188
+ text.slice(head(c), tail(c)),
189
+ text.slice(tail(c)),
190
+ truncated(c) ? text : undefined,
191
+ ] as const;
192
+ });
193
+ // Until the first cut, the browser's own end ellipsis stands in.
194
+ return (
195
+ <span
196
+ ref={handle.ref}
197
+ class={props.class}
198
+ style={{
199
+ display: "block",
200
+ overflow: "hidden",
201
+ "white-space": "nowrap",
202
+ "text-overflow": "ellipsis",
203
+ }}
204
+ title={full ? undefined : parts()?.[3]}
205
+ >
206
+ {full ? (
207
+ <>
208
+ {parts()?.[0] ?? props.text}
209
+ <span style={{ "font-size": "0" }}>{parts()?.[1]}</span>
210
+ <Show when={parts()?.[3]}>
211
+ <span
212
+ aria-hidden="true"
213
+ style={{ "user-select": "none", "-webkit-user-select": "none" }}
214
+ >
215
+ {ellipsis()}
216
+ </span>
217
+ </Show>
218
+ {parts()?.[2]}
219
+ </>
220
+ ) : (
221
+ <>{(parts()?.[0] ?? props.text) + (parts()?.[3] ? ellipsis() + parts()![2] : "")}</>
222
+ )}
223
+ </span>
224
+ );
225
+ }