From bcab038f69f77430e712d7092eff7c9145a45a23 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Wed, 12 Aug 2026 19:38:11 +0300 Subject: [PATCH 1/5] feat(ClampedText): add initial implementation --- .../components/ClampedText/ClampedText.mdx | 74 ++++ .../ClampedText/ClampedText.module.css | 29 ++ .../ClampedText/ClampedText.stories.tsx | 125 ++++++ .../ClampedText/ClampedText.test.tsx | 379 ++++++++++++++++++ .../components/ClampedText/ClampedText.tsx | 171 ++++++++ .../src/components/ClampedText/index.ts | 2 + .../src/components/ClampedText/intl.json | 10 + .../src/components/ClampedText/types.ts | 27 ++ .../src/components/Link/Link.test.tsx | 7 + packages/components/src/components/index.ts | 1 + .../primitives/src/components/Link/Link.tsx | 24 +- tools/api-extractor/config.json | 1 + .../components/ClampedText.api.md | 30 ++ 13 files changed, 878 insertions(+), 2 deletions(-) create mode 100644 packages/components/src/components/ClampedText/ClampedText.mdx create mode 100644 packages/components/src/components/ClampedText/ClampedText.module.css create mode 100644 packages/components/src/components/ClampedText/ClampedText.stories.tsx create mode 100644 packages/components/src/components/ClampedText/ClampedText.test.tsx create mode 100644 packages/components/src/components/ClampedText/ClampedText.tsx create mode 100644 packages/components/src/components/ClampedText/index.ts create mode 100644 packages/components/src/components/ClampedText/intl.json create mode 100644 packages/components/src/components/ClampedText/types.ts create mode 100644 tools/public_api_guard/components/ClampedText.api.md diff --git a/packages/components/src/components/ClampedText/ClampedText.mdx b/packages/components/src/components/ClampedText/ClampedText.mdx new file mode 100644 index 000000000..852ecc834 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.mdx @@ -0,0 +1,74 @@ +import { + Meta, + Story, + Props, + Status, +} from '../../../../../.storybook/components'; + +import * as Stories from './ClampedText.stories'; + + + +# ClampedText + + + +ClampedText keeps long non-interactive text compact while allowing users to reveal the full content. + +## Import + +```tsx +import { ClampedText } from '@koobiq/react-components'; +``` + +## Usage + +The collapsed component displays five rows by default. The toggle is shown only when the content occupies more than six rows. + + + +## Props + + + +## Number of rows + +Use `rows` to configure the number of rows visible while collapsed. + + + +## One additional row + +When the hidden part would contain only one row, ClampedText displays it immediately. The toggle is omitted because the full content occupies the same vertical space as the clamped text and its action. + + + +## Block content + +ClampedText supports non-interactive text split across semantic block elements, including headings and paragraphs rendered with Typography. The row limit applies to their shared content. + + + +## Controlled expansion + +Use `isExpanded` with `onExpandedChange` when expansion state is owned by the application. For an uncontrolled initial state, use `defaultExpanded`. + +```tsx +const [isExpanded, setExpanded] = useState(false); + + + {text} +; +``` + + + +## Resizing + +ClampedText recalculates the number of rows when its container changes size. The last expansion preference is preserved while the toggle is temporarily unnecessary. + + + +## Accessibility + +The toggle is a native button with `aria-expanded` and `aria-controls`. It supports mouse, touch, Enter, and Space interactions. Keep the component content non-interactive because visually clipped descendants remain in the accessibility tree. 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 000000000..855e905c4 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.module.css @@ -0,0 +1,29 @@ +@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); +} 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 000000000..743cef683 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { Button } from '../Button'; +import { FlexBox } from '../FlexBox'; +import { spacing } from '../layout'; +import { Link } from '../Link'; +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; + +export const Base: Story = { + render: (args) => { + 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.'; + + return ( + + {text} + + ); + }, +}; + +export const Rows: Story = { + render: (args) => { + const text = + 'Long descriptions are easier to scan when secondary details can be collapsed. Set rows to control how much text remains visible before the user explicitly expands the rest of the content.'; + + return ( + + {text} + + ); + }, +}; + +export const OneAdditionalRow: Story = { + render: (args) => ( + + First visible row. +
+ Second visible row. +
+ Third visible row. +
+ The only additional row is shown without a toggle. +
+ ), +}; + +export const BlockContent: Story = { + render: (args) => ( + + + Line clamp with block content + + + ClampedText can measure and truncate text across semantic block + elements. Typography variants remain attached to their respective + elements while the shared container controls expansion. The{' '} + + documentation link + {' '} + is intentionally placed near the end to demonstrate how an interactive + element behaves when it falls into the clamped part of the content. + + + ), +}; + +export const ControlledExpansion: Story = { + render: function Render(args) { + const [isExpanded, setExpanded] = useState(false); + + const text = + 'Controlled expansion is useful when the state needs to be synchronized with another part of an application, such as a route parameter or a shared details panel. The component still recalculates whether clamping is necessary when its available width changes.'; + + return ( + + {text} + + ); + }, +}; + +export const ResizePersistence: Story = { + render: function Render(args) { + const [width, setWidth] = useState(220); + + 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 ( + + + + + + + {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 000000000..98cf80a06 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx @@ -0,0 +1,379 @@ +import { createRef, type SVGProps } from 'react'; + +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +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(); + + beforeEach(() => { + rowTops = []; + + 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.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); + 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('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('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('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 000000000..9ae5d95c3 --- /dev/null +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { + forwardRef, + type CSSProperties, + useEffect, + useRef, + useState, +} from 'react'; + +import { + clsx, + useControlledState, + useId, + useIsomorphicEffect, + useLocalizedStringFormatter, + useObjectRef, + useResizeObserver, +} from '@koobiq/react-core'; +import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; + +import { Link } from '../Link'; + +import s from './ClampedText.module.css'; +import intlMessages from './intl.json'; +import type { ClampedTextProps, ClampedTextRef } from './types'; + +type ContentStyle = CSSProperties & { + '--clamped-text-rows': number; +}; + +const getRowsCount = (element: HTMLElement) => { + const range = document.createRange(); + const textNodes = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const rowTops = new Set(); + let textNode = textNodes.nextNode(); + + while (textNode) { + if (textNode.textContent?.trim()) { + range.selectNodeContents(textNode); + + Array.from(range.getClientRects()).forEach(({ top }) => { + rowTops.add(top); + }); + } + + textNode = textNodes.nextNode(); + } + + return rowTops.size; +}; + +/** + * 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, + className, + ...other + } = props; + + const rootRef = useObjectRef(ref); + const [contentRef, contentRect] = useResizeObserver(); + const contentId = useId(); + const strings = useLocalizedStringFormatter(intlMessages); + + const [preferredExpanded, setPreferredExpanded] = useControlledState( + isExpanded, + defaultExpanded ?? false, + onExpandedChange + ); + + const [rowsCount, setRowsCount] = useState(); + + const shouldScrollOnCollapseRef = useRef(false); + + useIsomorphicEffect(() => { + const contentElement = contentRef.current; + + if (!contentElement) return; + + setRowsCount(getRowsCount(contentElement)); + }, [children, rows, contentRect.width, contentRect.height, setRowsCount]); + + const isMeasured = rowsCount !== undefined; + const hasToggle = isMeasured && rowsCount > rows + 1; + + const effectiveExpanded = isMeasured + ? !hasToggle || preferredExpanded + : true; + + const isClamped = hasToggle && !effectiveExpanded; + + useEffect(() => { + if (!isClamped || !shouldScrollOnCollapseRef.current) return; + + shouldScrollOnCollapseRef.current = false; + + const timeoutId = window.setTimeout(() => { + rootRef.current?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + }); + + return () => window.clearTimeout(timeoutId); + }, [isClamped, rootRef]); + + const onToggle = () => { + const nextExpanded = !preferredExpanded; + + shouldScrollOnCollapseRef.current = !nextExpanded; + setPreferredExpanded(nextExpanded); + }; + + const contentStyle: ContentStyle = { + '--clamped-text-rows': rows, + }; + + return ( +
+
+ {children} +
+ + {hasToggle && ( + + ) : ( + + ) + } + > + {strings.format(effectiveExpanded ? 'collapse' : '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 000000000..6ceb40a37 --- /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 000000000..6e7e5e4e2 --- /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 000000000..0d46b7ec8 --- /dev/null +++ b/packages/components/src/components/ClampedText/types.ts @@ -0,0 +1,27 @@ +import type { ComponentRef, ReactNode } from 'react'; + +import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; + +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; + }, + 'div' +>; + +export type ClampedTextRef = ComponentRef<'div'>; diff --git a/packages/components/src/components/Link/Link.test.tsx b/packages/components/src/components/Link/Link.test.tsx index a07ada1dc..bf0db9015 100644 --- a/packages/components/src/components/Link/Link.test.tsx +++ b/packages/components/src/components/Link/Link.test.tsx @@ -62,12 +62,19 @@ describe('Link', () => { const props = { ...baseProps, onPress: vi.fn(), + type: 'button' as const, + 'aria-controls': 'content', + 'aria-expanded': false, }; render(); const linkAsButton = getRoot(); expect(linkAsButton.tagName).toBe('BUTTON'); + expect(linkAsButton).not.toHaveAttribute('role'); + expect(linkAsButton).toHaveAttribute('type', 'button'); + expect(linkAsButton).toHaveAttribute('aria-controls', 'content'); + expect(linkAsButton).toHaveAttribute('aria-expanded', 'false'); await userEvent.click(linkAsButton); diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts index d6d7948df..50c580268 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/packages/primitives/src/components/Link/Link.tsx b/packages/primitives/src/components/Link/Link.tsx index 495dc006d..daf555a82 100644 --- a/packages/primitives/src/components/Link/Link.tsx +++ b/packages/primitives/src/components/Link/Link.tsx @@ -14,6 +14,22 @@ import { useLink } from '../../behaviors'; import type { LinkBaseProps } from './types.js'; +const buttonDOMPropNames = new Set([ + 'type', + 'name', + 'value', + 'form', + 'formAction', + 'formEncType', + 'formMethod', + 'formNoValidate', + 'formTarget', + 'aria-controls', + 'aria-expanded', + 'aria-haspopup', + 'aria-pressed', +]); + /** * A link primitive allows a user to navigate to another page or resource within * a web page or application. @@ -27,7 +43,7 @@ export const Link = polymorphicForwardRef<'a', LinkBaseProps>((props, ref) => { useLink( { ...other, - elementType: `${Tag}`, + elementType: Tag === 'button' ? undefined : `${Tag}`, ...(other.isDisabled && { onPress: undefined, onPressStart: undefined, @@ -56,7 +72,11 @@ export const Link = polymorphicForwardRef<'a', LinkBaseProps>((props, ref) => { values: renderValues, }); - const DOMProps = filterDOMProps(props, { global: true }); + const DOMProps = filterDOMProps(props, { + global: true, + propNames: Tag === 'button' ? buttonDOMPropNames : undefined, + }); + delete DOMProps.onClick; return ( diff --git a/tools/api-extractor/config.json b/tools/api-extractor/config.json index 73bf00ca7..c02c100d7 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 000000000..3f129ad7c --- /dev/null +++ b/tools/public_api_guard/components/ClampedText.api.md @@ -0,0 +1,30 @@ +## 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 { ComponentRef } from 'react'; +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; +}, 'div'>; + +// @public (undocumented) +export type ClampedTextRef = ComponentRef<'div'>; + +// (No @packageDocumentation comment for this package) + +``` From c4e987df53ca0c99989867fdf3030c13c3b5b2df Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 13 Aug 2026 13:22:06 +0300 Subject: [PATCH 2/5] feat(ClampedText): extend API and improve documentation --- .storybook/components/Roadmap/data.ts | 3 +- .../components/ClampedText/ClampedText.mdx | 51 +++++---- .../ClampedText/ClampedText.stories.tsx | 108 +++++++++--------- .../ClampedText/ClampedText.test.tsx | 107 ++++++++++++++++- .../components/ClampedText/ClampedText.tsx | 69 ++++++----- .../src/components/ClampedText/types.ts | 29 ++++- .../src/components/ClampedText/utils.ts | 20 ++++ .../components/ClampedText.api.md | 17 +++ 8 files changed, 288 insertions(+), 116 deletions(-) create mode 100644 packages/components/src/components/ClampedText/utils.ts diff --git a/.storybook/components/Roadmap/data.ts b/.storybook/components/Roadmap/data.ts index e6dd1cd2f..9027c2791 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 index 852ecc834..b3367d61f 100644 --- a/packages/components/src/components/ClampedText/ClampedText.mdx +++ b/packages/components/src/components/ClampedText/ClampedText.mdx @@ -13,7 +13,7 @@ import * as Stories from './ClampedText.stories'; -ClampedText keeps long non-interactive text compact while allowing users to reveal the full content. +`ClampedText` keeps long text compact while allowing users to reveal the full content. ## Import @@ -23,8 +23,6 @@ import { ClampedText } from '@koobiq/react-components'; ## Usage -The collapsed component displays five rows by default. The toggle is shown only when the content occupies more than six rows. - ## Props @@ -33,42 +31,47 @@ The collapsed component displays five rows by default. The toggle is shown only ## Number of rows -Use `rows` to configure the number of rows visible while collapsed. - - - -## One additional row +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. -When the hidden part would contain only one row, ClampedText displays it immediately. The toggle is omitted because the full content occupies the same vertical space as the clamped text and its action. +Use the `rows` prop to configure the number of rows visible while collapsed. - + -## Block content +## Structured content -ClampedText supports non-interactive text split across semantic block elements, including headings and paragraphs rendered with Typography. The row limit applies to their shared 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 `isExpanded` with `onExpandedChange` when expansion state is owned by the application. For an uncontrolled initial state, use `defaultExpanded`. - -```tsx -const [isExpanded, setExpanded] = useState(false); - - - {text} -; -``` +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 -ClampedText recalculates the number of rows when its container changes size. The last expansion preference is preserved while the toggle is temporarily unnecessary. +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. Keep the component content non-interactive because visually clipped descendants remain in the accessibility tree. +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.stories.tsx b/packages/components/src/components/ClampedText/ClampedText.stories.tsx index 743cef683..8f5fa4054 100644 --- a/packages/components/src/components/ClampedText/ClampedText.stories.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx @@ -1,11 +1,10 @@ import { useState } from 'react'; -import type { Meta, StoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button } from '../Button'; import { FlexBox } from '../FlexBox'; import { spacing } from '../layout'; -import { Link } from '../Link'; +import { Toggle } from '../Toggle'; import { Typography } from '../Typography'; import { ClampedText, type ClampedTextProps } from './index'; @@ -22,62 +21,32 @@ const 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) => { - 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.'; - - return ( - - {text} - - ); + return {text}; }, }; export const Rows: Story = { render: (args) => { - const text = - 'Long descriptions are easier to scan when secondary details can be collapsed. Set rows to control how much text remains visible before the user explicitly expands the rest of the content.'; - return ( - + {text} ); }, }; -export const OneAdditionalRow: Story = { - render: (args) => ( - - First visible row. -
- Second visible row. -
- Third visible row. -
- The only additional row is shown without a toggle. -
- ), -}; - -export const BlockContent: Story = { +export const StructuredContent: Story = { render: (args) => ( - + - Line clamp with block content - - - ClampedText can measure and truncate text across semantic block - elements. Typography variants remain attached to their respective - elements while the shared container controls expansion. The{' '} - - documentation link - {' '} - is intentionally placed near the end to demonstrate how an interactive - element behaves when it falls into the clamped part of the content. + Line clamp with structured content + {text} ), }; @@ -86,16 +55,12 @@ export const ControlledExpansion: Story = { render: function Render(args) { const [isExpanded, setExpanded] = useState(false); - const text = - 'Controlled expansion is useful when the state needs to be synchronized with another part of an application, such as a route parameter or a shared details panel. The component still recalculates whether clamping is necessary when its available width changes.'; - return ( {text} @@ -104,22 +69,59 @@ export const ControlledExpansion: Story = { }; export const ResizePersistence: Story = { + parameters: { + layout: 'padded', + }, render: function Render(args) { - const [width, setWidth] = useState(220); + 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 index 98cf80a06..e1e057435 100644 --- a/packages/components/src/components/ClampedText/ClampedText.test.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx @@ -1,7 +1,14 @@ import { createRef, type SVGProps } from 'react'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { renderToString } from 'react-dom/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Provider } from '../Provider'; @@ -121,6 +128,104 @@ describe('ClampedText', () => { 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).not.toContain(' { rowTops = [0, 20, 40, 60, 80, 100]; const onExpandedChange = vi.fn(); diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index 9ae5d95c3..6987c8c62 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -10,6 +10,7 @@ import { import { clsx, + mergeProps, useControlledState, useId, useIsomorphicEffect, @@ -24,32 +25,12 @@ import { Link } from '../Link'; 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; }; -const getRowsCount = (element: HTMLElement) => { - const range = document.createRange(); - const textNodes = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - const rowTops = new Set(); - let textNode = textNodes.nextNode(); - - while (textNode) { - if (textNode.textContent?.trim()) { - range.selectNodeContents(textNode); - - Array.from(range.getClientRects()).forEach(({ top }) => { - rowTops.add(top); - }); - } - - textNode = textNodes.nextNode(); - } - - return rowTops.size; -}; - /** * ClampedText truncates long text to a configurable number of rows and lets * the user expand or collapse it. @@ -62,13 +43,17 @@ export const ClampedText = forwardRef( isExpanded, defaultExpanded, onExpandedChange, + moreText, + lessText, + slotProps, className, ...other } = props; const rootRef = useObjectRef(ref); const [contentRef, contentRect] = useResizeObserver(); - const contentId = useId(); + const generatedContentId = useId(); + const contentId = slotProps?.content?.id ?? generatedContentId; const strings = useLocalizedStringFormatter(intlMessages); const [preferredExpanded, setPreferredExpanded] = useControlledState( @@ -122,9 +107,30 @@ export const ClampedText = forwardRef( }; const contentStyle: ContentStyle = { + ...slotProps?.content?.style, '--clamped-text-rows': rows, }; + const contentProps = mergeProps( + { + ref: contentRef, + className: clsx(s.content, isClamped && s.clamped), + }, + slotProps?.content, + { + id: contentId, + style: contentStyle, + } + ); + + const toggleProps = mergeProps( + { + className: s.toggle, + onPress: onToggle, + }, + slotProps?.toggle + ); + return (
( data-overflowing={hasToggle || undefined} data-clamped={isClamped || undefined} > -
- {children} -
- +
{children}
{hasToggle && ( @@ -159,8 +155,11 @@ export const ClampedText = forwardRef( ) } + isPseudo > - {strings.format(effectiveExpanded ? 'collapse' : 'expand')} + {effectiveExpanded + ? (lessText ?? strings.format('collapse')) + : (moreText ?? strings.format('expand'))} )}
diff --git a/packages/components/src/components/ClampedText/types.ts b/packages/components/src/components/ClampedText/types.ts index 0d46b7ec8..fd5f575ca 100644 --- a/packages/components/src/components/ClampedText/types.ts +++ b/packages/components/src/components/ClampedText/types.ts @@ -1,6 +1,11 @@ -import type { ComponentRef, ReactNode } from 'react'; +import type { ComponentPropsWithRef, ComponentRef, ReactNode } from 'react'; -import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { + DataAttributeProps, + ExtendableComponentPropsWithRef, +} from '@koobiq/react-core'; + +import type { LinkProps } from '../Link'; export type ClampedTextProps = ExtendableComponentPropsWithRef< { @@ -20,6 +25,26 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef< 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< + LinkProps<'button'>, + | 'as' + | 'children' + | 'type' + | 'isPseudo' + | 'aria-controls' + | 'aria-expanded' + | 'startIcon' + > & + DataAttributeProps; + }; }, 'div' >; diff --git a/packages/components/src/components/ClampedText/utils.ts b/packages/components/src/components/ClampedText/utils.ts new file mode 100644 index 000000000..d89086d51 --- /dev/null +++ b/packages/components/src/components/ClampedText/utils.ts @@ -0,0 +1,20 @@ +export const getRowsCount = (element: HTMLElement) => { + const range = document.createRange(); + const textNodes = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const rowTops = new Set(); + let textNode = textNodes.nextNode(); + + while (textNode) { + if (textNode.textContent?.trim()) { + range.selectNodeContents(textNode); + + Array.from(range.getClientRects()).forEach(({ top }) => { + rowTops.add(top); + }); + } + + textNode = textNodes.nextNode(); + } + + return rowTops.size; +}; diff --git a/tools/public_api_guard/components/ClampedText.api.md b/tools/public_api_guard/components/ClampedText.api.md index 3f129ad7c..fdb27db8b 100644 --- a/tools/public_api_guard/components/ClampedText.api.md +++ b/tools/public_api_guard/components/ClampedText.api.md @@ -4,9 +4,16 @@ ```ts +import type { ComponentPropsWithRef } from 'react'; import type { ComponentRef } from 'react'; +import type { CSSProperties } from 'react'; +import type { DataAttributeProps } from '@koobiq/react-core'; +import type { ElementType } from 'react'; import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; +import type { ExtendableProps } from '@koobiq/react-core'; import { ForwardRefExoticComponent } from 'react'; +import type { LinkBaseProps as LinkBaseProps_2 } from '@koobiq/react-primitives'; +import { PolyForwardComponent } from '@koobiq/react-core'; import type { ReactNode } from 'react'; import { RefAttributes } from 'react'; @@ -20,11 +27,21 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef<{ isExpanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (isExpanded: boolean) => void; + moreText?: ReactNode; + lessText?: ReactNode; + slotProps?: { + content?: Omit, 'children'> & DataAttributeProps; + toggle?: Omit, 'as' | 'children' | 'type' | 'isPseudo' | 'aria-controls' | 'aria-expanded' | 'startIcon'> & DataAttributeProps; + }; }, 'div'>; // @public (undocumented) export type ClampedTextRef = ComponentRef<'div'>; +// Warnings were encountered during analysis: +// +// packages/components/dist/components/ClampedText/types.d.ts:28:9 - (ae-forgotten-export) The symbol "LinkProps" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` From 9a5fbc1ba9ff7b91ad4b087575726594fb0b62f5 Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 13 Aug 2026 15:51:15 +0300 Subject: [PATCH 3/5] fix(ClampedText): preserve clamping during SSR hydration --- .../ClampedText/ClampedText.test.tsx | 55 +++++++++++++++++++ .../components/ClampedText/ClampedText.tsx | 24 ++++++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/components/src/components/ClampedText/ClampedText.test.tsx b/packages/components/src/components/ClampedText/ClampedText.test.tsx index e1e057435..075b1a0e8 100644 --- a/packages/components/src/components/ClampedText/ClampedText.test.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx @@ -8,6 +8,7 @@ import { 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'; @@ -218,7 +219,19 @@ describe('ClampedText', () => { 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); @@ -226,6 +239,48 @@ describe('ClampedText', () => { } }); + 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(); diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index 6987c8c62..525c5bdb8 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -71,7 +71,20 @@ export const ClampedText = forwardRef( if (!contentElement) return; - setRowsCount(getRowsCount(contentElement)); + 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, rows, contentRect.width, contentRect.height, setRowsCount]); const isMeasured = rowsCount !== undefined; @@ -79,9 +92,11 @@ export const ClampedText = forwardRef( const effectiveExpanded = isMeasured ? !hasToggle || preferredExpanded - : true; + : preferredExpanded; - const isClamped = hasToggle && !effectiveExpanded; + const isClamped = isMeasured + ? hasToggle && !effectiveExpanded + : !preferredExpanded; useEffect(() => { if (!isClamped || !shouldScrollOnCollapseRef.current) return; @@ -108,7 +123,8 @@ export const ClampedText = forwardRef( const contentStyle: ContentStyle = { ...slotProps?.content?.style, - '--clamped-text-rows': rows, + '--clamped-text-rows': + !isMeasured && !preferredExpanded ? rows + 1 : rows, }; const contentProps = mergeProps( From 576cc14fbf99af8e186d0ed76ad256e2b5b3146b Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Thu, 13 Aug 2026 17:07:52 +0300 Subject: [PATCH 4/5] fix(ClampedText): improve row measurement and toggle semantics --- .../ClampedText/ClampedText.module.css | 33 +++++++ .../ClampedText/ClampedText.stories.tsx | 8 +- .../ClampedText/ClampedText.test.tsx | 64 ++++++++++++ .../components/ClampedText/ClampedText.tsx | 98 ++++++++++++------- .../src/components/ClampedText/types.ts | 13 +-- .../src/components/ClampedText/utils.test.ts | 77 +++++++++++++++ .../src/components/ClampedText/utils.ts | 19 +++- .../src/components/Link/Link.test.tsx | 7 -- .../primitives/src/components/Link/Link.tsx | 24 +---- .../components/ClampedText.api.md | 12 +-- 10 files changed, 262 insertions(+), 93 deletions(-) create mode 100644 packages/components/src/components/ClampedText/utils.test.ts diff --git a/packages/components/src/components/ClampedText/ClampedText.module.css b/packages/components/src/components/ClampedText/ClampedText.module.css index 855e905c4..b39345d03 100644 --- a/packages/components/src/components/ClampedText/ClampedText.module.css +++ b/packages/components/src/components/ClampedText/ClampedText.module.css @@ -26,4 +26,37 @@ .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 index 8f5fa4054..490adb937 100644 --- a/packages/components/src/components/ClampedText/ClampedText.stories.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.stories.tsx @@ -42,7 +42,7 @@ export const Rows: Story = { export const StructuredContent: Story = { render: (args) => ( - + Line clamp with structured content @@ -57,10 +57,10 @@ export const ControlledExpansion: Story = { return ( {text} @@ -89,8 +89,8 @@ export const ResizePersistence: Story = { Narrow {text} @@ -104,8 +104,8 @@ export const Customization: Story = { render: (args) => { return ( { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); + once.clear(); Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); }); @@ -372,6 +375,35 @@ describe('ClampedText', () => { 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]; @@ -469,6 +501,38 @@ describe('ClampedText', () => { 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(); diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index 525c5bdb8..7053568a0 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -8,6 +8,7 @@ import { useState, } from 'react'; +import { once } from '@koobiq/logger'; import { clsx, mergeProps, @@ -19,8 +20,10 @@ import { useResizeObserver, } from '@koobiq/react-core'; import { IconChevronDown16, IconChevronUp16 } from '@koobiq/react-icons'; - -import { Link } from '../Link'; +import { + Button as ButtonPrimitive, + composeRenderProps, +} from '@koobiq/react-primitives'; import s from './ClampedText.module.css'; import intlMessages from './intl.json'; @@ -50,6 +53,19 @@ export const ClampedText = forwardRef( ...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(); @@ -64,7 +80,7 @@ export const ClampedText = forwardRef( const [rowsCount, setRowsCount] = useState(); - const shouldScrollOnCollapseRef = useRef(false); + const scrollTimeoutRef = useRef(undefined); useIsomorphicEffect(() => { const contentElement = contentRef.current; @@ -85,10 +101,16 @@ export const ClampedText = forwardRef( contentElement.classList.toggle(s.clamped, wasClamped); setRowsCount(nextRowsCount); - }, [children, rows, contentRect.width, contentRect.height, setRowsCount]); + }, [ + children, + normalizedRows, + contentRect.width, + contentRect.height, + setRowsCount, + ]); const isMeasured = rowsCount !== undefined; - const hasToggle = isMeasured && rowsCount > rows + 1; + const hasToggle = isMeasured && rowsCount > normalizedRows + 1; const effectiveExpanded = isMeasured ? !hasToggle || preferredExpanded @@ -99,32 +121,35 @@ export const ClampedText = forwardRef( : !preferredExpanded; useEffect(() => { - if (!isClamped || !shouldScrollOnCollapseRef.current) return; - - shouldScrollOnCollapseRef.current = false; - - const timeoutId = window.setTimeout(() => { - rootRef.current?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center', - }); - }); - - return () => window.clearTimeout(timeoutId); - }, [isClamped, rootRef]); + return () => window.clearTimeout(scrollTimeoutRef.current); + }, []); const onToggle = () => { const nextExpanded = !preferredExpanded; - shouldScrollOnCollapseRef.current = !nextExpanded; + 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 ? rows + 1 : rows, + !isMeasured && !preferredExpanded ? normalizedRows + 1 : normalizedRows, }; const contentProps = mergeProps( @@ -141,7 +166,6 @@ export const ClampedText = forwardRef( const toggleProps = mergeProps( { - className: s.toggle, onPress: onToggle, }, slotProps?.toggle @@ -158,25 +182,27 @@ export const ClampedText = forwardRef( >
{children}
{hasToggle && ( - clsx(s.toggle, className) + )} aria-controls={contentId} aria-expanded={effectiveExpanded} - startIcon={ - effectiveExpanded ? ( - - ) : ( - - ) - } - isPseudo > - {effectiveExpanded - ? (lessText ?? strings.format('collapse')) - : (moreText ?? strings.format('expand'))} - + {effectiveExpanded ? ( + + ) : ( + + )} + + {effectiveExpanded + ? (lessText ?? strings.format('collapse')) + : (moreText ?? strings.format('expand'))} + + )} ); diff --git a/packages/components/src/components/ClampedText/types.ts b/packages/components/src/components/ClampedText/types.ts index fd5f575ca..d32a51634 100644 --- a/packages/components/src/components/ClampedText/types.ts +++ b/packages/components/src/components/ClampedText/types.ts @@ -4,8 +4,7 @@ import type { DataAttributeProps, ExtendableComponentPropsWithRef, } from '@koobiq/react-core'; - -import type { LinkProps } from '../Link'; +import type { ButtonProps } from '@koobiq/react-primitives'; export type ClampedTextProps = ExtendableComponentPropsWithRef< { @@ -34,14 +33,8 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef< content?: Omit, 'children'> & DataAttributeProps; toggle?: Omit< - LinkProps<'button'>, - | 'as' - | 'children' - | 'type' - | 'isPseudo' - | 'aria-controls' - | 'aria-expanded' - | 'startIcon' + ButtonProps, + 'as' | 'children' | 'type' | 'aria-controls' | 'aria-expanded' > & DataAttributeProps; }; 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 000000000..df2e39a33 --- /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 index d89086d51..0fcb15fec 100644 --- a/packages/components/src/components/ClampedText/utils.ts +++ b/packages/components/src/components/ClampedText/utils.ts @@ -1,20 +1,31 @@ export const getRowsCount = (element: HTMLElement) => { const range = document.createRange(); const textNodes = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); - const rowTops = new Set(); + 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 }) => { - rowTops.add(top); + Array.from(range.getClientRects()).forEach(({ top, bottom, height }) => { + if (height) rects.push([top, bottom]); }); } textNode = textNodes.nextNode(); } - return rowTops.size; + 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/Link/Link.test.tsx b/packages/components/src/components/Link/Link.test.tsx index bf0db9015..a07ada1dc 100644 --- a/packages/components/src/components/Link/Link.test.tsx +++ b/packages/components/src/components/Link/Link.test.tsx @@ -62,19 +62,12 @@ describe('Link', () => { const props = { ...baseProps, onPress: vi.fn(), - type: 'button' as const, - 'aria-controls': 'content', - 'aria-expanded': false, }; render(); const linkAsButton = getRoot(); expect(linkAsButton.tagName).toBe('BUTTON'); - expect(linkAsButton).not.toHaveAttribute('role'); - expect(linkAsButton).toHaveAttribute('type', 'button'); - expect(linkAsButton).toHaveAttribute('aria-controls', 'content'); - expect(linkAsButton).toHaveAttribute('aria-expanded', 'false'); await userEvent.click(linkAsButton); diff --git a/packages/primitives/src/components/Link/Link.tsx b/packages/primitives/src/components/Link/Link.tsx index daf555a82..495dc006d 100644 --- a/packages/primitives/src/components/Link/Link.tsx +++ b/packages/primitives/src/components/Link/Link.tsx @@ -14,22 +14,6 @@ import { useLink } from '../../behaviors'; import type { LinkBaseProps } from './types.js'; -const buttonDOMPropNames = new Set([ - 'type', - 'name', - 'value', - 'form', - 'formAction', - 'formEncType', - 'formMethod', - 'formNoValidate', - 'formTarget', - 'aria-controls', - 'aria-expanded', - 'aria-haspopup', - 'aria-pressed', -]); - /** * A link primitive allows a user to navigate to another page or resource within * a web page or application. @@ -43,7 +27,7 @@ export const Link = polymorphicForwardRef<'a', LinkBaseProps>((props, ref) => { useLink( { ...other, - elementType: Tag === 'button' ? undefined : `${Tag}`, + elementType: `${Tag}`, ...(other.isDisabled && { onPress: undefined, onPressStart: undefined, @@ -72,11 +56,7 @@ export const Link = polymorphicForwardRef<'a', LinkBaseProps>((props, ref) => { values: renderValues, }); - const DOMProps = filterDOMProps(props, { - global: true, - propNames: Tag === 'button' ? buttonDOMPropNames : undefined, - }); - + const DOMProps = filterDOMProps(props, { global: true }); delete DOMProps.onClick; return ( diff --git a/tools/public_api_guard/components/ClampedText.api.md b/tools/public_api_guard/components/ClampedText.api.md index fdb27db8b..f04b05508 100644 --- a/tools/public_api_guard/components/ClampedText.api.md +++ b/tools/public_api_guard/components/ClampedText.api.md @@ -4,16 +4,12 @@ ```ts +import type { ButtonProps } from '@koobiq/react-primitives'; import type { ComponentPropsWithRef } from 'react'; import type { ComponentRef } from 'react'; -import type { CSSProperties } from 'react'; import type { DataAttributeProps } from '@koobiq/react-core'; -import type { ElementType } from 'react'; import type { ExtendableComponentPropsWithRef } from '@koobiq/react-core'; -import type { ExtendableProps } from '@koobiq/react-core'; import { ForwardRefExoticComponent } from 'react'; -import type { LinkBaseProps as LinkBaseProps_2 } from '@koobiq/react-primitives'; -import { PolyForwardComponent } from '@koobiq/react-core'; import type { ReactNode } from 'react'; import { RefAttributes } from 'react'; @@ -31,17 +27,13 @@ export type ClampedTextProps = ExtendableComponentPropsWithRef<{ lessText?: ReactNode; slotProps?: { content?: Omit, 'children'> & DataAttributeProps; - toggle?: Omit, 'as' | 'children' | 'type' | 'isPseudo' | 'aria-controls' | 'aria-expanded' | 'startIcon'> & DataAttributeProps; + toggle?: Omit & DataAttributeProps; }; }, 'div'>; // @public (undocumented) export type ClampedTextRef = ComponentRef<'div'>; -// Warnings were encountered during analysis: -// -// packages/components/dist/components/ClampedText/types.d.ts:28:9 - (ae-forgotten-export) The symbol "LinkProps" needs to be exported by the entry point index.d.ts - // (No @packageDocumentation comment for this package) ``` From 8ffb35d85c84c2bba4f6f3dc583354e920878dce Mon Sep 17 00:00:00 2001 From: Kamil Emeleev Date: Fri, 14 Aug 2026 10:36:48 +0300 Subject: [PATCH 5/5] test(ClampedText): improve test isolation --- .../components/ClampedText/ClampedText.test.tsx | 17 ++++++++++++++++- .../src/components/ClampedText/ClampedText.tsx | 3 +++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/components/src/components/ClampedText/ClampedText.test.tsx b/packages/components/src/components/ClampedText/ClampedText.test.tsx index f7b2a7761..1cfdbf6da 100644 --- a/packages/components/src/components/ClampedText/ClampedText.test.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.test.tsx @@ -70,10 +70,16 @@ describe('ClampedText', () => { 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; @@ -110,7 +116,16 @@ describe('ClampedText', () => { vi.restoreAllMocks(); vi.clearAllMocks(); once.clear(); - Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); + + if (scrollIntoViewDescriptor) { + Object.defineProperty( + HTMLElement.prototype, + 'scrollIntoView', + scrollIntoViewDescriptor + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView'); + } }); it('forwards the ref and root element props', () => { diff --git a/packages/components/src/components/ClampedText/ClampedText.tsx b/packages/components/src/components/ClampedText/ClampedText.tsx index 7053568a0..8f0ecb71a 100644 --- a/packages/components/src/components/ClampedText/ClampedText.tsx +++ b/packages/components/src/components/ClampedText/ClampedText.tsx @@ -110,6 +110,9 @@ export const ClampedText = forwardRef( ]); 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