Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions .changeset/hook-doc-use-truncation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@astryxdesign/core': patch
'@astryxdesign/cli': patch
---

[docs] Add hook documentation and interactive table cell truncation example block for `useTruncation`, demonstrating responsive string truncation and accessibility tooltips.

@utkarshpatwa393-sys
1 change: 1 addition & 0 deletions apps/sandbox/scripts/next-export-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,7 @@
"/templates/useToastHookUsage/",
"/templates/useTranslatorHookUsage/",
"/templates/useTreeFocusHookUsage/",
"/templates/useTruncationHookUsage/",
"/templates/useTypeaheadHookUsage/",
"/templates/work-item-detail/",
"/themes/",
Expand Down
10 changes: 10 additions & 0 deletions internal/shadcn-registry/routes.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -3941,6 +3941,11 @@
"path": "examples/use-tree-focus/keyboard-file-tree",
"aliases": []
},
{
"name": "example-use-truncation-tooltip-on-cut-off-text",
"path": "examples/use-truncation/tooltip-on-cut-off-text",
"aliases": []
},
{
"name": "example-use-typeahead-type-to-focus-a-country",
"path": "examples/use-typeahead/type-to-focus-a-country",
Expand Down Expand Up @@ -4331,6 +4336,11 @@
"path": "hooks/use-tree-focus",
"aliases": []
},
{
"name": "hook-use-truncation",
"path": "hooks/use-truncation",
"aliases": []
},
{
"name": "hook-use-typeahead",
"path": "hooks/use-typeahead",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,5 +303,5 @@ useTableSortableState useTableSortableState Table/plugins/sortable/useTableSorta
useToastGesture useToastGesture Toast/Toast.doc.mjs Toast/useToastGesture.ts
useTouchTrigger useTouchTrigger Layer/Layer.doc.mjs Layer/useTouchTrigger.ts
useTriggerMenu useTriggerMenu Chat/Chat.doc.mjs Chat/useTriggerMenu.tsx
useTruncation useTruncation Text/Heading.doc.mjs Text/useTruncation.ts
useTruncation useTruncation Text/useTruncation.doc.mjs Text/useTruncation.ts
utils utils Calendar/Calendar.doc.mjs Calendar/utils.ts
76 changes: 76 additions & 0 deletions packages/cli/api/template/use-truncation-hook-usage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.
// @vitest-environment jsdom

/**
* The useTruncation example demonstrates text truncation across table cells
* with single-line and multi-line clamps.
*/

import {act} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import UseTruncationHookUsage from '../../assets/templates/blocks/components/Text/useTruncationHookUsage';

(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT =
true;

let root: Root | null = null;
let container: HTMLElement;

beforeEach(() => {
vi.stubGlobal(
'matchMedia',
(query: string) =>
({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList,
);
container = document.createElement('div');
document.body.appendChild(container);
});

afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.unstubAllGlobals();
});

describe('useTruncationHookUsage example', () => {
it('renders the narrow path column with the fixture rows and clamped cells', () => {
root = createRoot(container);
act(() => root!.render(<UseTruncationHookUsage />));

const cells = Array.from(container.querySelectorAll('td')).map(
cell => cell.textContent ?? '',
);
expect(cells).toContain('build');
expect(cells).toContain('bundle');
expect(cells).toContain('readme');

// The paths reach the DOM
expect(container.textContent).toContain('build-20261103-120344');
expect(container.textContent).toContain('reports/coverage');
expect(container.textContent).toContain('README.md');
});

it('renders measured text elements across all table cells', async () => {
root = createRoot(container);
act(() => {
root!.render(<UseTruncationHookUsage />);
});

await act(async () => {});

// In the DOM, cells containing paths render span elements with truncation refs
const spans = Array.from(container.querySelectorAll('td span'));
expect(spans.length).toBe(3);
Comment thread
astracat-bot[bot] marked this conversation as resolved.
Resolved
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').TemplateDoc} */
export const doc = {
type: 'block',
exampleFor: 'useTruncation',
name: 'useTruncation — Tooltip on Cut-Off Text',
displayName: 'useTruncation — Tooltip on Cut-Off Text',
description:
'A narrow table column shows the full path in a Tooltip only where useTruncation reports the text is cut off; the short path shows none. One cell clamped to two lines shows the multi-line case.',
isReady: true,
aspectRatio: 4 / 3,
componentsUsed: ['Table', 'Tooltip'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

'use client';

import {useTruncation} from '@astryxdesign/core/Text';
import {Tooltip} from '@astryxdesign/core/Tooltip';
import {
Table,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from '@astryxdesign/core/Table';

interface FileAsset {
name: string;
path: string;
mode: 'single' | 'clamped' | 'short';
}

const FILES: FileAsset[] = [
{
name: 'build',
path: 'artifacts/release/build-20261103-120344/index.html',
mode: 'single',
},
{
name: 'bundle',
path: 'reports/coverage/packages/core/src/components/Calendar/index.html',
mode: 'clamped',
},
{
name: 'readme',
path: 'README.md',
mode: 'short',
},
];

function TruncatedCell({children}: {children: string}) {
const truncation = useTruncation({maxLines: 1});
return (
<Tooltip
content={truncation.fullText}
placement="above"
isEnabled={truncation.isTruncated}>
<span
ref={truncation.ref}
tabIndex={truncation.isTruncated ? 0 : undefined}
style={{
display: 'block',
maxWidth: 120,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{children}
</span>
</Tooltip>
);
}

function ClampedCell({children}: {children: string}) {
const truncation = useTruncation({maxLines: 2});
return (
<Tooltip
content={truncation.fullText}
placement="above"
isEnabled={truncation.isTruncated}>
<span
ref={truncation.ref}
tabIndex={truncation.isTruncated ? 0 : undefined}
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
maxWidth: 120,
wordBreak: 'break-all',
}}>
{children}
</span>
</Tooltip>
);
}

export default function UseTruncationHookUsage() {
return (
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Asset</TableHeaderCell>
<TableHeaderCell>Path</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{FILES.map(file => (
<TableRow key={file.name}>
<TableCell>{file.name}</TableCell>
<TableCell>
{file.mode === 'clamped' ? (
<ClampedCell>{file.path}</ClampedCell>
) : (
<TruncatedCell>{file.path}</TruncatedCell>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
127 changes: 127 additions & 0 deletions packages/core/src/Text/useTruncation.doc.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/** @type {import('@astryxdesign/cli/authoring').HookDoc} */
export const docs = {
name: 'useTruncation',
displayName: 'useTruncation',
group: 'Text',
keywords: [
'truncation',
'truncate',
'ellipsis',
'overflow',
'line-clamp',
'clamp',
'tooltip',
'full text',
'text cut off',
'table cell',
],
params: [
{
name: 'options',
type: 'UseTruncationOptions',
description: 'Configuration object.',
required: true,
},
{
name: 'options.maxLines',
type: 'number',
description:
'Maximum number of lines before truncation. 1 detects single-line overflow (scrollWidth against offsetWidth); values above 1 detect a -webkit-line-clamp cut by measuring the real content height with a Range, bypassing the clamped scrollHeight. 0 disables detection: isTruncated stays false and fullText stays empty.',
required: true,
},
],
returns: [
{
name: 'ref',
type: 'RefCallback<HTMLElement>',
description:
'Ref callback to attach to the text element being measured. Re-attaching to another element re-observes it and cleans up the previous observation.',
},
{
name: 'isTruncated',
type: 'boolean',
description:
'Whether the text is currently cut off. Drives composing a Tooltip with the full text: render the tooltip only when this is true, so a cell that fits shows none.',
},
{
name: 'fullText',
type: 'string',
description:
"The element's current text content, captured on every measurement, for the tooltip a truncated cell shows. Follows content changes that resize nothing (a fixed-width status line whose text swaps).",
},
],
usage: {
description:
"Detects whether an element's text is cut off — single-line overflow or clamped to several lines — and gives back the full text, updating when the size or the content changes. Built on a shared ResizeObserver and a shared MutationObserver, so hundreds of table cells create one observer of each kind, and a first measurement runs batched in a microtask after the commit instead of forcing a layout pass per text during mount. Use it wherever a truncated cell should show its full content only when it is actually truncated: compose a Tooltip off isTruncated and fullText instead of always rendering one. Text and Heading build their truncation tooltips on it; reach for it directly when composing truncation feedback into your own component.",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: since #10668 landed on main, a new element's first measurement comes from the shared ResizeObserver's first notification after layout, and the microtask runs only for an element another subscriber already observes, so "a first measurement runs batched in a microtask after the commit" no longer matches the hook. Suggested: "a new element is first measured in the shared ResizeObserver's first notification, after the browser's layout, so mounting many texts forces no extra layout pass." The docsDense text at lines 89 and 104 says the same.

bestPractices: [
{
guidance: true,
description:
'Render the full-content Tooltip only when isTruncated is true, and read the tooltip text from fullText: a cell that fits shows no tooltip, and the text always matches what was measured.',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the example block keeps every Tooltip mounted and gates it with isEnabled, so it does not do what this line says, and the fitting README.md span still gets aria-describedby to a hidden tooltip. Either say "enable the Tooltip only when isTruncated is true" here (and on line 46 and in the usage text), or mount the Tooltip conditionally in the block.

},
{
guidance: true,
description:
'Apply the matching clamp yourself next to the hook — white-space: nowrap for maxLines 1, -webkit-line-clamp for more — so what the user sees and what the hook measures stay the same shape.',
},
{
guidance: true,
description:
'Reuse one useTruncation instance per measured element; the ref callback cleans up its own observers, so swapping elements is safe.',
},
{
guidance: false,
description:
'Compare scrollHeight to offsetHeight yourself for clamped text: with -webkit-line-clamp active browsers report the clamped size, which is exactly the case the Range measurement exists for.',
},
],
},
relatedComponents: ['Text', 'Heading', 'Tooltip'],
importPath: '@astryxdesign/core/Text',
category: 'text',
};

/** @type {import('@astryxdesign/cli/authoring').HookTranslationDoc} */
export const docsDense = {
description:
'Detects whether text is cut off (single-line overflow or -webkit-line-clamp) and returns the full text, updating on size and content changes. Shared Resize/Mutation observers keep hundreds of cells at one observer each; first measurement batches in a microtask after commit.',
paramDescriptions: {
options: 'config object.',
'options.maxLines':
'lines allowed before truncation; 1 = single-line overflow check, >1 = clamp detection via Range measurement, 0 = off.',
},
returnDescriptions: {
ref: 'ref callback for the measured text element; swapping elements re-observes and cleans up.',
isTruncated:
'true while the text is cut off; gate the full-content Tooltip on it.',
fullText:
'current text content for the tooltip; follows content changes that resize nothing.',
},
usage: {
description:
'Truncation detection + full text for tooltips. Gate a Tooltip on isTruncated and read its text from fullText so only actually-cut cells show one. Shared observers; batched first measurement. Text/Heading build their truncation tooltips on it; use directly when composing truncation feedback yourself.',
bestPractices: [
{
guidance: true,
description: 'Tooltip only when isTruncated; text from fullText.',
},
{
guidance: true,
description:
'Apply the matching clamp (nowrap / line-clamp) next to the hook so view and measurement agree.',
},
{
guidance: true,
description:
'One hook instance per measured element; the ref cleans up after itself.',
},
{
guidance: false,
description:
'Manual scrollHeight checks on clamped text — browsers report the clamped size.',
},
],
},
};
Loading