diff --git a/.changeset/hook-doc-use-truncation.md b/.changeset/hook-doc-use-truncation.md new file mode 100644 index 0000000000000..f38f5f64a1635 --- /dev/null +++ b/.changeset/hook-doc-use-truncation.md @@ -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 diff --git a/apps/sandbox/scripts/next-export-routes.json b/apps/sandbox/scripts/next-export-routes.json index 32b6875b13fb2..990fe6f6cf767 100644 --- a/apps/sandbox/scripts/next-export-routes.json +++ b/apps/sandbox/scripts/next-export-routes.json @@ -843,6 +843,7 @@ "/templates/useToastHookUsage/", "/templates/useTranslatorHookUsage/", "/templates/useTreeFocusHookUsage/", + "/templates/useTruncationHookUsage/", "/templates/useTypeaheadHookUsage/", "/templates/work-item-detail/", "/themes/", diff --git a/internal/shadcn-registry/routes.lock.json b/internal/shadcn-registry/routes.lock.json index a8bea310e3c09..4fd0cf1d31f01 100644 --- a/internal/shadcn-registry/routes.lock.json +++ b/internal/shadcn-registry/routes.lock.json @@ -4036,6 +4036,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", @@ -4466,6 +4471,11 @@ "path": "hooks/use-tree-focus", "aliases": [] }, + { + "name": "hook-use-truncation", + "path": "hooks/use-truncation", + "aliases": [] + }, { "name": "hook-use-typeahead", "path": "hooks/use-typeahead", diff --git a/packages/cli/api/component/__fixtures__/resolutions/modules.txt b/packages/cli/api/component/__fixtures__/resolutions/modules.txt index c638525ff0978..71c7af2095fb1 100644 --- a/packages/cli/api/component/__fixtures__/resolutions/modules.txt +++ b/packages/cli/api/component/__fixtures__/resolutions/modules.txt @@ -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 diff --git a/packages/cli/api/template/use-truncation-hook-usage.test.tsx b/packages/cli/api/template/use-truncation-hook-usage.test.tsx new file mode 100644 index 0000000000000..14751aad928e1 --- /dev/null +++ b/packages/cli/api/template/use-truncation-hook-usage.test.tsx @@ -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()); + + 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(); + }); + + 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); + }); +}); diff --git a/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.doc.mjs b/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.doc.mjs new file mode 100644 index 0000000000000..4dde32bf99a98 --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.doc.mjs @@ -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'], +}; diff --git a/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.tsx b/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.tsx new file mode 100644 index 0000000000000..19b0d46b3824f --- /dev/null +++ b/packages/cli/assets/templates/blocks/components/Text/useTruncationHookUsage.tsx @@ -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 ( + + + {children} + + + ); +} + +function ClampedCell({children}: {children: string}) { + const truncation = useTruncation({maxLines: 2}); + return ( + + + {children} + + + ); +} + +export default function UseTruncationHookUsage() { + return ( + + + + Asset + Path + + + + {FILES.map(file => ( + + {file.name} + + {file.mode === 'clamped' ? ( + {file.path} + ) : ( + {file.path} + )} + + + ))} + +
+ ); +} diff --git a/packages/core/src/Text/useTruncation.doc.mjs b/packages/core/src/Text/useTruncation.doc.mjs new file mode 100644 index 0000000000000..d5eb9c9e37600 --- /dev/null +++ b/packages/core/src/Text/useTruncation.doc.mjs @@ -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', + 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.", + 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.', + }, + { + 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.', + }, + ], + }, +};