Build a TableEditor Component
This guide walks you through building a
TableEditor
component for XMLUI, using the Tiptap editor as a foundation. It will provide a visual and Markdown-friendly way to create and edit tables for use in XMLUI-powered documentation.If you operate in the XMLUI repo you can test your work live. Follow the instructions indev-docs/next/generating-component-reference.md
to build the XMLUI docs site, then load localhost:5173. When you edit.tsx
files they will automatically recompile, so you can iterate rapidly as you develop your component. And you can add a test page to the site in order to use your evolving component
Step 1: Create the folder.
mkdir -p xmlui/src/components/TableEditor
Step 2: Create a minimal renderer
Add
TableEditor.tsx
in that folder.export function TableEditor() {
return (
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
);
}
export const editorComponentRenderer = {
type: "TableEditor",
renderer: (props: any) => <TableEditor {...props} />
};
Register it in
ComponentProvider.tsx
.import { editorComponentRenderer as TableEditorRenderer }
from "./components/TableEditor/TableEditor";
if (process.env.VITE_USED_COMPONENTS_TableEditor !== "false") {
this.registerCoreComponent(TableEditorRenderer);
}
You can now use this XMLUI markup.
<App>
<TableEditor />
</App>

We are just rendering an HTML table at this point, with no XMLUI theme support and no editing capability.
Step 3: Make the table editable with Tiptap
Install Tiptap dependencies.
npm install @tiptap/react @tiptap/starter-kit \
@tiptap/extension-table @tiptap/extension-table-row \
@tiptap/extension-table-cell @tiptap/extension-table-header
Update
TableEditor.tsx
.TableEditor.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
export function TableEditor() {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
return <EditorContent editor={editor} />;
}
export const editorComponentRenderer = {
type: "TableEditor",
renderer: (props: any) => <TableEditor {...props} />
};
Use the same XMLUI markup.
<App>
<TableEditor />
</App>
You can now edit the cells in the table.

Step 4: Add an Insert Row button
TableEditor.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
export function TableEditor() {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
return (
<div>
<button
onClick={() => editor && editor.commands.addRowAfter()}
disabled={!editor}
>
Insert Row
</button>
<EditorContent editor={editor} />
</div>
);
}
export const editorComponentRenderer = {
type: "TableEditor",
renderer: (props: any) => <TableEditor {...props} />
};
Use the same XMLUI markup.
<App>
<TableEditor />
</App>
You can now insert rows.

Step 5: Use XMLUI's Button
Import and use
Button
.TableEditor.tsx
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { Button } from "../Button/ButtonNative";
export function TableEditor() {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
return (
<div>
<Button
onClick={() => editor && editor.commands.addRowAfter()}
disabled={!editor}
>
Insert Row
</Button>
<EditorContent editor={editor} />
</div>
);
}
export const editorComponentRenderer = {
type: "TableEditor",
renderer: (props: any) => <TableEditor {...props} />
};
You now have a proper themed XMLUI button.

Step 6: Show the HTML
Tiptap works natively with HTML, not Markdown. We'll eventually show Markdown but first let's add a method to show the HTML.
To keep our code modular, we'll separate the editor's rendering logic into a minimal presentational component. This makes it easy to reuse the editor UI in different contexts.
Create
TableEditorNative.tsx
alongside TableEditor.tsx
.TableEditorNative.tsx
import { EditorContent } from "@tiptap/react";
export function TableEditorNative({ editor }: { editor: any }) {
return <EditorContent editor={editor} />;
}
This component simply renders the Tiptap editor UI for a given editor instance.
Now, let's expose a method to get the current HTML from the editor. Add this to
TableEditor.tsx
.TableEditorNative.tsx
React.useEffect(() => {
if (registerComponentApi && editor) {
registerComponentApi({
getHtmlSource: () => editor.getHTML(),
});
}
}, [registerComponentApi, editor]);
Now we can show the HTML using this markup.
<App>
<TableEditor id="tableEditor" />
<Text> { tableEditor.getHtmlSource() } </Text>
</App>

