Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,7 @@ const COMPONENTS = {
<span className='font-sans text-[var(--text-tertiary)] text-xs'>
{codeProps.className?.replace('language-', '') || 'code'}
</span>
<CopyCodeButton
code={extractTextContent(codeContent)}
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
<CopyCodeButton code={extractTextContent(codeContent)} appearance='code-header' />
</div>
<pre className='overflow-x-auto bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)]'>
{codeContent}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,10 +315,7 @@ const MARKDOWN_COMPONENTS = {
<div className='not-prose my-6 overflow-hidden rounded-lg border border-[var(--border)]'>
<div className='flex items-center justify-between border-[var(--border)] border-b bg-[var(--surface-4)] px-4 py-2 dark:bg-[var(--surface-4)]'>
<span className='text-[var(--text-tertiary)] text-xs'>{language || 'code'}</span>
<CopyCodeButton
code={codeString}
className='-mr-2 text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
/>
<CopyCodeButton code={codeString} appearance='code-header' className='-mr-2' />
</div>
<div className='code-editor-theme bg-[var(--surface-5)] dark:bg-[var(--code-bg)]'>
<pre className='m-0 overflow-x-auto whitespace-pre p-4 font-mono text-[var(--text-primary)] text-small leading-[21px]'>
Expand Down
80 changes: 80 additions & 0 deletions packages/emcn/src/components/code/copy-code-button.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import { CopyCodeButton } from './copy-code-button'

describe('CopyCodeButton', () => {
it.each(['default', 'code-header'] as const)(
'names the icon-only button in the %s appearance',
(appearance) => {
const markup = renderToStaticMarkup(<CopyCodeButton code='example' appearance={appearance} />)
const host = document.createElement('div')
host.innerHTML = markup
const button = host.querySelector('button')

expect(markup).toContain('aria-label="Copy code"')
expect(markup).toContain('type="button"')
Comment thread
BillLeoutsakosvl346 marked this conversation as resolved.
expect(button).not.toBeNull()
for (const className of [
'flex',
'items-center',
'gap-1',
'rounded',
'px-1.5',
'py-0.5',
'text-xs',
]) {
expect(button?.classList.contains(className)).toBe(true)
}

const codeHeaderClasses = [
'text-[var(--text-tertiary)]',
'hover-hover:bg-[var(--surface-5)]',
'hover-hover:text-[var(--text-secondary)]',
]
for (const className of codeHeaderClasses) {
expect(button?.classList.contains(className)).toBe(appearance === 'code-header')
}
}
)

it('copies code, swaps to the check icon, and keeps its accessible name', async () => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const writeText = vi.fn().mockResolvedValue(undefined)
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)

try {
await act(async () => {
root.render(<CopyCodeButton code='example' appearance='code-header' />)
})
const button = host.querySelector('button')
expect(button?.getAttribute('aria-label')).toBe('Copy code')
expect(button?.querySelector('svg rect')).not.toBeNull()

await act(async () => {
button?.click()
})

expect(writeText).toHaveBeenCalledExactlyOnceWith('example')
expect(button?.querySelector('svg rect')).toBeNull()
expect(button?.querySelector('svg path')).not.toBeNull()
expect(button?.getAttribute('aria-label')).toBe('Copy code')
} finally {
await act(async () => root.unmount())
host.remove()
if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
})
23 changes: 21 additions & 2 deletions packages/emcn/src/components/code/copy-code-button.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,44 @@
'use client'

import { cva, type VariantProps } from 'class-variance-authority'
import { useCopyToClipboard } from '../../hooks/use-copy-to-clipboard'
import { Button, Check, Duplicate } from '../../index'
import { cn } from '../../lib/cn'

const copyCodeButtonVariants = cva('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', {
variants: {
appearance: {
default: '',
'code-header':
'text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]',
},
},
defaultVariants: {
appearance: 'default',
},
})

interface CopyCodeButtonProps {
code: string
className?: string
/** Use the muted copy action treatment in code block headers. */
appearance?: VariantProps<typeof copyCodeButtonVariants>['appearance']
}

export function CopyCodeButton({ code, className }: CopyCodeButtonProps) {
export function CopyCodeButton({ code, className, appearance }: CopyCodeButtonProps) {
const { copied, copy } = useCopyToClipboard()

return (
<Button
type='button'
aria-label='Copy code'
variant='ghost'
onClick={() => copy(code)}
className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', className)}
className={cn(copyCodeButtonVariants({ appearance }), className)}
>
{copied ? <Check className='size-3.5' /> : <Duplicate className='size-3.5' />}
</Button>
)
}

export { copyCodeButtonVariants }
2 changes: 1 addition & 1 deletion packages/emcn/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export {
calculateGutterWidth,
getCodeEditorProps,
} from './code/code'
export { CopyCodeButton } from './code/copy-code-button'
export { CopyCodeButton, copyCodeButtonVariants } from './code/copy-code-button'
export { highlight, languages } from './code/prism'
export { CollapsibleCard, type CollapsibleCardProps } from './collapsible-card/collapsible-card'
export {
Expand Down
Loading