Skip to content
5 changes: 5 additions & 0 deletions .changeset/fn-cui-336-alert-max-height-0fd0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clickhouse/click-ui': minor
---

`Alert` accepts `maxHeight` to cap its collapsed height and show a Show more / Show less toggle when the text overflows. Expanded, the alert fills its container and the text scrolls inside it. Visual: unchanged unless `maxHeight` is set.
89 changes: 89 additions & 0 deletions src/components/Alert/Alert.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* stylelint-disable custom-property-pattern -- design tokens use camelCase (e.g. iconBackground) */

.alert {
--alert-max-height: initial;

display: flex;
width: 100%;
overflow: hidden;
Expand All @@ -10,6 +12,18 @@
color: var(--click-alert-color-text-neutral);
}

.alert_max-height {
max-height: var(--alert-max-height, none);
}

/* Expanded lifts the collapsed cap and fills the parent. The text then scrolls. */
.alert_max-height.alert_expanded {
height: 100%;
min-height: 0;
max-height: none;
flex: 1 1 auto;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expanding drops the height cap

High Severity

Expanding applies alert_expanded, which sets max-height to none. If the parent has no definite height, height: 100% and flex: 1 1 auto do nothing, so the alert grows with the text. Overflow then measures as false, the Show less toggle unmounts, and the expanded layout cannot be collapsed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ca3b277. Configure here.


.alert_type_default {
justify-content: start;
border-radius: var(--click-alert-radii-end);
Expand Down Expand Up @@ -175,3 +189,78 @@
color: inherit;
cursor: pointer;
}

.alert__toggle {
margin: var(--click-container-gap-xxs) 0 0;
padding: 0;
flex-shrink: 0;
align-self: flex-start;
border: none;
background-color: transparent;
color: inherit;
text-decoration: underline;
cursor: pointer;
}

.alert__toggle:focus-visible {
outline: 2px solid var(--click-global-color-outline-default);
outline-offset: 1px;
}

.alert__toggle_size_small {
font: var(--click-alert-small-typography-text-default);
}

.alert__toggle_size_medium {
font: var(--click-alert-medium-typography-text-default);
}

/* Height cap. Lives after the base rules so these selectors do not precede lower-specificity ones. */
.alert_max-height .alert__text-wrapper {
min-width: 0;
min-height: 0;
}

.alert_max-height.alert_expanded .alert__text-wrapper {
min-height: 0;
overflow: hidden;
}

.alert_max-height .alert__title {
flex-shrink: 0;
}

/* stylelint-disable plugin/no-unsupported-browser-features, property-no-vendor-prefix -- Chrome below 120 and Safari below 15.4 only know the prefixed mask, and the build has no autoprefixer */
.alert_max-height .alert__text {
/* Only the alpha channel matters, so a system color stands in for a literal one. */
--alert-fade: linear-gradient(
to bottom,
canvastext calc(100% - var(--click-container-space-sm)),
transparent
);

min-height: 0;
overflow: hidden;
-webkit-mask-image: var(--alert-fade);
mask-image: var(--alert-fade);
scroll-behavior: auto;
}

.alert_max-height .alert__text_expanded {
min-height: 0;
overflow: auto;
flex: 1 1 auto;
}

.alert_max-height .alert__text_no-fade {
-webkit-mask-image: none;
mask-image: none;
}

@media (forced-colors: active) {
.alert_max-height .alert__text {
-webkit-mask-image: none;
mask-image: none;
}
}
/* stylelint-enable plugin/no-unsupported-browser-features, property-no-vendor-prefix */
127 changes: 127 additions & 0 deletions src/components/Alert/Alert.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Meta, StoryObj } from '@storybook/react-vite';
import type { ComponentType } from 'react';

import { Alert } from '@/components/Alert';
import { Container } from '@/components/Container';
import { Link } from '@/components/Link';
import { ICON_NAMES } from '@/components/Icon/IconCommon';
import { expect, fn, userEvent, within } from 'storybook/test';

