Modernize MaxSizedBox to use <Box maxHeight> and ResizeObservers (#16565)

This commit is contained in:
Jacob Richman
2026-01-13 20:22:10 -08:00
committed by GitHub
parent 4afd3741df
commit 933bc5774f
16 changed files with 482 additions and 1436 deletions

View File

@@ -5,19 +5,14 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox, setMaxSizedBoxDebugging } from './MaxSizedBox.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { Box, Text } from 'ink';
import { describe, it, expect } from 'vitest';
describe('<MaxSizedBox />', () => {
// Make sure MaxSizedBox logs errors on invalid configurations.
// This is useful for debugging issues with the component.
// It should be set to false in production for performance and to avoid
// cluttering the console if there are ignorable issues.
setMaxSizedBoxDebugging(true);
it('renders children without truncation when they fit', () => {
it('renders children without truncation when they fit', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
@@ -27,53 +22,105 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('Hello, World!');
await waitFor(() => expect(lastFrame()).toContain('Hello, World!'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides lines when content exceeds maxHeight', () => {
it('hides lines when content exceeds maxHeight', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Box flexDirection="column">
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
await waitFor(() =>
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', () => {
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Box flexDirection="column">
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
await waitFor(() =>
expect(lastFrame()).toContain('... last 2 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('wraps text that exceeds maxWidth', () => {
it('shows plural "lines" when more than one line is hidden', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box flexDirection="column">
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows singular "line" when exactly one line is hidden', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={1}>
<Box flexDirection="column">
<Text>Line 1</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 1 line hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('accounts for additionalHiddenLinesCount', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box flexDirection="column">
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 7 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('wraps text that exceeds maxWidth', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={10} maxHeight={5}>
@@ -84,325 +131,66 @@ Line 3`);
</OverflowProvider>,
);
expect(lastFrame()).equals(`This is a
long line
of text`);
await waitFor(() => expect(lastFrame()).toContain('This is a'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('handles mixed wrapping and non-wrapping segments', () => {
const multilineText = `This part will wrap around.
And has a line break.
Leading spaces preserved.`;
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={20} maxHeight={20}>
<Box>
<Text>Example</Text>
</Box>
<Box>
<Text>No Wrap: </Text>
<Text wrap="wrap">{multilineText}</Text>
</Box>
<Box>
<Text>Longer No Wrap: </Text>
<Text wrap="wrap">This part will wrap around.</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(
`Example
No Wrap: This part
will wrap
around.
And has a
line break.
Leading
spaces
preserved.
Longer No Wrap: This
part
will
wrap
arou
nd.`,
);
unmount();
});
it('handles words longer than maxWidth by splitting them', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">Supercalifragilisticexpialidocious</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... …
istic
expia
lidoc
ious`);
unmount();
});
it('does not truncate when maxHeight is undefined', () => {
it('does not truncate when maxHeight is undefined', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box>
<Box flexDirection="column">
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
Line 2`);
await waitFor(() => expect(lastFrame()).toContain('Line 1'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows plural "lines" when more than one line is hidden', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`... first 2 lines hidden ...
Line 3`);
unmount();
});
it('shows plural "lines" when more than one line is hidden and overflowDirection is bottom', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1
... last 2 lines hidden ...`);
unmount();
});
it('renders an empty box for empty children', () => {
it('renders an empty box for empty children', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
);
// Expect an empty string or a box with nothing in it.
// Ink renders an empty box as an empty string.
expect(lastFrame()).equals('');
// Use waitFor to ensure ResizeObserver has a chance to run
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()?.trim()).equals('');
unmount();
});
it('wraps text with multi-byte unicode characters correctly', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap"></Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// "你好" has a visual width of 4. "世界" has a visual width of 4.
// With maxWidth=5, it should wrap after the second character.
expect(lastFrame()).equals(`你好
世界`);
unmount();
});
it('wraps text with multi-byte emoji characters correctly', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={5} maxHeight={5}>
<Box>
<Text wrap="wrap">🐶🐶🐶🐶🐶</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// Each "🐶" has a visual width of 2.
// With maxWidth=5, it should wrap every 2 emojis.
expect(lastFrame()).equals(`🐶🐶
🐶🐶
🐶`);
unmount();
});
it('falls back to an ellipsis when width is extremely small', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={2} maxHeight={2}>
<Box>
<Text>No</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('N…');
unmount();
});
it('truncates long non-wrapping text with ellipsis', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>ABCDE</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals('AB…');
unmount();
});
it('truncates non-wrapping text containing line breaks', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>{'A\nBCDE'}</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`A\n…`);
unmount();
});
it('truncates emoji characters correctly with ellipsis', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={2}>
<Box>
<Text>🐶🐶🐶</Text>
<Text wrap="wrap">wrap</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`🐶…`);
unmount();
});
it('shows ellipsis for multiple rows with long non-wrapping text', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={3} maxHeight={3}>
<Box>
<Text>AAA</Text>
<Text wrap="wrap">first</Text>
</Box>
<Box>
<Text>BBB</Text>
<Text wrap="wrap">second</Text>
</Box>
<Box>
<Text>CCC</Text>
<Text wrap="wrap">third</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`AA…\nBB…\nCC…`);
unmount();
});
it('accounts for additionalHiddenLinesCount', () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box>
<Text>Line 1</Text>
</Box>
<Box>
<Text>Line 2</Text>
</Box>
<Box>
<Text>Line 3</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
// 1 line is hidden by overflow, 5 are additionally hidden.
expect(lastFrame()).equals(`... first 7 lines hidden ...
Line 3`);
unmount();
});
it('handles React.Fragment as a child', () => {
it('handles React.Fragment as a child', async () => {
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<>
<Box>
<Box flexDirection="column">
<>
<Text>Line 1 from Fragment</Text>
</Box>
<Box>
<Text>Line 2 from Fragment</Text>
</Box>
</>
<Box>
</>
<Text>Line 3 direct child</Text>
</Box>
</MaxSizedBox>
</OverflowProvider>,
);
expect(lastFrame()).equals(`Line 1 from Fragment
Line 2 from Fragment
Line 3 direct child`);
await waitFor(() => expect(lastFrame()).toContain('Line 1 from Fragment'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('clips a long single text child from the top', () => {
it('clips a long single text child from the top', async () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="top">
<Box>
<Text>{THIRTY_LINES}</Text>
</Box>
@@ -410,21 +198,18 @@ Line 3 direct child`);
</OverflowProvider>,
);
const expected = [
'... first 21 lines hidden ...',
...Array.from({ length: 9 }, (_, i) => `Line ${22 + i}`),
].join('\n');
expect(lastFrame()).equals(expected);
await waitFor(() =>
expect(lastFrame()).toContain('... first 21 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('clips a long single text child from the bottom', () => {
it('clips a long single text child from the bottom', async () => {
const THIRTY_LINES = Array.from(
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
@@ -435,12 +220,10 @@ Line 3 direct child`);
</OverflowProvider>,
);
const expected = [
...Array.from({ length: 9 }, (_, i) => `Line ${i + 1}`),
'... last 21 lines hidden ...',
].join('\n');
expect(lastFrame()).equals(expected);
await waitFor(() =>
expect(lastFrame()).toContain('... last 21 lines hidden ...'),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});