diff --git a/.changeset/feat-cui-283-text-truncate.md b/.changeset/feat-cui-283-text-truncate.md new file mode 100644 index 000000000..ffa3625fd --- /dev/null +++ b/.changeset/feat-cui-283-text-truncate.md @@ -0,0 +1,9 @@ +--- +'@clickhouse/click-ui': minor +--- + +Add `TextTruncate`, which truncates a single line of text with an ellipsis at the end or in the middle (`ellipsisPosition="middle"` keeps file extensions and ID suffixes visible). While the text is cut off, the full text shows in a tooltip on hover, and the text becomes a tab stop that shows it on keyboard focus. Inside a button, link, tab, menu item or option it adds no tab stop, so the tooltip is hover-only there. Supports `maxWidth`, `showTooltip={false}` for large lists, a custom `tooltipContent`, and a polymorphic `component`. It warns in the console when `children` holds a link or button and no `tooltipContent` is given. + +Add the `useIsTruncated` hook, which tells whether an element's single line of text is cut off and re-measures on resize and content changes. + +`EllipsisContent` and `EllipsisContentProps` are deprecated in favor of `TextTruncate`. They keep working unchanged. The internal `MiddleTruncator` behind `Table`'s `overflowMode="truncate-middle"` and the `FileUpload` file name is deprecated too; both keep their current look and behavior for now. diff --git a/package.json b/package.json index 50d98364e..a48a12064 100644 --- a/package.json +++ b/package.json @@ -319,6 +319,11 @@ "import": "./dist/esm/components/TextField/index.js", "require": "./dist/cjs/components/TextField/index.cjs" }, + "./TextTruncate": { + "types": "./dist/types/components/TextTruncate/index.d.ts", + "import": "./dist/esm/components/TextTruncate/index.js", + "require": "./dist/cjs/components/TextTruncate/index.cjs" + }, "./Title": { "types": "./dist/types/components/Title/index.d.ts", "import": "./dist/esm/components/Title/index.js", diff --git a/src/components/EllipsisContent/EllipsisContent.stories.tsx b/src/components/EllipsisContent/EllipsisContent.stories.tsx index c7fa08beb..3ff89c12f 100644 --- a/src/components/EllipsisContent/EllipsisContent.stories.tsx +++ b/src/components/EllipsisContent/EllipsisContent.stories.tsx @@ -5,6 +5,14 @@ const meta: Meta = { component: EllipsisContent, title: 'Display/EllipsisContent', tags: ['ellipsis-content', 'autodocs'], + parameters: { + docs: { + description: { + component: + '**Deprecated:** superseded by `TextTruncate`, which adds keyboard access, a hoverable tooltip, live re-measuring and middle truncation. `EllipsisContent` keeps working unchanged and will be removed in a future version.', + }, + }, + }, // EllipsisContent fills its parent's width and truncates overflowing text with // an ellipsis. The decorator constrains the available width so the truncation // (or lack of it) is measurable in the snapshot. diff --git a/src/components/EllipsisContent/EllipsisContent.tsx b/src/components/EllipsisContent/EllipsisContent.tsx index f213b93df..0fbf93a69 100644 --- a/src/components/EllipsisContent/EllipsisContent.tsx +++ b/src/components/EllipsisContent/EllipsisContent.tsx @@ -58,6 +58,9 @@ const EllipsisContentComponent = ( ); }; +/** + * @deprecated Use `TextTruncate` instead. This component will be removed in a future version. + */ export const EllipsisContent: EllipsisPolymorphicComponent = forwardRef( EllipsisContentComponent ); diff --git a/src/components/EllipsisContent/EllipsisContent.types.ts b/src/components/EllipsisContent/EllipsisContent.types.ts index d56cd9bf3..c5a816c0f 100644 --- a/src/components/EllipsisContent/EllipsisContent.types.ts +++ b/src/components/EllipsisContent/EllipsisContent.types.ts @@ -1,6 +1,9 @@ import { ElementType } from 'react'; import type { TooltipContentProps } from '@/components/Tooltip/Tooltip.types'; +/** + * @deprecated Use `TextTruncateProps` instead. This type will be removed in a future version. + */ export interface EllipsisContentProps { component?: T; /** diff --git a/src/components/MiddleTruncator/MiddleTruncator.stories.tsx b/src/components/MiddleTruncator/MiddleTruncator.stories.tsx index 93843b433..c40e293f0 100644 --- a/src/components/MiddleTruncator/MiddleTruncator.stories.tsx +++ b/src/components/MiddleTruncator/MiddleTruncator.stories.tsx @@ -18,6 +18,14 @@ const meta: Meta = { component: MiddleTruncator, title: 'Display/MiddleTruncator', tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + '**Deprecated:** superseded by `TextTruncate` with `ellipsisPosition="middle"`. Internal component, still used by `Table` (`overflowMode="truncate-middle"`) and `FileUpload`; it will be removed in a future version.', + }, + }, + }, argTypes: { text: { control: { type: 'text' }, diff --git a/src/components/MiddleTruncator/MiddleTruncator.tsx b/src/components/MiddleTruncator/MiddleTruncator.tsx index 2d393d7ec..f86f6a098 100644 --- a/src/components/MiddleTruncator/MiddleTruncator.tsx +++ b/src/components/MiddleTruncator/MiddleTruncator.tsx @@ -1,5 +1,8 @@ import styles from './MiddleTruncator.module.css'; +/** + * @deprecated Use `` instead. This component will be removed in a future version. + */ export const MiddleTruncator = ({ text, trailingChars = 10, diff --git a/src/components/TextTruncate/TextTruncate.module.css b/src/components/TextTruncate/TextTruncate.module.css new file mode 100644 index 000000000..ecda75351 --- /dev/null +++ b/src/components/TextTruncate/TextTruncate.module.css @@ -0,0 +1,66 @@ +.text-truncate { + --text-truncate-max-width: initial; + + min-width: 0; + max-width: var(--text-truncate-max-width, 100%); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Doubled so `display` beats the base class of a `component` such as Link, whatever the bundle order. */ +.text-truncate.text-truncate { + display: block; +} + +.text-truncate_pos-middle.text-truncate_pos-middle { + display: flex; +} + +/* Isolated so the full-text copy at z-index -1 stays above the backgrounds of ancestors. */ +.text-truncate_split { + position: relative; + isolation: isolate; +} + +/* Inset so ancestors with `overflow: hidden` cannot clip the ring. */ +.text-truncate:focus-visible { + outline: 2px solid var(--click-global-color-outline-default); + outline-offset: -2px; +} + +.text-truncate__start { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; +} + +.text-truncate__end { + max-width: 100%; + overflow: hidden; + flex-shrink: 0; + text-overflow: ellipsis; +} + +.text-truncate_split .text-truncate__start, +.text-truncate_split .text-truncate__end { + pointer-events: none; + /* stylelint-disable-next-line property-no-vendor-prefix -- Safari still needs the prefix and the build has no autoprefixer */ + -webkit-user-select: none; + user-select: none; +} + +/* Full text behind the halves: what screen readers read and what a click, triple-click or copy selects. */ +.text-truncate__label { + position: absolute; + z-index: -1; + inset: 0; + overflow: hidden; + color: transparent; + forced-color-adjust: none; +} + +/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- iOS Safari ignores `::selection` and draws its own selection UI */ +.text-truncate__label::selection { + background-color: highlight; +} diff --git a/src/components/TextTruncate/TextTruncate.stories.tsx b/src/components/TextTruncate/TextTruncate.stories.tsx new file mode 100644 index 000000000..8b57225d6 --- /dev/null +++ b/src/components/TextTruncate/TextTruncate.stories.tsx @@ -0,0 +1,214 @@ +import { Meta, StoryObj } from '@storybook/react-vite'; +import { Badge } from '@/components/Badge'; +import { Container } from '@/components/Container'; +import { Icon } from '@/components/Icon'; +import { IconButton } from '@/components/IconButton'; +import { Link } from '@/components/Link'; +import { Text } from '@/components/Text'; +import { TextTruncate } from '@/components/TextTruncate'; + +const LONG_TEXT = 'This is a very long line of text that should be truncated'; +const FILE_NAME = 'console.clickhouse.cloud_Archive.01-01-1975.lorem-ipsum-01.csv'; + +const meta: Meta = { + component: TextTruncate, + title: 'Display/TextTruncate', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'Truncates a single line of text with an ellipsis at the end or in the middle. While the text is cut off, it shows the full text in a tooltip on hover, and becomes a tab stop that shows it on keyboard focus. Inside a button, link, tab, menu item or option it adds no tab stop of its own, so the tooltip is hover-only there. Replaces `EllipsisContent` and `MiddleTruncator`.', + }, + }, + }, + argTypes: { + ellipsisPosition: { control: 'inline-radio', options: ['end', 'middle'] }, + trailingChars: { control: 'number' }, + maxWidth: { control: 'text' }, + showTooltip: { control: 'boolean' }, + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = { + args: { + children: LONG_TEXT, + ellipsisPosition: 'end', + showTooltip: true, + }, +}; + +export const Truncated: Story = { + args: { children: LONG_TEXT }, +}; + +export const NotTruncated: Story = { + args: { children: 'Short text' }, + parameters: { + docs: { + description: { + story: 'Text that fits has no tooltip and is not a tab stop.', + }, + }, + }, +}; + +export const Middle: Story = { + args: { ellipsisPosition: 'middle', children: FILE_NAME }, + parameters: { + docs: { + description: { + story: + 'Keeps the end of the text visible: file names, IDs, host names, paths. Text is not split when the kept end would be longer than the rest. In a box narrower than the kept end, the end gets its own ellipsis; use `ellipsisPosition="end"` or fewer `trailingChars` there.', + }, + }, + }, +}; + +export const MiddleTrailingChars: Story = { + args: { ellipsisPosition: 'middle', trailingChars: 14, children: FILE_NAME }, +}; + +export const MaxWidth: Story = { + args: { maxWidth: '15ch', children: LONG_TEXT }, +}; + +export const WithoutTooltip: Story = { + args: { showTooltip: false, children: LONG_TEXT }, + parameters: { + docs: { + description: { + story: + 'No measuring, tooltip or tab stop. Use it in large lists and in grid cells, where the grid owns keyboard focus.', + }, + }, + }, +}; + +export const TooltipContent: Story = { + args: { + tooltipContent: 'Owned by the data-platform-observability team', + children: ( + <> + Owned by the data-platform-observability team + + ), + }, + parameters: { + docs: { + description: { + story: + 'When `children` holds links or buttons, pass plain text as `tooltipContent` so the tooltip does not copy them.', + }, + }, + }, +}; + +export const TooltipOnRight: Story = { + args: { tooltipProps: { side: 'right' }, children: LONG_TEXT }, +}; + +export const AsText: Story = { + render: () => ( + + {FILE_NAME} + + ), + parameters: { + docs: { + description: { + story: + 'Use `component={Text}` for typography. A `` child would be a block inside the root and get clipped without an ellipsis.', + }, + }, + }, +}; + +export const InFlexRow: Story = { + render: () => ( + + + {LONG_TEXT} + + + ), + parameters: { + docs: { + description: { + story: + 'Truncates as a flex item without extra props. Pass `style={{ flex: 1 }}` or a `className` to push the next item to the end of the row.', + }, + }, + }, +}; + +export const InBadge: Story = { + render: () => ( + {FILE_NAME}
} + /> + ), + parameters: { + docs: { + description: { + story: 'Pass `ellipsisContent={false}` so Badge does not truncate twice.', + }, + }, + }, +}; + +export const InsideLink: Story = { + render: () => ( + + {FILE_NAME} + + ), + parameters: { + docs: { + description: { + story: + 'Inside a control the link is the only tab stop; the tooltip still opens on hover. Link and Badge are `inline-flex` and grow with their text, so cap their width (here `maxWidth: 100%`) or there is nothing to truncate against.', + }, + }, + }, +}; diff --git a/src/components/TextTruncate/TextTruncate.test.tsx b/src/components/TextTruncate/TextTruncate.test.tsx new file mode 100644 index 000000000..9288f1ea1 --- /dev/null +++ b/src/components/TextTruncate/TextTruncate.test.tsx @@ -0,0 +1,470 @@ +import { ReactNode, createRef } from 'react'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TextTruncate } from '@/components/TextTruncate'; +import { renderCUI } from '@/utils/test-utils'; + +const CHAR_WIDTH = 10; +const LONG_TEXT = 'This text is far wider than its container'; +let containerWidth = 100; + +class FakeResizeObserver { + static instances: FakeResizeObserver[] = []; + active = true; + constructor(private callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this); + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(() => { + this.active = false; + }); + trigger() { + if (this.active) { + this.callback([], this as unknown as ResizeObserver); + } + } +} + +const resizeTo = (width: number) => { + containerWidth = width; + act(() => FakeResizeObserver.instances.forEach(observer => observer.trigger())); +}; + +describe('TextTruncate', () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get(this: HTMLElement) { + return (this.textContent ?? '').length * CHAR_WIDTH; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => containerWidth, + }); + }); + + afterAll(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); + }); + + beforeEach(() => { + containerWidth = 100; + FakeResizeObserver.instances = []; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders its children in a span by default', () => { + const { getByText } = renderCUI(Hello); + expect(getByText('Hello').tagName).toBe('SPAN'); + }); + + it('renders as the given component and forwards ref, props and className', () => { + const ref = createRef(); + const { getByTestId } = renderCUI( + + Hello + + ); + const root = getByTestId('root'); + expect(root.tagName).toBe('DIV'); + expect(root).toHaveAttribute('aria-label', 'Label'); + expect(root).toHaveClass('custom'); + expect(ref.current).toBe(root); + }); + + describe('when the text fits', () => { + it('is not a tab stop and has no role', () => { + const { getByText } = renderCUI(Short); + expect(getByText('Short')).not.toHaveAttribute('tabindex'); + expect(getByText('Short')).not.toHaveAttribute('role'); + }); + + it('shows no tooltip on hover', async () => { + const user = userEvent.setup(); + const { getByText, queryByRole } = renderCUI(Short); + await user.hover(getByText('Short')); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('when the text is truncated', () => { + it('becomes a focusable group named by its text', () => { + const { getByRole } = renderCUI({LONG_TEXT}); + expect(getByRole('group', { name: LONG_TEXT })).toHaveAttribute('tabindex', '0'); + }); + + it('keeps a consumer id, aria-label and aria-labelledby', () => { + const { getByRole } = renderCUI( + <> + {LONG_TEXT} + {LONG_TEXT} + External name + {LONG_TEXT} + + ); + expect(getByRole('group', { name: LONG_TEXT })).toHaveAttribute('id', 'own-id'); + expect(getByRole('group', { name: 'Custom name' })).not.toHaveAttribute( + 'aria-labelledby' + ); + expect(getByRole('group', { name: 'External name' })).toBeInTheDocument(); + }); + + it('is not described by the default tooltip, which repeats its name', async () => { + const user = userEvent.setup(); + const { getByRole, findByRole } = renderCUI( + {LONG_TEXT} + ); + await user.tab(); + await findByRole('tooltip'); + expect(getByRole('group')).not.toHaveAttribute('aria-describedby'); + }); + + it('shows a custom tooltipContent and is described by it', async () => { + const user = userEvent.setup(); + const { getByRole, findByRole } = renderCUI( + {LONG_TEXT} + ); + await user.tab(); + expect(await findByRole('tooltip')).toHaveTextContent('Custom tooltip'); + expect(getByRole('group')).toHaveAccessibleDescription('Custom tooltip'); + }); + + it('shows the full text in a tooltip on hover', async () => { + const user = userEvent.setup(); + const { getByRole, findByRole } = renderCUI( + {LONG_TEXT} + ); + await user.hover(getByRole('group')); + expect(await findByRole('tooltip')).toHaveTextContent(LONG_TEXT); + }); + + it('shows the tooltip on keyboard focus and closes it on Escape', async () => { + const user = userEvent.setup(); + const { getByRole, findByRole, queryByRole } = renderCUI( + {LONG_TEXT} + ); + await user.tab(); + expect(getByRole('group')).toHaveFocus(); + expect(await findByRole('tooltip')).toHaveTextContent(LONG_TEXT); + + await user.keyboard('{Escape}'); + await waitFor(() => expect(queryByRole('tooltip')).not.toBeInTheDocument()); + }); + + it('positions the tooltip with tooltipProps', async () => { + const user = userEvent.setup(); + const { getByRole, findByRole } = renderCUI( + {LONG_TEXT} + ); + await user.hover(getByRole('group')); + expect(await findByRole('tooltip')).toHaveAttribute('data-side', 'right'); + }); + + it('keeps the native role of a link or heading', () => { + const { getByRole } = renderCUI( + <> + + {LONG_TEXT} + + {LONG_TEXT} + + ); + expect(getByRole('link', { name: LONG_TEXT })).not.toHaveAttribute('role'); + expect(getByRole('heading', { name: LONG_TEXT })).not.toHaveAttribute('role'); + }); + }); + + describe('inside an interactive control', () => { + it.each([ + ['a button', (text: ReactNode) => ], + ['a link', (text: ReactNode) => {text}], + [ + 'a tab', + (text: ReactNode) => ( +
+ {text} +
+ ), + ], + [ + 'a menu item', + (text: ReactNode) => ( +
+ {text} +
+ ), + ], + [ + 'an option', + (text: ReactNode) => ( +
+ {text} +
+ ), + ], + [ + 'a Select option', + (text: ReactNode) =>
{text}
, + ], + ])('adds no tab stop or role inside %s', (_, renderControl) => { + const { getByText } = renderCUI( + renderControl({LONG_TEXT}) + ); + const text = getByText(LONG_TEXT); + expect(text).not.toHaveAttribute('tabindex'); + expect(text).not.toHaveAttribute('role'); + }); + + it('adds no tab stop inside a control in middle mode', () => { + const { getByRole } = renderCUI( + + ); + expect(getByRole('button').querySelector('[tabindex]')).toBeNull(); + }); + + it('still shows the tooltip on hover', async () => { + const user = userEvent.setup(); + const { getByText, findByRole } = renderCUI( + + ); + await user.hover(getByText(LONG_TEXT)); + expect(await findByRole('tooltip')).toHaveTextContent(LONG_TEXT); + }); + }); + + describe('re-measuring', () => { + it('toggles the tab stop on resize without remounting', () => { + containerWidth = 1000; + const { getByText } = renderCUI({LONG_TEXT}); + const root = getByText(LONG_TEXT); + expect(root).not.toHaveAttribute('tabindex'); + + resizeTo(100); + expect(getByText(LONG_TEXT)).toBe(root); + expect(root).toHaveAttribute('tabindex', '0'); + + resizeTo(1000); + expect(getByText(LONG_TEXT)).toBe(root); + expect(root).not.toHaveAttribute('tabindex'); + }); + + it('never opens the tooltip on a resize by itself', async () => { + const user = userEvent.setup(); + containerWidth = 1000; + const { getByText, queryByRole, findByRole } = renderCUI( + {LONG_TEXT} + ); + await user.hover(getByText(LONG_TEXT)); + await user.unhover(getByText(LONG_TEXT)); + resizeTo(100); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await user.tab(); + await findByRole('tooltip'); + resizeTo(1000); + act(() => getByText(LONG_TEXT).blur()); + resizeTo(100); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('with showTooltip={false}', () => { + it('does not measure, add a tab stop or show a tooltip', async () => { + const user = userEvent.setup(); + const { getByText, queryByRole } = renderCUI( + {LONG_TEXT} + ); + const root = getByText(LONG_TEXT); + expect(FakeResizeObserver.instances).toHaveLength(0); + expect(root).not.toHaveAttribute('tabindex'); + expect(root).not.toHaveAttribute('role'); + + await user.hover(root); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + }); + }); + + describe('when children hold a link', () => { + it('warns once to pass a tooltipContent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { rerender } = renderCUI( + + Owned by the team + + ); + rerender( + + Owned by the platform team + + ); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('tooltipContent'); + warn.mockRestore(); + }); + + it.each([ + [ + 'a tooltipContent', + + Owned by the team + , + ], + [ + 'showTooltip={false}', + + Owned by the team + , + ], + ])('does not warn with %s', (_, element) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderCUI(element); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('does not warn for plain text', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderCUI({LONG_TEXT}); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + }); + + describe('with ellipsisPosition="middle"', () => { + const FILE_NAME = 'console-export-2024-final.csv'; + + it('keeps the last trailingChars characters in their own part', () => { + const { getByText } = renderCUI( + {FILE_NAME} + ); + expect(getByText('inal.csv')).toBeInTheDocument(); + expect(getByText('console-export-2024-f')).toBeInTheDocument(); + }); + + it('uses a custom trailingChars', () => { + const { getByText } = renderCUI( + + {FILE_NAME} + + ); + expect(getByText('.csv')).toBeInTheDocument(); + }); + + it('reads as one string to assistive technology', () => { + const { getByRole, getAllByText } = renderCUI( + <> + {FILE_NAME} + + + ); + expect(getByRole('group', { name: FILE_NAME })).toBeInTheDocument(); + expect(getByRole('button', { name: FILE_NAME })).toBeInTheDocument(); + [...getAllByText('console-export-2024-f'), ...getAllByText('inal.csv')].forEach( + part => expect(part).toHaveAttribute('aria-hidden', 'true') + ); + + resizeTo(1000); + expect(getByRole('button', { name: FILE_NAME })).toBeInTheDocument(); + }); + + it('detects truncation when only the start part is cut', () => { + containerWidth = 300; + const { getByTestId } = renderCUI( + + {FILE_NAME} + + ); + expect(getByTestId('root')).not.toHaveAttribute('tabindex'); + + resizeTo(200); + expect(getByTestId('root')).toHaveAttribute('tabindex', '0'); + }); + + it.each([ + ['the end would be longer than the start', 'abcdefghij', 8], + ['trailingChars is 0', FILE_NAME, 0], + ])('does not split when %s', (_, text, trailingChars) => { + const { getByTestId } = renderCUI( + + {text} + + ); + const root = getByTestId('root'); + expect(root.children).toHaveLength(1); + expect(root.children[0]).toHaveTextContent(text); + expect(root.children[0]).not.toHaveAttribute('aria-hidden'); + }); + + it('keeps the space at the split point', () => { + const { getByText } = renderCUI( + + Quarterly revenue report 2024 Q3 + + ); + expect(getByText('Quarterly revenue report')).toBeInTheDocument(); + expect(getByText('2024 Q3').textContent).toBe('\u00a02024 Q3'); + }); + }); + + describe('maxWidth', () => { + it('sets the max-width custom property and keeps the consumer style', () => { + const { getByText } = renderCUI( + + Hello + + ); + expect(getByText('Hello')).toHaveStyle({ color: 'rgb(255, 0, 0)' }); + expect(getByText('Hello').style.getPropertyValue('--text-truncate-max-width')).toBe( + '20ch' + ); + }); + }); +}); diff --git a/src/components/TextTruncate/TextTruncate.tsx b/src/components/TextTruncate/TextTruncate.tsx new file mode 100644 index 000000000..82ce56c2b --- /dev/null +++ b/src/components/TextTruncate/TextTruncate.tsx @@ -0,0 +1,208 @@ +import { + ComponentProps, + ComponentPropsWithRef, + CSSProperties, + ElementType, + HTMLAttributes, + ReactNode, + forwardRef, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { cn, cva } from '@/lib/cva'; +import { mergeRefs } from '@/utils/mergeRefs'; +import { useIsTruncated } from '@/hooks/useIsTruncated'; +import { Tooltip } from '@/components/Tooltip'; +import { TextTruncateProps } from './TextTruncate.types'; +import styles from './TextTruncate.module.css'; + +const NO_BREAK_SPACE = '\u00a0'; + +const INTERACTIVE_ELEMENT = [ + 'a[href]', + 'button', + 'summary', + '[role="button"]', + '[role="link"]', + '[role="tab"]', + '[role="option"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="checkbox"]', + '[role="radio"]', + '[role="switch"]', + '[role="treeitem"]', + '[cui-select-item]', +].join(', '); + +const textTruncateVariants = cva(styles['text-truncate'], { + variants: { + middle: { true: styles['text-truncate_pos-middle'] }, + split: { true: styles['text-truncate_split'] }, + }, +}); + +const splitMiddle = (text: string, trailingChars: number): [string, string] => { + const chars = Array.from(text); + const endLength = Number.isFinite(trailingChars) ? Math.floor(trailingChars) : 0; + if (endLength <= 0 || endLength > chars.length - endLength) { + return [text, '']; + } + const start = chars.slice(0, -endLength).join(''); + const end = chars.slice(-endLength).join(''); + return [start.replace(/\s+$/, NO_BREAK_SPACE), end.replace(/^\s+/, NO_BREAK_SPACE)]; +}; + +type TextTruncatePolymorphicComponent = ( + props: Omit, keyof TextTruncateProps> & TextTruncateProps +) => ReactNode; + +const TextTruncateComponent = ( + { + component, + ellipsisPosition, + trailingChars = 8, + maxWidth, + showTooltip = true, + tooltipContent, + tooltipProps, + className, + style, + children, + ...props + }: Omit, keyof TextTruncateProps> & TextTruncateProps, + ref: ComponentPropsWithRef['ref'] +) => { + const Component = component ?? 'span'; + const middleText = + ellipsisPosition === 'middle' && typeof children === 'string' ? children : null; + const isMiddle = middleText !== null; + + const { ref: measureRef, isTruncated } = useIsTruncated({ enabled: showTooltip }); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); + const [rootElement, setRootElement] = useState(null); + const hasWarnedRef = useRef(false); + const generatedId = useId(); + const { + id, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledBy, + } = props as Pick, 'id' | 'aria-label' | 'aria-labelledby'>; + + useEffect(() => { + if (!isTruncated) { + setIsTooltipOpen(false); + } + }, [isTruncated]); + + const rootRef = useMemo( + () => + !showTooltip + ? ref + : isMiddle + ? mergeRefs([ref, setRootElement]) + : mergeRefs([ref, setRootElement, measureRef]), + [showTooltip, isMiddle, ref, measureRef] + ); + + const isInsideControl = useMemo( + () => rootElement?.parentElement?.closest(INTERACTIVE_ELEMENT) != null, + [rootElement] + ); + + useEffect(() => { + if ( + hasWarnedRef.current || + tooltipContent !== undefined || + !rootElement?.querySelector(INTERACTIVE_ELEMENT) + ) { + return; + } + hasWarnedRef.current = true; + console.warn( + '[Click UI] TextTruncate: `children` holds an interactive element such as a link or button, which the tooltip would copy. Pass plain text as `tooltipContent`, or, when the whole text is the link or button, render `TextTruncate` inside it.' + ); + }, [rootElement, tooltipContent, children]); + + const mergedStyle = useMemo( + () => + maxWidth === undefined + ? style + : ({ '--text-truncate-max-width': maxWidth, ...style } as CSSProperties), + [maxWidth, style] + ); + + const [start, end] = isMiddle ? splitMiddle(middleText, trailingChars) : ['', '']; + const hasTabStop = showTooltip && isTruncated && !isInsideControl; + const hasGroupRole = hasTabStop && (Component === 'span' || Component === 'div'); + const isSelfLabelled = + hasGroupRole && ariaLabel === undefined && ariaLabelledBy === undefined; + const rootId = id ?? generatedId; + + const root = ( + + {isMiddle ? ( + <> + + {start} + + {end && ( + <> + + {end} + + {middleText} + + )} + + ) : ( + children + )} + + ); + + if (!showTooltip) { + return root; + } + + return ( + setIsTooltipOpen(open && isTruncated)} + > + {root} + {tooltipContent ?? children} + + ); +}; + +TextTruncateComponent.displayName = 'TextTruncate'; + +export const TextTruncate: TextTruncatePolymorphicComponent = + forwardRef(TextTruncateComponent); diff --git a/src/components/TextTruncate/TextTruncate.types.ts b/src/components/TextTruncate/TextTruncate.types.ts new file mode 100644 index 000000000..350da05f1 --- /dev/null +++ b/src/components/TextTruncate/TextTruncate.types.ts @@ -0,0 +1,40 @@ +import { ElementType, ReactNode } from 'react'; +import type { TooltipContentProps } from '@/components/Tooltip/Tooltip.types'; + +export type TextTruncateEllipsisPosition = 'end' | 'middle'; + +interface TextTruncateBaseProps { + /** Element or component to render as, shown as a block. Defaults to `span`; a component must forward `ref` and spread props. */ + component?: T; + /** Caps the width with any CSS length, e.g. `'160px'` or `'20ch'`. */ + maxWidth?: string; + /** Shows the full text in a tooltip and adds a tab stop while truncated; `false` skips both, e.g. in large lists or grid cells. Defaults to `true`. */ + showTooltip?: boolean; + /** Tooltip content. Defaults to `children`; pass plain text when `children` holds links or buttons, or put `TextTruncate` inside the link or button when it is the whole text. */ + tooltipContent?: ReactNode; + /** Positions the tooltip, e.g. `{ side: 'right' }`. */ + tooltipProps?: Pick; + className?: string; +} + +interface TextTruncateEndProps extends TextTruncateBaseProps { + /** `middle` keeps the end of the text visible and needs a string child. Defaults to `end`. */ + ellipsisPosition?: 'end'; + /** The content to truncate. */ + children: ReactNode; + trailingChars?: never; +} + +interface TextTruncateMiddleProps< + T extends ElementType, +> extends TextTruncateBaseProps { + /** `middle` keeps the end of the text visible and needs a string child. Defaults to `end`. */ + ellipsisPosition: 'middle'; + /** The text to truncate. */ + children: string; + /** Characters kept after the ellipsis; the text is not split when they would outnumber the rest. Defaults to `8`. */ + trailingChars?: number; +} + +export type TextTruncateProps = + TextTruncateEndProps | TextTruncateMiddleProps; diff --git a/src/components/TextTruncate/index.ts b/src/components/TextTruncate/index.ts new file mode 100644 index 000000000..ffb50b40b --- /dev/null +++ b/src/components/TextTruncate/index.ts @@ -0,0 +1,5 @@ +export { TextTruncate } from './TextTruncate'; +export type { + TextTruncateEllipsisPosition, + TextTruncateProps, +} from './TextTruncate.types'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ecfd9ab25..4f35b8203 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,6 +1,8 @@ export { useToast } from './useToast'; export { useInitialTheme } from './useInitialTheme'; export type { UseThemeParams } from './useInitialTheme'; +export { useIsTruncated } from './useIsTruncated'; +export type { UseIsTruncatedOptions, UseIsTruncatedResult } from './useIsTruncated'; /** * @deprecated This hook is deprecated and will be removed in a future version. diff --git a/src/hooks/useIsTruncated.test.tsx b/src/hooks/useIsTruncated.test.tsx new file mode 100644 index 000000000..45dec5576 --- /dev/null +++ b/src/hooks/useIsTruncated.test.tsx @@ -0,0 +1,135 @@ +import { act, waitFor } from '@testing-library/react'; +import { renderCUI } from '@/utils/test-utils'; +import { useIsTruncated } from './useIsTruncated'; + +const CHAR_WIDTH = 10; +let containerWidth = 100; + +class FakeResizeObserver { + static instances: FakeResizeObserver[] = []; + active = true; + constructor(private callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this); + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(() => { + this.active = false; + }); + trigger() { + if (this.active) { + this.callback([], this as unknown as ResizeObserver); + } + } +} + +const resizeTo = (width: number) => { + containerWidth = width; + act(() => FakeResizeObserver.instances.forEach(observer => observer.trigger())); +}; + +const Probe = ({ text, enabled }: { text: string; enabled?: boolean }) => { + const { ref, isTruncated } = useIsTruncated({ enabled }); + return ( + + {text} + + ); +}; + +describe('useIsTruncated', () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { + configurable: true, + get(this: HTMLElement) { + return (this.textContent ?? '').length * CHAR_WIDTH; + }, + }); + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get: () => containerWidth, + }); + }); + + afterAll(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollWidth'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); + }); + + beforeEach(() => { + containerWidth = 100; + FakeResizeObserver.instances = []; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('reports false when the text fits', () => { + const { getByTestId } = renderCUI(); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'false'); + }); + + it('reports true when the text is wider than the element', () => { + const { getByTestId } = renderCUI(); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'true'); + }); + + it('re-measures when the element resizes', () => { + const { getByTestId } = renderCUI(); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'true'); + + resizeTo(1000); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'false'); + + resizeTo(100); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'true'); + }); + + it('re-measures when the text changes without a resize', async () => { + const { getByTestId, rerender } = renderCUI(); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'false'); + + rerender(); + await waitFor(() => + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'true') + ); + }); + + it('does not observe or measure when disabled', () => { + const observe = vi.spyOn(MutationObserver.prototype, 'observe'); + const { getByTestId } = renderCUI( + + ); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'false'); + expect(FakeResizeObserver.instances).toHaveLength(0); + expect(observe).not.toHaveBeenCalled(); + observe.mockRestore(); + }); + + it('disconnects its observers on unmount', () => { + const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect'); + const { unmount } = renderCUI(); + const [observer] = FakeResizeObserver.instances; + expect(observer.observe).toHaveBeenCalledTimes(1); + + unmount(); + expect(observer.disconnect).toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + disconnect.mockRestore(); + }); + + it('still measures when ResizeObserver is missing (jsdom)', () => { + vi.stubGlobal('ResizeObserver', undefined); + const { getByTestId } = renderCUI(); + expect(getByTestId('probe')).toHaveAttribute('data-truncated', 'true'); + }); +}); diff --git a/src/hooks/useIsTruncated.ts b/src/hooks/useIsTruncated.ts new file mode 100644 index 000000000..9c5aa6a04 --- /dev/null +++ b/src/hooks/useIsTruncated.ts @@ -0,0 +1,59 @@ +import { RefCallback, useCallback, useEffect, useLayoutEffect, useState } from 'react'; + +const useIsomorphicLayoutEffect = + typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +export interface UseIsTruncatedOptions { + /** Set `false` to skip measuring; `isTruncated` then stays `false`. Defaults to `true`. */ + enabled?: boolean; +} + +export interface UseIsTruncatedResult { + /** Attach to the element that clips the text. */ + ref: RefCallback; + /** Whether the element's content is wider than the element. */ + isTruncated: boolean; +} + +/** Tracks whether the element's text overflows its width, re-measuring on resize and content changes. */ +export const useIsTruncated = ({ + enabled = true, +}: UseIsTruncatedOptions = {}): UseIsTruncatedResult => { + const [element, setElement] = useState(null); + const [isTruncated, setIsTruncated] = useState(false); + + const measure = useCallback(() => { + setIsTruncated( + enabled && element !== null && element.scrollWidth > element.clientWidth + ); + }, [element, enabled]); + + useIsomorphicLayoutEffect(() => { + measure(); + + if (!enabled || !element) { + return undefined; + } + + const mutationObserver = new MutationObserver(measure); + mutationObserver.observe(element, { + childList: true, + subtree: true, + characterData: true, + }); + + if (typeof ResizeObserver === 'undefined') { + return () => mutationObserver.disconnect(); + } + + const resizeObserver = new ResizeObserver(measure); + resizeObserver.observe(element); + + return () => { + mutationObserver.disconnect(); + resizeObserver.disconnect(); + }; + }, [element, enabled, measure]); + + return { ref: setElement, isTruncated }; +}; diff --git a/src/index.ts b/src/index.ts index dc46acbab..16a6ccdf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -266,8 +266,13 @@ export type { ToastProps } from './components/Toast'; // Hooks // ================================================ -export { useInitialTheme, useToast, useCUITheme } from './hooks'; -export type { CUIThemeType, UseThemeParams } from './hooks'; +export { useInitialTheme, useIsTruncated, useToast, useCUITheme } from './hooks'; +export type { + CUIThemeType, + UseIsTruncatedOptions, + UseIsTruncatedResult, + UseThemeParams, +} from './hooks'; // Tooltip export { Tooltip } from './components/Tooltip/Tooltip'; @@ -277,6 +282,13 @@ export type { TooltipContentProps, TooltipProps } from './components/Tooltip'; export { Text } from './components/Text'; export type { TextProps } from './components/Text'; +// Text Truncate +export { TextTruncate } from './components/TextTruncate'; +export type { + TextTruncateEllipsisPosition, + TextTruncateProps, +} from './components/TextTruncate'; + // Title export { Title } from './components/Title'; export type { TitleProps } from './components/Title'; diff --git a/src/styles/visually-hidden.module.css b/src/styles/visually-hidden.module.css new file mode 100644 index 000000000..8f8f6aa53 --- /dev/null +++ b/src/styles/visually-hidden.module.css @@ -0,0 +1,13 @@ +/* Visually hidden, still read by assistive technology. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + border: 0; + white-space: nowrap; + /* stylelint-disable-next-line plugin/no-unsupported-browser-features -- `clip-path: inset()` works in every .browserslistrc target; the warning is about other clip-path values */ + clip-path: inset(50%); +}