const meta: Meta<typeof Alert> = {
component: Alert,
Expand All @@ -12,6 +14,9 @@ const meta: Meta<typeof Alert> = {
argTypes: {
customIcon: { type: { name: 'enum', value: [...ICON_NAMES] } },
preserveLineBreaks: { control: 'boolean' },
maxHeight: { control: 'number' },
expandLabel: { control: 'text' },
collapseLabel: { control: 'text' },
},
decorators: Story => (
<Container maxWidth="65%">
Expand All @@ -34,6 +39,8 @@ export const Playground: Story = {
showIcon: true,
dismissible: false,
preserveLineBreaks: false,
expandLabel: 'Show more',
collapseLabel: 'Show less',
},
};

Expand Down Expand Up @@ -266,3 +273,123 @@ export const PreservedSignaturesError: Story = {
preserveLineBreaks: true,
},
};

// Max height: collapsed text is capped. Expanded, the alert fills its container.

const alertBounds = (Story: ComponentType) => (
<div
data-testid="alert-bounds"
style={{ display: 'flex', flexDirection: 'column', width: '100%', height: 240 }}
>
<Story />
</div>
);

export const MaxHeightFits: Story = {
args: {
title: 'Query failed',
text: 'Short enough to fit, so there is no toggle.',
state: 'warning',
size: 'small',
type: 'default',
maxHeight: 200,
},
};

export const MaxHeightOverflow: Story = {
args: {
title: 'Query failed',
text: multilineS3SignaturesError,
state: 'warning',
size: 'small',
type: 'default',
preserveLineBreaks: true,
maxHeight: 140,
},
};

export const MaxHeightBounded: Story = {
args: MaxHeightOverflow.args,
decorators: [alertBounds],
};

export const MaxHeightExpanded: Story = {
args: {
...MaxHeightOverflow.args,
state: 'danger',
onExpandedChange: fn(),
},
decorators: [alertBounds],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const alert = canvas.getByTestId('click-alert');
const collapsedHeight = alert.getBoundingClientRect().height;
const toggle = canvas.getByRole('button', { name: 'Show more' });
await userEvent.click(toggle);
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(alert.getBoundingClientRect().height).toBeGreaterThan(collapsedHeight);
const region = canvas.getByRole('region', { name: 'Query failed' });
await expect(region.scrollHeight).toBeGreaterThan(region.clientHeight);
},
};

export const MaxHeightWithLink: Story = {
args: {
title: 'Query failed',
text: (
<>
{multilineS3SignaturesError}{' '}
<Link href="https://clickhouse.com/docs/sql-reference/table-functions/s3">
S3 table function docs
</Link>
</>
),
state: 'info',
size: 'medium',
type: 'default',
maxHeight: 140,
},
};

export const MaxHeightCustomLabels: Story = {
args: {
...MaxHeightOverflow.args,
expandLabel: 'Read more',
collapseLabel: 'Read less',
},
};

export const MaxHeightFocusVisible: Story = {
args: MaxHeightOverflow.args,
parameters: { pseudo: { focusVisible: true } },
};

export const MaxHeightOnBackground: Story = {
args: MaxHeightOverflow.args,
decorators: [
Story => (
<div style={{ backgroundColor: 'var(--click-global-color-background-muted)' }}>
<Story />
</div>
),
],
};

export const MaxHeightKeyboard: Story = {
args: { ...MaxHeightOverflow.args, onExpandedChange: fn() },
decorators: [alertBounds],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.tab();
const toggle = canvas.getByRole('button', { name: 'Show more' });
await expect(toggle).toHaveFocus();

await userEvent.keyboard('{Enter}');
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(toggle).toHaveFocus();

// The scrollable text comes before the toggle in the DOM, so it is one Shift+Tab back.
await userEvent.tab({ shift: true });
await expect(canvas.getByRole('region', { name: 'Query failed' })).toHaveFocus();
},
};
Loading
Loading