Step 7: Show the Markdown
Tiptap does not provide an HTML-to-Markdown converter. We'll start with a basic one called
turndown
. First install it.npm install turndown
Then update
TableEditor.tsx
to use it.TableEditor.tsx
import React from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { Button } from "../Button/ButtonNative";
import TurndownService from "turndown";
export function TableEditor({ registerComponentApi }: { registerComponentApi?: (api: any) => void }) {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
React.useEffect(() => {
if (registerComponentApi && editor) {
const turndownService = new TurndownService();
turndownService.addRule('table', {
filter: 'table',
replacement: function (content, node) {
let rows = [];
for (let row of node.querySelectorAll('tr')) {
let cells = Array.from(row.children).map(cell => cell.textContent.trim());
rows.push('| ' + cells.join(' | ') + ' |');
}
if (rows.length > 1) {
// Add a separator after the header row
const headerSep = '| ' + rows[0].split('|').slice(1, -1).map(() => '---').join(' | ') + ' |';
rows.splice(1, 0, headerSep);
}
return rows.join('\n') + '\n';
}
});
registerComponentApi({
getHtmlSource: () => editor.getHTML(),
getMarkdownSource: () => turndownService.turndown(editor.getHTML()),
});
}
}, [registerComponentApi, editor]);
return (
<div>
<Button
onClick={() => editor && editor.commands.addRowAfter()}
disabled={!editor}
>
Insert Row
</Button>
<EditorContent editor={editor} />
</div>
);
}
export const editorComponentRenderer2 = {
type: "TableEditor",
renderer: ({ registerComponentApi, ...props }: any) => (
<TableEditor {...props} registerComponentApi={registerComponentApi} />
),
};
Now we can show the Markdown.
<App>
<TableEditor id="tableEditor" />
<Text variant="codefence" preserveLinebreaks="{true}">
{ tableEditor.getMarkdownSource() }
</Text>
</App>

Rendered in XMLUI Markdown:

Step 8: Add controls
We can improve the TableEditor by adding more table editing controls like Insert Column, Delete Row, and Delete Column. But where should these controls live?
- TableEditorNative.tsx (lowest level)
- Pros: Closest to the editor instance
- Cons: Not idiomatic for XMLUI; the "native" component should be minimal
- TableEditor.tsx (middle level)
- Pros: Provides a good default "batteries included" experience; keeps the native component minimal; still allows advanced users to build custom controls if needed
- Cons: Slightly less flexible than fully user-defined controls
- User-defined component (highest level)
- Pros: Maximum flexibility for users
- Cons: Every user has to reimplement the same basic controls; not user-friendly
We chose to implement the controls in
TableEditor.tsx
because it provides the best balance of usability and flexibility. Users get a working table editor with sensible controls out of the box, while advanced users can still build custom UIs using the exposed API if needed.TableEditorNative.tsx
import React from "react";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { Button } from "../Button/ButtonNative";
import { Stack } from "../Stack/StackNative";
import TurndownService from "turndown";
import { TableEditorNative } from "./TableEditorNative";
export function TableEditor({ registerComponentApi }: { registerComponentApi?: (api: any) => void }) {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
React.useEffect(() => {
if (registerComponentApi && editor) {
const turndownService = new TurndownService();
turndownService.addRule('table', {
filter: 'table',
replacement: function (content, node) {
let rows = [];
for (let row of node.querySelectorAll('tr')) {
let cells = Array.from(row.children).map(cell => (cell as HTMLElement).textContent?.trim() ?? "");
rows.push('| ' + cells.join(' | ') + ' |');
}
if (rows.length > 1) {
// Add a separator after the header row
const headerSep = '| ' + rows[0].split('|').slice(1, -1).map(() => '---').join(' | ') + ' |';
rows.splice(1, 0, headerSep);
}
return rows.join('\n') + '\n';
}
});
registerComponentApi({
getHtmlSource: () => editor.getHTML(),
getMarkdownSource: () => turndownService.turndown(editor.getHTML()),
});
}
}, [registerComponentApi, editor]);
return (
<>
<Stack orientation="horizontal">
<Button onClick={() => editor && editor.commands.addRowAfter()} disabled={!editor}>
Insert Row
</Button>
<Button onClick={() => editor && editor.commands.deleteRow()} disabled={!editor}>
Delete Row
</Button>
<Button onClick={() => editor && editor.commands.addColumnAfter()} disabled={!editor}>
Insert Column
</Button>
<Button onClick={() => editor && editor.commands.deleteColumn()} disabled={!editor}>
Delete Column
</Button>
</Stack>
<TableEditorNative editor={editor} />
</>
);
}
export const editorComponentRenderer = {
type: "TableEditor",
renderer: ({ registerComponentApi, ...props }: any) => (
<TableEditor {...props} registerComponentApi={registerComponentApi} />
),
};
We import and use
Stack
from ../Stack/StackNative
. Note that HStack
is not available as a native component, so we can't use that shortcut.A component must return a single element. We use a React Fragment (
<>...</>
) to group the buttons into an anonymous container.Now we have this result.
<App>
<TableEditor id="tableEditor" />
<Text variant="codefence" preserveLinebreaks="{true}">
{ tableEditor.getMarkdownSource() }
</Text>
</App>

