Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/codeblock-render-copy-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] CodeBlock: add a `renderCopyButton` render prop to supply a custom copy control. The block keeps ownership of placement, the clipboard write, the copied-state timer, and the copy announcement — the render prop only provides the visual button, wired to the passed `copy`/`isCopied`/`label`. Ignored when `hasCopyButton` is `false`.

@freddymeta
24 changes: 24 additions & 0 deletions apps/storybook/stories/CodeBlock.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import type {Meta, StoryObj} from '@storybook/react';
import {CodeBlock} from '@astryxdesign/core/CodeBlock';
import {IconButton} from '@astryxdesign/core/IconButton';
import {Icon} from '@astryxdesign/core/Icon';

const meta: Meta<typeof CodeBlock> = {
title: 'Core/CodeBlock',
Expand Down Expand Up @@ -320,3 +322,25 @@ export const Collapsible: Story = {
isCollapsible: true,
},
};

export const CustomCopyButton: Story = {
args: {
code: tsExample,
language: 'typescript',
title: 'useUser.ts',
},
render: args => (
<CodeBlock
{...args}
renderCopyButton={({isCopied, copy, label}) => (
<IconButton
label={label}
size="sm"
variant="ghost"
icon={<Icon icon={isCopied ? 'check' : 'copy'} />}
onClick={copy}
/>
)}
/>
),
};
5 changes: 5 additions & 0 deletions packages/core/src/CodeBlock/CodeBlock.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export const docs = {
description: 'Show a copy-to-clipboard button.',
default: 'true',
},
{
name: 'renderCopyButton',
type: '(props: { isCopied: boolean; copy: () => void; label: string }) => ReactNode',
description: 'Render a custom copy control in place of the built-in button. The block keeps ownership of placement, the clipboard write, the copied-state timer, and the copy announcement; the render prop only supplies the visual control. Ignored when hasCopyButton is false.',
},
{
name: 'onCopy',
type: '() => void',
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/CodeBlock/CodeBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,133 @@ describe('CodeBlock', () => {
expect(container.querySelector('[data-astryx-syntax-theme]')).toBeNull();
expect(container.firstElementChild?.tagName).toBe('PRE');
});

describe('renderCopyButton', () => {
it('renders the custom control instead of the built-in copy button', () => {
render(
<CodeBlock
code="const x = 1;"
language="javascript"
renderCopyButton={({copy, label}) => (
<button type="button" onClick={copy}>
{label}
</button>
)}
/>,
);
// The built-in button uses the localized "Copy code" label; the custom
// one here surfaces that same label as its text, and there is exactly one
// copy control (the built-in button is not also rendered).
const buttons = screen
.getAllByRole('button')
.filter(b => b.textContent === 'Copy code');
expect(buttons).toHaveLength(1);
});

it('drives the block clipboard + copied flow through copy/copied/label', async () => {
render(
<CodeBlock
code="const x = 1;"
language="javascript"
renderCopyButton={({isCopied, copy, label}) => (
<button type="button" onClick={copy} data-copied={isCopied}>
{label}
</button>
)}
/>,
);
const button = screen.getByRole('button', {name: 'Copy code'});
expect(button).toHaveAttribute('data-copied', 'false');

fireEvent.click(button);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
'const x = 1;',
);

// The block owns the copied-state timer, so the render prop re-renders
// with copied=true and the localized "Copied" label.
await waitFor(() => {
expect(screen.getByRole('button', {name: 'Copied'})).toHaveAttribute(
'data-copied',
'true',
);
});
});

it('still announces "Copied" to the live region for a custom control', async () => {
render(
<CodeBlock
code="const x = 1;"
language="javascript"
renderCopyButton={({copy, label}) => (
<button type="button" onClick={copy}>
{label}
</button>
)}
/>,
);
fireEvent.click(screen.getByRole('button', {name: 'Copy code'}));
await waitFor(() => {
expect(politeRegion()).toHaveTextContent('Copied');
});
});

it('fires onCopy for a custom control', async () => {
const onCopy = vi.fn();
render(
<CodeBlock
code="const x = 1;"
language="javascript"
onCopy={onCopy}
renderCopyButton={({copy, label}) => (
<button type="button" onClick={copy}>
{label}
</button>
)}
/>,
);
fireEvent.click(screen.getByRole('button', {name: 'Copy code'}));
await waitFor(() => {
expect(onCopy).toHaveBeenCalledTimes(1);
});
});

it('renders no copy control when hasCopyButton is false, even with renderCopyButton', () => {
render(
<CodeBlock
code="const x = 1;"
language="javascript"
hasCopyButton={false}
renderCopyButton={({copy, label}) => (
<button type="button" onClick={copy}>
{label}
</button>
)}
/>,
);
expect(screen.queryByRole('button', {name: 'Copy code'})).toBeNull();
});

it('keeps a custom control out of the collapsible header role="button"', () => {
render(
<CodeBlock
code={LONG_CODE}
language="javascript"
title="example"
isCollapsible
renderCopyButton={({copy, label}) => (
<button type="button" onClick={copy}>
{label}
</button>
)}
/>,
);
const header = screen
.getAllByRole('button')
.find(el => el.hasAttribute('aria-expanded'));
const copyButton = screen.getByRole('button', {name: 'Copy code'});
expect(header).toBeTruthy();
expect(header!.contains(copyButton)).toBe(false);
});
});
});
95 changes: 77 additions & 18 deletions packages/core/src/CodeBlock/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
/**
* @file CodeBlock.tsx
* @input Uses React, StyleX, theme tokens, CSS Custom Highlight API, SyntaxTheme provider
* @output Exports CodeBlock component and CodeBlockProps
* @output Exports CodeBlock component, CodeBlockProps, and CodeBlockCopyRenderProps
* @position Core implementation; read-only syntax-highlighted code display
*/

