diff --git a/.changeset/fn-cui-336-alert-max-height-0fd0.md b/.changeset/fn-cui-336-alert-max-height-0fd0.md new file mode 100644 index 000000000..ae00916fa --- /dev/null +++ b/.changeset/fn-cui-336-alert-max-height-0fd0.md @@ -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. diff --git a/src/components/Alert/Alert.module.css b/src/components/Alert/Alert.module.css index e16ea7652..de29b6d81 100644 --- a/src/components/Alert/Alert.module.css +++ b/src/components/Alert/Alert.module.css @@ -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; @@ -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; +} + .alert_type_default { justify-content: start; border-radius: var(--click-alert-radii-end); @@ -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 */ diff --git a/src/components/Alert/Alert.stories.tsx b/src/components/Alert/Alert.stories.tsx index 6156ad022..2446ad1ea 100644 --- a/src/components/Alert/Alert.stories.tsx +++ b/src/components/Alert/Alert.stories.tsx @@ -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 = { component: Alert, @@ -12,6 +14,9 @@ const meta: Meta = { argTypes: { customIcon: { type: { name: 'enum', value: [...ICON_NAMES] } }, preserveLineBreaks: { control: 'boolean' }, + maxHeight: { control: 'number' }, + expandLabel: { control: 'text' }, + collapseLabel: { control: 'text' }, }, decorators: Story => ( @@ -34,6 +39,8 @@ export const Playground: Story = { showIcon: true, dismissible: false, preserveLineBreaks: false, + expandLabel: 'Show more', + collapseLabel: 'Show less', }, }; @@ -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) => ( +
+ +
+); + +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}{' '} + + S3 table function docs + + + ), + 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 => ( +
+ +
+ ), + ], +}; + +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(); + }, +}; diff --git a/src/components/Alert/Alert.test.tsx b/src/components/Alert/Alert.test.tsx index d7aadd234..d053c633d 100644 --- a/src/components/Alert/Alert.test.tsx +++ b/src/components/Alert/Alert.test.tsx @@ -1,9 +1,51 @@ import { Alert, AlertProps, WarningAlert } from '@/components/Alert'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { act, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderCUI } from '@/utils/test-utils'; import styles from './Alert.module.css'; +const TOGGLE_RESERVED = 20; +const TOGGLE_TEST_ID = 'click-alert-toggle'; + +let contentHeight = 0; +let availableHeight = 0; +let frameQueue: FrameRequestCallback[] = []; + +class FakeResizeObserver { + static instances: FakeResizeObserver[] = []; + active = true; + constructor(private callback: ResizeObserverCallback) { + FakeResizeObserver.instances.push(this); + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(() => { + this.active = false; + }); + trigger() { + if (this.active) { + this.callback([], this as unknown as ResizeObserver); + } + } +} + +const hasToggle = (element: Element) => + element.parentElement?.querySelector(`[data-testid="${TOGGLE_TEST_ID}"]`) != null; + +const flushFrames = () => { + act(() => { + const callbacks = frameQueue; + frameQueue = []; + callbacks.forEach(callback => callback(0)); + }); +}; + +const resizeTo = (height: number) => { + availableHeight = height; + act(() => FakeResizeObserver.instances.forEach(observer => observer.trigger())); + flushFrames(); +}; + describe('Alert', () => { const renderAlert = (props: AlertProps) => renderCUI(); @@ -125,4 +167,508 @@ describe('Alert', () => { expect(body.tagName).toBe('SPAN'); expect(body.parentElement).not.toHaveClass(styles['alert__text_pre-wrap']); }); + + describe('maxHeight', () => { + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { + configurable: true, + get: () => contentHeight, + }); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get(this: HTMLElement) { + return hasToggle(this) + ? availableHeight - TOGGLE_RESERVED + : Math.min(contentHeight, availableHeight); + }, + }); + }); + + afterAll(() => { + Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight'); + Reflect.deleteProperty(HTMLElement.prototype, 'clientHeight'); + }); + + beforeEach(() => { + contentHeight = 40; + availableHeight = 100; + frameQueue = []; + FakeResizeObserver.instances = []; + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameQueue.push(callback); + return frameQueue.length; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: Element + ) { + const bottom = + this.getAttribute('data-testid') === TOGGLE_TEST_ID ? TOGGLE_RESERVED : 0; + return { + bottom, + top: bottom, + left: 0, + right: 0, + width: 0, + height: 0, + x: 0, + y: bottom, + toJSON: () => ({}), + }; + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('adds no styling, toggle or observers when maxHeight is not set', () => { + contentHeight = 500; + const onExpandedChange = vi.fn(); + const { getByTestId, queryByTestId } = renderAlert({ + text: 'plain', + onExpandedChange, + }); + + const root = getByTestId('click-alert'); + expect(root).not.toHaveAttribute('style'); + expect(root).not.toHaveClass(styles['alert_max-height']); + expect(queryByTestId(TOGGLE_TEST_ID)).not.toBeInTheDocument(); + expect(FakeResizeObserver.instances).toHaveLength(0); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it('converts a number maxHeight to px', () => { + const { getByTestId } = renderAlert({ text: 'body', maxHeight: 120 }); + + const root = getByTestId('click-alert'); + expect(root.style.getPropertyValue('--alert-max-height')).toBe('120px'); + expect(root).toHaveClass(styles['alert_max-height']); + }); + + it('passes a string maxHeight through as a CSS length', () => { + const { getByTestId } = renderAlert({ text: 'body', maxHeight: '12lh' }); + + expect( + getByTestId('click-alert').style.getPropertyValue('--alert-max-height') + ).toBe('12lh'); + }); + + it('shows no toggle when the text fits', () => { + contentHeight = 80; + const { queryByRole } = renderAlert({ text: 'short', maxHeight: 100 }); + + expect(queryByRole('button')).not.toBeInTheDocument(); + }); + + it('shows a collapsed Show more toggle controlling the text when the text overflows', () => { + contentHeight = 300; + const { getByRole, getByText } = renderAlert({ text: 'long text', maxHeight: 100 }); + + const toggle = getByRole('button', { name: 'Show more' }); + expect(toggle).toHaveAttribute('type', 'button'); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveAttribute('data-testid', TOGGLE_TEST_ID); + expect(toggle).toHaveAttribute('aria-controls', getByText('long text').id); + expect(getByText('long text').id).not.toBe(''); + }); + + it('expands on click and reports it', () => { + contentHeight = 300; + const onExpandedChange = vi.fn(); + const { getByRole, getByTestId } = renderAlert({ + text: 'long text', + maxHeight: 100, + onExpandedChange, + }); + + const root = getByTestId('click-alert'); + expect(root).not.toHaveClass(styles.alert_expanded); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + + const toggle = getByRole('button', { name: 'Show less' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(root).toHaveClass(styles.alert_expanded); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(onExpandedChange).toHaveBeenLastCalledWith(true); + + fireEvent.click(toggle); + expect(root).not.toHaveClass(styles.alert_expanded); + }); + + it('collapses on a second click, scrolling the text back to the top', () => { + contentHeight = 300; + const onExpandedChange = vi.fn(); + const { getByRole, getByText } = renderAlert({ + text: 'long text', + maxHeight: 100, + onExpandedChange, + }); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + getByText('long text').scrollTop = 60; + fireEvent.click(getByRole('button', { name: 'Show less' })); + + expect(getByRole('button', { name: 'Show more' })).toHaveAttribute( + 'aria-expanded', + 'false' + ); + expect(getByText('long text').scrollTop).toBe(0); + expect(onExpandedChange).toHaveBeenCalledTimes(2); + expect(onExpandedChange).toHaveBeenLastCalledWith(false); + }); + + it('keeps focus on the toggle after expanding and collapsing', async () => { + contentHeight = 300; + const user = userEvent.setup(); + const { getByRole } = renderAlert({ text: 'long text', maxHeight: 100 }); + + await user.tab(); + expect(getByRole('button', { name: 'Show more' })).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(getByRole('button', { name: 'Show less' })).toHaveFocus(); + + await user.keyboard(' '); + expect(getByRole('button', { name: 'Show more' })).toHaveFocus(); + }); + + it('uses custom labels for the toggle', () => { + contentHeight = 300; + const { getByRole } = renderAlert({ + text: 'long text', + maxHeight: 100, + expandLabel: 'Read more', + collapseLabel: 'Read less', + }); + + fireEvent.click(getByRole('button', { name: 'Read more' })); + expect(getByRole('button', { name: 'Read less' })).toBeInTheDocument(); + }); + + it('shows and hides the toggle as the available height changes', () => { + contentHeight = 150; + const { queryByRole } = renderAlert({ text: 'long text', maxHeight: '100%' }); + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + + resizeTo(400); + expect(queryByRole('button')).not.toBeInTheDocument(); + + resizeTo(100); + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('does not keep the toggle stuck when the text only overflows because the toggle takes space', () => { + contentHeight = 100; + availableHeight = 90; + const { queryByRole } = renderAlert({ text: 'long text', maxHeight: '100%' }); + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + + resizeTo(105); + expect(queryByRole('button')).not.toBeInTheDocument(); + + resizeTo(90); + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('treats a 1px overflow as fitting', () => { + contentHeight = 101; + const { queryByRole } = renderAlert({ text: 'text', maxHeight: '100%' }); + expect(queryByRole('button')).not.toBeInTheDocument(); + + resizeTo(99); + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('keeps the expanded state without calling back when the text starts to fit', () => { + contentHeight = 150; + const onExpandedChange = vi.fn(); + const { getByRole, queryByRole } = renderAlert({ + text: 'long text', + maxHeight: '100%', + onExpandedChange, + }); + fireEvent.click(getByRole('button', { name: 'Show more' })); + onExpandedChange.mockClear(); + + resizeTo(400); + expect(queryByRole('button')).not.toBeInTheDocument(); + + resizeTo(100); + expect(getByRole('button', { name: 'Show less' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it('re-measures when the text changes', async () => { + contentHeight = 40; + const { queryByRole, rerender } = renderAlert({ text: 'short', maxHeight: 100 }); + expect(queryByRole('button')).not.toBeInTheDocument(); + + contentHeight = 300; + rerender( + + ); + await waitFor(() => expect(frameQueue.length).toBeGreaterThan(0)); + flushFrames(); + + expect(queryByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('measures on mount even without ResizeObserver', () => { + vi.stubGlobal('ResizeObserver', undefined); + contentHeight = 300; + const { getByRole } = renderAlert({ text: 'long text', maxHeight: 100 }); + + expect(getByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('fades the text while it is clipped or has more below, and not once it fits', () => { + contentHeight = 300; + const { getByText, getByRole, rerender } = renderAlert({ + text: 'long text', + maxHeight: 100, + }); + expect(getByText('long text')).not.toHaveClass(styles['alert__text_no-fade']); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + expect(getByText('long text')).toHaveClass(styles.alert__text_expanded); + expect(getByText('long text')).not.toHaveClass(styles['alert__text_no-fade']); + + const text = getByText('long text'); + text.scrollTop = 220; + fireEvent.scroll(text); + flushFrames(); + expect(text).toHaveClass(styles['alert__text_no-fade']); + + contentHeight = 40; + rerender( + + ); + resizeTo(100); + expect(text).toHaveClass(styles['alert__text_no-fade']); + }); + + it('makes the expanded text a focusable region named by the title when it scrolls', () => { + contentHeight = 300; + const { getByRole, getByText } = renderAlert({ + title: 'Query failed', + text: 'long text', + maxHeight: 100, + }); + const text = getByText('long text'); + expect(text).not.toHaveAttribute('tabindex'); + expect(text).not.toHaveAttribute('role'); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + + const region = getByRole('region', { name: 'Query failed' }); + expect(region).toBe(text); + expect(region).toHaveAttribute('tabindex', '0'); + + fireEvent.click(getByRole('button', { name: 'Show less' })); + expect(text).not.toHaveAttribute('tabindex'); + expect(text).not.toHaveAttribute('role'); + }); + + it('makes the expanded text focusable without a role when there is no title', () => { + contentHeight = 300; + const { getByRole, getByText } = renderAlert({ + text: 'long text', + maxHeight: 100, + }); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + + expect(getByText('long text')).toHaveAttribute('tabindex', '0'); + expect(getByText('long text')).not.toHaveAttribute('role'); + expect(getByText('long text')).not.toHaveAttribute('aria-labelledby'); + }); + + it('does not make the expanded text focusable when it no longer scrolls', () => { + contentHeight = 150; + const { getByRole, getByText } = renderAlert({ + text: 'long text', + maxHeight: '100%', + }); + fireEvent.click(getByRole('button', { name: 'Show more' })); + + resizeTo(400); + + expect(getByText('long text')).not.toHaveAttribute('tabindex'); + }); + + it('expands when focus lands on a link inside the clipped text', async () => { + contentHeight = 300; + const user = userEvent.setup(); + const onExpandedChange = vi.fn(); + const { getByRole } = renderAlert({ + text: the docs, + maxHeight: 100, + onExpandedChange, + }); + + await user.tab(); + expect(getByRole('link', { name: 'the docs' })).toHaveFocus(); + expect(getByRole('button', { name: 'Show less' })).toHaveAttribute( + 'aria-expanded', + 'true' + ); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + expect(onExpandedChange).toHaveBeenCalledWith(true); + + await user.tab(); + expect(getByRole('button', { name: 'Show less' })).toHaveFocus(); + expect(onExpandedChange).toHaveBeenCalledTimes(1); + }); + + it('does not expand when focus lands on a link in text that fits', async () => { + const user = userEvent.setup(); + const onExpandedChange = vi.fn(); + renderAlert({ + text: the docs, + maxHeight: 100, + onExpandedChange, + }); + + await user.tab(); + + expect(onExpandedChange).not.toHaveBeenCalled(); + }); + + it('does not construct new observers when toggling', () => { + contentHeight = 300; + const { getByRole } = renderAlert({ text: 'long text', maxHeight: 100 }); + const created = FakeResizeObserver.instances.length; + expect(created).toBe(1); + + fireEvent.click(getByRole('button', { name: 'Show more' })); + fireEvent.click(getByRole('button', { name: 'Show less' })); + + expect(FakeResizeObserver.instances).toHaveLength(created); + expect(FakeResizeObserver.instances[0].disconnect).not.toHaveBeenCalled(); + }); + + it('disconnects its observers when maxHeight is removed and on unmount', () => { + contentHeight = 300; + const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect'); + const { getByTestId, queryByRole, rerender, unmount } = renderAlert({ + text: 'long text', + maxHeight: 100, + }); + const [observer] = FakeResizeObserver.instances; + expect(observer.observe).toHaveBeenCalledTimes(2); + + rerender(); + + expect(observer.disconnect).toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + expect(queryByRole('button')).not.toBeInTheDocument(); + expect( + getByTestId('click-alert').style.getPropertyValue('--alert-max-height') + ).toBe(''); + + rerender( + + ); + expect(FakeResizeObserver.instances).toHaveLength(2); + + unmount(); + expect(FakeResizeObserver.instances[1].disconnect).toHaveBeenCalled(); + }); + + it('disconnects its observers when the alert is dismissed', () => { + contentHeight = 300; + const { getByTestId } = renderAlert({ + text: 'long text', + maxHeight: 100, + dismissible: true, + }); + const [observer] = FakeResizeObserver.instances; + + fireEvent.click(getByTestId('click-alert-dismiss-button')); + + expect(observer.disconnect).toHaveBeenCalled(); + }); + + it('passes every maxHeight prop through WarningAlert', () => { + contentHeight = 300; + const onExpandedChange = vi.fn(); + const { getByRole, getByTestId } = renderCUI( + + ); + expect( + getByTestId('click-alert').style.getPropertyValue('--alert-max-height') + ).toBe('100px'); + + fireEvent.click(getByRole('button', { name: 'More' })); + + expect(getByRole('button', { name: 'Less' })).toBeInTheDocument(); + expect(onExpandedChange).toHaveBeenCalledWith(true); + }); + + it('supports ReactNode text', () => { + contentHeight = 300; + const { getByRole, getByText } = renderAlert({ + text: ( + + see details + + ), + maxHeight: 100, + }); + + expect(getByText('details')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Show more' })).toBeInTheDocument(); + }); + + it('keeps maxHeight independent from preserveLineBreaks', () => { + contentHeight = 300; + const { getByText } = renderAlert({ text: 'line 1\nline 2', maxHeight: 100 }); + + expect(getByText(withExactText('line 1\nline 2'))).not.toHaveClass( + styles['alert__text_pre-wrap'] + ); + }); + + it('does not forward the maxHeight props onto the root element', () => { + const { getByTestId } = renderAlert({ + text: 'body', + maxHeight: 100, + onExpandedChange: vi.fn(), + expandLabel: 'More', + collapseLabel: 'Less', + }); + + const root = getByTestId('click-alert'); + for (const name of [ + 'maxheight', + 'onexpandedchange', + 'expandlabel', + 'collapselabel', + ]) { + expect(root).not.toHaveAttribute(name); + } + }); + }); }); diff --git a/src/components/Alert/Alert.tsx b/src/components/Alert/Alert.tsx index 6c3db5862..1da812a1e 100644 --- a/src/components/Alert/Alert.tsx +++ b/src/components/Alert/Alert.tsx @@ -1,8 +1,9 @@ import { Icon, type IconName } from '@/components/Icon'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useId, useMemo, type CSSProperties } from 'react'; import { cn, cva } from '@/lib/cva'; import { AlertProps } from './Alert.types'; import styles from './Alert.module.css'; +import { useAlertOverflow } from './useAlertOverflow'; type AlertState = 'neutral' | 'success' | 'warning' | 'danger' | 'info'; @@ -107,6 +108,18 @@ const textVariants = cva(styles.alert__text, { }, }); +const toggleVariants = cva(styles.alert__toggle, { + variants: { + size: { + small: styles.alert__toggle_size_small, + medium: styles.alert__toggle_size_medium, + }, + }, + defaultVariants: { + size: 'small', + }, +}); + export const Alert = ({ text, title = '', @@ -119,20 +132,74 @@ export const Alert = ({ customIcon, className, preserveLineBreaks = false, + maxHeight, + onExpandedChange, + expandLabel = 'Show more', + collapseLabel = 'Show less', ...delegated }: AlertProps) => { const [isVisible, setIsVisible] = useState(true); + const [isExpanded, setIsExpanded] = useState(false); + const textId = useId(); + const titleId = useId(); + const hasMaxHeight = maxHeight !== undefined; + const { rootRef, textRef, toggleRef, isOverflowing, isFadeVisible } = useAlertOverflow({ + enabled: hasMaxHeight && isVisible, + expanded: isExpanded, + }); + + const rootStyle = useMemo( + () => + hasMaxHeight + ? ({ + '--alert-max-height': + typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight, + } as CSSProperties) + : undefined, + [hasMaxHeight, maxHeight] + ); const handleDismiss = useCallback(() => { setIsVisible(false); onDismiss?.(); }, [onDismiss]); + const changeExpanded = useCallback( + (expanded: boolean) => { + setIsExpanded(expanded); + onExpandedChange?.(expanded); + }, + [onExpandedChange] + ); + + const handleToggle = useCallback(() => { + if (isExpanded && textRef.current) { + textRef.current.scrollTop = 0; + } + changeExpanded(!isExpanded); + }, [isExpanded, textRef, changeExpanded]); + + const handleTextFocus = useCallback(() => { + if (isOverflowing && !isExpanded) { + changeExpanded(true); + } + }, [isOverflowing, isExpanded, changeExpanded]); + + const isScrollable = isExpanded && isOverflowing; + const hasTitle = Boolean(title); + return isVisible ? (
{dismissible && type === 'banner' && ( )}
- {title &&
{title}
} + {title && ( +
+ {title} +
+ )}
{text}
+ {isOverflowing && ( + + )}
{dismissible && (