Step 9: Add custom icons
The table editor buttons currently use only text labels. Let's add custom SVG icons.
<Button
onClick={() => editor && editor.commands.addRowAfter()}
disabled={!editor}
themeColor={themeColor}
variant={variant}
size={size}
orientation="horizontal"
>
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" />
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" />
<line x1="19" y1="6" x2="19" y2="10" />
<line x1="17" y1="8" x2="21" y2="8" />
</svg>
Insert Row
</Button>
We'll repeat this pattern for the other buttons.

Step 10: Theme the buttons
Our TableEditor component currently uses hardcoded button styling. To make TableEditor behave like a proper XMLUI component, we need to add theme support.
Try adding different theme props to your TableEditor:
<TableEditor themeColor="secondary" variant="outlined" size="lg" />
The buttons remain blue and solid. This happens because XMLUI doesn't know which props are valid for custom components. Without proper metadata, theme attributes are filtered out.
XMLUI components need metadata to define their allowed props.
TableEditorNative.tsx
import React from "react";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { Button } from "../Button/ButtonNative";
import { Stack } from "../Stack/StackNative";
import TurndownService from "turndown";
import { TableEditorNative } from "./TableEditorNative";
import { createMetadata } from "../metadata-helpers";
import { createComponentRenderer } from "../../components-core/renderers";
import { buttonThemeMd, buttonVariantMd, sizeMd } from "../abstractions";
export function TableEditor({
registerComponentApi,
themeColor = "primary",
variant = "solid",
size = "sm",
}: {
registerComponentApi?: (api: any) => void;
themeColor?: "primary" | "secondary" | "attention";
variant?: "solid" | "outlined" | "ghost";
size?: "xs" | "sm" | "md" | "lg";
}) {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
React.useEffect(() => {
if (registerComponentApi && editor) {
const turndownService = new TurndownService();
turndownService.addRule('table', {
filter: 'table',
replacement: function (content, node) {
let rows = [];
for (let row of node.querySelectorAll('tr')) {
let cells = Array.from(row.children).map(cell => (cell as HTMLElement).textContent?.trim() ?? "");
rows.push('| ' + cells.join(' | ') + ' |');
}
if (rows.length > 1) {
// Add a separator after the header row
const headerSep = '| ' + rows[0].split('|').slice(1, -1).map(() => '---').join(' | ') + ' |';
rows.splice(1, 0, headerSep);
}
return rows.join('\n') + '\n';
}
});
registerComponentApi({
getHtmlSource: () => editor.getHTML(),
getMarkdownSource: () => turndownService.turndown(editor.getHTML()),
});
}
}, [registerComponentApi, editor]);
return (
<>
<Stack orientation="horizontal">
<Button onClick={() => editor && editor.commands.addRowAfter()} disabled={!editor} themeColor={themeColor} variant={variant} size={size}>
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width="20"
height="16"
style={{ marginRight: 4 }}
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" />
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" />
<line x1="19" y1="6" x2="19" y2="10" />
<line x1="17" y1="8" x2="21" y2="8" />
</svg>
Insert Row
</Button>
<Button onClick={() => editor && editor.commands.deleteRow()} disabled={!editor} themeColor={themeColor} variant={variant} size={size}>
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width="20"
height="16"
style={{ marginRight: 4 }}
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" />
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" />
<line x1="17" y1="8" x2="21" y2="8" />
</svg>
Delete Row
</Button>
<Button onClick={() => editor && editor.commands.addColumnAfter()} disabled={!editor} themeColor={themeColor} variant={variant} size={size}>
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width="24"
height="16"
style={{ marginRight: 4 }}
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="5.5" y1="3.5" x2="5.5" y2="11.5" />
<line x1="9" y1="3.5" x2="9" y2="11.5" />
<line x1="19" y1="6.5" x2="19" y2="9.5" />
<line x1="17.5" y1="8" x2="20.5" y2="8" />
</svg>
Insert Column
</Button>
<Button onClick={() => editor && editor.commands.deleteColumn()} disabled={!editor} themeColor={themeColor} variant={variant} size={size}>
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
width="24"
height="16"
style={{ marginRight: 4 }}
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="5.5" y1="3.5" x2="5.5" y2="11.5" />
<line x1="9" y1="3.5" x2="9" y2="11.5" />
{/* Minus sign for delete */}
<line x1="17" y1="8" x2="21" y2="8" />
</svg>
Delete Column
</Button>
</Stack>
<TableEditorNative editor={editor} />
</>
);
}
export const TableEditorMd = createMetadata({
description:
"`TableEditor` provides an interactive table editing interface with controls for adding and deleting rows and columns. It supports theme customization and exports table data in HTML and Markdown formats.",
status: "experimental",
props: {
themeColor: {
description: "Sets the color scheme for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonThemeMd,
defaultValue: "primary",
},
variant: {
description: "Sets the visual style for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonVariantMd,
defaultValue: "solid",
},
size: {
description: "Sets the size for all editor buttons.",
isRequired: false,
type: "string",
availableValues: sizeMd,
defaultValue: "sm",
},
},
events: {},
});
export const editorComponentRenderer = createComponentRenderer(
"TableEditor",
TableEditorMd,
({ node, extractValue, registerComponentApi }) => (
<TableEditor
themeColor={extractValue.asOptionalString(node.props.themeColor)}
variant={extractValue.asOptionalString(node.props.variant)}
size={extractValue.asOptionalString(node.props.size)}
registerComponentApi={registerComponentApi}
/>
),
);
We add the necessary imports to
TableEditor.tsx
.TableEditor.tsx
import { createMetadata } from "../metadata-helpers";
import { createComponentRenderer } from "../../components-core/renderers";
import { buttonThemeMd, buttonVariantMd, sizeMd } from "../abstractions";
We modify the TableEditor function to accept theme props.
TableEditor.tsx
export function TableEditor({
registerComponentApi,
themeColor = "primary",
variant = "solid",
size = "sm",
}: {
registerComponentApi?: (api: any) => void;
themeColor?: "primary" | "secondary" | "attention";
variant?: "solid" | "outlined" | "ghost";
size?: "xs" | "sm" | "md" | "lg";
}) {
// ... existing logic
}
We forward these props to all Button components.
<Button
onClick={() => editor && editor.commands.addRowAfter()}
disabled={!editor}
themeColor={themeColor}
variant={variant}
size={size}
>
Insert Row
</Button>
We add metadata that defines the allowed props:
TableEditor.tsx
export const TableEditorMd = createMetadata({
description:
"`TableEditor` provides an interactive table editing interface with controls for adding and deleting rows and columns. It supports theme customization and exports table data in HTML and Markdown formats.",
status: "stable",
props: {
themeColor: {
description: "Sets the color scheme for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonThemeMd,
defaultValue: "primary",
},
variant: {
description: "Sets the visual style for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonVariantMd,
defaultValue: "solid",
},
size: {
description: "Sets the size for all editor buttons.",
isRequired: false,
type: "string",
availableValues: sizeMd,
defaultValue: "sm",
},
},
events: {},
});
We replace the simple renderer with a proper one that uses metadata:
TableEditor.tsx
export const editorComponentRenderer = createComponentRenderer(
"TableEditor",
TableEditorMd,
({ node, extractValue, registerComponentApi }) => {
return (
<TableEditor
themeColor={extractValue.asOptionalString(node.props.themeColor)}
variant={extractValue.asOptionalString(node.props.variant)}
size={extractValue.asOptionalString(node.props.size)}
registerComponentApi={registerComponentApi}
/>
);
},
);
Now TableEditor supports full theme customization.
<TableEditor
id="tableEditor"
/>
<TableEditor
id="tableEditor2"
themeColor="primary"
size="lg"
variant="outlined"
/>
<TableEditor
id="tableEditor3"
themeColor="attention"
variant="ghost"
size="sm"
/>

