Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .storybook/components/Roadmap/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,8 @@ export const rows: Rows = [
},
{
component: 'ClampedText',
status: '🚧 Planned',
status: '✅ Done',
stage: '🔵 experimental',
planned: 'Q3 2026',
},
{
Expand Down
77 changes: 77 additions & 0 deletions packages/components/src/components/ClampedText/ClampedText.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Meta,
Story,
Props,
Status,
} from '../../../../../.storybook/components';

import * as Stories from './ClampedText.stories';

<Meta of={Stories} />

# ClampedText

<Status variant="experimental" />

`ClampedText` keeps long text compact while allowing users to reveal the full content.

## Import

```tsx
import { ClampedText } from '@koobiq/react-components';
```

## Usage

<Story of={Stories.Base} />

## Props

<Props of={Stories.Base} />

## 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.

<Story of={Stories.Rows} />

## 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.

<Story of={Stories.StructuredContent} />

## 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.

<Story of={Stories.ControlledExpansion} />

## 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.

<Story of={Stories.ResizePersistence} />

## 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.

<Story of={Stories.Customization} />

## 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.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof ClampedText>;

export default meta;
type Story = StoryObj<ClampedTextProps>;

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 <ClampedText {...args}>{text}</ClampedText>;
},
};

export const Rows: Story = {
render: (args) => {
return (
<ClampedText rows={2} {...args}>
{text}
</ClampedText>
);
},
};

export const StructuredContent: Story = {
render: (args) => (
<ClampedText rows={3} {...args}>
<Typography as="h3" variant="title" className={spacing({ pbe: 's' })}>
Line clamp with structured content
</Typography>
<Typography>{text}</Typography>
</ClampedText>
),
};

export const ControlledExpansion: Story = {
render: function Render(args) {
const [isExpanded, setExpanded] = useState(false);

return (
<ClampedText
rows={2}
{...args}
isExpanded={isExpanded}
onExpandedChange={setExpanded}
>
{text}
</ClampedText>
);
},
};

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 (
<FlexBox
direction="column"
gap="m"
alignItems="flex-start"
style={{ inlineSize: '100%' }}
>
<Toggle isSelected={isNarrow} onChange={setNarrow}>
Narrow
</Toggle>
<ClampedText
rows={2}
{...args}
style={{ inlineSize: isNarrow ? 200 : '100%' }}
>
{text}
</ClampedText>
</FlexBox>
);
},
};

export const Customization: Story = {
render: (args) => {
return (
<ClampedText
rows={2}
{...args}
id="custom-clamped-text"
moreText="Show details"
lessText="Hide details"
slotProps={{
content: {
id: 'custom-clamped-text-content',
'data-testid': 'custom-content',
},
toggle: {
'data-testid': 'custom-toggle',
style: { alignSelf: 'flex-end' },
},
}}
>
{text}
</ClampedText>
);
},
};
Loading
Loading