From 9c3d2a02afe2182a370bd285d18dc75bc689ea01 Mon Sep 17 00:00:00 2001
From: saifmohamedsv
Date: Fri, 17 Jul 2026 19:51:36 +0300
Subject: [PATCH 1/2] =?UTF-8?q?feat(hookli):=20add=20useExpandableText=20?=
=?UTF-8?q?=E2=80=94=20combined=20character=20+=20line=20budget?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Collapse long text behind a show-more toggle, capping by a character
budget (pure/SSR-safe, word-boundary aware), a line budget (CSS
line-clamp measured via ResizeObserver, responsive), or both — whichever
clips first wins. Returns { text, isExpanded, isTruncated, toggle,
expand, collapse, ref, clampStyle }. Registered in the barrel + manifest
(40 → 41); README/count regenerated from the manifest.
Co-Authored-By: Claude Opus 4.8
---
README.md | 3 +-
packages/hookli/README.md | 3 +-
packages/hookli/hooks.manifest.json | 7 +
packages/hookli/src/hooks/index.ts | 1 +
.../src/hooks/use-expandable-text/index.ts | 1 +
.../use-expandable-text.test.ts | 77 ++++++++
.../use-expandable-text.ts | 182 ++++++++++++++++++
7 files changed, 272 insertions(+), 2 deletions(-)
create mode 100644 packages/hookli/src/hooks/use-expandable-text/index.ts
create mode 100644 packages/hookli/src/hooks/use-expandable-text/use-expandable-text.test.ts
create mode 100644 packages/hookli/src/hooks/use-expandable-text/use-expandable-text.ts
diff --git a/README.md b/README.md
index 296356d..1dfa548 100644
--- a/README.md
+++ b/README.md
@@ -29,7 +29,7 @@
-
+
@@ -106,6 +106,7 @@ function Component() {
- **[`useClickOutside`](https://hookli.vercel.app/docs/use-click-outside)** — Runs a callback on outside click.
- **[`useMousePosition`](https://hookli.vercel.app/docs/use-mouse-position)** — Cursor coordinates within an element.
- **[`useInfiniteScroll`](https://hookli.vercel.app/docs/use-infinite-scroll)** — Triggers loading near the scroll end.
+- **[`useExpandableText`](https://hookli.vercel.app/docs/use-expandable-text)** — Collapse long text by a character and/or line budget with a show-more toggle.
- **[`useHover`](https://hookli.vercel.app/docs/use-hover)** — Tracks whether the pointer is hovering an element.
- **[`useIntersectionObserver`](https://hookli.vercel.app/docs/use-intersection-observer)** — Observe an element's viewport intersection reactively.
- **[`useResizeObserver`](https://hookli.vercel.app/docs/use-resize-observer)** — Measure an element's size reactively via ResizeObserver.
diff --git a/packages/hookli/README.md b/packages/hookli/README.md
index e653de1..ea52f77 100644
--- a/packages/hookli/README.md
+++ b/packages/hookli/README.md
@@ -27,7 +27,7 @@
-
+
@@ -104,6 +104,7 @@ function Component() {
- **[`useClickOutside`](https://hookli.vercel.app/docs/use-click-outside)** — Runs a callback on outside click.
- **[`useMousePosition`](https://hookli.vercel.app/docs/use-mouse-position)** — Cursor coordinates within an element.
- **[`useInfiniteScroll`](https://hookli.vercel.app/docs/use-infinite-scroll)** — Triggers loading near the scroll end.
+- **[`useExpandableText`](https://hookli.vercel.app/docs/use-expandable-text)** — Collapse long text by a character and/or line budget with a show-more toggle.
- **[`useHover`](https://hookli.vercel.app/docs/use-hover)** — Tracks whether the pointer is hovering an element.
- **[`useIntersectionObserver`](https://hookli.vercel.app/docs/use-intersection-observer)** — Observe an element's viewport intersection reactively.
- **[`useResizeObserver`](https://hookli.vercel.app/docs/use-resize-observer)** — Measure an element's size reactively via ResizeObserver.
diff --git a/packages/hookli/hooks.manifest.json b/packages/hookli/hooks.manifest.json
index e865afe..468ba53 100644
--- a/packages/hookli/hooks.manifest.json
+++ b/packages/hookli/hooks.manifest.json
@@ -197,6 +197,13 @@
"category": "dom",
"signature": "useInfiniteScroll(fetchMoreData: () => Promise): boolean"
},
+ {
+ "slug": "use-expandable-text",
+ "name": "useExpandableText",
+ "description": "Collapse long text by a character and/or line budget with a show-more toggle.",
+ "category": "dom",
+ "signature": "useExpandableText(text: string, options?: UseExpandableTextOptions): UseExpandableTextResult"
+ },
{
"slug": "use-hover",
"name": "useHover",
diff --git a/packages/hookli/src/hooks/index.ts b/packages/hookli/src/hooks/index.ts
index 7f7f628..f3da7c8 100644
--- a/packages/hookli/src/hooks/index.ts
+++ b/packages/hookli/src/hooks/index.ts
@@ -18,6 +18,7 @@ export * from "./use-debounce-value";
export * from "./use-document-title";
export * from "./use-event-callback";
export * from "./use-event-listener";
+export * from "./use-expandable-text";
export * from "./use-fetch";
export * from "./use-form";
export * from "./use-geo-location";
diff --git a/packages/hookli/src/hooks/use-expandable-text/index.ts b/packages/hookli/src/hooks/use-expandable-text/index.ts
new file mode 100644
index 0000000..3ab6820
--- /dev/null
+++ b/packages/hookli/src/hooks/use-expandable-text/index.ts
@@ -0,0 +1 @@
+export * from "./use-expandable-text";
diff --git a/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.test.ts b/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.test.ts
new file mode 100644
index 0000000..f9cd4f4
--- /dev/null
+++ b/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.test.ts
@@ -0,0 +1,77 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { useExpandableText } from "./use-expandable-text";
+
+const LONG = "The quick brown fox jumps over the lazy dog again and again";
+
+describe("useExpandableText", () => {
+ it("returns the full text and is not truncated with no limits", () => {
+ const { result } = renderHook(() => useExpandableText(LONG));
+ expect(result.current.text).toBe(LONG);
+ expect(result.current.isTruncated).toBe(false);
+ });
+
+ it("caps to maxChars on a word boundary and appends the ellipsis", () => {
+ const { result } = renderHook(() => useExpandableText(LONG, { maxChars: 20 }));
+ // 20 chars lands mid-word ("The quick brown fox "); backs off to "fox".
+ expect(result.current.text).toBe("The quick brown fox…");
+ expect(result.current.isTruncated).toBe(true);
+ expect(result.current.isExpanded).toBe(false);
+ });
+
+ it("does not truncate when the text fits the character budget", () => {
+ const { result } = renderHook(() =>
+ useExpandableText("short", { maxChars: 20 }),
+ );
+ expect(result.current.text).toBe("short");
+ expect(result.current.isTruncated).toBe(false);
+ });
+
+ it("honours a custom ellipsis", () => {
+ const { result } = renderHook(() =>
+ useExpandableText(LONG, { maxChars: 20, ellipsis: " [more]" }),
+ );
+ expect(result.current.text).toBe("The quick brown fox [more]");
+ });
+
+ it("toggle/expand/collapse reveal and re-hide the full text", () => {
+ const { result } = renderHook(() => useExpandableText(LONG, { maxChars: 20 }));
+
+ act(() => result.current.toggle());
+ expect(result.current.isExpanded).toBe(true);
+ expect(result.current.text).toBe(LONG);
+
+ act(() => result.current.collapse());
+ expect(result.current.isExpanded).toBe(false);
+ expect(result.current.text).toBe("The quick brown fox…");
+
+ act(() => result.current.expand());
+ expect(result.current.isExpanded).toBe(true);
+ expect(result.current.text).toBe(LONG);
+ });
+
+ it("respects defaultExpanded", () => {
+ const { result } = renderHook(() =>
+ useExpandableText(LONG, { maxChars: 20, defaultExpanded: true }),
+ );
+ expect(result.current.isExpanded).toBe(true);
+ expect(result.current.text).toBe(LONG);
+ // still reports truncation so the "show less" control stays visible
+ expect(result.current.isTruncated).toBe(true);
+ });
+
+ it("applies the line-clamp style only while collapsed with maxLines set", () => {
+ const { result } = renderHook(() =>
+ useExpandableText(LONG, { maxLines: 3 }),
+ );
+ expect(result.current.clampStyle.WebkitLineClamp).toBe(3);
+ expect(result.current.clampStyle.overflow).toBe("hidden");
+
+ act(() => result.current.expand());
+ expect(result.current.clampStyle.WebkitLineClamp).toBeUndefined();
+ });
+
+ // NOTE: line-based truncation detection relies on real layout (scrollHeight /
+ // getComputedStyle line-height), which jsdom does not compute — so isTruncated
+ // via `maxLines` is exercised in the browser demo, not here.
+});
diff --git a/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.ts b/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.ts
new file mode 100644
index 0000000..0c4094f
--- /dev/null
+++ b/packages/hookli/src/hooks/use-expandable-text/use-expandable-text.ts
@@ -0,0 +1,182 @@
+import {
+ useCallback,
+ useEffect,
+ useState,
+ type CSSProperties,
+ type RefCallback,
+} from "react";
+
+/**
+ * Options accepted by {@link useExpandableText}. Provide `maxChars`, `maxLines`,
+ * or both — whichever limit clips first wins.
+ */
+export interface UseExpandableTextOptions {
+ /**
+ * Maximum characters shown while collapsed. Pure string logic, so it works on
+ * the server and yields a genuinely shortened string (trimmed to a word
+ * boundary). Omit for no character cap.
+ */
+ maxChars?: number;
+ /**
+ * Maximum lines shown while collapsed. Applied as a CSS line-clamp via
+ * {@link UseExpandableTextResult.clampStyle} and measured in the DOM, so it is
+ * responsive (re-clips as the container narrows). Omit for no line cap.
+ */
+ maxLines?: number;
+ /** Appended to character-truncated text. Defaults to `"…"`. */
+ ellipsis?: string;
+ /** Whether the text starts expanded. Defaults to `false`. */
+ defaultExpanded?: boolean;
+}
+
+/**
+ * The value returned by {@link useExpandableText}.
+ */
+export interface UseExpandableTextResult {
+ /**
+ * The text to render — character-capped when collapsed and `maxChars` is set,
+ * otherwise the full text.
+ */
+ text: string;
+ /** Whether the full text is currently shown. */
+ isExpanded: boolean;
+ /**
+ * `true` when either limit actually clips the text. Use it to hide the toggle
+ * when the text fits and no control is needed.
+ */
+ isTruncated: boolean;
+ /** Flip between expanded and collapsed. */
+ toggle: () => void;
+ /** Show the full text. */
+ expand: () => void;
+ /** Collapse back to the limit. */
+ collapse: () => void;
+ /**
+ * Attach to the text element. Required for the `maxLines` clamp and its
+ * overflow measurement; harmless when only `maxChars` is used.
+ */
+ ref: RefCallback;
+ /**
+ * Inline style applying the CSS line-clamp while collapsed and `maxLines` is
+ * set (empty otherwise). Spread onto the text element.
+ */
+ clampStyle: CSSProperties;
+}
+
+/** Cut `text` to at most `maxChars`, backing off to the last word boundary. */
+const truncateChars = (
+ text: string,
+ maxChars: number,
+ ellipsis: string,
+): string => {
+ if (text.length <= maxChars) return text;
+ const slice = text.slice(0, maxChars);
+ const lastSpace = slice.lastIndexOf(" ");
+ const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
+ return cut.trimEnd() + ellipsis;
+};
+
+/**
+ * Collapse long text behind a "show more / less" toggle, capping it by a
+ * **character** budget, a **line** budget, or both — whichever clips first wins.
+ *
+ * The two limits cover each other's blind spots. `maxChars` is pure string
+ * logic: SSR-safe and it produces a genuinely shortened string. `maxLines` can
+ * only be resolved in the DOM, so it is applied as a CSS line-clamp and measured
+ * with a `ResizeObserver`, making it responsive to container width. With both
+ * set, the string is capped to `maxChars` *and* the element is clamped to
+ * `maxLines`, and `isTruncated` is `true` if either would clip.
+ *
+ * Measurement runs inside an effect and the observer is disconnected on cleanup,
+ * so the hook is SSR-safe and leaks nothing.
+ *
+ * @param text - The full text to (maybe) collapse.
+ * @param options - Character and/or line budgets and display options.
+ * @returns The text to render plus `{ isExpanded, isTruncated, toggle, expand, collapse, ref, clampStyle }`.
+ *
+ * @example
+ * ```tsx
+ * const { text, isTruncated, isExpanded, toggle, ref, clampStyle } =
+ * useExpandableText(review.body, { maxChars: 180, maxLines: 3 });
+ *
+ * return (
+ * <>
+ *
{text}
+ * {isTruncated && (
+ *
+ * )}
+ * >
+ * );
+ * ```
+ */
+export const useExpandableText = (
+ text: string,
+ options: UseExpandableTextOptions = {},
+): UseExpandableTextResult => {
+ const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options;
+
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ const [node, setNode] = useState(null);
+ const [lineOverflow, setLineOverflow] = useState(false);
+
+ const charTruncated = maxChars !== undefined && text.length > maxChars;
+ const collapsedText = charTruncated
+ ? truncateChars(text, maxChars, ellipsis)
+ : text;
+ const displayText = isExpanded ? text : collapsedText;
+
+ const ref = useCallback>((el) => setNode(el), []);
+
+ // Measure whether the full text overflows `maxLines`. `scrollHeight` reflects
+ // the full content height regardless of the clamp, so the read is accurate in
+ // both states — except when the char cap has already shortened the string, in
+ // which case `charTruncated` alone drives `isTruncated`.
+ useEffect(() => {
+ if (!node || maxLines === undefined) {
+ setLineOverflow(false);
+ return;
+ }
+ const measure = () => {
+ const style = window.getComputedStyle(node);
+ let lineHeight = Number.parseFloat(style.lineHeight);
+ if (!Number.isFinite(lineHeight)) {
+ lineHeight = Number.parseFloat(style.fontSize) * 1.2;
+ }
+ const padding =
+ Number.parseFloat(style.paddingTop) +
+ Number.parseFloat(style.paddingBottom);
+ const maxHeight = lineHeight * maxLines + (padding || 0);
+ setLineOverflow(node.scrollHeight > maxHeight + 1);
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [node, maxLines, text, isExpanded]);
+
+ const toggle = useCallback(() => setIsExpanded((value) => !value), []);
+ const expand = useCallback(() => setIsExpanded(true), []);
+ const collapse = useCallback(() => setIsExpanded(false), []);
+
+ const clampStyle: CSSProperties =
+ !isExpanded && maxLines !== undefined
+ ? {
+ display: "-webkit-box",
+ WebkitBoxOrient: "vertical",
+ WebkitLineClamp: maxLines,
+ overflow: "hidden",
+ }
+ : {};
+
+ return {
+ text: displayText,
+ isExpanded,
+ isTruncated: charTruncated || lineOverflow,
+ toggle,
+ expand,
+ collapse,
+ ref,
+ clampStyle,
+ };
+};
From 81e16948101b437bb832e897e8e74b430882f232 Mon Sep 17 00:00:00 2001
From: saifmohamedsv
Date: Fri, 17 Jul 2026 19:51:36 +0300
Subject: [PATCH 2/2] feat(docs): document useExpandableText + re-render banner
to 41 hooks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the doc-page entry (usage, API tables, type aliases), an interactive
three-mode demo (chars / lines / both), and the vendored source snapshot.
Re-renders the brand banner's hook-count pill 40 → 41 (SVG + 2x PNG,
mirrored to public/).
Co-Authored-By: Claude Opus 4.8
---
.../demos/use-expandable-text-demo.tsx | 73 ++++++++++++
apps/docs/lib/hook-docs.ts | 106 +++++++++++++++++
apps/docs/lib/hook-sources.ts | 112 ++++++++++++++++++
apps/docs/public/hookli-banner.png | Bin 405491 -> 404970 bytes
apps/docs/public/hookli-banner.svg | 4 +-
assets/hookli-banner.png | Bin 405491 -> 404970 bytes
assets/hookli-banner.svg | 4 +-
7 files changed, 295 insertions(+), 4 deletions(-)
create mode 100644 apps/docs/components/demos/use-expandable-text-demo.tsx
diff --git a/apps/docs/components/demos/use-expandable-text-demo.tsx b/apps/docs/components/demos/use-expandable-text-demo.tsx
new file mode 100644
index 0000000..3b2b476
--- /dev/null
+++ b/apps/docs/components/demos/use-expandable-text-demo.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useState } from "react";
+import { useExpandableText } from "hookli";
+import { DemoButton, DemoReadout } from "./ui";
+
+const SAMPLE =
+ "hookli bundles the React hooks you reach for most into one typed, " +
+ "SSR-safe, zero-dependency package — state, effects, DOM and data helpers " +
+ "that tree-shake cleanly so you only ship what you import. No more hunting " +
+ "across the ecosystem or copy-pasting the same useDebounce for the hundredth time.";
+
+const MODES = {
+ chars: { label: "180 chars", options: { maxChars: 180 } },
+ lines: { label: "3 lines", options: { maxLines: 3 } },
+ both: { label: "both", options: { maxChars: 180, maxLines: 3 } },
+} as const;
+
+type Mode = keyof typeof MODES;
+
+/* Docs-page demo: the same paragraph collapsed three ways — a pure character
+ budget, a responsive line budget, or both at once (whichever clips first
+ wins). Narrow the viewport to watch the line-based clamp re-measure. Mirrors
+ lib/hook-docs.ts — keep in sync. */
+export function UseExpandableTextDocDemo() {
+ const [mode, setMode] = useState("both");
+
+ const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
+ useExpandableText(SAMPLE, MODES[mode].options);
+
+ return (
+
+ The character budget yields a genuinely shortened string (SSR-safe); the
+ line budget clamps via CSS and re-measures on resize. Switch to “both” and
+ narrow the window to see whichever limit clips first win.
+
+
+ );
+}
diff --git a/apps/docs/lib/hook-docs.ts b/apps/docs/lib/hook-docs.ts
index 5b0d0d2..7a775fb 100644
--- a/apps/docs/lib/hook-docs.ts
+++ b/apps/docs/lib/hook-docs.ts
@@ -13,6 +13,7 @@ import { UseDebounceValueDocDemo } from "@/components/demos/use-debounce-value-d
import { UseDocumentTitleDocDemo } from "@/components/demos/use-document-title-demo";
import { UseEventCallbackDocDemo } from "@/components/demos/use-event-callback-demo";
import { UseEventListenerDocDemo } from "@/components/demos/use-event-listener-demo";
+import { UseExpandableTextDocDemo } from "@/components/demos/use-expandable-text-demo";
import { UseFetchDocDemo } from "@/components/demos/use-fetch-demo";
import { UseFormDocDemo } from "@/components/demos/use-form-demo";
import { UseGeoLocationDocDemo } from "@/components/demos/use-geo-location-demo";
@@ -1493,6 +1494,111 @@ export function Demo() {
},
],
},
+ "use-expandable-text": {
+ demo: UseExpandableTextDocDemo,
+ usage: `
+import { useExpandableText } from "hookli";
+
+export function Review({ body }: { body: string }) {
+ const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
+ useExpandableText(body, { maxChars: 180, maxLines: 3 });
+
+ return (
+
+
{text}
+ {isTruncated && (
+
+ )}
+
+ );
+}
+`,
+ parameters: [
+ {
+ name: "text",
+ type: "string",
+ description: "The full text to (maybe) collapse.",
+ },
+ {
+ name: "options",
+ type: "UseExpandableTextOptions",
+ defaultValue: "{}",
+ description: "Character and/or line budgets and display options. Whichever limit clips first wins.",
+ },
+ ],
+ returns: [
+ {
+ name: "text",
+ type: "string",
+ description: "The text to render — character-capped when collapsed and maxChars is set, otherwise the full text.",
+ },
+ {
+ name: "isExpanded",
+ type: "boolean",
+ description: "Whether the full text is currently shown.",
+ },
+ {
+ name: "isTruncated",
+ type: "boolean",
+ description: "True when either limit actually clips the text — use it to hide the toggle when the text fits.",
+ },
+ {
+ name: "toggle",
+ type: "() => void",
+ description: "Flip between expanded and collapsed.",
+ },
+ {
+ name: "expand",
+ type: "() => void",
+ description: "Show the full text.",
+ },
+ {
+ name: "collapse",
+ type: "() => void",
+ description: "Collapse back to the limit.",
+ },
+ {
+ name: "ref",
+ type: "RefCallback",
+ description: "Attach to the text element. Required for the maxLines clamp and its overflow measurement.",
+ },
+ {
+ name: "clampStyle",
+ type: "CSSProperties",
+ description: "Spread onto the text element; applies the CSS line-clamp while collapsed and maxLines is set.",
+ },
+ ],
+ typeAliases: [
+ {
+ name: "UseExpandableTextOptions",
+ description: "Character and line budgets — provide either, or both.",
+ rows: [
+ {
+ name: "maxChars",
+ type: "number",
+ description: "Max characters shown while collapsed. Pure string logic (SSR-safe), trimmed to a word boundary.",
+ },
+ {
+ name: "maxLines",
+ type: "number",
+ description: "Max lines shown while collapsed. Applied as a CSS line-clamp and measured in the DOM, so it re-clips on resize.",
+ },
+ {
+ name: "ellipsis",
+ type: "string",
+ defaultValue: '"…"',
+ description: "Appended to character-truncated text.",
+ },
+ {
+ name: "defaultExpanded",
+ type: "boolean",
+ defaultValue: "false",
+ description: "Whether the text starts expanded.",
+ },
+ ],
+ },
+ ],
+ },
"use-hover": {
demo: UseHoverDocDemo,
usage: `
diff --git a/apps/docs/lib/hook-sources.ts b/apps/docs/lib/hook-sources.ts
index 022ade4..b44564e 100644
--- a/apps/docs/lib/hook-sources.ts
+++ b/apps/docs/lib/hook-sources.ts
@@ -1681,6 +1681,118 @@ function getCurrentPosition(): Promise {
navigator.geolocation.getCurrentPosition(resolve, reject);
});
}
+`,
+ },
+ "use-expandable-text": {
+ path: "src/hooks/use-expandable-text/use-expandable-text.ts",
+ source: `import {
+ useCallback,
+ useEffect,
+ useState,
+ type CSSProperties,
+ type RefCallback,
+} from "react";
+
+export interface UseExpandableTextOptions {
+ maxChars?: number;
+ maxLines?: number;
+ ellipsis?: string;
+ defaultExpanded?: boolean;
+}
+
+export interface UseExpandableTextResult {
+ text: string;
+ isExpanded: boolean;
+ isTruncated: boolean;
+ toggle: () => void;
+ expand: () => void;
+ collapse: () => void;
+ ref: RefCallback;
+ clampStyle: CSSProperties;
+}
+
+const truncateChars = (
+ text: string,
+ maxChars: number,
+ ellipsis: string,
+): string => {
+ if (text.length <= maxChars) return text;
+ const slice = text.slice(0, maxChars);
+ const lastSpace = slice.lastIndexOf(" ");
+ const cut = lastSpace > 0 ? slice.slice(0, lastSpace) : slice;
+ return cut.trimEnd() + ellipsis;
+};
+
+export const useExpandableText = (
+ text: string,
+ options: UseExpandableTextOptions = {},
+): UseExpandableTextResult => {
+ const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options;
+
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+ const [node, setNode] = useState(null);
+ const [lineOverflow, setLineOverflow] = useState(false);
+
+ const charTruncated = maxChars !== undefined && text.length > maxChars;
+ const collapsedText = charTruncated
+ ? truncateChars(text, maxChars, ellipsis)
+ : text;
+ const displayText = isExpanded ? text : collapsedText;
+
+ const ref = useCallback>((el) => setNode(el), []);
+
+ // scrollHeight reflects the full content height regardless of the clamp, so
+ // the overflow read is accurate in both states; the char cap alone drives
+ // isTruncated when it has already shortened the string.
+ useEffect(() => {
+ if (!node || maxLines === undefined) {
+ setLineOverflow(false);
+ return;
+ }
+ const measure = () => {
+ const style = window.getComputedStyle(node);
+ let lineHeight = Number.parseFloat(style.lineHeight);
+ if (!Number.isFinite(lineHeight)) {
+ lineHeight = Number.parseFloat(style.fontSize) * 1.2;
+ }
+ const padding =
+ Number.parseFloat(style.paddingTop) +
+ Number.parseFloat(style.paddingBottom);
+ const maxHeight = lineHeight * maxLines + (padding || 0);
+ setLineOverflow(node.scrollHeight > maxHeight + 1);
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(measure);
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [node, maxLines, text, isExpanded]);
+
+ const toggle = useCallback(() => setIsExpanded((value) => !value), []);
+ const expand = useCallback(() => setIsExpanded(true), []);
+ const collapse = useCallback(() => setIsExpanded(false), []);
+
+ const clampStyle: CSSProperties =
+ !isExpanded && maxLines !== undefined
+ ? {
+ display: "-webkit-box",
+ WebkitBoxOrient: "vertical",
+ WebkitLineClamp: maxLines,
+ overflow: "hidden",
+ }
+ : {};
+
+ return {
+ text: displayText,
+ isExpanded,
+ isTruncated: charTruncated || lineOverflow,
+ toggle,
+ expand,
+ collapse,
+ ref,
+ clampStyle,
+ };
+};
`,
},
};
diff --git a/apps/docs/public/hookli-banner.png b/apps/docs/public/hookli-banner.png
index 5825246c179cac5bf7885af9c43c4fddacb99eab..d1cb18527871c0332e9b80d3734609afe42a8cf3 100644
GIT binary patch
delta 54789
zcmXuKWl+@b`~LkA1Svrplm?}1kuFg>C6jl;ip6ls{s#ix5PLa2tetA$#-k<4->-
zRpilWh4DXAZQcWpGmB=G^_Gvbn$PtmmN+|Q&xN8LPb;exZLYAA=eMQ%hp~j#E^6eT
zMuN8z#!XW$0o>*Os~t1yZOn~e7NLf>?M%5Mx)PGmY2sdD8Hl8d4c`HmmG?^#H>}3m
zg~lvsKV(n>Hfllu%zr{VtiqA@)!wb2ldm2m^U^qy6EE^((AlLJPPoT51^CMLL`0qN
zi)OSe5M0H!{pW<>?5uhv&EQ>BrF-7gyDE_n7Lsk~>WexBSqbNbvSn5J5q+3Nkfja7
zV{_9w^*Vg+mc|EZ0=)8Uo1|l(^!tN`)q}`NRNuYY9*?lkAK@Di@VLSGSTT%)ZO!d{zn`$$7!qe@CnkAi
zvQ1-!ku+*H4HpbAjyiJmsN3_h2U_ofXH>hba+OhX>L)pM4I*L{oH5^UA3
zhNt3!39F5AD0!BnG)}hP6ewp=pCL16aMPP^ScWHLEhB$0(>QYKy6|=di+iV>wA6zE
zN}V~7oqO4joT~gty55bqarOt~u!L1r$fSbkI>HUvp+=AUI&Vx>LL^{fa
zKIsaA227tO_C~X!_uejm6hElxsS;XwAU35dcW``=8s*FV-tGWsQSB{4m
zn-RG$9c1$Vv@#;q)a;om;mS#@!W$Jk#TU=e%SJJa1y=upSC|_gX
zmYnKln5vO>!MY4t5rvp)X{EkxKkphfAklzm*&`O&2@XjNlZK98y{#Cp8}$
zFQ$-iFFPo(1fw~61uqN4oeh{l+9XGXeG?Wkf~E%JZu4qir-ZvyhV`;x)O-P`!~YN~
zQ|e{-x#AR@s-Y_W1dTZ$K|Ef24F$JE%JV+xCbEU|#|u8vcKrA826wsOLQE@ai?%(o
zsgi%LzwRN#dh?ECls+Zn=8I%6ap(|h4GStfkG|yQo}Qc}n@{Bdb2E_zC}*E2^mI&t>}
z{jOo)s=uSh0~{@Tb5|PKKbebW|DBehA8tmE%nupL6&`IcQM`>Ctx2#%LhKnd+LnJZ
zAfLe0^E~z`D(}&dMQ{L23ff@t^b*h+8E3mEctLO4Ri@?Gdt=$a;AyLctY+P8YW>s_Q9#sLpYwW!RZezcP1L?YsFaUAw$0R
z1ljCfl-XHDs^8gVMjhfiRZaMoFKIVD!zzyFXt=166P??!G(lb(7T;dASNK;g{kmh-
zl{AtdZ!6!>Mh=UALOtQ(7@wixG3}ep&H-DK=_>pg;fA-M$
zd_z#1*G}P#K1KKSTGBbEG-M*kEcj&yO3;a`>fh$*vd0ByhUZk|0@v>o{;rv{beo~(
zSr{?ZmA@3MBLr()ye$(jmDqRc@K@*6?=+&B@Yw$Lp$ZpgQ}Uk;14XXfeO!A{D)fId
z2yd}uICZWLjR+t|zAcd!r1DDT4RIV?`er{~sPuc!l9pM{s))c6I`F5{DJ}g99_!sidT2BJZg;JY05iH=7sQa@_@B860+heGj
zVC*~PUTkX?;Hz*xpsaE-LrlO#)Q;4YZBxZ%kN6I0{idw>BwV@?pfENCa|U3`HMyHvc8WAr
zRz~OYNzhNA38vJ%digdY)u&xR7j(j)Hy=C
z!L@7GQ@{ziK=bR&BA={n#ZM+$ri};+$(dh$yuK4XIU)56_f0?V6j7%HyFVqvsa#_$
z60_NRG=67%tBIP|(+omJnR<1W4GBC+TM#hw7@z?iGJ{-IU9R#ImYYpAFRMrHnpu&~
z*6(2vWl8||){a`U0EAlhFlN^<4P{!Ub~%(ToTlF{v-FIWOAYi8GtVS-i96pvZSN7B
zVc||?vQdmg3eXs`ICqfuO5HHkJQ>ZWSVeXd<;MF8)=jL@I1;)W#na9org
zsh^CiK7B
zBU&m1b(>z>h-kS;EDm@`u9*)-7oll#c$I>g>3)vr=W{v_ZP^h@zkvj_L(2=)7uX0K
z!5>HoS=?Db?M|8;DcNXqCAB4thVQ;voOAZy;mdRS5}>C+Uk?*VsYz;HCUt3`5CKg{
zblhmU!7{@8{d5`Z-m6umGzR4~2Bm0onQ$UjZGs>~WBl8iHRI0?qY%?5#pAI_Hxzbx
z6*kYKNKxtWS4SJp(u^J7v)!XI^0eTguRrH$%z99r!5CCM=fa$8*Wnk@fqo#&yv3>1
zqFD3NmDaZ2Ta=w<{oL7vaw{8H
z!ku9?3%erT(MndQ*~Hm?%iY(IKD5j?R)Ebz(XDcPjJIZ@y~(Hr(q0hB$SGvl&BiNR
zHROjG_j|&Q*f%-z7#rX6yi)&t!2nGb(CjIF7hcH;Mv)SA2qPakv|f&42k3R~a6sIp
zk)vL}APQ9;^w4VxjwZ|ZVp&BoTqW(U(?9U*rE)7y-B?d8du69v&e;O4`mBij)mNHv
z>pSqqAQHSs{yiLv1nK+J77!C;-Bfe#(ZpG`c^titC94S|6yu)DgFF4`xQ?KRUGg_U
z6U&BO2491U*F=aJYseT%fVuwGkCz9ZUxuL#(@5e-5mwnSEV1PrjKg)y(Hg(zxBVK9
zs(RhC^8m>@G0aHWieRYipW&1wH^!P_q}5ZmdVkF}_$__Zq$-meD^rj~g%pI;5Sq`o
zMXz;t$V}AT?h%>cX}}-YBV8kX7$yy3;>)2K+TAyZzyv2&la19R0?MPH{#yvs!pg_r
zgIs(pq$1(5hIIVp2Jl=^2?;KQYQ-%?S&QRgLW_suyQ?R!hPCdMfT=IL5X)?dWw`ir1eoJtPJZ6FsG*N2jekA2y2vngBci)b
zv0)}yx4Jn_ulhyWW~pS6GO1m8$sLWjDD941Fjp8>k}LJ8=F30@iGEl(X3AgjhYV`!q1$uOiqRa=a)hW&=S0jT*aR_!_x+7bK!MNFds
zPNarWyvt+MiRu*CvlT80I7Tw%y7z@Q-$xJ7qJ;lRqdxZ@(ZzxlqjDOk`yc=~6-*3bz`V!@&(?tw5E&n^q|
zR2CCt|A%PgpLAwU}@hi
zetA?^-4)U3lOT7v6^iGOsFcowsyE0hVy3`O!%c)K4Qybsum7yGN-am+_=vaf
zeBMngH~mNYY&H0I-(VvOU7SDXlmwf$XAsWREXW?_HA>>Y11)OHTxFaV(x_=OrD`?o
z0ps3K!y|lcI0->C?WbM@@I>+-l7Zk@6PaTcWWx>rrRTXy;
zC#PNjS?>Pw!w>#&wA}G=QNclDaqsnMv3bs`To%TjoA+l@W;%Zo%TFwaqD%hsE0|hF
z-wf$x3!{)sJgPveIf!CkvdIy#_(0-=GhRRbYvij|pFbGn#YQ`HS4PTnkoMO-