diff --git a/.changeset/codeblock-render-copy-button.md b/.changeset/codeblock-render-copy-button.md new file mode 100644 index 000000000000..661a98d332ad --- /dev/null +++ b/.changeset/codeblock-render-copy-button.md @@ -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 diff --git a/apps/storybook/stories/CodeBlock.stories.tsx b/apps/storybook/stories/CodeBlock.stories.tsx index 24a41fbbaed7..e76dec81aa94 100644 --- a/apps/storybook/stories/CodeBlock.stories.tsx +++ b/apps/storybook/stories/CodeBlock.stories.tsx @@ -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 = { title: 'Core/CodeBlock', @@ -320,3 +322,25 @@ export const Collapsible: Story = { isCollapsible: true, }, }; + +export const CustomCopyButton: Story = { + args: { + code: tsExample, + language: 'typescript', + title: 'useUser.ts', + }, + render: args => ( + ( + } + onClick={copy} + /> + )} + /> + ), +}; diff --git a/packages/core/src/CodeBlock/CodeBlock.doc.mjs b/packages/core/src/CodeBlock/CodeBlock.doc.mjs index 3ad0960c5338..7baa4efd8deb 100644 --- a/packages/core/src/CodeBlock/CodeBlock.doc.mjs +++ b/packages/core/src/CodeBlock/CodeBlock.doc.mjs @@ -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', diff --git a/packages/core/src/CodeBlock/CodeBlock.test.tsx b/packages/core/src/CodeBlock/CodeBlock.test.tsx index 0f8e0027d315..5186590edad7 100644 --- a/packages/core/src/CodeBlock/CodeBlock.test.tsx +++ b/packages/core/src/CodeBlock/CodeBlock.test.tsx @@ -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( + ( + + )} + />, + ); + // 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( + ( + + )} + />, + ); + 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( + ( + + )} + />, + ); + 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( + ( + + )} + />, + ); + 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( + ( + + )} + />, + ); + expect(screen.queryByRole('button', {name: 'Copy code'})).toBeNull(); + }); + + it('keeps a custom control out of the collapsible header role="button"', () => { + render( + ( + + )} + />, + ); + 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); + }); + }); }); diff --git a/packages/core/src/CodeBlock/CodeBlock.tsx b/packages/core/src/CodeBlock/CodeBlock.tsx index 8d6ab69b7897..01ab7f759754 100644 --- a/packages/core/src/CodeBlock/CodeBlock.tsx +++ b/packages/core/src/CodeBlock/CodeBlock.tsx @@ -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 */ @@ -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 { ref?: React.Ref; code: string; @@ -430,6 +442,29 @@ export interface CodeBlockProps extends BaseProps { 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 + * ``` + * ( + * } + * onClick={copy} + * /> + * )} + * /> + * ``` + */ + renderCopyButton?: (props: CodeBlockCopyRenderProps) => React.ReactNode; onCopy?: () => void; isWrapped?: boolean; maxHeight?: number | string; @@ -729,6 +764,7 @@ export function CodeBlock({ hasLineNumbers = false, highlightLines, hasCopyButton = true, + renderCopyButton, onCopy, isWrapped = false, maxHeight, @@ -826,24 +862,47 @@ export function CodeBlock({ ); - const copyButtonEl = hasCopyButton ? ( - - ) : null; + }, + label: copyLabel, + }); + copyButtonEl = showHeader ? ( + custom + ) : ( + {custom} + ); + } else if (hasCopyButton) { + copyButtonEl = ( + + ); + } const headerEl = showHeader ? (