diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index e6dd1cd2..9027c279 100644 --- a/.storybook/components/Roadmap/data.ts +++ b/.storybook/components/Roadmap/data.ts @@ -427,7 +427,8 @@ export const rows: Rows = [ }, { component: 'ClampedText', - status: '🚧 Planned', + status: '✅ Done', + stage: '🔵 experimental', planned: 'Q3 2026', }, { diff --git a/packages/components/src/components/ClampedText/ClampedText.mdx b/packages/components/src/components/ClampedText/ClampedText.mdx new file mode 100644 index 00000000..b3367d61 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.mdx @@ -0,0 +1,77 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './ClampedText.stories'; + + + +# ClampedText + + + +`ClampedText` keeps long text compact while allowing users to reveal the full content. + +## Import + +```tsx +import { ClampedText } from '@koobiq/react-components'; +``` + +## Usage + + + +## Props + + + +## Number of rows + +The collapsed component displays `5` rows by default. The toggle appears only +when the content exceeds `rows + 1`, so a single additional row is shown in +full. + +Use the `rows` prop to configure the number of rows visible while collapsed. + + + +## Structured content + +ClampedText supports text split across semantic block elements, +including headings and paragraphs rendered with Typography. +The row limit applies to their shared content. + + + +## Controlled expansion + +Use the `isExpanded` prop with the `onExpandedChange` prop when expansion state is owned by the application. +For an uncontrolled initial state, use the `defaultExpanded` prop. + + + +## Resizing + +The `ClampedText` recalculates the number of rows when its container changes size. +The last expansion preference is preserved while the toggle is temporarily unnecessary. + + + +## Customization + +Use the `moreText` and `lessText` props to replace the localized toggle labels. + +Use the `slotProps.content` and `slotProps.toggle` props to customize the content container +and toggle. + + + +## Accessibility + +The toggle is a native button with `aria-expanded` and `aria-controls`. It supports mouse, touch, Enter, and Space interactions. + +Interactive elements inside collapsed content remain in keyboard navigation even when they are visually clipped. When a hidden link receives focus, the browser may reveal part of the clipped area and cause an unpleasant visual effect. Account for this behavior when placing links or controls inside ClampedText. diff --git a/packages/components/src/components/ClampedText/ClampedText.module.css b/packages/components/src/components/ClampedText/ClampedText.module.css new file mode 100644 index 00000000..b39345d0 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.module.css @@ -0,0 +1,62 @@ +@import url('../../styles/mixins.css'); + +.base { + display: inline-flex; + max-inline-size: 100%; + flex-direction: column; + + @mixin typography text-normal; +} + +.content { + min-inline-size: 0; +} + +.clamped { + display: -webkit-box; + overflow: hidden; + + /* Required by the legacy -webkit-line-clamp implementation. */ + /* stylelint-disable-next-line plugin/use-logical-properties-and-values */ + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--clamped-text-rows); + line-clamp: var(--clamped-text-rows); +} + +.toggle { + align-self: flex-start; + margin-block-start: var(--kbq-size-xxs); + padding: 0; + border: none; + gap: var(--kbq-size-xxs); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + background: none; + color: var(--kbq-foreground-theme); + outline: var(--kbq-size-3xs) solid transparent; + text-decoration: underline; + text-decoration-color: transparent; + transition: + color var(--kbq-transition-default), + outline var(--kbq-transition-default), + text-decoration-color var(--kbq-transition-default); + + @mixin typography text-normal; + + &[data-hovered] { + color: var(--kbq-states-foreground-theme-hover); + text-decoration-color: var(--kbq-line-theme-less); + } + + &[data-pressed] { + color: var(--kbq-states-foreground-theme-active); + text-decoration-color: var(--kbq-line-theme-less); + } + + &[data-focus-visible] { + color: var(--kbq-foreground-theme); + outline-color: var(--kbq-states-line-focus-theme); + } +} diff --git a/packages/components/src/components/ClampedText/ClampedText.stories.tsx b/packages/components/src/components/ClampedText/ClampedText.stories.tsx new file mode 100644 index 00000000..490adb93 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { FlexBox } from '../FlexBox'; +import { spacing } from '../layout'; +import { Toggle } from '../Toggle'; +import { Typography } from '../Typography'; + +import { ClampedText, type ClampedTextProps } from './index'; + +const meta = { + title: 'Components/ClampedText', + component: ClampedText, + parameters: { + layout: 'centered', + }, + tags: ['status:new', 'date:2026-08-12'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const text = + 'In a distributed denial-of-service attack (DDoS attack), the incoming traffic flooding the victim originates from many different sources. More sophisticated strategies are required to mitigate this type of attack; simply attempting to block a single source is insufficient as there are multiple sources. A DoS or DDoS attack is analogous to a group of people crowding the entry door of a shop, making it hard for legitimate customers to enter, thus disrupting trade and losing the business money. Criminal perpetrators of DoS attacks often target sites or services hosted on high-profile web servers such as banks or credit card payment gateways. Revenge and blackmail, as well as hacktivism, can motivate these attacks.'; + +export const Base: Story = { + render: (args) => { + return {text}; + }, +}; + +export const Rows: Story = { + render: (args) => { + return ( + + {text} + + ); + }, +}; + +export const StructuredContent: Story = { + render: (args) => ( + + + Line clamp with structured content + + {text} + + ), +}; + +export const ControlledExpansion: Story = { + render: function Render(args) { + const [isExpanded, setExpanded] = useState(false); + + return ( + + {text} + + ); + }, +}; + +export const ResizePersistence: Story = { + parameters: { + layout: 'padded', + }, + render: function Render(args) { + const [isNarrow, setNarrow] = useState(false); + + const text = + 'The expansion preference is preserved when resizing temporarily makes all of the text visible. Narrow the container again and the component restores the state selected before the resize.'; + + return ( + + + Narrow + + + {text} + + + ); + }, +}; + +export const Customization: Story = { + render: (args) => { + return ( + + {text} + + ); + }, +}; diff --git a/packages/components/src/components/ClampedText/ClampedText.test.tsx b/packages/components/src/components/ClampedText/ClampedText.test.tsx new file mode 100644 index 00000000..1cfdbf6d --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx @@ -0,0 +1,618 @@ +import { createRef, type SVGProps } from 'react'; + +import { once } from '@koobiq/logger'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Provider } from '../Provider'; +import { Typography } from '../Typography'; + +import { ClampedText } from './ClampedText'; + +vi.mock('@koobiq/react-icons', () => ({ + IconChevronDown16: (props: SVGProps) => ( + + ), + IconChevronUp16: (props: SVGProps) => ( + + ), +})); + +const createResizeEntry = (width: number): ResizeObserverEntry => + ({ + contentRect: { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: 100, + width, + height: 100, + }, + borderBoxSize: [{ inlineSize: width, blockSize: 100 }], + }) as unknown as ResizeObserverEntry; + +const createClientRects = (tops: number[]): DOMRectList => { + const rects = tops.map( + (top) => + ({ + x: 0, + y: top, + top, + left: 0, + right: 100, + bottom: top + 20, + width: 100, + height: 20, + toJSON: () => ({}), + }) as DOMRect + ); + + return Object.assign(rects, { + item: (index: number) => rects[index] ?? null, + }) as DOMRectList; +}; + +describe('ClampedText', () => { + let resize: ResizeObserverCallback; + let rowTops: number[]; + const observe = vi.fn(); + const disconnect = vi.fn(); + const scrollIntoView = vi.fn(); + const selectNodeContents = vi.fn(); + let scrollIntoViewDescriptor: PropertyDescriptor | undefined; + + beforeEach(() => { + rowTops = []; + + scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'scrollIntoView' + ); + + class ResizeObserverMock { + constructor(callback: ResizeObserverCallback) { + resize = callback; + } + + observe = observe; + disconnect = disconnect; + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + + return 1; + }); + + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + vi.spyOn(document, 'createRange').mockReturnValue({ + selectNodeContents, + getClientRects: () => createClientRects(rowTops), + } as unknown as Range); + + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + once.clear(); + + if (scrollIntoViewDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + 'scrollIntoView', + scrollIntoViewDescriptor + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); + } + }); + + it('forwards the ref and root element props', () => { + const ref = createRef(); + + render( + + Text + + ); + + expect(ref.current).toBe(screen.getByTestId('root')); + expect(ref.current).toHaveClass('custom'); + expect(ref.current).toHaveStyle({ padding: '4px' }); + }); + + it('supports custom labels and content and toggle slot props', async () => { + rowTops = [0, 20, 40, 60]; + const contentRef = createRef(); + const toggleRef = createRef(); + const onContentClick = vi.fn(); + const onTogglePress = vi.fn(); + const user = userEvent.setup(); + + render( + Show full text} + lessText={Show less text} + data-testid="root" + slotProps={{ + content: { + id: 'custom-content', + ref: contentRef, + className: 'custom-content', + style: { color: 'red' }, + onClick: onContentClick, + }, + toggle: { + ref: toggleRef, + className: 'custom-toggle', + style: { color: 'blue' }, + 'data-testid': 'toggle', + onPress: onTogglePress, + }, + }} + > + Long text + + ); + + const root = screen.getByTestId('root'); + const content = contentRef.current!; + const toggle = screen.getByTestId('toggle'); + + expect(root).toHaveAttribute('id', 'custom-root'); + expect(content).toHaveAttribute('id', 'custom-content'); + expect(content).toHaveClass('custom-content'); + expect(content.style.color).toBe('red'); + expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('2'); + + expect(toggleRef.current).toBe(toggle); + expect(toggle).toHaveClass('custom-toggle'); + expect(toggle.style.color).toBe('blue'); + expect(toggle).toHaveAttribute('aria-controls', 'custom-content'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAccessibleName('Show full text'); + + fireEvent.click(content); + expect(onContentClick).toHaveBeenCalledTimes(1); + + await user.click(toggle); + + expect(onTogglePress).toHaveBeenCalledTimes(1); + expect(toggle).toHaveAccessibleName('Show less text'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders on the server without browser measurement APIs', () => { + const documentDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'document' + ); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: undefined, + }); + + vi.stubGlobal('Range', undefined); + vi.stubGlobal('ResizeObserver', undefined); + + try { + const html = renderToString( + + Server text + + ); + + expect(html).toContain('id="server-root"'); + expect(html).toContain('id="server-content"'); + expect(html).toContain('Server text'); + expect(html).toContain('data-clamped="true"'); + expect(html).toContain('--clamped-text-rows:6'); + expect(html).not.toContain(' + Expanded server text + + ); + + expect(expandedHtml).toContain('data-expanded="true"'); + expect(expandedHtml).toContain('--clamped-text-rows:2'); + expect(expandedHtml).not.toContain('data-clamped'); + } finally { + if (documentDescriptor) { + Object.defineProperty(globalThis, 'document', documentDescriptor); + } + } + }); + + it('hydrates the server markup without a mismatch', async () => { + rowTops = [0, 20, 40, 60]; + + const element = ( + + Long text + + ); + + const container = document.createElement('div'); + + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + container.innerHTML = renderToString(element); + document.body.append(container); + + expect(container.firstElementChild).toHaveAttribute('data-clamped'); + + expect(container.querySelector('#content')).toHaveStyle( + '--clamped-text-rows: 3' + ); + + expect(container.querySelector('button')).not.toBeInTheDocument(); + + const root = hydrateRoot(container, element); + + await act(async () => {}); + + expect(consoleError).not.toHaveBeenCalled(); + + expect(container.querySelector('#content')).toHaveStyle( + '--clamped-text-rows: 2' + ); + + expect(container.querySelector('button')).toHaveAccessibleName('Expand'); + + act(() => root.unmount()); + container.remove(); + }); + + it('shows rows + 1 lines without a toggle or change event', () => { + rowTops = [0, 20, 40, 60, 80, 100]; + const onExpandedChange = vi.fn(); + + render( + + Text + + ); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByTestId('root')).not.toHaveAttribute('data-clamped'); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it('counts equal top positions as one row', () => { + rowTops = [0, 0, 20, 40, 60, 80, 100]; + + render(Text with inline fragments); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('measures text across block children without an inline wrapper', () => { + rowTops = [0, 20, 40, 60]; + + render( + + + Heading + + Paragraph text + + ); + + const content = screen.getByTestId('root').firstElementChild as HTMLElement; + + expect(content.children).toHaveLength(2); + expect(content.children[0].tagName).toBe('H3'); + expect(content.children[1].tagName).toBe('P'); + + expect(selectNodeContents).toHaveBeenCalledWith( + screen.getByText('Heading').firstChild + ); + + expect(selectNodeContents).toHaveBeenCalledWith( + screen.getByText('Paragraph text').firstChild + ); + + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('auto-collapses without a change event when rows exceed rows + 1', () => { + rowTops = [0, 20, 40, 60, 80, 100, 120]; + const onExpandedChange = vi.fn(); + + render( + + Long text + + ); + + const root = screen.getByTestId('root'); + const button = screen.getByRole('button', { name: 'Expand' }); + const content = root.firstElementChild as HTMLElement; + + expect(root).toHaveAttribute('data-overflowing'); + expect(root).toHaveAttribute('data-clamped'); + expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('5'); + expect(button).toHaveAttribute('type', 'button'); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(button).toHaveAttribute('aria-controls', content.id); + expect(screen.getByTestId('expand-icon')).toBeInTheDocument(); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it('uses the custom rows value as the clamp', () => { + rowTops = [0, 20, 40, 60]; + + render( + + Long text + + ); + + const content = screen.getByTestId('root').firstElementChild as HTMLElement; + + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('2'); + }); + + it('normalizes invalid rows values to a positive integer', () => { + rowTops = [0, 20, 40, 60]; + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const { rerender } = render( + + Long text + + ); + + const content = screen.getByTestId('root').firstElementChild as HTMLElement; + + expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('1'); + + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + + expect(consoleWarn).toHaveBeenCalledWith( + '[koobiq] ClampedText: the "rows" prop must be a positive integer. The received value was normalized.' + ); + + rerender( + + Long text + + ); + + expect(content.style.getPropertyValue('--clamped-text-rows')).toBe('2'); + }); + + it('supports an initially expanded uncontrolled state', () => { + rowTops = [0, 20, 40, 60]; + + render( + + Long text + + ); + + expect(screen.getByTestId('root')).not.toHaveAttribute('data-clamped'); + + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + }); + + it('supports keyboard toggling and scrolls into view after collapsing', async () => { + rowTops = [0, 20, 40, 60]; + const onExpandedChange = vi.fn(); + const user = userEvent.setup(); + + render( + + Long text + + ); + + await user.tab(); + await user.keyboard('{Enter}'); + + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + expect(screen.getByTestId('collapse-icon')).toBeInTheDocument(); + + await user.keyboard(' '); + + expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + expect(onExpandedChange.mock.calls.map(([value]) => value)).toEqual([ + true, + false, + ]); + + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + + expect(scrollIntoView).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + }); + + it('supports controlled expansion', async () => { + rowTops = [0, 20, 40, 60]; + const onExpandedChange = vi.fn(); + const user = userEvent.setup(); + + const { rerender } = render( + + Long text + + ); + + await user.click(screen.getByRole('button', { name: 'Expand' })); + + expect(onExpandedChange).toHaveBeenLastCalledWith(true); + + expect(screen.getByRole('button', { name: 'Expand' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + + rerender( + + Long text + + ); + + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + + expect(onExpandedChange).toHaveBeenCalledTimes(1); + }); + + it('does not retain a scroll request when controlled collapse is ignored', () => { + vi.useFakeTimers(); + rowTops = [0, 20, 40, 60]; + const onExpandedChange = vi.fn(); + + const { rerender } = render( + + Long text + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + act(() => vi.runAllTimers()); + + expect(onExpandedChange).toHaveBeenCalledWith(false); + expect(scrollIntoView).not.toHaveBeenCalled(); + + rerender( + + Long text + + ); + + act(() => vi.runAllTimers()); + + expect(scrollIntoView).not.toHaveBeenCalled(); + }); + + it('preserves the toggle preference across resize changes', async () => { + rowTops = [0, 20, 40, 60]; + const onExpandedChange = vi.fn(); + const user = userEvent.setup(); + + render( + + Long text + + ); + + await user.click(screen.getByRole('button', { name: 'Expand' })); + + rowTops = [0, 20, 40]; + act(() => resize([createResizeEntry(400)], {} as ResizeObserver)); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rowTops = [0, 20, 40, 60]; + act(() => resize([createResizeEntry(200)], {} as ResizeObserver)); + + expect( + screen.getByRole('button', { name: 'Collapse' }) + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Collapse' })); + + rowTops = [0, 20, 40]; + act(() => resize([createResizeEntry(400)], {} as ResizeObserver)); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rowTops = [0, 20, 40, 60]; + act(() => resize([createResizeEntry(200)], {} as ResizeObserver)); + + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + + expect(onExpandedChange.mock.calls.map(([value]) => value)).toEqual([ + true, + false, + ]); + }); + + it('remeasures when the content changes', () => { + rowTops = [0, 20, 40]; + const { rerender } = render(Short text); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + + rowTops = [0, 20, 40, 60]; + rerender(Long replacement text); + + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('uses localized toggle labels', () => { + rowTops = [0, 20, 40, 60]; + + render( + + Длинный текст + + ); + + expect( + screen.getByRole('button', { name: 'Развернуть' }) + ).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx new file mode 100644 index 00000000..8f0ecb71 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { + forwardRef, + type CSSProperties, + useEffect, + useRef, + useState, +} from 'react'; + +import { once } from '@koobiq/logger'; +import { + clsx, + mergeProps, + useControlledState, + useId, + useIsomorphicEffect, + useLocalizedStringFormatter, + useObjectRef, + useResizeObserver, +} from '@koobiq/react-core'; +import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; +import { + Button as ButtonPrimitive, + composeRenderProps, +} from '@koobiq/react-primitives'; + +import s from './ClampedText.module.css'; +import intlMessages from './intl.json'; +import type { ClampedTextProps, ClampedTextRef } from './types'; +import { getRowsCount } from './utils'; + +type ContentStyle = CSSProperties & { + '--clamped-text-rows': number; +}; + +/** + * ClampedText truncates long text to a configurable number of rows and lets + * the user expand or collapse it. + */ +export const ClampedText = forwardRef( + (props, ref) => { + const { + children, + rows = 5, + isExpanded, + defaultExpanded, + onExpandedChange, + moreText, + lessText, + slotProps, + className, + ...other + } = props; + + const normalizedRows = Number.isFinite(rows) + ? Math.max(1, Math.trunc(rows)) + : 1; + + if ( + process.env.NODE_ENV !== 'production' && + (!Number.isInteger(rows) || rows < 1) + ) { + once.warn( + 'ClampedText: the "rows" prop must be a positive integer. The received value was normalized.' + ); + } + + const rootRef = useObjectRef(ref); + const [contentRef, contentRect] = useResizeObserver(); + const generatedContentId = useId(); + const contentId = slotProps?.content?.id ?? generatedContentId; + const strings = useLocalizedStringFormatter(intlMessages); + + const [preferredExpanded, setPreferredExpanded] = useControlledState( + isExpanded, + defaultExpanded ?? false, + onExpandedChange + ); + + const [rowsCount, setRowsCount] = useState(); + + const scrollTimeoutRef = useRef(undefined); + + useIsomorphicEffect(() => { + const contentElement = contentRef.current; + + if (!contentElement) return; + + const wasClamped = contentElement.classList.contains(s.clamped); + + if (wasClamped) { + contentElement.classList.remove(s.clamped); + + // Flush the clamped layout before Range reads all line boxes. + contentElement.getBoundingClientRect(); + } + + const nextRowsCount = getRowsCount(contentElement); + + contentElement.classList.toggle(s.clamped, wasClamped); + + setRowsCount(nextRowsCount); + }, [ + children, + normalizedRows, + contentRect.width, + contentRect.height, + setRowsCount, + ]); + + const isMeasured = rowsCount !== undefined; + + // A single additional row is shown in full because a toggle would occupy + // the same vertical space without revealing more content. + const hasToggle = isMeasured && rowsCount > normalizedRows + 1; + + const effectiveExpanded = isMeasured + ? !hasToggle || preferredExpanded + : preferredExpanded; + + const isClamped = isMeasured + ? hasToggle && !effectiveExpanded + : !preferredExpanded; + + useEffect(() => { + return () => window.clearTimeout(scrollTimeoutRef.current); + }, []); + + const onToggle = () => { + const nextExpanded = !preferredExpanded; + + window.clearTimeout(scrollTimeoutRef.current); + + setPreferredExpanded(nextExpanded); + + if (!nextExpanded) { + scrollTimeoutRef.current = window.setTimeout(() => { + const rootElement = rootRef.current; + + if (!rootElement?.hasAttribute('data-clamped')) return; + + rootElement.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + }); + } + }; + + const contentStyle: ContentStyle = { + ...slotProps?.content?.style, + '--clamped-text-rows': + !isMeasured && !preferredExpanded ? normalizedRows + 1 : normalizedRows, + }; + + const contentProps = mergeProps( + { + ref: contentRef, + className: clsx(s.content, isClamped && s.clamped), + }, + slotProps?.content, + { + id: contentId, + style: contentStyle, + } + ); + + const toggleProps = mergeProps( + { + onPress: onToggle, + }, + slotProps?.toggle + ); + + return ( +
+
{children}
+ {hasToggle && ( + clsx(s.toggle, className) + )} + aria-controls={contentId} + aria-expanded={effectiveExpanded} + > + {effectiveExpanded ? ( + + ) : ( + + )} + + {effectiveExpanded + ? (lessText ?? strings.format('collapse')) + : (moreText ?? strings.format('expand'))} + + + )} +
+ ); + } +); + +ClampedText.displayName = 'ClampedText'; diff --git a/packages/components/src/components/ClampedText/index.ts b/packages/components/src/components/ClampedText/index.ts new file mode 100644 index 00000000..6ceb40a3 --- /dev/null +++ b/packages/components/src/components/ClampedText/index.ts @@ -0,0 +1,2 @@ +export * from './ClampedText'; +export * from './types'; diff --git a/packages/components/src/components/ClampedText/intl.json b/packages/components/src/components/ClampedText/intl.json new file mode 100644 index 00000000..6e7e5e4e --- /dev/null +++ b/packages/components/src/components/ClampedText/intl.json @@ -0,0 +1,10 @@ +{ + "ru-RU": { + "expand": "Развернуть", + "collapse": "Свернуть" + }, + "en-US": { + "expand": "Expand", + "collapse": "Collapse" + } +} diff --git a/packages/components/src/components/ClampedText/types.ts b/packages/components/src/components/ClampedText/types.ts new file mode 100644 index 00000000..d32a5163 --- /dev/null +++ b/packages/components/src/components/ClampedText/types.ts @@ -0,0 +1,45 @@ +import type { ComponentPropsWithRef, ComponentRef, ReactNode } from 'react'; + +import type { + DataAttributeProps, + ExtendableComponentPropsWithRef, +} from '@koobiq/react-core'; +import type { ButtonProps } from '@koobiq/react-primitives'; + +export type ClampedTextProps = ExtendableComponentPropsWithRef< + { + /** The content of the component. */ + children?: ReactNode; + /** + * Maximum number of visible rows when the text is collapsed. + * @default 5 + */ + rows?: number; + /** Whether the text is expanded. */ + isExpanded?: boolean; + /** + * Whether the text is expanded by default. + * @default false + */ + defaultExpanded?: boolean; + /** Handler called when the user toggles the expanded state. */ + onExpandedChange?: (isExpanded: boolean) => void; + /** Content displayed in the toggle when the text is collapsed. */ + moreText?: ReactNode; + /** Content displayed in the toggle when the text is expanded. */ + lessText?: ReactNode; + /** The props used for each slot inside. */ + slotProps?: { + content?: Omit, 'children'> & + DataAttributeProps; + toggle?: Omit< + ButtonProps, + 'as' | 'children' | 'type' | 'aria-controls' | 'aria-expanded' + > & + DataAttributeProps; + }; + }, + 'div' +>; + +export type ClampedTextRef = ComponentRef<'div'>; diff --git a/packages/components/src/components/ClampedText/utils.test.ts b/packages/components/src/components/ClampedText/utils.test.ts new file mode 100644 index 00000000..df2e39a3 --- /dev/null +++ b/packages/components/src/components/ClampedText/utils.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getRowsCount } from './utils'; + +type Rect = Pick; + +const createClientRects = (rects: Rect[]): DOMRectList => + Object.assign(rects, { + item: (index: number) => rects[index] ?? null, + }) as unknown as DOMRectList; + +describe('getRowsCount', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('merges overlapping rects from mixed inline typography into rows', () => { + const element = document.createElement('div'); + + element.innerHTML = + 'Hash a1b2c3 seen at 2 nodes
Second row'; + + const rectsByText = new Map([ + ['Hash ', [{ top: 10, bottom: 30, height: 20 }]], + ['a1b2c3', [{ top: 14, bottom: 26, height: 12 }]], + [' seen at ', [{ top: 10, bottom: 30, height: 20 }]], + ['2', [{ top: 5, bottom: 18, height: 13 }]], + [' nodes', [{ top: 10, bottom: 30, height: 20 }]], + ['Second row', [{ top: 30, bottom: 50, height: 20 }]], + ]); + + let selectedNode: Node | undefined; + + vi.spyOn(document, 'createRange').mockReturnValue({ + selectNodeContents: (node: Node) => { + selectedNode = node; + }, + getClientRects: () => + createClientRects( + rectsByText.get(selectedNode?.textContent ?? '') ?? [] + ), + } as unknown as Range); + + expect(getRowsCount(element)).toBe(2); + }); + + it('ignores whitespace and zero-height rects', () => { + const element = document.createElement('div'); + + element.innerHTML = 'First Second'; + + const rectsByText = new Map([ + [ + 'First', + [ + { top: 0, bottom: 20, height: 20 }, + { top: 20, bottom: 20, height: 0 }, + ], + ], + ['Second', [{ top: 20, bottom: 40, height: 20 }]], + ]); + + let selectedNode: Node | undefined; + + vi.spyOn(document, 'createRange').mockReturnValue({ + selectNodeContents: (node: Node) => { + selectedNode = node; + }, + getClientRects: () => + createClientRects( + rectsByText.get(selectedNode?.textContent ?? '') ?? [] + ), + } as unknown as Range); + + expect(getRowsCount(element)).toBe(2); + }); +}); diff --git a/packages/components/src/components/ClampedText/utils.ts b/packages/components/src/components/ClampedText/utils.ts new file mode 100644 index 00000000..0fcb15fe --- /dev/null +++ b/packages/components/src/components/ClampedText/utils.ts @@ -0,0 +1,31 @@ +export const getRowsCount = (element: HTMLElement) => { + const range = document.createRange(); + const textNodes = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const rects: Array<[top: number, bottom: number]> = []; + let textNode = textNodes.nextNode(); + + while (textNode) { + if (textNode.textContent?.trim()) { + range.selectNodeContents(textNode); + + Array.from(range.getClientRects()).forEach(({ top, bottom, height }) => { + if (height) rects.push([top, bottom]); + }); + } + + textNode = textNodes.nextNode(); + } + + rects.sort(([firstTop], [secondTop]) => firstTop - secondTop); + + let rowsCount = 0; + let rowBottom = -Infinity; + + rects.forEach(([top, bottom]) => { + if (top >= rowBottom) rowsCount += 1; + + rowBottom = Math.max(rowBottom, bottom); + }); + + return rowsCount; +}; diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index d6d7948d..50c58026 100644 --- a/packages/components/src/components/index.ts +++ b/packages/components/src/components/index.ts @@ -10,6 +10,7 @@ export * from './IconButton'; export * from './Typography'; export * from './Checkbox'; export * from './CheckboxGroup'; +export * from './ClampedText'; export * from './Link'; export * from './Badge'; export * from './Input'; diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 73bf00ca..c02c100d 100644 --- a/tools/api-extractor/config.json +++ b/tools/api-extractor/config.json @@ -13,6 +13,7 @@ "Calendar", "Checkbox", "CheckboxGroup", + "ClampedText", "Container", "ContentPanel", "DateInput", diff --git a/tools/public_api_guard/components/ClampedText.api.md b/tools/public_api_guard/components/ClampedText.api.md new file mode 100644 index 00000000..f04b0550 --- /dev/null +++ b/tools/public_api_guard/components/ClampedText.api.md @@ -0,0 +1,39 @@ +## API Report File for "koobiq-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonProps } from '@koobiq/react-primitives'; +import type { ComponentPropsWithRef } from 'react'; +import type { ComponentRef } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import { ForwardRefExoticComponent } from 'react'; +import type { ReactNode } from 'react'; +import { RefAttributes } from 'react'; + +// @public +export const ClampedText: ForwardRefExoticComponent & RefAttributes>; + +// @public (undocumented) +export type ClampedTextProps = ExtendableComponentPropsWithRef<{ + children?: ReactNode; + rows?: number; + isExpanded?: boolean; + defaultExpanded?: boolean; + onExpandedChange?: (isExpanded: boolean) => void; + moreText?: ReactNode; + lessText?: ReactNode; + slotProps?: { + content?: Omit, 'children'> & DataAttributeProps; + toggle?: Omit & DataAttributeProps; + }; +}, 'div'>; + +// @public (undocumented) +export type ClampedTextRef = ComponentRef<'div'>; + +// (No @packageDocumentation comment for this package) + +```