diff --git a/.changeset/copysnippet-wrap.md b/.changeset/copysnippet-wrap.md new file mode 100644 index 000000000..ad42fb582 --- /dev/null +++ b/.changeset/copysnippet-wrap.md @@ -0,0 +1,14 @@ +--- +'@cube-dev/ui-kit': minor +--- + +`PrismCode` and `CopySnippet` take an `isWrapped` prop that soft-wraps long content instead of scrolling it sideways. + +By default both components keep each line on one line and scroll horizontally, which buries long error messages and logs off to the right. `isWrapped` lays the content out on multiple lines instead: unbreakable runs like URLs, tokens and identifiers break too (`overflow-wrap: anywhere`), not just spaces. On `CopySnippet` the block additionally grows vertically to fit — so even a single very long line is fully readable rather than clamped to the collapsed height — and the prop is forwarded to the inner `PrismCode`, which owns the wrapping itself. + +```jsx + + +``` + +On `CopySnippet` it is a different axis from `nowrap` — `nowrap` collapses real newlines into one scrolling line, `isWrapped` breaks long lines — and `nowrap` wins when both are set. The copy button and syntax highlighting are unchanged. diff --git a/src/components/content/CopySnippet/CopySnippet.docs.mdx b/src/components/content/CopySnippet/CopySnippet.docs.mdx index 0a83e4e19..7aaa141e0 100644 --- a/src/components/content/CopySnippet/CopySnippet.docs.mdx +++ b/src/components/content/CopySnippet/CopySnippet.docs.mdx @@ -21,6 +21,7 @@ A code block with copy-to-clipboard functionality and syntax highlighting. Exten - **`title`** `string` (default: `'Code example'`) — Accessible title used in the copy toast message - **`prefix`** `string` (default: `''`) — Prefix for each line of code (e.g. `'$ '` for bash snippets) - **`nowrap`** `boolean` — Force single-line display even for multi-line code +- **`isWrapped`** `boolean` — Soft-wrap long content onto multiple lines instead of scrolling it horizontally. The block grows to fit, so even a single long line stays fully readable, and unbreakable runs (URLs, tokens, identifiers) wrap too. Useful for error messages and logs. Ignored when `nowrap` is set (a different axis: `nowrap` collapses newlines into one scrolling line, `isWrapped` breaks long lines). - **`serif`** `boolean` — Use serif (non-monospace) font for the code - **`hideText`** `string | string[] | boolean` — Hide sensitive text with bullet characters. `true` hides all text, a string hides that substring, an array hides multiple substrings. A toggle button appears to reveal hidden content. - **`actions`** `ReactNode` — Additional action buttons displayed alongside the copy button. Use `CopySnippet.Button` for consistent styling. @@ -55,14 +56,13 @@ These properties allow direct style application without using the `styles` prop: ```jsx - + Save'} language="html" /> + +// Wrap a long single-line error instead of scrolling it sideways + ``` diff --git a/src/components/content/CopySnippet/CopySnippet.stories.tsx b/src/components/content/CopySnippet/CopySnippet.stories.tsx index b31ee5e9c..3b744aa10 100644 --- a/src/components/content/CopySnippet/CopySnippet.stories.tsx +++ b/src/components/content/CopySnippet/CopySnippet.stories.tsx @@ -65,6 +65,16 @@ WithScroll.args = { }, }; +export const Wrapped = Template.bind({}); +Wrapped.args = { + code: 'XMLA Internal Error: Arrow error: External error: Database Execution Error: Internal: Error during planning: Error decoding LogicalPlanNode.logical_plan_type:SubqueryAliasNode.input as protobuf message', + language: 'bash', + isWrapped: true, + styles: { + width: 'max 400px', + }, +}; + export const JavascriptSyntax = Template.bind({}); JavascriptSyntax.args = { language: 'javascript', diff --git a/src/components/content/CopySnippet/CopySnippet.tsx b/src/components/content/CopySnippet/CopySnippet.tsx index 0f5d8e367..0db4dfcab 100644 --- a/src/components/content/CopySnippet/CopySnippet.tsx +++ b/src/components/content/CopySnippet/CopySnippet.tsx @@ -62,6 +62,9 @@ const StyledBlock = tasty({ '': 'monospace', serif: true, }, + // The wrapping itself (`white-space` / `overflow-wrap` on the ``) + // is owned by `PrismCode` via its `isWrapped` prop, which this component + // forwards. }, }, }); @@ -160,6 +163,16 @@ export interface CubeCopySnippetProps extends CubeCardProps { title?: string; /** Whether the snippet is single-lined */ nowrap?: boolean; + /** + * Soft-wrap long content onto multiple lines instead of scrolling it + * horizontally. The block grows vertically to fit (so even a single long line + * is fully readable rather than clamped), and unbreakable runs like URLs, + * tokens and identifiers wrap too. Useful for error messages and logs. + * Has no effect when `nowrap` is set. Note this is a different axis from + * `nowrap`: `nowrap` collapses real newlines into one scrolling line, while + * `isWrapped` breaks long lines that would otherwise scroll. + */ + isWrapped?: boolean; /** The prefix for each line of code. Useful for bash snippets. */ prefix?: string; /** The code language of the snippet */ @@ -186,6 +199,7 @@ function CopySnippet(allProps: CubeCopySnippetProps) { code = '', title = t('copySnippet.title', 'Code example'), nowrap, + isWrapped, prefix = '', language, serif, @@ -208,6 +222,11 @@ function CopySnippet(allProps: CubeCopySnippetProps) { const pristineCode = code.replace(/\n$/, ''); const multiline = pristineCode.includes('\n') && !nowrap; + // `isWrapped` reuses the multiline block layout (auto height, no right fade, + // copy button on top) so wrapped content grows vertically instead of being + // clamped to the single-line height. `nowrap` (force one scrolling line) wins + // over it. + const shouldWrap = !!isWrapped && !nowrap; let formattedCode = pristineCode .replace(/\r/g, '') .split(/\n/g) @@ -236,11 +255,11 @@ function CopySnippet(allProps: CubeCopySnippetProps) { const mods = useMemo(() => { return { nowrap, - multiline, + multiline: multiline || shouldWrap, serif, hidden: !!hideText, }; - }, [nowrap, multiline, hideText, serif]); + }, [nowrap, multiline, shouldWrap, hideText, serif]); const Snippet = ( @@ -250,6 +269,7 @@ function CopySnippet(allProps: CubeCopySnippetProps) { style={{ margin: 0, overflow: 'visible' }} code={formattedCode} language={language || 'javascript'} + isWrapped={shouldWrap} /> diff --git a/src/components/content/PrismCode/PrismCode.docs.mdx b/src/components/content/PrismCode/PrismCode.docs.mdx index 21720ad93..56359f9f7 100644 --- a/src/components/content/PrismCode/PrismCode.docs.mdx +++ b/src/components/content/PrismCode/PrismCode.docs.mdx @@ -18,6 +18,7 @@ Syntax-highlighted code block using Prism. Supports multiple languages. - **`code`** `string` (default: `''`) — The code snippet to highlight - **`language`** `string` (default: `'javascript'`) — Language for syntax highlighting (`javascript`, `typescript`, `json`, `yaml`, `bash`, `sql`, `css`, `html`, etc.) +- **`isWrapped`** `boolean` — Soft-wrap long lines onto multiple lines instead of scrolling them horizontally. Unbreakable runs (URLs, tokens, identifiers) wrap too. Useful for error messages and logs. ### Style Defaults diff --git a/src/components/content/PrismCode/PrismCode.stories.tsx b/src/components/content/PrismCode/PrismCode.stories.tsx index 846bd4950..d1f8f7b28 100644 --- a/src/components/content/PrismCode/PrismCode.stories.tsx +++ b/src/components/content/PrismCode/PrismCode.stories.tsx @@ -30,6 +30,16 @@ export const MultiLine = { }, }; +export const Wrapped = { + render: Template, + args: { + language: 'bash', + isWrapped: true, + width: 'max 400px', + code: 'XMLA Internal Error: Arrow error: External error: Database Execution Error: Internal: Error during planning: Error decoding LogicalPlanNode.logical_plan_type:SubqueryAliasNode.input as protobuf message', + }, +}; + export const JavascriptSyntax = { render: Template, args: { diff --git a/src/components/content/PrismCode/PrismCode.tsx b/src/components/content/PrismCode/PrismCode.tsx index 7d21d4f6f..0e33623c0 100644 --- a/src/components/content/PrismCode/PrismCode.tsx +++ b/src/components/content/PrismCode/PrismCode.tsx @@ -31,6 +31,20 @@ const PreElement = tasty({ Code: { display: 'block', preset: 's3', + // The global Prism CSS pins `white-space: pre` on + // `code[class*="language-"]`, so the wrap has to be re-declared right on + // the `` element — `pre-wrap` on the surrounding block alone does + // not cascade past it. `overflow-wrap: anywhere` also breaks runs with no + // spaces (URLs, tokens, identifiers) that `pre-wrap` on its own would let + // overflow. + whiteSpace: { + '': 'pre', + wrapped: 'pre-wrap', + }, + overflowWrap: { + '': 'normal', + wrapped: 'anywhere', + }, }, }, }); @@ -39,6 +53,12 @@ export interface CubePrismCodeProps extends ContainerStyleProps { /** The CSS style map */ style?: BaseProps['style']; styles?: Styles; + /** + * Soft-wrap long lines onto multiple lines instead of scrolling them + * horizontally. Unbreakable runs like URLs, tokens and identifiers wrap too. + * Useful for error messages and logs. + */ + isWrapped?: boolean; /** The code snippet */ code?: string; /** The language of the code snippet */ @@ -82,7 +102,7 @@ function isDiffCode(code: string): boolean { } function PrismCode(props: CubePrismCodeProps, ref) { - let { code = '', language = 'javascript', ...otherProps } = props; + let { code = '', language = 'javascript', isWrapped, ...otherProps } = props; if (!code) { code = ''; @@ -109,7 +129,7 @@ function PrismCode(props: CubePrismCodeProps, ref) { } return ( - + {({ className, style, tokens, getLineProps, getTokenProps }) => { return ( diff --git a/src/components/content/PrismCode/__tests__/PrismCode.test.tsx b/src/components/content/PrismCode/__tests__/PrismCode.test.tsx index 099302696..7f3512191 100644 --- a/src/components/content/PrismCode/__tests__/PrismCode.test.tsx +++ b/src/components/content/PrismCode/__tests__/PrismCode.test.tsx @@ -93,4 +93,18 @@ describe('PrismCode component', () => { expect(codeElement?.querySelector('.token.keyword')).toBeInTheDocument(); expect(codeElement?.querySelector('.token.string')).toBeInTheDocument(); }); + + test('sets the wrapped mod only when isWrapped is passed', () => { + const code = 'a very long single-line error message'; + + const { container: plain } = render( + , + ); + const { container: wrapped } = render( + , + ); + + expect(plain.querySelector('pre')).not.toHaveAttribute('data-wrapped'); + expect(wrapped.querySelector('pre')).toHaveAttribute('data-wrapped'); + }); });