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('\\alert(1)\\') + }) + + 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]) +}