diff --git a/README.md b/README.md index 124a8dc..f5f3328 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![npm](https://img.shields.io/npm/v/@tonybonet/truncate?label=npm&color=111318)](https://www.npmjs.com/package/@tonybonet/truncate) [![downloads](https://img.shields.io/npm/dm/@tonybonet/truncate?label=downloads&color=111318)](https://www.npmjs.com/package/@tonybonet/truncate) -[![bundle size](https://img.shields.io/bundlephobia/minzip/@tonybonet/truncate?color=111318)](https://bundlephobia.com/package/@tonybonet/truncate) +[![bundle size](https://img.shields.io/bundlephobia/minzip/@tonybonet/truncate@0.4.2?color=111318)](https://bundlephobia.com/package/@tonybonet/truncate@0.4.2) [![license](https://img.shields.io/npm/l/@tonybonet/truncate?color=111318)](LICENSE) DOM-free core, grapheme-safe text truncation for JavaScript and TypeScript, powered by [`@chenglou/pretext`](https://github.com/chenglou/pretext). Fit copy by pixel width, line count, target string, explicit range, or measured height without layout reads; opt into element binding only when component code already owns a DOM-compatible node. @@ -213,7 +213,7 @@ truncateByWidth(articleIntro, { font: "16px Inter", maxWidth: 260, ellipsis: " R ### Bind to a DOM-Compatible Element -When you already have a real DOM element, custom element, or DOM-compatible object, bind once. After that, calls do not take an element reference, `font`, or text. Width and other options can still override inferred defaults after binding, but the element must provide an initial width source. +When you already have a real DOM element, custom element, or DOM-compatible object, bind once. After that, calls do not take an element reference, `font`, or text. The library reads the element's computed typography internally and uses the element's rendered width as `maxWidth` by default. Pass `maxWidth` only when you want an operation-specific override; that override can be a number or CSS width such as `18rem`, `32em`, `40ch`, `50vw`, or `50vh`. ```ts const title = document.querySelector("[data-truncate]")!; @@ -221,7 +221,7 @@ const titleTruncator = createTruncator(title); titleTruncator.truncate(); titleTruncator.truncate({ maxLines: 2 }); -titleTruncator.truncateMiddle({ ellipsis: "....." }); +titleTruncator.truncateMiddle({ maxWidth: "40ch", ellipsis: "....." }); ``` The bound truncator avoids compounded truncation: if it wrote a previous result, the next call still uses the last source text unless external code changes `textContent`. @@ -245,7 +245,7 @@ const label = { createTruncator(label).truncate(); ``` -See [`docs/element-bound.md`](docs/element-bound.md) for the full adapter contract, width requirement, style inference rules, and no-DOM TypeScript notes. +See [`docs/element-bound.md`](docs/element-bound.md) for the full adapter contract, width requirement, `maxWidth` override rules, style inference rules, and no-DOM TypeScript notes. ## API @@ -371,7 +371,7 @@ t.measureHeight("Hello\nworld", { maxWidth: 320 }); ### `createTruncator(element)` -Component convenience for DOM or DOM-compatible code. It accepts any `DOMCompatibleElement`: normal DOM nodes, custom elements, or objects that expose `nodeType`, `textContent`, a width source, and optional `computedStyle`. It reads style and width at creation time, reads current `textContent` per call, and writes the truncated text back without compounding prior write-back. +Component convenience for DOM or DOM-compatible code. It accepts any `DOMCompatibleElement`: normal DOM nodes, custom elements, or objects that expose `nodeType`, `textContent`, a width source, and optional `computedStyle`. It reads typography and width at creation time, reads current `textContent` per call, and writes the truncated text back without compounding prior write-back. You do not pass `font` for normal element-bound calls; only pass `maxWidth` when overriding the element width for a specific operation. ```ts const element = document.querySelector("[data-truncate]")!; @@ -379,7 +379,7 @@ const t = createTruncator(element); t.truncate(); t.truncate({ maxLines: 2 }); -t.truncateStart({ ellipsis: " READ MORE" }); +t.truncateStart({ maxWidth: "32ch", ellipsis: " READ MORE" }); ``` For custom adapters, provide the DOM-compatible surface directly: @@ -422,7 +422,7 @@ truncateByWidth("Hello", { | --------------- | ------------------------ | ------------------------ | ----------------------------- | | `font` | `string` | auto-detect in browser | measurement APIs | | `selector` | `string` | - | font lookup | -| `maxWidth` | `CssWidth` | required | truncation and measurement | +| `maxWidth` | `CssWidth` | required unless bound | truncation and measurement | | `ellipsis` | `string` | `…` | custom marker or suffix | | `maxLines` | `number` | `1` | `truncate`, `truncateByLines` | | `keepLines` | `number[]` | - | `truncate`, `truncateByLines` | diff --git a/docs/element-bound.md b/docs/element-bound.md index dea29c2..7ed32a5 100644 --- a/docs/element-bound.md +++ b/docs/element-bound.md @@ -2,7 +2,7 @@ `createTruncator(element)` is the explicit DOM boundary for this package. The core API remains text-first and DOM-free; element binding is for component code that already owns a node or a DOM-compatible adapter. -Use it when you want to bind once, infer defaults from the element, and then call truncation methods without passing `(element)` or `(text)` every time. +Use it when you want to bind once, infer defaults from the element, and then call truncation methods without passing `(element)`, `(text)`, or `font` every time. The bound API reads typography internally; callers usually only pass content options such as `maxLines`, `ellipsis`, or an operation-specific `maxWidth`. ```ts import { createTruncator, type DOMCompatibleElement } from "@tonybonet/truncate"; @@ -12,7 +12,7 @@ const t = createTruncator(title); t.truncate(); t.truncate({ maxLines: 2 }); -t.truncateMiddle({ ellipsis: "....." }); +t.truncateMiddle({ maxWidth: "40ch", ellipsis: "....." }); ``` ## Contract @@ -24,6 +24,7 @@ t.truncateMiddle({ ellipsis: "....." }); - The result is written back to `element.textContent`. - Repeated calls do not compound prior write-back; the bound truncator keeps the last external source text unless outside code changes `textContent`. - If styles change, create a new bound truncator. +- Per-call `maxWidth` overrides are optional and can use supported CSS width units. ## What Counts as DOM-Compatible @@ -82,7 +83,9 @@ t.truncate(); // label.textContent now contains the truncated result ``` -## Width Requirement +## Width and `maxWidth` + +For normal element-bound usage, you do not calculate or pass `maxWidth`. The library reads the element's own rendered width and uses that as the default `maxWidth`. An element must provide width at bind time: @@ -102,7 +105,17 @@ const missingWidth = { createTruncator(missingWidth as unknown as DOMCompatibleElement); ``` -Per-call `maxWidth` can override the cached width for an operation, but it does not replace the bind-time width requirement. The bound API needs a safe default before it can create the truncator. +Per-call `maxWidth` overrides the cached element width for one operation. It can be a number or a supported CSS width string, including `px`, `rem`, `em`, `ch`, `vw`, and `vh`. `em` and `ch` are resolved against the inferred element font, so you do not need to pass `font` just to use those units. + +```ts +const t = createTruncator(document.querySelector("[data-truncate]")!); + +t.truncate(); // uses the element width +t.truncate({ maxWidth: "32ch" }); // overrides width, keeps inferred font +t.truncateByLines({ maxWidth: "24em", maxLines: 2 }); +``` + +A per-call override does not replace the bind-time width requirement. The bound API still needs a safe default width before it can create the truncator. ## Style Inference diff --git a/tests/element.test.ts b/tests/element.test.ts index 543867b..c8e2a53 100644 --- a/tests/element.test.ts +++ b/tests/element.test.ts @@ -270,6 +270,14 @@ test("explicit maxWidth overrides inferred width", () => { expect(r.truncated).toBe(true); }); +test("explicit CSS maxWidth override uses inferred element font", () => { + const el = mockElement(LONG, { fontSize: "20px", fontFamily: "serif" }, 500); + const t = createTruncator(el); + + expect(t.truncateByWidth({ maxWidth: "4em" }).truncated).toBe(true); + expect(t.truncateByWidth({ maxWidth: "4ch" }).truncated).toBe(true); +}); + test("explicit lineHeight overrides inferred lineHeight", () => { const el = mockElement(PARA, { lineHeight: "20px" }, 80); const t = createTruncator(el); diff --git a/website/src/components/apiDocsModel.ts b/website/src/components/apiDocsModel.ts index edb5fae..32094db 100644 --- a/website/src/components/apiDocsModel.ts +++ b/website/src/components/apiDocsModel.ts @@ -240,7 +240,7 @@ export const functionDocs: ApiFunctionDoc[] = [ title: "createTruncator", badge: "factory", description: - "Pre-binds shared text options, or binds to a DOM-compatible element/adapter for component code.", + "Pre-binds shared text options, or binds to a DOM-compatible element/adapter and infers its typography and width.", signature: `createTruncator(config: Partial): Truncator createTruncator(element: DOMCompatibleElement): BoundTruncator`, returns: "Truncator | BoundTruncator", @@ -249,7 +249,8 @@ textTruncator.truncateByLines(longArticle, { maxWidth: 320, maxLines: 3 }) const element = document.querySelector("[data-truncate]")! const elementTruncator = createTruncator(element) -elementTruncator.truncate({ maxLines: 2 })`, +elementTruncator.truncate({ maxLines: 2 }) +elementTruncator.truncateMiddle({ maxWidth: "40ch" })`, }, { id: "fn-detectFont", @@ -300,7 +301,7 @@ export const typeDocs: ApiTypeDoc[] = [ title: "BoundTruncator", signature: `interface BoundTruncator {\n truncate(opts?: Partial): TruncateResult\n truncateByWidth(opts?: Partial): TruncateResult\n truncateByLines(opts?: Partial): TruncateResult\n truncateStart(opts?: Partial): TruncateResult\n truncateMiddle(opts?: Partial): TruncateResult\n truncateAtOffset(opts?: Partial): TruncateResult\n truncateRange(opts?: Partial): TruncateResult\n truncateAround(opts?: Partial): TruncateResult\n measureHeight(opts?: Partial): number\n}`, description: - "Element-bound truncator returned by `createTruncator(element)`. Accepts DOM nodes, custom elements, or DOM-compatible objects; reads current textContent per call, writes back the result, and avoids compounding prior write-back. Recreate it when styles change.", + "Element-bound truncator returned by `createTruncator(element)`. Accepts DOM nodes, custom elements, or DOM-compatible objects; infers font/width at bind time, reads current textContent per call, writes back the result, and avoids compounding prior write-back. Pass maxWidth only to override the inferred width for one operation.", }, { id: "type-DOMCompatibleElement", @@ -341,7 +342,7 @@ type DOMCompatibleElement = DOMNativeElementLike | DOMCompatibleAdapter`, export const optionRows = [ ["font", "string", "auto-detect", "All measurement APIs"], ["selector", "string", "—", "Font lookup"], - ["maxWidth", "CssWidth", "required", "All truncation and measurement APIs"], + ["maxWidth", "CssWidth", "required unless bound", "All truncation and measurement APIs"], ["lineHeight", "number", "20 for line truncation", "Lines and height"], ["maxLines", "number", "1", "truncateByLines"], ["keepLines", "number[]", "—", "truncateByLines"],