diff --git a/.changeset/datatable-copy-as-markdown.md b/.changeset/datatable-copy-as-markdown.md
new file mode 100644
index 00000000000..8f3d71f79c4
--- /dev/null
+++ b/.changeset/datatable-copy-as-markdown.md
@@ -0,0 +1,13 @@
+---
+'@primer/react': minor
+---
+
+DataTable: Add `Table.CopyAsMarkdownButton` for copying visible rows as a
+GitHub-flavoured Markdown table. The button is a slot designed for
+composition inside `
`. Cell values are projected via a new
+optional `Column.getExportValue` (preferred) or the field value — never
+`renderCell`, so React/JSX output stays out of the plain-text payload.
+Pipes, newlines, and ASCII/Unicode control characters are escaped or
+stripped before reaching the clipboard. The implementation prefers the
+async Clipboard API and falls back to `document.execCommand('copy')` for
+non-secure contexts.
diff --git a/packages/react/src/DataTable/CopyAsMarkdownButton.module.css b/packages/react/src/DataTable/CopyAsMarkdownButton.module.css
new file mode 100644
index 00000000000..e065d50c890
--- /dev/null
+++ b/packages/react/src/DataTable/CopyAsMarkdownButton.module.css
@@ -0,0 +1,6 @@
+/* Reserve a stable layout while the transient copied/error label is shown
+ * so the button doesn't reflow under the cursor. */
+.CopyAsMarkdownButton {
+ min-inline-size: 9rem;
+ justify-content: center;
+}
diff --git a/packages/react/src/DataTable/CopyAsMarkdownButton.tsx b/packages/react/src/DataTable/CopyAsMarkdownButton.tsx
new file mode 100644
index 00000000000..2653738d002
--- /dev/null
+++ b/packages/react/src/DataTable/CopyAsMarkdownButton.tsx
@@ -0,0 +1,141 @@
+import {clsx} from 'clsx'
+import React, {useCallback, useRef, useState} from 'react'
+import {CopyIcon, CheckIcon} from '@primer/octicons-react'
+import {Button} from '../Button'
+import type {Column} from './column'
+import type {UniqueRow} from './row'
+import {rowsToMarkdown, writeTextToClipboard} from './clipboard'
+import {useDataTableSnapshot} from './snapshotContext'
+import classes from './CopyAsMarkdownButton.module.css'
+
+export type CopyAsMarkdownButtonProps = {
+ /**
+ * Rows to serialise. When the button is rendered inside a
+ * `` that contains a sibling ``, the button
+ * automatically reads the visible rows from context (after sort, filter,
+ * and pagination have been applied). Provide this prop explicitly when
+ * using the button standalone, or to override the context-sourced rows.
+ */
+ rows?: ReadonlyArray
+
+ /**
+ * Columns to serialise. Like `rows`, the button reads columns from the
+ * sibling `` context when available. Provide this prop
+ * explicitly when using the button standalone.
+ */
+ columns?: ReadonlyArray>
+
+ /** Override the label shown on the button. */
+ children?: React.ReactNode
+
+ /**
+ * Override the transient success label (defaults to "Copied").
+ * The label reverts to `children` after `successDurationMs`.
+ */
+ copiedLabel?: React.ReactNode
+
+ /**
+ * Override the transient failure label (defaults to "Copy failed").
+ * The label reverts to `children` after `successDurationMs`.
+ */
+ errorLabel?: React.ReactNode
+
+ /** How long to show the copied/error label in ms. Default 2000. */
+ successDurationMs?: number
+
+ /** Called with the markdown string and the success status of the copy. */
+ onCopy?: (markdown: string, success: boolean) => void
+
+ /** Optional additional class name. */
+ className?: string
+}
+
+type CopyState = 'idle' | 'copied' | 'error'
+
+/**
+ * Slot-style button that serialises the visible rows of a sibling
+ * `` as a GitHub-flavoured Markdown table and writes the
+ * result to the system clipboard. Designed for composition inside
+ * ``:
+ *
+ * ```tsx
+ *
+ * Repositories
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * The button reads the rows currently displayed by the sibling
+ * `` via React context — *after* the table's internal sort,
+ * filter, and pagination have been applied — so the clipboard payload
+ * matches what the user is looking at. Standalone usage outside a
+ * `` still works by passing `rows` and `columns` as
+ * explicit props.
+ *
+ * Serialisation explicitly does NOT call `renderCell`. Cell values are
+ * projected via `column.getExportValue` (preferred) or the field value,
+ * then escaped (pipes, backslashes, angle brackets, newlines, control
+ * characters). See `clipboard.ts` for the full security model.
+ */
+export function CopyAsMarkdownButton({
+ rows: rowsProp,
+ columns: columnsProp,
+ children = 'Copy as Markdown',
+ copiedLabel = 'Copied',
+ errorLabel = 'Copy failed',
+ successDurationMs = 2000,
+ onCopy,
+ className,
+}: CopyAsMarkdownButtonProps) {
+ const snapshot = useDataTableSnapshot()
+ // Explicit props override the context snapshot so standalone usage and
+ // override scenarios both keep working. Wrapped in useMemo so the
+ // useCallback below has stable dependencies — without this the `??`
+ // chain creates a new fallback array on every render.
+ const rows = React.useMemo(() => rowsProp ?? snapshot?.rows ?? [], [rowsProp, snapshot?.rows])
+ const columns = React.useMemo(() => columnsProp ?? snapshot?.columns ?? [], [columnsProp, snapshot?.columns])
+
+ const [state, setState] = useState('idle')
+ const timeoutRef = useRef | null>(null)
+
+ const handleClick = useCallback(async () => {
+ const markdown = rowsToMarkdown(rows, columns)
+ const ok = await writeTextToClipboard(markdown)
+ setState(ok ? 'copied' : 'error')
+ onCopy?.(markdown, ok)
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current)
+ }
+ timeoutRef.current = setTimeout(() => {
+ setState('idle')
+ timeoutRef.current = null
+ }, successDurationMs)
+ }, [rows, columns, onCopy, successDurationMs])
+
+ React.useEffect(() => {
+ return () => {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [])
+
+ const label = state === 'copied' ? copiedLabel : state === 'error' ? errorLabel : children
+ const icon = state === 'copied' ? CheckIcon : CopyIcon
+
+ return (
+
+ )
+}
diff --git a/packages/react/src/DataTable/DataTable.docs.json b/packages/react/src/DataTable/DataTable.docs.json
index 3b0f5c7006e..1028b11f1ea 100644
--- a/packages/react/src/DataTable/DataTable.docs.json
+++ b/packages/react/src/DataTable/DataTable.docs.json
@@ -42,6 +42,9 @@
},
{
"id": "experimental-components-datatable-features--with-pagination"
+ },
+ {
+ "id": "experimental-components-datatable-features--with-copy-as-markdown"
}
],
"importPath": "@primer/react/experimental",
@@ -353,6 +356,52 @@
}
]
},
+ {
+ "name": "Table.CopyAsMarkdownButton",
+ "props": [
+ {
+ "name": "rows",
+ "type": "ReadonlyArray",
+ "required": true,
+ "description": "Rows to serialise. Usually the same array passed to ``."
+ },
+ {
+ "name": "columns",
+ "type": "ReadonlyArray>",
+ "required": true,
+ "description": "Columns to serialise. Usually the same array passed to ``."
+ },
+ {
+ "name": "children",
+ "type": "React.ReactNode",
+ "description": "Override the label shown on the button.",
+ "defaultValue": "'Copy as Markdown'"
+ },
+ {
+ "name": "copiedLabel",
+ "type": "React.ReactNode",
+ "description": "Override the transient success label.",
+ "defaultValue": "'Copied'"
+ },
+ {
+ "name": "errorLabel",
+ "type": "React.ReactNode",
+ "description": "Override the transient failure label.",
+ "defaultValue": "'Copy failed'"
+ },
+ {
+ "name": "successDurationMs",
+ "type": "number",
+ "description": "How long to show the copied/error label in ms.",
+ "defaultValue": "2000"
+ },
+ {
+ "name": "onCopy",
+ "type": "(markdown: string, success: boolean) => void",
+ "description": "Called with the markdown string and the success status of the copy."
+ }
+ ]
+ },
{
"name": "Table.SortHeader",
"props": [
@@ -422,6 +471,11 @@
"type": "boolean | 'alphanumeric' | 'basic' | 'datetime' | (a: Data, b: Data) => number",
"description": "Specify if the table should sort by this column and, if applicable, a specific sort strategy or custom sort strategy"
},
+ {
+ "name": "getExportValue",
+ "type": "(data: Data) => string",
+ "description": "Returns the string representation used by clipboard exports (`Table.CopyAsMarkdownButton`). Kept separate from `renderCell` so consumers can keep React output (icons, links, labels) out of the plain-text payload."
+ },
{
"name": "width",
"defaultValue": "'grow'",
diff --git a/packages/react/src/DataTable/DataTable.features.stories.tsx b/packages/react/src/DataTable/DataTable.features.stories.tsx
index 6563a3054bd..75b82f9e949 100644
--- a/packages/react/src/DataTable/DataTable.features.stories.tsx
+++ b/packages/react/src/DataTable/DataTable.features.stories.tsx
@@ -1715,3 +1715,45 @@ export const WithNetworkError = () => {
)
}
+
+export const WithCopyAsMarkdown = () => {
+ const ch = createColumnHelper<(typeof data)[number]>()
+ const columns = [
+ ch.column({header: 'Repository', field: 'name', rowHeader: true}),
+ ch.column({
+ header: 'Type',
+ field: 'type',
+ // `renderCell` is NOT used for the export — we'd get a React element.
+ // Instead, declare `getExportValue` to keep the clipboard payload as
+ // plain text.
+ renderCell: row => ,
+ getExportValue: row => uppercase(row.type),
+ }),
+ ch.column({
+ header: 'Updated',
+ field: 'updatedAt',
+ renderCell: row => ,
+ getExportValue: row => new Date(row.updatedAt).toISOString(),
+ }),
+ ]
+ return (
+
+
+ Repositories
+
+
+ The action button serialises the visible rows as a GitHub-flavoured Markdown table and writes them to the
+ clipboard.
+
+
+
+
+
+
+ )
+}
diff --git a/packages/react/src/DataTable/DataTable.tsx b/packages/react/src/DataTable/DataTable.tsx
index ea682fe3b90..da6c201dccb 100644
--- a/packages/react/src/DataTable/DataTable.tsx
+++ b/packages/react/src/DataTable/DataTable.tsx
@@ -1,10 +1,11 @@
-import type React from 'react'
+import React from 'react'
import type {Column} from './column'
import {useTable} from './useTable'
import type {SortDirection} from './sorting'
import type {UniqueRow} from './row'
import type {ObjectPaths} from './utils'
import {Table, TableHead, TableBody, TableRow, TableHeader, TableSortHeader, TableCell} from './Table'
+import {usePublishDataTableSnapshot} from './snapshotContext'
// ----------------------------------------------------------------------------
// DataTable
@@ -100,6 +101,16 @@ function DataTable({
externalSorting,
})
+ // Publish the visible rows so sibling export helpers (e.g.
+ // Table.CopyAsMarkdownButton) can read what the user is actually
+ // looking at — sort/filter/pagination already applied — rather than
+ // the original `data` array. No-op when no `Table.Container` ancestor
+ // provides a snapshot store.
+ usePublishDataTableSnapshot(
+ React.useMemo(() => rows.map(row => row.getValue()), [rows]),
+ columns,
+ )
+
return (
({
...rest
}: TableContainerProps) {
const Component = as || 'div'
+ // Provide a snapshot store so sibling components inside the Container
+ // (e.g. `Table.CopyAsMarkdownButton`) can read the rows currently
+ // displayed by `` after sort/filter/pagination have been
+ // applied. Standalone usage outside a Container still works — the
+ // consumer just passes `rows` / `columns` props explicitly.
+ const snapshotStore = useDataTableSnapshotStore()
return (
-
- {children}
-
+
+
+ {children}
+
+
)
}
diff --git a/packages/react/src/DataTable/__tests__/clipboard.test.tsx b/packages/react/src/DataTable/__tests__/clipboard.test.tsx
new file mode 100644
index 00000000000..f69696883ab
--- /dev/null
+++ b/packages/react/src/DataTable/__tests__/clipboard.test.tsx
@@ -0,0 +1,330 @@
+import {describe, expect, it, test, vi, beforeEach, afterEach} from 'vitest'
+import userEvent from '@testing-library/user-event'
+import {render, screen, waitFor} from '@testing-library/react'
+import {DataTable, Table} from '../../DataTable'
+import type {Column} from '../column'
+import {createColumnHelper} from '../column'
+import {escapeMarkdownCell, getExportableCellValue, rowsToMarkdown, writeTextToClipboard} from '../clipboard'
+import type {UniqueRow} from '../row'
+
+type Repo = {id: number; name: string; type: 'public' | 'private'; tags: string[]}
+
+const repos: Repo[] = [
+ {id: 1, name: 'github', type: 'public', tags: ['core']},
+ {id: 2, name: 'enterprise-security', type: 'private', tags: ['security', 'audit']},
+]
+
+function buildColumns(): Array> {
+ const ch = createColumnHelper()
+ return [ch.column({header: 'Name', field: 'name'}), ch.column({header: 'Type', field: 'type'})]
+}
+
+describe('Markdown clipboard export', () => {
+ describe('escapeMarkdownCell — security model', () => {
+ test.each([
+ ['pipe characters are backslash-escaped', 'a|b|c', 'a\\|b\\|c'],
+ ['backslashes are doubled first so escaped pipes survive', 'a\\b', 'a\\\\b'],
+ ['LF newlines collapse to a single space', 'line1\nline2', 'line1 line2'],
+ ['CRLF newlines collapse to a single space', 'line1\r\nline2', 'line1 line2'],
+ ['lone CR collapses to a single space', 'line1\rline2', 'line1 line2'],
+ ['null bytes are stripped', 'a\u0000b', 'ab'],
+ ['ASCII control characters are stripped', 'a\u0001\u0002\u001fb', 'ab'],
+ ['DEL is stripped', 'a\u007fb', 'ab'],
+ ['C1 control range is stripped', 'a\u0080\u009fb', 'ab'],
+ ['leading and trailing whitespace is trimmed', ' hello ', 'hello'],
+ ['the empty string is preserved', '', ''],
+ ])('%s', (_label, input, expected) => {
+ expect(escapeMarkdownCell(input)).toBe(expected)
+ })
+
+ it('strips TAB as part of the control-char policy (consistent for table cells)', () => {
+ expect(escapeMarkdownCell('a\tb')).toBe('ab')
+ })
+
+ it('backslash-escapes angle brackets so raw HTML is rendered as literal text', () => {
+ // The single most important hardening: a clipboard payload pasted
+ // into a GitHub comment or other GFM-compatible renderer will NOT
+ // execute or render the embedded HTML.
+ // eslint-disable-next-line github/unescaped-html-literal
+ const imgInput = ''
+ expect(escapeMarkdownCell(imgInput)).toBe('\\')
+ // eslint-disable-next-line github/unescaped-html-literal
+ const scriptInput = ''
+ expect(escapeMarkdownCell(scriptInput)).toBe('\\')
+ })
+
+ it('does not introduce HTML or break out into Markdown structures', () => {
+ // Crafted input that *would* be a table breakout in naive
+ // implementations: pipes, newlines, backticks, brackets, asterisks.
+ const malicious = '| evil |\n| --- |\n| `cmd` | [link](javascript:alert(1)) | **bold** |'
+ const escaped = escapeMarkdownCell(malicious)
+ // Pipes are escaped.
+ expect(escaped).not.toMatch(/(^|[^\\])\|/)
+ // No newlines remain.
+ expect(escaped).not.toMatch(/[\r\n]/)
+ // Backticks, brackets, asterisks are intentionally NOT escaped — they
+ // can't break the table layout. The cell renders as styled text in
+ // the consumer's Markdown renderer, which is acceptable. The contract
+ // is "cannot break out of the cell" not "renders as plain text".
+ expect(escaped).toContain('`cmd`')
+ })
+ })
+
+ describe('getExportableCellValue', () => {
+ it('prefers `column.getExportValue` when provided', () => {
+ const col: Column = {header: 'Name', field: 'name', getExportValue: row => `<<${row.name}>>`}
+ expect(getExportableCellValue(repos[0], col)).toBe('<>')
+ })
+
+ it('falls back to the field value when getExportValue is omitted', () => {
+ const col: Column = {header: 'Name', field: 'name'}
+ expect(getExportableCellValue(repos[0], col)).toBe('github')
+ })
+
+ it('joins arrays with ", "', () => {
+ const col: Column = {header: 'Tags', field: 'tags'}
+ expect(getExportableCellValue(repos[1], col)).toBe('security, audit')
+ })
+
+ it('JSON-stringifies plain objects', () => {
+ type T = {id: number; meta: {kind: string}}
+ const col: Column = {header: 'Meta', field: 'meta'}
+ expect(getExportableCellValue({id: 1, meta: {kind: 'a'}}, col)).toBe('{"kind":"a"}')
+ })
+
+ it('returns empty string for null / undefined', () => {
+ type T = UniqueRow & {value?: string}
+ const col: Column = {header: 'Value', field: 'value'}
+ expect(getExportableCellValue({id: 1}, col)).toBe('')
+ })
+
+ it('does NOT call renderCell — keeps React output out of the export', () => {
+ const renderCell = vi.fn(() => null)
+ const col: Column = {header: 'Name', field: 'name', renderCell}
+ expect(getExportableCellValue(repos[0], col)).toBe('github')
+ expect(renderCell).not.toHaveBeenCalled()
+ })
+
+ it('handles dotted field paths', () => {
+ type T = UniqueRow & {nested: {value: string}}
+ const col: Column = {header: 'Nested', field: 'nested.value'} as Column
+ expect(getExportableCellValue({id: 1, nested: {value: 'deep'}}, col)).toBe('deep')
+ })
+ })
+
+ describe('rowsToMarkdown', () => {
+ it('produces a well-formed GitHub-flavoured Markdown table', () => {
+ const md = rowsToMarkdown(repos, buildColumns())
+ expect(md).toBe(
+ ['| Name | Type |', '| --- | --- |', '| github | public |', '| enterprise-security | private |'].join('\n'),
+ )
+ })
+
+ it('escapes pipes and newlines in cell content', () => {
+ const data: Array = [{id: 1, note: 'a|b\nc'}]
+ const ch = createColumnHelper<(typeof data)[number]>()
+ const cols = [ch.column({header: 'Note', field: 'note'})]
+ const md = rowsToMarkdown(data, cols)
+ expect(md.split('\n')[2]).toBe('| a\\|b c |')
+ })
+
+ it('escapes pipes in the column header label', () => {
+ const data: Array = [{id: 1, value: 'x'}]
+ const ch = createColumnHelper<(typeof data)[number]>()
+ const cols = [ch.column({header: 'Pipe | header', field: 'value'})]
+ const md = rowsToMarkdown(data, cols)
+ expect(md.split('\n')[0]).toBe('| Pipe \\| header |')
+ })
+
+ it('uses the column id when the header is a function', () => {
+ const data: Array = [{id: 1, value: 'x'}]
+ const ch = createColumnHelper<(typeof data)[number]>()
+ const cols = [ch.column({id: 'fancy', header: () => null, field: 'value'})]
+ const md = rowsToMarkdown(data, cols)
+ expect(md.split('\n')[0]).toBe('| fancy |')
+ })
+
+ it('returns empty string when there are no columns', () => {
+ expect(rowsToMarkdown(repos, [])).toBe('')
+ })
+
+ it('still produces header and separator rows for empty data', () => {
+ const md = rowsToMarkdown([], buildColumns())
+ expect(md).toBe(['| Name | Type |', '| --- | --- |'].join('\n'))
+ })
+ })
+
+ describe('writeTextToClipboard', () => {
+ let originalClipboard: Clipboard | undefined
+
+ beforeEach(() => {
+ originalClipboard = navigator.clipboard
+ })
+
+ afterEach(() => {
+ if (originalClipboard !== undefined) {
+ Object.defineProperty(navigator, 'clipboard', {value: originalClipboard, configurable: true})
+ }
+ })
+
+ it('uses the Clipboard API when available and resolves to true', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+ await expect(writeTextToClipboard('hello')).resolves.toBe(true)
+ expect(writeText).toHaveBeenCalledWith('hello')
+ })
+
+ it('falls back to execCommand when the Clipboard API throws', async () => {
+ const writeText = vi.fn().mockRejectedValue(new Error('not allowed'))
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+ const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true)
+ await expect(writeTextToClipboard('hello')).resolves.toBe(true)
+ expect(execCommand).toHaveBeenCalledWith('copy')
+ execCommand.mockRestore()
+ })
+ })
+
+ describe('Table.CopyAsMarkdownButton (component)', () => {
+ let originalClipboard: Clipboard | undefined
+
+ beforeEach(() => {
+ originalClipboard = navigator.clipboard
+ })
+
+ afterEach(() => {
+ if (originalClipboard !== undefined) {
+ Object.defineProperty(navigator, 'clipboard', {value: originalClipboard, configurable: true})
+ }
+ })
+
+ it('renders a button with the default label', () => {
+ render()
+ expect(screen.getByRole('button', {name: /copy as markdown/i})).toBeInTheDocument()
+ })
+
+ it('writes the serialised markdown to the clipboard on click', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+ render()
+ await user.click(screen.getByRole('button'))
+ expect(writeText).toHaveBeenCalledWith(rowsToMarkdown(repos, buildColumns()))
+ })
+
+ it('shows the success label transiently after copying', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+ render()
+ await user.click(screen.getByRole('button'))
+ await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('data-state', 'copied'))
+ await waitFor(() => expect(screen.getByRole('button')).toHaveAttribute('data-state', 'idle'), {timeout: 500})
+ })
+
+ it('calls onCopy with the markdown payload and success boolean', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+ const onCopy = vi.fn()
+ render()
+ await user.click(screen.getByRole('button'))
+ const expected = rowsToMarkdown(repos, buildColumns())
+ expect(onCopy).toHaveBeenCalledWith(expected, true)
+ })
+
+ it('respects a custom button label via children', () => {
+ render(
+
+ Export as MD
+ ,
+ )
+ expect(screen.getByRole('button', {name: /export as md/i})).toBeInTheDocument()
+ })
+
+ // Lint requires every component test to verify the className prop is
+ // honoured. Manual test (instead of `implementsClassName`) because the
+ // helper renders `` with no other
+ // props, but this component needs `rows`/`columns` to render anything
+ // meaningful. The behaviour is identical: forwarded to the rendered
+ // button element via clsx.
+ it('implementsClassName: forwards the className prop to the rendered button', () => {
+ const {container} = render(
+ ,
+ )
+ expect(container.querySelector('.test-class')).not.toBeNull()
+ })
+ })
+
+ describe('Table.CopyAsMarkdownButton (context source)', () => {
+ it('reads rows and columns from a sibling DataTable via Table.Container context', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+
+ render(
+
+
+
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', {name: /copy as markdown/i}))
+ // The clipboard payload matches what DataTable is displaying.
+ expect(writeText).toHaveBeenCalledWith(rowsToMarkdown(repos, buildColumns()))
+ })
+
+ it('reflects the sorted order when DataTable applies sorting', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+
+ const ch = createColumnHelper()
+ const cols = [ch.column({header: 'Name', field: 'name', sortBy: 'alphanumeric'})]
+
+ render(
+
+
+
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', {name: /copy as markdown/i}))
+ // Sorted DESC by name: `github` then `enterprise-security`.
+ const expectedSorted: Repo[] = [
+ {id: 1, name: 'github', type: 'public', tags: ['core']},
+ {id: 2, name: 'enterprise-security', type: 'private', tags: ['security', 'audit']},
+ ]
+ expect(writeText).toHaveBeenCalledWith(rowsToMarkdown(expectedSorted, cols))
+ })
+
+ it('explicit `rows` prop overrides the context snapshot', async () => {
+ const user = userEvent.setup()
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ Object.defineProperty(navigator, 'clipboard', {value: {writeText}, configurable: true})
+
+ const overrideRows: Repo[] = [{id: 99, name: 'override', type: 'public', tags: []}]
+
+ render(
+
+
+
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', {name: /copy as markdown/i}))
+ expect(writeText).toHaveBeenCalledWith(rowsToMarkdown(overrideRows, buildColumns()))
+ })
+ })
+})
diff --git a/packages/react/src/DataTable/clipboard.ts b/packages/react/src/DataTable/clipboard.ts
new file mode 100644
index 00000000000..eb344c40c43
--- /dev/null
+++ b/packages/react/src/DataTable/clipboard.ts
@@ -0,0 +1,157 @@
+import type {Column} from './column'
+import type {UniqueRow} from './row'
+
+// ---------------------------------------------------------------------------
+// Markdown export
+// ---------------------------------------------------------------------------
+//
+// Security model: the contents copied to the clipboard are always
+// `text/plain`. Cell values are projected to strings using the following
+// resolution order — never `renderCell`, which can emit React nodes:
+// 1. `column.getExportValue(row)` if provided
+// 2. The value at `column.field` (recursively stringified for arrays /
+// JSON.stringify for objects)
+// 3. Empty string
+//
+// Each projected string is then escaped so it cannot break out of a Markdown
+// table cell:
+// - `\` → `\\`
+// - `|` → `\|`
+// - CR/LF → single space
+// - ASCII / Unicode control characters → removed
+//
+// Output is never HTML. There is no fall-through to inline-HTML escape
+// hatches like ` ` (which would let cell content emit markup if a
+// downstream renderer chose to honour it).
+
+// Intentional regex that matches ASCII and C1 control characters; the
+// no-control-regex lint rule is the wrong tool for this since stripping
+// these is exactly the goal.
+// eslint-disable-next-line no-control-regex
+const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g
+
+export function escapeMarkdownCell(value: string): string {
+ return (
+ value
+ .replace(/\\/g, '\\\\')
+ .replace(/\|/g, '\\|')
+ // Backslash-escape angle brackets so a cell containing raw HTML
+ // (e.g. ``) is rendered as literal text by
+ // CommonMark-compatible renderers instead of being interpreted as
+ // an HTML tag. This is the single most important safety hardening
+ // for the markdown export — the clipboard payload is plain text but
+ // gets pasted into places like GitHub comments that DO render raw
+ // HTML embedded in markdown.
+ .replace(//g, '\\>')
+ .replace(/\r\n|\r|\n/g, ' ')
+ .replace(CONTROL_CHARACTERS, '')
+ .trim()
+ )
+}
+
+/**
+ * Resolve a cell's exportable string. Intentionally bypasses `renderCell`
+ * to keep React/JSX/HTML out of the clipboard payload.
+ */
+export function getExportableCellValue(row: Data, column: Column): string {
+ if (column.getExportValue) {
+ // Cast through `unknown` because the typed return is `string` but the
+ // runtime contract should remain forgiving (some callers project from
+ // unknown sources and may legitimately return `null`/`undefined`).
+ return String((column.getExportValue(row) as unknown) ?? '')
+ }
+ if (column.field === undefined) return ''
+ // We intentionally re-implement field traversal here (rather than import
+ // `useTable`'s `get`) to keep this file dependency-light.
+ const path = String(column.field).split('.')
+ let value: unknown = row
+ for (const key of path) {
+ if (value === null || value === undefined) break
+ value = (value as Record)[key]
+ }
+ return stringifyForExport(value)
+}
+
+function stringifyForExport(value: unknown): string {
+ if (value === null || value === undefined) return ''
+ if (Array.isArray(value)) {
+ return value.map(v => stringifyForExport(v)).join(', ')
+ }
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value)
+ } catch {
+ return ''
+ }
+ }
+ return String(value)
+}
+
+/**
+ * Serialize a set of rows as a GitHub-flavoured Markdown table. Columns are
+ * rendered in the order they're provided; the column header is the literal
+ * `header` string (callable `header` functions fall back to the column id).
+ */
+export function rowsToMarkdown(
+ rows: ReadonlyArray,
+ columns: ReadonlyArray>,
+): string {
+ if (columns.length === 0) return ''
+ const headerLabels = columns.map(column => (typeof column.header === 'string' ? column.header : (column.id ?? '')))
+ const headerLine = `| ${headerLabels.map(label => escapeMarkdownCell(label)).join(' | ')} |`
+ const separatorLine = `| ${columns.map(() => '---').join(' | ')} |`
+ const bodyLines = rows.map(
+ row => `| ${columns.map(column => escapeMarkdownCell(getExportableCellValue(row, column))).join(' | ')} |`,
+ )
+ return [headerLine, separatorLine, ...bodyLines].join('\n')
+}
+
+/**
+ * Copy text to the system clipboard. Returns a promise that resolves to
+ * `true` on success. Prefers the asynchronous Clipboard API; falls back to
+ * the synchronous `document.execCommand('copy')` path for non-secure
+ * contexts (HTTP, sandboxed iframes) where `navigator.clipboard` is
+ * undefined.
+ */
+export async function writeTextToClipboard(text: string): Promise {
+ if (
+ typeof navigator !== 'undefined' &&
+ // navigator.clipboard is undefined in non-secure contexts even though
+ // the typings claim it's always defined; the runtime check is a real
+ // guard. The eslint disable is necessary because the typed value is
+ // not nullable.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ navigator.clipboard &&
+ typeof navigator.clipboard.writeText === 'function'
+ ) {
+ try {
+ await navigator.clipboard.writeText(text)
+ return true
+ } catch {
+ // Some browsers reject in non-focused contexts; fall through to the
+ // execCommand fallback rather than surfacing the error.
+ }
+ }
+
+ if (typeof document === 'undefined') {
+ return false
+ }
+
+ const textarea = document.createElement('textarea')
+ textarea.value = text
+ textarea.setAttribute('readonly', '')
+ textarea.style.position = 'fixed'
+ textarea.style.top = '-1000px'
+ textarea.style.left = '-1000px'
+ textarea.style.opacity = '0'
+ document.body.appendChild(textarea)
+ try {
+ textarea.select()
+ return document.execCommand('copy')
+ } catch {
+ return false
+ } finally {
+ document.body.removeChild(textarea)
+ }
+}
diff --git a/packages/react/src/DataTable/column.ts b/packages/react/src/DataTable/column.ts
index f476f461c33..af1690a4dcd 100644
--- a/packages/react/src/DataTable/column.ts
+++ b/packages/react/src/DataTable/column.ts
@@ -58,6 +58,14 @@ export interface Column {
*/
sortBy?: boolean | SortStrategy | CustomSortStrategy
+ /**
+ * Returns the string representation used by clipboard exports
+ * (`Table.CopyAsMarkdownButton`). Intentionally separate from `renderCell`
+ * so consumers can keep rich React output (icons, links, labels) out of
+ * the plain-text payload.
+ */
+ getExportValue?: (data: Data) => string
+
/**
* Controls the width of the column.
* - 'grow': Stretch to fill available space, and min width is the width of the widest cell in the column
diff --git a/packages/react/src/DataTable/index.ts b/packages/react/src/DataTable/index.ts
index ce7f9514a8e..3fd0dfa9fbd 100644
--- a/packages/react/src/DataTable/index.ts
+++ b/packages/react/src/DataTable/index.ts
@@ -1,5 +1,6 @@
import {DataTable} from './DataTable'
import {ErrorDialog} from './ErrorDialog'
+import {CopyAsMarkdownButton} from './CopyAsMarkdownButton'
import {
Table as TableImpl,
TableHead,
@@ -34,6 +35,7 @@ const Table: typeof TableImpl &
CellPlaceholder: typeof TableCellPlaceholder
Pagination: typeof Pagination
ErrorDialog: typeof ErrorDialog
+ CopyAsMarkdownButton: typeof CopyAsMarkdownButton
} = Object.assign(TableImpl, {
Container: TableContainer,
Title: TableTitle,
@@ -49,6 +51,7 @@ const Table: typeof TableImpl &
CellPlaceholder: TableCellPlaceholder,
Pagination,
ErrorDialog,
+ CopyAsMarkdownButton,
})
Table.__SLOT__ = Symbol('Table')
@@ -72,3 +75,5 @@ export {createColumnHelper} from './column'
export type {Column, CellAlignment, ColumnWidth} from './column'
export type {UniqueRow} from './row'
export type {ObjectPaths} from './utils'
+export type {CopyAsMarkdownButtonProps} from './CopyAsMarkdownButton'
+export {escapeMarkdownCell, rowsToMarkdown, writeTextToClipboard} from './clipboard'
diff --git a/packages/react/src/DataTable/snapshotContext.tsx b/packages/react/src/DataTable/snapshotContext.tsx
new file mode 100644
index 00000000000..44cdd26ae24
--- /dev/null
+++ b/packages/react/src/DataTable/snapshotContext.tsx
@@ -0,0 +1,115 @@
+import React, {createContext, useCallback, useContext, useMemo, useRef, useSyncExternalStore} from 'react'
+import type {Column} from './column'
+import type {UniqueRow} from './row'
+
+// ---------------------------------------------------------------------------
+// DataTableSnapshotContext
+//
+// Lets sibling components (`Table.CopyAsMarkdownButton`, future export
+// helpers, etc.) consume the rows currently displayed by a sibling
+// `` — *after* the table's internal sort / filter / pagination
+// have been applied. Without this, a button rendered inside `Table.Actions`
+// could only see the raw `data` prop and would copy the original, unsorted
+// order even though the user is looking at sorted rows.
+//
+// Architecture:
+// - `Table.Container` mounts a small subscribe/publish store and provides
+// it via context.
+// - `` publishes its visible rows whenever they change.
+// - Consumers read the latest snapshot via `useSyncExternalStore` so they
+// re-render in sync with the publisher.
+//
+// Standalone usage (no `Table.Container` ancestor) is still supported —
+// consumers pass `rows` and `columns` props directly.
+// ---------------------------------------------------------------------------
+
+export type DataTableSnapshot = {
+ rows: ReadonlyArray
+ columns: ReadonlyArray>
+}
+
+type Listener = () => void
+
+export type DataTableSnapshotStore = {
+ subscribe: (listener: Listener) => () => void
+ getSnapshot: () => DataTableSnapshot | null
+ setSnapshot: (next: DataTableSnapshot | null) => void
+}
+
+const DataTableSnapshotContext = createContext(null)
+
+/**
+ * Create a snapshot store. Returns a stable reference suitable for passing
+ * straight to ``.
+ */
+export function useDataTableSnapshotStore(): DataTableSnapshotStore {
+ const snapshotRef = useRef(null)
+ const listenersRef = useRef>(new Set())
+
+ const subscribe = useCallback(listener => {
+ listenersRef.current.add(listener)
+ return () => {
+ listenersRef.current.delete(listener)
+ }
+ }, [])
+
+ const getSnapshot = useCallback(() => snapshotRef.current, [])
+
+ const setSnapshot = useCallback(next => {
+ snapshotRef.current = next
+ for (const listener of listenersRef.current) {
+ listener()
+ }
+ }, [])
+
+ return useMemo(() => ({subscribe, getSnapshot, setSnapshot}), [subscribe, getSnapshot, setSnapshot])
+}
+
+/**
+ * Provider element. Re-exports the underlying context's Provider so the
+ * inferred children prop type is correct.
+ */
+export const DataTableSnapshotProvider = DataTableSnapshotContext.Provider
+
+/**
+ * Read the snapshot store from context. Returns `null` when no ancestor
+ * `Table.Container` is providing one.
+ */
+export function useDataTableSnapshotStoreFromContext(): DataTableSnapshotStore | null {
+ return useContext(DataTableSnapshotContext)
+}
+
+/**
+ * Subscribe to the snapshot store and re-render on changes. Safe to call
+ * even when no store is present — returns `null` in that case.
+ */
+export function useDataTableSnapshot(): DataTableSnapshot | null {
+ const store = useDataTableSnapshotStoreFromContext()
+ return useSyncExternalStore(
+ store?.subscribe ?? noopSubscribe,
+ () => (store?.getSnapshot() ?? null) as DataTableSnapshot | null,
+ () => null,
+ )
+}
+
+const noopSubscribe = () => () => {}
+
+/**
+ * Helper for `` to publish its visible rows whenever they
+ * change. The publish runs in an effect to avoid setState-during-render.
+ */
+export function usePublishDataTableSnapshot(
+ rows: ReadonlyArray,
+ columns: ReadonlyArray>,
+): void {
+ const store = useDataTableSnapshotStoreFromContext()
+ React.useEffect(() => {
+ if (!store) return
+ store.setSnapshot({rows, columns} as unknown as DataTableSnapshot)
+ return () => {
+ // Clear on unmount so a stale snapshot from a removed table doesn't
+ // leak to siblings that mount later inside the same Container.
+ store.setSnapshot(null)
+ }
+ }, [store, rows, columns])
+}