Step 11: Register the icons
Registering icons is optional if you only use them inline within your component, but it is strongly recommended for reusability, maintainability, and accessibility.
Global usage. Registered icons can be referenced anywhere in your app or docs by name (e.g.,
<Icon name="table-insert-row" />
or as a Button's icon
prop).Accessibility. Using the
<Icon />
component or Button's icon
prop helps ensure ARIA attributes and accessible labeling are handled consistently.To make your custom icon usable in XMLUI, wrap your SVG markup in a React component. This allows the icon to inherit color, size, and accessibility props from its parent.
// File: xmlui/src/components/Icon/TableInsertRowIcon.tsx
import React from "react";
export default function TableInsertRowIcon(props) {
return (
<svg
viewBox="0 0 24 16"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props} // Enables theming, sizing, and accessibility
>
<rect x="1.5" y="1.5" width="13" height="11" rx="1" />
<line x1="1.5" y1="5.5" x2="14.5" y2="5.5" />
<line x1="1.5" y1="9.5" x2="14.5" y2="9.5" />
<line x1="19" y1="6" x2="19" y2="10" />
<line x1="17" y1="8" x2="21" y2="8" />
</svg>
);
}
The{...props}
spread is essential—it lets XMLUI pass color, size, and ARIA attributes to your icon automatically.
Register the icon in
IconProvider.tsx
.IconProvider.tsx
import TableInsertRowIcon from "./Icon/TableInsertRowIcon";
registerIconRenderer("table-insert-row", (props) => <TableInsertRowIcon {...props} />);
Update
TableEditor.tsx
.TableEditor.tsx
import React from "react";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
import { Button } from "../Button/ButtonNative";
import { Stack } from "../Stack/StackNative";
import TurndownService from "turndown";
import { TableEditorNative2 } from "./TableEditorNative2";
import { createMetadata } from "../metadata-helpers";
import { createComponentRenderer } from "../../components-core/renderers";
import { buttonThemeMd, buttonVariantMd, sizeMd } from "../abstractions";
import Icon from "../Icon/IconNative";
export function TableEditor({
registerComponentApi,
themeColor = "primary",
variant = "solid",
size = "sm",
}: {
registerComponentApi?: (api: any) => void;
themeColor?: "primary" | "secondary" | "attention";
variant?: "solid" | "outlined" | "ghost";
size?: "xs" | "sm" | "md" | "lg";
}) {
const editor = useEditor({
extensions: [
StarterKit,
Table.configure({ resizable: true }),
TableRow,
TableHeader,
TableCell,
],
content: `
<table>
<thead>
<tr>
<th>Fruit</th>
<th>Color</th>
</tr>
</thead>
<tbody>
<tr>
<td>Apple</td>
<td>Red</td>
</tr>
<tr>
<td>Banana</td>
<td>Yellow</td>
</tr>
</tbody>
</table>
`,
});
React.useEffect(() => {
if (registerComponentApi && editor) {
const turndownService = new TurndownService();
turndownService.addRule('table', {
filter: 'table',
replacement: function (content, node) {
let rows = [];
for (let row of node.querySelectorAll('tr')) {
let cells = Array.from(row.children).map(cell => (cell as HTMLElement).textContent?.trim() ?? "");
rows.push('| ' + cells.join(' | ') + ' |');
}
if (rows.length > 1) {
// Add a separator after the header row
const headerSep = '| ' + rows[0].split('|').slice(1, -1).map(() => '---').join(' | ') + ' |';
rows.splice(1, 0, headerSep);
}
return rows.join('\n') + '\n';
}
});
registerComponentApi({
getHtmlSource: () => editor.getHTML(),
getMarkdownSource: () => turndownService.turndown(editor.getHTML()),
});
}
}, [registerComponentApi, editor]);
return (
<>
<Stack orientation="horizontal">
<Button onClick={() => editor && editor.commands.addRowAfter()} disabled={!editor}
themeColor={themeColor} variant={variant} size={size}
icon={<Icon name="table-insert-row" aria-hidden />} contextualLabel="Insert Row"
>
Insert Row
</Button>
<Button onClick={() => editor && editor.commands.deleteRow()} disabled={!editor}
themeColor={themeColor} variant={variant} size={size}
icon={<Icon name="table-delete-row" aria-hidden />} contextualLabel="Delete Row">
Delete Row
</Button>
<Button onClick={() => editor && editor.commands.addColumnAfter()} disabled={!editor}
themeColor={themeColor} variant={variant} size={size}
icon={<Icon name="table-insert-column" aria-hidden />} contextualLabel="Insert Column">
Insert Column
</Button>
<Button onClick={() => editor && editor.commands.deleteColumn()} disabled={!editor}
themeColor={themeColor} variant={variant} size={size}
icon={<Icon name="table-delete-column" aria-hidden />} contextualLabel="Delete Column">
Delete Column
</Button>
</Stack>
<TableEditorNative2 editor={editor} />
</>
);
}
export const TableEditorMd = createMetadata({
description:
"`TableEditor` provides an interactive table editing interface with controls for adding and deleting rows and columns. It supports theme customization and exports table data in HTML and Markdown formats.",
status: "experimental",
props: {
themeColor: {
description: "Sets the color scheme for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonThemeMd,
defaultValue: "primary",
},
variant: {
description: "Sets the visual style for all editor buttons.",
isRequired: false,
type: "string",
availableValues: buttonVariantMd,
defaultValue: "solid",
},
size: {
description: "Sets the size for all editor buttons.",
isRequired: false,
type: "string",
availableValues: sizeMd,
defaultValue: "sm",
},
},
events: {},
});
export const editorComponentRenderer = createComponentRenderer(
"TableEditor",
TableEditorMd,
({ node, extractValue, registerComponentApi }) => (
<TableEditor2
themeColor={extractValue.asOptionalString(node.props.themeColor)}
variant={extractValue.asOptionalString(node.props.variant)}
size={extractValue.asOptionalString(node.props.size)}
registerComponentApi={registerComponentApi}
/>
),
);
For icon-only buttons, use the
contextualLabel
prop to provide an accessible name.<Button icon="table-insert-row" contextualLabel="Insert a new row" />
This ensures screen readers announce the button's purpose, even if only the icon is visible.
For decorative icons, use
aria-hidden="true"
to hide the icon from assistive technology.<Icon name="table-insert-row" aria-hidden="true" />
Step 12: Make the Markdown reactive
We want the Markdown display to update when changes happen in the editor. Make this change in
TableEditor.tsx
.TableEditor.tsx
React.useEffect(() => {
if (!editor) return;
const handler = () => {
const html = editor.getHTML();
const markdown = turndownService.turndown(html);
onDidChange({ html, markdown });
};
editor.on("update", handler);
// Emit once on mount
handler();
return () => {
editor.off("update", handler);
};
}, [editor, onDidChange]);
Replace
events: {}
with this snippet.events: {
didChange: {
description: "Fired whenever the table content changes. Payload: { html, markdown }.",
isRequired: false,
type: "function",
},
},
And do this.
export const editorComponentRenderer = createComponentRenderer(
"TableEditor",
TableEditorMd,
({ node, extractValue, registerComponentApi, lookupEventHandler }) => {
const handler = lookupEventHandler?.("didChange");
return (
<TableEditor
themeColor={extractValue.asOptionalString(node.props.themeColor)}
variant={extractValue.asOptionalString(node.props.variant)}
size={extractValue.asOptionalString(node.props.size)}
registerComponentApi={registerComponentApi}
onDidChange={handler}
/>
);
},
)
Now the Markdown updates as you change the table.

