Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<img src="https://img.shields.io/badge/SSR--safe-61DAFB?style=for-the-badge" alt="SSR-safe" />
<img src="https://img.shields.io/badge/tree--shakable-0A0D12?style=for-the-badge" alt="tree-shakable" />
<img src="https://img.shields.io/badge/ESM_%2B_CJS-61DAFB?style=for-the-badge" alt="ESM + CJS" />
<img src="https://img.shields.io/badge/40_hooks-0A0D12?style=for-the-badge" alt="40 hooks" />
<img src="https://img.shields.io/badge/41_hooks-0A0D12?style=for-the-badge" alt="41 hooks" />
</p>

<p align="center">
Expand Down Expand Up @@ -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.
Expand Down
73 changes: 73 additions & 0 deletions apps/docs/components/demos/use-expandable-text-demo.tsx
Original file line number Diff line number Diff line change
@@ -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<Mode>("both");

const { text, isExpanded, isTruncated, toggle, ref, clampStyle } =
useExpandableText<HTMLParagraphElement>(SAMPLE, MODES[mode].options);

return (
<div className="flex w-full max-w-md flex-col gap-4">
<div className="flex flex-wrap gap-2">
{(Object.keys(MODES) as Mode[]).map((key) => (
<DemoButton
key={key}
aria-pressed={mode === key}
onClick={() => setMode(key)}
>
{MODES[key].label}
</DemoButton>
))}
</div>

<div className="rounded-md border border-slate-syntax bg-ground p-4">
<p ref={ref} style={clampStyle} className="text-sm text-fg">
{text}
</p>
{isTruncated && (
<button
type="button"
onClick={toggle}
aria-expanded={isExpanded}
className="mt-2 rounded font-mono text-xs text-accent transition-colors duration-200 hover:text-accent-hover focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
{isExpanded ? "Show less ↑" : "Show more ↓"}
</button>
)}
</div>

<dl className="w-full">
<DemoReadout label="isExpanded">{String(isExpanded)}</DemoReadout>
<DemoReadout label="isTruncated">{String(isTruncated)}</DemoReadout>
</dl>

<p className="text-xs text-gray-body">
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.
</p>
</div>
);
}
106 changes: 106 additions & 0 deletions apps/docs/lib/hook-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLParagraphElement>(body, { maxChars: 180, maxLines: 3 });

return (
<div>
<p ref={ref} style={clampStyle}>{text}</p>
{isTruncated && (
<button onClick={toggle}>{isExpanded ? "Show less" : "Show more"}</button>
)}
</div>
);
}
`,
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<T>",
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: `
Expand Down
112 changes: 112 additions & 0 deletions apps/docs/lib/hook-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1681,6 +1681,118 @@ function getCurrentPosition(): Promise<globalThis.GeolocationPosition> {
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<T extends HTMLElement = HTMLElement> {
text: string;
isExpanded: boolean;
isTruncated: boolean;
toggle: () => void;
expand: () => void;
collapse: () => void;
ref: RefCallback<T>;
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 = <T extends HTMLElement = HTMLElement>(
text: string,
options: UseExpandableTextOptions = {},
): UseExpandableTextResult<T> => {
const { maxChars, maxLines, ellipsis = "…", defaultExpanded = false } = options;

const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const [node, setNode] = useState<T | null>(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<RefCallback<T>>((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,
};
};
`,
},
};
Expand Down
Binary file modified apps/docs/public/hookli-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/docs/public/hookli-banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified assets/hookli-banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions assets/hookli-banner.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion packages/hookli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<img src="https://img.shields.io/badge/SSR--safe-61DAFB?style=for-the-badge" alt="SSR-safe" />
<img src="https://img.shields.io/badge/tree--shakable-0A0D12?style=for-the-badge" alt="tree-shakable" />
<img src="https://img.shields.io/badge/ESM_%2B_CJS-61DAFB?style=for-the-badge" alt="ESM + CJS" />
<img src="https://img.shields.io/badge/40_hooks-0A0D12?style=for-the-badge" alt="40 hooks" />
<img src="https://img.shields.io/badge/41_hooks-0A0D12?style=for-the-badge" alt="41 hooks" />
</p>

<p align="center">
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions packages/hookli/hooks.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@
"category": "dom",
"signature": "useInfiniteScroll(fetchMoreData: () => Promise<void>): 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<T extends HTMLElement = HTMLElement>(text: string, options?: UseExpandableTextOptions): UseExpandableTextResult<T>"
},
{
"slug": "use-hover",
"name": "useHover",
Expand Down
1 change: 1 addition & 0 deletions packages/hookli/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading