mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
feat(markdown): Support collapsible sections and code copy (#65138)
* Add copy button to markdown code blocks * Add markdown collapsable sections * Add tests * Handle malformed/incomplete `<details>` section * Add default title example to story * Support stacked `<details>` sections * Improve accessibility * Fix wasted iterations when there's no closing `<details>` tag * Fix paragraph consuming details tags * Fix test grouping * Pass through options to nested Markdown components * Fix `aria-controls` * Enable keyboard controls * Fix typo * Render section content with `hidden` * Add missing `e.preventDefault()` * Fix paragraph exceptions for headings * Fix content assertion * Use `CopyButton` component * Use theme border * Trim title content * Improve `open` attribute parsing * Fix copy button type * Remove unused async * Use more consistent element key * Add extra right padding to code blocks * Support nested sections * Limit nesting depth at 16
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import Box from 'design/Box';
|
||||
import Box, { BoxProps } from 'design/Box';
|
||||
import ButtonIcon from 'design/ButtonIcon';
|
||||
import { Check, Copy } from 'design/Icon';
|
||||
import { HoverTooltip } from 'design/Tooltip';
|
||||
@@ -27,17 +27,14 @@ import { copyToClipboard } from 'design/utils/copyToClipboard';
|
||||
/**
|
||||
* Renders a button that copies the provided value to clipboard when clicked.
|
||||
*/
|
||||
export function CopyButton({
|
||||
value,
|
||||
mr,
|
||||
ml,
|
||||
tooltip,
|
||||
}: {
|
||||
value: string;
|
||||
mr?: number;
|
||||
ml?: number;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
export function CopyButton(
|
||||
props: {
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
} & BoxProps
|
||||
) {
|
||||
const { tooltip, value, ...rest } = props;
|
||||
|
||||
const copySuccess = 'Copied!';
|
||||
const copyDefault = tooltip || 'Click to copy';
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
@@ -69,13 +66,14 @@ export function CopyButton({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box mr={mr} ml={ml}>
|
||||
<Box {...rest}>
|
||||
<HoverTooltip tipContent={copiedText}>
|
||||
<ButtonIcon
|
||||
ref={copyAnchorEl}
|
||||
size={0}
|
||||
onClick={handleCopy}
|
||||
aria-label="copy"
|
||||
type="button"
|
||||
>
|
||||
{copiedText === copySuccess ? (
|
||||
<Check size="small" />
|
||||
|
||||
@@ -95,6 +95,11 @@ function hello() {
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`plain
|
||||
Scrolls horizontally
|
||||
-> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
|
||||
\`\`\`
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> This is a blockquote.
|
||||
@@ -138,7 +143,37 @@ This creates a line break without a paragraph break.
|
||||
|
||||
\\# This won't be a header
|
||||
|
||||
Use backslashes to escape special characters: \\\\ \\\` \\* \\_ \\{ \\} \\[ \\] \\( \\) \\# \\+ \\- \\. \\!`;
|
||||
Use backslashes to escape special characters: \\\\ \\\` \\* \\_ \\{ \\} \\[ \\] \\( \\) \\# \\+ \\- \\. \\!
|
||||
|
||||
## Collapsible Sections
|
||||
|
||||
<details>
|
||||
<summary>What's in here?</summary>
|
||||
# Heading 1
|
||||
|
||||
This is a paragraph.
|
||||
|
||||
\`\`\`
|
||||
main() {
|
||||
print("Here's a code block")
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
<details>
|
||||
<summary>A nested section</summary>
|
||||
|
||||
[And, a link](https://example.com)
|
||||
</details>
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary>Already open</summary>
|
||||
This section is open by default.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
This section has a default title
|
||||
</details>`;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
import { render } from 'design/utils/testing';
|
||||
import { render, userEvent } from 'design/utils/testing';
|
||||
|
||||
import { Markdown, MarkdownOptions } from './Markdown';
|
||||
|
||||
@@ -737,7 +737,12 @@ This is **bold <script>alert('xss')</script>** text.
|
||||
- List with <script>alert('xss')</script>
|
||||
- [Link](javascript:alert('xss'))
|
||||
|
||||
\`code <script>alert('xss')</script>\``;
|
||||
\`code <script>alert('xss')</script>\`
|
||||
|
||||
<details open>
|
||||
<summary>summary <script>alert('xss')</script></summary>
|
||||
details <script>alert('xss')</script>
|
||||
</details>`;
|
||||
|
||||
renderMarkdown(text, { enableLinks: true });
|
||||
|
||||
@@ -750,6 +755,149 @@ This is **bold <script>alert('xss')</script>** text.
|
||||
expect(
|
||||
screen.getByText("code <script>alert('xss')</script>")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("summary <script>alert('xss')</script>")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("details <script>alert('xss')</script>")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapsible sections', () => {
|
||||
it('renders a closed section', async () => {
|
||||
const text = `
|
||||
<details>
|
||||
<summary>title goes here</summary>
|
||||
content goes here
|
||||
</details>
|
||||
more content here`;
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('title goes here')).toBeInTheDocument();
|
||||
expect(screen.queryByText('content goes here')).not.toBeVisible();
|
||||
expect(screen.getByText('more content here')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'title goes here' }));
|
||||
expect(screen.getByText('content goes here')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders an open section', async () => {
|
||||
const text = `
|
||||
<details open>
|
||||
<summary>title goes here</summary>
|
||||
content goes here
|
||||
</details>
|
||||
more content here`;
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('title goes here')).toBeInTheDocument();
|
||||
expect(screen.getByText('content goes here')).toBeVisible();
|
||||
expect(screen.getByText('more content here')).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'title goes here' }));
|
||||
expect(screen.queryByText('content goes here')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('renders nested sections', async () => {
|
||||
const text = `
|
||||
<details>
|
||||
<summary>outer title</summary>
|
||||
outer content
|
||||
<details>
|
||||
<summary>inner title</summary>
|
||||
inner content
|
||||
</details>
|
||||
inner other content
|
||||
</details>
|
||||
outer other content`;
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('outer title')).toBeInTheDocument();
|
||||
expect(screen.getByText('outer content')).not.toBeVisible();
|
||||
expect(screen.getByText('inner title')).not.toBeVisible();
|
||||
expect(screen.getByText('outer other content')).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'outer title' }));
|
||||
expect(screen.getByText('outer content')).toBeVisible();
|
||||
expect(screen.getByText('inner title')).toBeVisible();
|
||||
expect(screen.getByText('inner content')).not.toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'inner title' }));
|
||||
expect(screen.getByText('inner content')).toBeVisible();
|
||||
});
|
||||
|
||||
it('caps at 16 levels of nesting', () => {
|
||||
const text = Array.from({ length: 17 }, (_, i) => 17 - i).reduce(
|
||||
(acc, cur) => {
|
||||
return `<details open>
|
||||
level:${cur}
|
||||
${acc}
|
||||
</details>`;
|
||||
},
|
||||
''
|
||||
);
|
||||
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('level:16')).toBeInTheDocument();
|
||||
expect(screen.queryByText('level:17')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an empty section', () => {
|
||||
const text = `
|
||||
<details open>
|
||||
<summary>title goes here</summary>
|
||||
</details>`;
|
||||
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('title goes here')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stacked sections', () => {
|
||||
const text = `
|
||||
<details open>
|
||||
<summary>section 1</summary>
|
||||
section content 1
|
||||
</details>
|
||||
<details open>
|
||||
<summary>section 2</summary>
|
||||
section content 2
|
||||
</details>`;
|
||||
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('section 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('section 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('section content 1')).toBeVisible();
|
||||
expect(screen.getByText('section content 2')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a malformed/incomplete closed section', () => {
|
||||
const text = `
|
||||
<details>
|
||||
some other content`;
|
||||
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.queryByText('some other content')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a malformed/incomplete open section', () => {
|
||||
const text = `
|
||||
<details open>
|
||||
some other content`;
|
||||
|
||||
renderMarkdown(text);
|
||||
|
||||
expect(screen.getByText('some other content')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,9 +16,22 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createElement, useMemo, type ReactNode } from 'react';
|
||||
import {
|
||||
createElement,
|
||||
PropsWithChildren,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import Flex from 'design/Flex';
|
||||
import { ChevronDown, ChevronRight } from 'design/Icon';
|
||||
import { P2 } from 'design/Text';
|
||||
|
||||
import { CopyButton } from '../CopyButton/CopyButton';
|
||||
|
||||
export interface MarkdownOptions {
|
||||
/**
|
||||
* `true` if links should be rendered. Defaults to `false` to protect from
|
||||
@@ -51,15 +64,6 @@ const StyledLink = styled.a`
|
||||
text-transform: none;
|
||||
`;
|
||||
|
||||
const StyledPre = styled.pre`
|
||||
background: ${p => p.theme.colors.spotBackground[1]};
|
||||
border-radius: ${p => p.theme.radii[2]}px;
|
||||
padding: ${p => p.theme.space[2]}px ${p => p.theme.space[3]}px;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: ${p => p.theme.space[2]}px 0;
|
||||
`;
|
||||
|
||||
const parsers: MarkdownParser[] = [
|
||||
{
|
||||
pattern: /\*\*(?<content>[^*]+?)\*\*/,
|
||||
@@ -143,6 +147,7 @@ const headerRegex = /^(?<hashes>#{1,6})\s*(?<content>.*)$/;
|
||||
const fencedCodeRegex = /^```(\w*)\s*$/;
|
||||
|
||||
const MAX_ITERATIONS = 10000;
|
||||
const MAX_DEPTH = 16;
|
||||
|
||||
/**
|
||||
* Turns a Markdown string into a list of React nodes. CAUTION: this function
|
||||
@@ -248,10 +253,70 @@ function processMarkdown(text: string, options: MarkdownOptions): ReactNode[] {
|
||||
i += 1;
|
||||
}
|
||||
|
||||
items.push(<CodeBlock key={`code-${i}`} code={codeLines.join('\n')} />);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('<details')) {
|
||||
const expanded = /\bopen\b/.test(line);
|
||||
let summary = '';
|
||||
const content: string[] = [];
|
||||
const startI = i;
|
||||
i += 1; // skip the opening tag
|
||||
|
||||
const nextLine = lines.at(i);
|
||||
if (nextLine === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextLine.trim().startsWith('<summary>')) {
|
||||
summary = nextLine.replaceAll(/<\/?summary>/g, '').trim();
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// Support nested sections by counting opening tags and ignoring that
|
||||
// many closing tags.
|
||||
let nested = 0;
|
||||
while (true) {
|
||||
const nextLine = lines.at(i);
|
||||
if (nextLine === undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (i - startI > MAX_ITERATIONS) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextLine.trim().startsWith('<details')) {
|
||||
nested += 1;
|
||||
}
|
||||
|
||||
const filter = nested >= MAX_DEPTH;
|
||||
|
||||
if (nextLine.trim() === '</details>') {
|
||||
// If we're nested, then treat this line like any other content.
|
||||
if (nested < 1) {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
nested -= 1;
|
||||
}
|
||||
|
||||
if (!filter) {
|
||||
content.push(nextLine);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
items.push(
|
||||
<StyledPre key={`code-${i}`}>
|
||||
<code>{codeLines.join('\n')}</code>
|
||||
</StyledPre>
|
||||
<Section
|
||||
key={`section-${startI}`}
|
||||
title={summary || 'Expand'}
|
||||
expanded={expanded}
|
||||
>
|
||||
<Markdown text={content.join('\n')} {...options} />
|
||||
</Section>
|
||||
);
|
||||
|
||||
continue;
|
||||
@@ -264,9 +329,10 @@ function processMarkdown(text: string, options: MarkdownOptions): ReactNode[] {
|
||||
const currentLine = lines[i];
|
||||
|
||||
if (
|
||||
headerRegex.test(currentLine) ||
|
||||
headerRegex.test(currentLine.trim()) ||
|
||||
currentLine.trim().startsWith('- ') ||
|
||||
fencedCodeRegex.test(currentLine.trim())
|
||||
fencedCodeRegex.test(currentLine.trim()) ||
|
||||
currentLine.trim().startsWith('<details')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -305,3 +371,90 @@ export function Markdown({ text, ...options }: MarkdownProps) {
|
||||
[text, ...Object.values(options)]
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock(props: { code: string }) {
|
||||
const { code } = props;
|
||||
return (
|
||||
<CodeBlockContainer>
|
||||
<StyledPre>
|
||||
<code>{code}</code>
|
||||
</StyledPre>
|
||||
<StyledCopyButton value={code} />
|
||||
</CodeBlockContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const CodeBlockContainer = styled.div`
|
||||
position: relative;
|
||||
background: ${p => p.theme.colors.interactive.tonal.neutral[1]};
|
||||
border-radius: ${p => p.theme.radii[2]}px;
|
||||
margin: ${p => p.theme.space[2]}px 0;
|
||||
`;
|
||||
|
||||
const StyledPre = styled.pre`
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
padding: ${p => p.theme.space[2]}px ${p => p.theme.space[3]}px;
|
||||
padding-right: ${p => p.theme.space[6]}px;
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledCopyButton = styled(CopyButton)`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: ${({ theme }) => theme.space[2]}px;
|
||||
`;
|
||||
|
||||
function Section(
|
||||
props: { title: string; expanded: boolean } & PropsWithChildren
|
||||
) {
|
||||
const { children, title, expanded: preExpanded } = props;
|
||||
const [expanded, setExpanded] = useState(preExpanded);
|
||||
const accessibilityId = useId();
|
||||
|
||||
return (
|
||||
<SectionContainer>
|
||||
<SectionHeadingContainer
|
||||
onClick={() => setExpanded(prev => !prev)}
|
||||
role="button"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={accessibilityId}
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setExpanded(prev => !prev);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown size="medium" />
|
||||
) : (
|
||||
<ChevronRight size="medium" />
|
||||
)}
|
||||
<P2>
|
||||
<strong>{title}</strong>
|
||||
</P2>
|
||||
</SectionHeadingContainer>
|
||||
<SectionContentContainer id={accessibilityId} hidden={!expanded}>
|
||||
{children}
|
||||
</SectionContentContainer>
|
||||
</SectionContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const SectionContainer = styled.div`
|
||||
padding: ${({ theme }) => theme.space[2]}px 0;
|
||||
`;
|
||||
|
||||
const SectionHeadingContainer = styled(Flex)`
|
||||
cursor: pointer;
|
||||
gap: ${({ theme }) => theme.space[2]}px;
|
||||
`;
|
||||
|
||||
const SectionContentContainer = styled.div`
|
||||
padding-left: ${({ theme }) => theme.space[3]}px;
|
||||
border-left: ${({ theme }) => theme.borders[3]}
|
||||
${({ theme }) => theme.colors.interactive.tonal.neutral[1]};
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user