Expand Down Expand Up @@ -421,6 +421,18 @@ function renderLines(
// Props
// ---------------------------------------------------------------------------

/**
* State and helpers passed to a `renderCopyButton` render prop.
*/
export interface CodeBlockCopyRenderProps {
/** Whether the code was copied within the last confirmation window. */
isCopied: boolean;
/** Copy the code to the clipboard and run the confirmation flow. */
copy: () => void;
/** Localized accessible label reflecting the current copied state. */
label: string;
}

export interface CodeBlockProps extends BaseProps<HTMLPreElement> {
ref?: React.Ref<HTMLPreElement>;
code: string;
Expand All @@ -430,6 +442,29 @@ export interface CodeBlockProps extends BaseProps<HTMLPreElement> {
hasLineNumbers?: boolean;
highlightLines?: number[];
hasCopyButton?: boolean;
/**
* Render your own copy control in place of the built-in one. The block keeps
* ownership of placement (in the header, or the floating corner when there is
* no header), the clipboard write, the copied-state timer, and the polite
* live-region announcement — the render prop only supplies the visual
* control. Return an element wired to the given `copy`/`isCopied`/`label`.
* Ignored when `hasCopyButton` is `false`.
*
* @example
* ```
* <CodeBlock
* code={code}
* renderCopyButton={({isCopied, copy, label}) => (
* <IconButton
* label={label}
* icon={<Icon icon={isCopied ? 'check' : 'copy'} />}
* onClick={copy}
* />
* )}
* />
* ```
*/
renderCopyButton?: (props: CodeBlockCopyRenderProps) => React.ReactNode;
onCopy?: () => void;
isWrapped?: boolean;
maxHeight?: number | string;
Expand Down Expand Up @@ -729,6 +764,7 @@ export function CodeBlock({
hasLineNumbers = false,
highlightLines,
hasCopyButton = true,
renderCopyButton,
onCopy,
isWrapped = false,
maxHeight,
Expand Down Expand Up @@ -826,24 +862,47 @@ export function CodeBlock({
<Icon icon={copied ? 'check' : 'copy'} size="sm" color="inherit" />
);

const copyButtonEl = hasCopyButton ? (
<button
type="button"
onClick={e => {
// Stop propagation so copying does not toggle the collapsible header.
e.stopPropagation();
const copyLabel = copied
? t('@astryx.codeBlock.copied')
: t('@astryx.codeBlock.copyCode');

let copyButtonEl: React.ReactNode = null;
if (hasCopyButton && renderCopyButton) {
// The consumer owns the control's appearance; the block still owns the
// clipboard write, the copied-state timer, the announcement, and — in the
// header-less case — the corner placement, so the render prop needs no
// positioning of its own. In header mode the control sits as the header's
// trailing flex child, exactly where the built-in button goes.
const custom = renderCopyButton({
isCopied: copied,
copy: () => {
void handleCopy();
}}
aria-label={
copied ? t('@astryx.codeBlock.copied') : t('@astryx.codeBlock.copyCode')
}
{...stylex.props(
styles.copyButton,
!showHeader && styles.copyButtonAbsolute,
)}>
{copyIcon}
</button>
) : null;
},
label: copyLabel,
});
copyButtonEl = showHeader ? (
custom
) : (
<span {...stylex.props(styles.copyButtonAbsolute)}>{custom}</span>
);
} else if (hasCopyButton) {
copyButtonEl = (
<button
type="button"
onClick={e => {
// Stop propagation so copying does not toggle the collapsible header.
e.stopPropagation();
void handleCopy();
}}
aria-label={copyLabel}
{...stylex.props(
styles.copyButton,
!showHeader && styles.copyButtonAbsolute,
)}>
{copyIcon}
</button>
);
}

const headerEl = showHeader ? (
<div
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/CodeBlock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

export {CodeBlock} from './CodeBlock';
export type {CodeBlockProps} from './CodeBlock';
export type {CodeBlockProps, CodeBlockCopyRenderProps} from './CodeBlock';

export {Code} from '../Code';
export type {CodeProps, CodeColor, CodeSize} from '../Code';
Expand Down
Loading