Step 13: Style the editor
We'll want to control the editor's style with XMLUI theme variables, but first let's add basic styling in
TableEditor.module.scss
.TableEditor.module.css
.table-editor-root {
padding: 8px;
overflow-x: auto;
}
.ProseMirror {
outline: none;
white-space: pre-wrap;
word-break: break-word;
min-height: 100px;
}
.ProseMirror table {
border-collapse: collapse;
}
.ProseMirror th,
.ProseMirror td {
border: 1px solid #ccc;
padding: 4px;
}
.button-stack {
margin-bottom: 16px;
}
Use the styles in
TableEditor.tsx
.TableEditor.tsx
return (
<div className="table-editor-root">
<div className="button-stack">
<Stack orientation="horizontal">
...
</Stack>
</div>
<TableEditorNative editor={editor} />
</div>
);
Use this XMLUI markup.
<App var.markdown="">
<Card>
<TableEditor
id="tableEditor"
size="xs"
onDidChange="{(e) => { markdown = e.markdown }}"
/>
</Card>
<Card>
<Text variant="codefence" preserverLinebreaks="{true}">
{ markdown }
</Text>
</Card>
</App>
Here is the result.

Step 14: Theme the editor
Now let's make
TableEditor
visually consistent with Table by reusing XMLUI theme variables for header, cell, and spacing styles. Here is the new TableEditor.module.css
.TableEditor.module.css
@use "../../components-core/theming/themes" as t;
$componentName: "TableEditor";
$themeVars: ();
@function createThemeVar($componentVariable) {
$themeVars: t.appendThemeVar($themeVars, $componentVariable) !global;
@return t.getThemeVar($themeVars, $componentVariable);
}
// Reuse Table theme variables
$backgroundColorHeadingTable: createThemeVar("backgroundColor-heading-Table");
$borderCellTable: createThemeVar("border-cell-Table");
$fontSizeHeadingTable: createThemeVar("fontSize-heading-Table");
$fontWeightHeadingTable: createThemeVar("fontWeight-heading-Table");
$fontSizeRowTable: createThemeVar("fontSize-row-Table");
$fontWeightRowTable: createThemeVar("fontWeight-row-Table");
$paddingCellTable: createThemeVar("padding-cell-Table");
$paddingHeadingTable: createThemeVar("padding-heading-Table");
$textColorHeadingTable: createThemeVar("textColor-heading-Table");
$textColorPaginationTable: createThemeVar("textColor-pagination-Table");
$buttonStackSpacing: createThemeVar("spacing-buttonStack-Table");
$textTransformHeadingTable: createThemeVar("textTransform-heading-Table");
$tableMarginTop: t.$space-4;
$paddingHtmlTd: createThemeVar("padding-HtmlTd");
$paddingHtmlTh: createThemeVar("padding-HtmlTh");
.editor {
:global(.ProseMirror) {
outline: none;
white-space: pre-wrap;
word-break: break-word;
min-height: 100px;
table {
border-collapse: collapse;
margin-top: $tableMarginTop;
}
th {
background-color: $backgroundColorHeadingTable;
font-size: $fontSizeHeadingTable;
font-weight: $fontWeightHeadingTable;
padding: $paddingHtmlTh;
color: $textColorHeadingTable;
text-transform: $textTransformHeadingTable;
}
td {
font-size: $fontSizeRowTable;
font-weight: $fontWeightRowTable;
padding: $paddingHtmlTd;
border: $borderCellTable;
}
}
}
.button-stack {
margin-bottom: $buttonStackSpacing;
}
.table-editor-root {
padding: 8px;
overflow-x: auto;
}
.pagination {
color: $textColorPaginationTable;
}
:export {
themeVars: t.json-stringify($themeVars);
}
Now the editor matches the look of XMLUI tables rendered directly using
Table
or indirectly when tables appear in a XMLUI Markdown
context. Try it!<App var.markdown="">
<Card>
<TableEditor
id="tableEditor"
size="xs"
onDidChange="{(e) => { markdown = e.markdown }}"
/>
</Card>
<Card>
<HStack>
<Text variant="codefence" preserveLinebreaks="{true}">
{ markdown }
</Text>
<SpaceFiller />
<Button
icon="copy"
variant="ghost"
size="xs"
onClick="navigator.clipboard.writeText(markdown)"
/>
</HStack>
</Card>
</App>
<App var.markdown="">
<Card>
<TableEditor
id="tableEditor"
size="xs"
onDidChange="{(e) => { markdown = e.markdown }}"
/>
</Card>
<Card>
<HStack>
<Text variant="codefence" preserveLinebreaks="{true}">
{ markdown }
</Text>
<SpaceFiller />
<Button
icon="copy"
variant="ghost"
size="xs"
onClick="navigator.clipboard.writeText(markdown)"
/>
</HStack>
</Card>
</App>