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
13 changes: 13 additions & 0 deletions .changeset/datatable-copy-as-markdown.md
Original file line number Diff line number Diff line change
@@ -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 `<Table.Actions>`. 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.
Original file line number Diff line number Diff line change
@@ -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;
}
141 changes: 141 additions & 0 deletions packages/react/src/DataTable/CopyAsMarkdownButton.tsx
Original file line number Diff line number Diff line change
@@ -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<Data extends UniqueRow> = {
/**
* Rows to serialise. When the button is rendered inside a
* `<Table.Container>` that contains a sibling `<DataTable>`, 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<Data>

/**
* Columns to serialise. Like `rows`, the button reads columns from the
* sibling `<DataTable>` context when available. Provide this prop
* explicitly when using the button standalone.
*/
columns?: ReadonlyArray<Column<Data>>

/** 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
* `<DataTable>` as a GitHub-flavoured Markdown table and writes the
* result to the system clipboard. Designed for composition inside
* `<Table.Actions>`:
*
* ```tsx
* <Table.Container>
* <Table.Title id="repositories">Repositories</Table.Title>
* <Table.Actions>
* <Table.CopyAsMarkdownButton />
* </Table.Actions>
* <DataTable data={repos} columns={columns} />
* </Table.Container>
* ```
*
* The button reads the rows currently displayed by the sibling
* `<DataTable>` 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
* `<Table.Container>` 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<Data extends UniqueRow>({
rows: rowsProp,
columns: columnsProp,
children = 'Copy as Markdown',
copiedLabel = 'Copied',
errorLabel = 'Copy failed',
successDurationMs = 2000,
onCopy,
className,
}: CopyAsMarkdownButtonProps<Data>) {
const snapshot = useDataTableSnapshot<Data>()
// 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<CopyState>('idle')
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

const handleClick = useCallback(async () => {
const markdown = rowsToMarkdown(rows, columns)
const ok = await writeTextToClipboard(markdown)
Comment on lines +104 to +106
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 (
<Button
className={clsx(classes.CopyAsMarkdownButton, className)}
data-component="Table.CopyAsMarkdownButton"
data-state={state}
leadingVisual={icon}
onClick={handleClick}
type="button"
>
{label}
</Button>
)
}
54 changes: 54 additions & 0 deletions packages/react/src/DataTable/DataTable.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
},
{
"id": "experimental-components-datatable-features--with-pagination"
},
{
"id": "experimental-components-datatable-features--with-copy-as-markdown"
}
],
"importPath": "@primer/react/experimental",
Expand Down Expand Up @@ -353,6 +356,52 @@
}
]
},
{
"name": "Table.CopyAsMarkdownButton",
"props": [
{
"name": "rows",
"type": "ReadonlyArray<Data>",
"required": true,
"description": "Rows to serialise. Usually the same array passed to `<DataTable>`."
},
{
"name": "columns",
"type": "ReadonlyArray<Column<Data>>",
"required": true,
"description": "Columns to serialise. Usually the same array passed to `<DataTable>`."
},
{
"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": [
Expand Down Expand Up @@ -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'",
Expand Down
42 changes: 42 additions & 0 deletions packages/react/src/DataTable/DataTable.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1715,3 +1715,45 @@ export const WithNetworkError = () => {
</Table.Container>
)
}

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 => <Label>{uppercase(row.type)}</Label>,
getExportValue: row => uppercase(row.type),
}),
ch.column({
header: 'Updated',
field: 'updatedAt',
renderCell: row => <RelativeTime date={new Date(row.updatedAt)} />,
getExportValue: row => new Date(row.updatedAt).toISOString(),
}),
]
return (
<Table.Container>
<Table.Title as="h2" id="repositories">
Repositories
</Table.Title>
<Table.Subtitle as="p" id="repositories-subtitle">
The action button serialises the visible rows as a GitHub-flavoured Markdown table and writes them to the
clipboard.
</Table.Subtitle>
<Table.Actions>
<Table.CopyAsMarkdownButton rows={data} columns={columns} />
</Table.Actions>
<DataTable
aria-labelledby="repositories"
aria-describedby="repositories-subtitle"
data={data}
columns={columns}
/>
</Table.Container>
)
}
13 changes: 12 additions & 1 deletion packages/react/src/DataTable/DataTable.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -100,6 +101,16 @@ function DataTable<Data extends UniqueRow>({
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 (
<Table
aria-labelledby={labelledby}
Expand Down
16 changes: 13 additions & 3 deletions packages/react/src/DataTable/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {Button} from '../internal/components/ButtonReset'
import classes from './Table.module.css'
import type {PolymorphicProps} from '../utils/modern-polymorphic'

import {DataTableSnapshotProvider, useDataTableSnapshotStore} from './snapshotContext'

// ----------------------------------------------------------------------------
// Table
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -250,10 +252,18 @@ function TableContainer<As extends React.ElementType = 'div'>({
...rest
}: TableContainerProps<As>) {
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 `<DataTable>` 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 (
<Component {...rest} className={clsx(className, classes.TableContainer)} data-component="Table.Container">
{children}
</Component>
<DataTableSnapshotProvider value={snapshotStore}>
<Component {...rest} className={clsx(className, classes.TableContainer)} data-component="Table.Container">
{children}
</Component>
</DataTableSnapshotProvider>
)
}

Expand Down
Loading
Loading