diff --git a/README.md b/README.md index 296356d..1dfa548 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ SSR-safe tree-shakable ESM + CJS - 40 hooks + 41 hooks

@@ -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/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 ( +

+
+ {(Object.keys(MODES) as Mode[]).map((key) => ( + setMode(key)} + > + {MODES[key].label} + + ))} +
+ +
+

+ {text} +

+ {isTruncated && ( + + )} +
+ +
+ {String(isExpanded)} + {String(isTruncated)} +
+ +

+ 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 5825246..d1cb185 100644 Binary files a/apps/docs/public/hookli-banner.png and b/apps/docs/public/hookli-banner.png differ diff --git a/apps/docs/public/hookli-banner.svg b/apps/docs/public/hookli-banner.svg index 2d193b6..f634013 100644 --- a/apps/docs/public/hookli-banner.svg +++ b/apps/docs/public/hookli-banner.svg @@ -1,4 +1,4 @@ - + SSR-safe tree-shakable ESM + CJS - 40 hooks + 41 hooks

@@ -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, + }; +};