diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts
new file mode 100644
index 00000000000..d7df5816eaa
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-column-popover.component.ts
@@ -0,0 +1,214 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ inject,
+ input,
+ signal,
+ untracked
+} from '@angular/core';
+import { FormsModule } from '@angular/forms';
+
+import { Select } from 'primeng/select';
+
+import { Editor } from '@tiptap/core';
+
+import { DotMessageService } from '@dotcms/data-access';
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { EditorPopoverService } from '../../services/editor-popover.service';
+import { EditorPopoverComponent } from '../editor-popover/editor-popover.component';
+
+/**
+ * Column-scoped popover, opened from the column handle. Provides the actions that apply
+ * to the entire column the user is hovering: insert left, insert right, toggle header,
+ * delete, plus a header-scope select that only appears when the anchor cell is a `
`.
+ *
+ * The anchor cell position is snapshotted in the popover payload so the actions still
+ * target the right column even if the cursor wanders while the popover is open.
+ */
+@Component({
+ selector: 'dot-table-column-popover',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [FormsModule, Select, EditorPopoverComponent, DotMessagePipe],
+ template: `
+
+
+
+
+ add_column_left
+
+ {{ 'dot.block.editor.table.column.insert-left' | dm }}
+
+
+
+ add_column_right
+
+ {{ 'dot.block.editor.table.column.insert-right' | dm }}
+
+
+ view_column
+ {{ 'dot.block.editor.table.column.toggle-header' | dm }}
+
+
+ @if (showScope()) {
+
+
+ {{ 'dot.block.editor.toolbar.table.header-scope' | dm }}
+
+
+
+ }
+
+
+ delete
+ {{ 'dot.block.editor.table.column.delete' | dm }}
+
+
+
+ `,
+ styles: [
+ `
+ .popover-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ color: rgb(55 65 81);
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ text-align: left;
+ }
+ .popover-item:hover {
+ background: rgb(238 242 255);
+ }
+ .popover-item--danger {
+ color: rgb(185 28 28);
+ }
+ .popover-item--danger:hover {
+ background: rgb(254 226 226);
+ }
+ .popover-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.5rem 0.75rem;
+ }
+ .popover-field__label {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: rgb(55 65 81);
+ }
+ /* Stretch PrimeNG's to the full popover width.
+ styleClass="popover-field__select" lands on the .p-select root. */
+ :host ::ng-deep .popover-field__select.p-select,
+ :host ::ng-deep .popover-field__select .p-select {
+ width: 100%;
+ }
+ `
+ ]
+})
+export class TableColumnPopoverComponent {
+ readonly editor = input.required();
+ protected readonly manager = inject(EditorPopoverService);
+ private readonly dotMessageService = inject(DotMessageService);
+
+ protected readonly scope = signal('');
+ protected readonly showScope = computed(
+ () => this.manager.tableColumnPayload()?.isHeader ?? false
+ );
+
+ protected readonly scopeOptions: ReadonlyArray<{ label: string; value: string }>;
+
+ constructor() {
+ const msg = (key: string) => this.dotMessageService.get(key);
+ this.scopeOptions = [
+ { label: msg('dot.block.editor.toolbar.table.scope.auto'), value: '' },
+ { label: msg('dot.block.editor.toolbar.table.scope.col'), value: 'col' },
+ { label: msg('dot.block.editor.toolbar.table.scope.row'), value: 'row' },
+ { label: msg('dot.block.editor.toolbar.table.scope.colgroup'), value: 'colgroup' },
+ { label: msg('dot.block.editor.toolbar.table.scope.rowgroup'), value: 'rowgroup' }
+ ];
+
+ // Seed the scope value from the payload whenever the popover opens.
+ effect(() => {
+ const payload = this.manager.tableColumnPayload();
+ const open = this.manager.isOpen('table-column');
+ untracked(() => {
+ if (open && payload) {
+ this.scope.set(payload.headerScope);
+ }
+ });
+ });
+ }
+
+ protected action(event: MouseEvent, fn: () => void): void {
+ event.preventDefault();
+ event.stopPropagation();
+ fn();
+ this.manager.close();
+ }
+
+ /** Place selection inside the anchor cell, then run a TipTap chain. */
+ private withCell(chain: (editor: Editor) => void): void {
+ const payload = this.manager.tableColumnPayload();
+ if (!payload) return;
+ const editor = this.editor();
+ editor
+ .chain()
+ .focus()
+ .setTextSelection(payload.cellPos + 1)
+ .run();
+ chain(editor);
+ }
+
+ protected insertLeft = (): void => {
+ this.withCell((editor) => editor.chain().focus().addColumnBefore().run());
+ };
+
+ protected insertRight = (): void => {
+ this.withCell((editor) => editor.chain().focus().addColumnAfter().run());
+ };
+
+ protected toggleHeader = (): void => {
+ this.withCell((editor) => editor.chain().focus().toggleHeaderColumn().run());
+ };
+
+ protected deleteColumn = (): void => {
+ this.withCell((editor) => editor.chain().focus().deleteColumn().run());
+ };
+
+ protected onScopeChange(value: string): void {
+ const payload = this.manager.tableColumnPayload();
+ if (!payload) return;
+ this.editor()
+ .chain()
+ .focus()
+ .setTextSelection(payload.cellPos + 1)
+ .updateAttributes('tableHeader', { scope: value === '' ? null : value })
+ .run();
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts
new file mode 100644
index 00000000000..064815c60a9
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-handles/table-row-popover.component.ts
@@ -0,0 +1,122 @@
+import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core';
+
+import { Editor } from '@tiptap/core';
+
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { EditorPopoverService } from '../../services/editor-popover.service';
+import { EditorPopoverComponent } from '../editor-popover/editor-popover.component';
+
+/**
+ * Row-scoped popover, opened from the row handle. Insert above / below, toggle the row
+ * as a header row, delete the row. Operates on the cell whose `cellPos` is in the popover
+ * payload — snapshotted at open time so concurrent editor selection changes don't move the
+ * target out from under the user.
+ */
+@Component({
+ selector: 'dot-table-row-popover',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [EditorPopoverComponent, DotMessagePipe],
+ template: `
+
+
+
+ add_row_above
+ {{ 'dot.block.editor.table.row.insert-above' | dm }}
+
+
+ add_row_below
+ {{ 'dot.block.editor.table.row.insert-below' | dm }}
+
+
+ table_rows
+ {{ 'dot.block.editor.table.row.toggle-header' | dm }}
+
+
+ delete
+ {{ 'dot.block.editor.table.row.delete' | dm }}
+
+
+
+ `,
+ styles: [
+ `
+ .popover-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ font-size: 0.875rem;
+ color: rgb(55 65 81);
+ cursor: pointer;
+ background: transparent;
+ border: none;
+ text-align: left;
+ }
+ .popover-item:hover {
+ background: rgb(238 242 255);
+ }
+ .popover-item--danger {
+ color: rgb(185 28 28);
+ }
+ .popover-item--danger:hover {
+ background: rgb(254 226 226);
+ }
+ `
+ ]
+})
+export class TableRowPopoverComponent {
+ readonly editor = input.required();
+ protected readonly manager = inject(EditorPopoverService);
+
+ protected action(event: MouseEvent, fn: () => void): void {
+ event.preventDefault();
+ event.stopPropagation();
+ fn();
+ this.manager.close();
+ }
+
+ private withCell(chain: (editor: Editor) => void): void {
+ const payload = this.manager.tableRowPayload();
+ if (!payload) return;
+ const editor = this.editor();
+ editor
+ .chain()
+ .focus()
+ .setTextSelection(payload.cellPos + 1)
+ .run();
+ chain(editor);
+ }
+
+ protected insertAbove = (): void => {
+ this.withCell((editor) => editor.chain().focus().addRowBefore().run());
+ };
+
+ protected insertBelow = (): void => {
+ this.withCell((editor) => editor.chain().focus().addRowAfter().run());
+ };
+
+ protected toggleHeader = (): void => {
+ this.withCell((editor) => editor.chain().focus().toggleHeaderRow().run());
+ };
+
+ protected deleteRow = (): void => {
+ this.withCell((editor) => editor.chain().focus().deleteRow().run());
+ };
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts
new file mode 100644
index 00000000000..5e7ee6ca0d0
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-properties-popover/table-properties-popover.component.ts
@@ -0,0 +1,165 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ effect,
+ inject,
+ input,
+ untracked
+} from '@angular/core';
+import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
+
+import { Editor } from '@tiptap/core';
+
+import { DotMessagePipe } from '@dotcms/ui';
+
+import { EditorPopoverService } from '../../services/editor-popover.service';
+import { EditorPopoverComponent } from '../editor-popover/editor-popover.component';
+
+const EMPTY_VALUES = {
+ caption: '',
+ hasCaption: false,
+ ariaLabel: '',
+ ariaLabelledby: ''
+};
+
+/**
+ * Toolbar-anchored a11y popover for the active table. Edits:
+ *
+ * - **Caption** — sets the table's `caption` attribute (rendered as a `` child).
+ * - **aria-label** — accessible name for the ``.
+ * - **aria-labelledby** — references an `id` of an external label.
+ *
+ * Phase 3: stripped down from the prior `TableActionsPopover` — merge / split / delete-table
+ * were dropped as out-of-scope for the a11y ticket. Opened from the new `table_edit` toolbar
+ * button only when the cursor is inside a table.
+ */
+@Component({
+ selector: 'dot-table-properties-popover',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ReactiveFormsModule, EditorPopoverComponent, DotMessagePipe],
+ template: `
+
+
+
+ `
+})
+export class TablePropertiesPopoverComponent {
+ readonly editor = input.required();
+ protected readonly manager = inject(EditorPopoverService);
+
+ readonly form = new FormGroup({
+ hasCaption: new FormControl(false, { nonNullable: true }),
+ caption: new FormControl('', { nonNullable: true }),
+ ariaLabel: new FormControl('', { nonNullable: true }),
+ ariaLabelledby: new FormControl('', { nonNullable: true })
+ });
+
+ constructor() {
+ effect(() => {
+ const payload = this.manager.tablePropertiesPayload();
+ const open = this.manager.isOpen('table-properties');
+
+ untracked(() => {
+ if (open && payload) {
+ this.form.reset(payload.initialValues);
+ } else if (!open) {
+ this.form.reset(EMPTY_VALUES);
+ }
+ });
+ });
+ }
+
+ onApply(): void {
+ const { hasCaption, caption, ariaLabel, ariaLabelledby } = this.form.getRawValue();
+
+ // All three a11y fields are stored as table attributes — set them in one shot.
+ this.editor()
+ .chain()
+ .focus()
+ .updateAttributes('table', {
+ caption: hasCaption && caption.trim() ? caption.trim() : null,
+ ariaLabel: ariaLabel?.trim() ? ariaLabel.trim() : null,
+ ariaLabelledby: ariaLabelledby?.trim() ? ariaLabelledby.trim() : null
+ })
+ .run();
+
+ this.manager.close();
+ }
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts
index b038a46ddd9..4f7540c8410 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/editor-toolbar.store.ts
@@ -29,9 +29,9 @@ export class EditorToolbarStore {
readonly textAlign = signal<'left' | 'center' | 'right' | 'justify'>('left');
readonly isSuperscript = signal(false);
readonly isSubscript = signal(false);
+ /** True when the editor selection is anywhere inside a table — drives the toolbar's
+ * `table_edit` (Table properties) button's enabled state. */
readonly isInTable = signal(false);
- readonly canMergeCells = signal(false);
- readonly canSplitCell = signal(false);
readonly selectedContentlet = signal(null);
connect(editor: Editor): () => void {
@@ -84,10 +84,7 @@ export class EditorToolbarStore {
);
this.isSuperscript.set(editor.isActive('superscript'));
this.isSubscript.set(editor.isActive('subscript'));
- this.isInTable.set(editor.isActive('table'));
- this.canMergeCells.set(editor.can().mergeCells());
- this.canSplitCell.set(editor.can().splitCell());
-
+ this.isInTable.set(inTable);
const { selection } = editor.state;
const contentletNode =
selection instanceof NodeSelection && selection.node.type.name === 'dotContent'
diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html
index 046fce0fe90..a3f221ed841 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html
+++ b/core-web/libs/new-block-editor/src/lib/editor/components/toolbar/toolbar.component.html
@@ -425,168 +425,27 @@
(mousedown)="openTableDialog($event)">
table
- }
-
- @if (isAllowed('table')) {
-
-
-
-
- add_row_above
-
- add_row_below
-
-
- add_column_left
-
-
- add_column_right
-
-
-
-
-
-
- cell_merge
-
-
- call_split
-
-
-
-
-
-
- table_rows
-
-
- view_column
-
-
-
-
-
-
- border_horizontal
-
-
- border_vertical
-
-
- border_clear
+ (mousedown)="openTablePropertiesDialog($event)">
+ table_edit
}
+
+
@if (isAllowed('emoji')) {
btn.getBoundingClientRect(), {
+ initialValues: {
+ caption: captionText,
+ hasCaption,
+ ariaLabel: tableAttrs['ariaLabel'] ?? '',
+ ariaLabelledby: tableAttrs['ariaLabelledby'] ?? ''
+ }
+ });
}
- protected tableDeleteTable(): void {
- this.editor().chain().focus().deleteTable().run();
+ /** Reads the active table's `caption` attribute from the current selection. */
+ private readActiveTableCaption(editor: Editor): { captionText: string; hasCaption: boolean } {
+ const { $from } = editor.state.selection;
+ for (let depth = $from.depth; depth >= 0; depth--) {
+ const node = $from.node(depth);
+ if (node.type.name !== 'table') continue;
+ const caption = (node.attrs['caption'] as string | null) ?? '';
+ return { captionText: caption, hasCaption: caption.length > 0 };
+ }
+ return { captionText: '', hasCaption: false };
}
// ── Keyboard navigation (roving tabindex) ────────────────────────────────
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.css b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css
index 248e3bdba06..2d8902b8983 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.css
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.css
@@ -207,7 +207,8 @@
border-collapse: collapse;
width: 100%;
table-layout: fixed;
- overflow: hidden;
+ /* No `overflow: hidden` — the cell-anchored handles (.dot-cell-handle) sit on the
+ table's outer border via negative top/left offsets and would otherwise be clipped. */
margin: 0;
}
@@ -242,7 +243,72 @@
}
:host ::ng-deep .tiptap .tableWrapper {
- overflow-x: auto;
+ /* `overflow: visible` (instead of `overflow-x: auto`) so the column / row handles can
+ protrude past the table's outer edges without being clipped. Wide-table horizontal
+ scrolling is handled by the editor's outer scroll container; combined with
+ `table-layout: fixed; width: 100%` above, tables stay within the editor width by
+ resizing columns instead of overflowing. */
+ overflow: visible;
+}
+
+/* ─── Table contextual handles (cell-anchored NodeView) ─── */
+/*
+ * Handles live INSIDE the cell DOM (rendered by the DotTableCell / DotTableHeader
+ * NodeViews). Pure CSS positioning — no floating-ui, no autoUpdate. The cell's
+ * `position: relative` is already set on the .tiptap td/th rule above; the handles
+ * absolute-position themselves against the cell.
+ */
+:host ::ng-deep .tiptap .dot-cell-handle {
+ position: absolute;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ border: 1px solid #c7d2fe;
+ border-radius: 4px;
+ background-color: #ffffff;
+ color: #4f46e5;
+ cursor: pointer;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
+ z-index: 5;
+}
+
+:host ::ng-deep .tiptap .dot-cell-handle .material-symbols-outlined {
+ font-size: 14px;
+ line-height: 1;
+}
+
+:host ::ng-deep .tiptap .dot-cell-handle:hover {
+ background-color: #eef2ff;
+ color: #4338ca;
+}
+
+:host ::ng-deep .tiptap .dot-cell-handle--col {
+ top: -12px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+:host ::ng-deep .tiptap .dot-cell-handle--row {
+ top: 50%;
+ left: -12px;
+ transform: translateY(-50%);
+}
+
+/*
+ * Visibility rules — the TableActiveCellsPlugin marks every cell in the cursor's
+ * column with `is-active-column` and every cell in the cursor's row with `is-active-row`.
+ * Column handle only shows on the first-row cell of the active column; row handle only
+ * shows on the first cell of the active row.
+ */
+:host ::ng-deep .tiptap tbody > tr:first-child > .is-active-column > .dot-cell-handle--col {
+ display: inline-flex;
+}
+
+:host ::ng-deep .tiptap tbody > tr > .is-active-row:first-child > .dot-cell-handle--row {
+ display: inline-flex;
}
/* ─── Placeholders ──────────────────────────────────────── */
diff --git a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts
index 0c14c27ffcc..8bf2bd9b94c 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts
+++ b/core-web/libs/new-block-editor/src/lib/editor/editor.component.ts
@@ -34,7 +34,10 @@ import { ImagePropertiesPopoverComponent } from './components/image-popover/imag
import { LinkPopoverComponent } from './components/link-popover/link-popover.component';
import { SlashMenuComponent } from './components/slash-menu/slash-menu.component';
import { SlashMenuService } from './components/slash-menu/slash-menu.service';
+import { TableColumnPopoverComponent } from './components/table-handles/table-column-popover.component';
+import { TableRowPopoverComponent } from './components/table-handles/table-row-popover.component';
import { TablePopoverComponent } from './components/table-popover/table-popover.component';
+import { TablePropertiesPopoverComponent } from './components/table-properties-popover/table-properties-popover.component';
import { EditorToolbarStore } from './components/toolbar/editor-toolbar.store';
import { ToolbarComponent } from './components/toolbar/toolbar.component';
import { syncCharacterStatsFromEditor } from './editor-character-stats';
@@ -153,6 +156,9 @@ function normalizeEditorContent(
SlashMenuComponent,
EmojiPickerComponent,
TablePopoverComponent,
+ TableColumnPopoverComponent,
+ TableRowPopoverComponent,
+ TablePropertiesPopoverComponent,
ImagePropertiesPopoverComponent,
LinkPopoverComponent,
AssetByUrlPopoverComponent,
@@ -219,6 +225,9 @@ function normalizeEditorContent(
+
+
+
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts
index 81568d989d1..79c359f69d7 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/editor-extensions.ts
@@ -8,7 +8,6 @@ import Emoji, { emojis } from '@tiptap/extension-emoji';
import Placeholder from '@tiptap/extension-placeholder';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
-import { TableKit } from '@tiptap/extension-table';
import TextAlign from '@tiptap/extension-text-align';
import { Youtube } from '@tiptap/extension-youtube';
import StarterKit from '@tiptap/starter-kit';
@@ -30,6 +29,10 @@ import {
import { Video } from './nodes/video.extension';
import { SelectionPreserveExtension } from './selection-preserve.extension';
import { createSlashCommandExtension } from './slash-command.extension';
+import { TableActiveCellsPlugin } from './table-active-cells.plugin';
+import { createDotTableExtensions } from './table-extensions';
+
+import { EditorPopoverService } from '../services/editor-popover.service';
import type { SlashMenuService } from '../components/slash-menu/slash-menu.service';
@@ -80,7 +83,24 @@ export function createEditorExtensions(
...(has('codeBlock') ? [createCodeBlock(injector, lowlight)] : []),
createBlockGutterDragHandle(t('dot.block.editor.gutter.add-block')),
CharacterCount,
- ...(has('table') ? [TableKit.configure({ table: { resizable: true } })] : []),
+ ...(has('table')
+ ? [
+ ...createDotTableExtensions({
+ table: { resizable: true },
+ cell: {
+ popovers: injector.get(EditorPopoverService),
+ columnAriaLabel: t('dot.block.editor.table.handle.column.aria-label'),
+ rowAriaLabel: t('dot.block.editor.table.handle.row.aria-label')
+ },
+ header: {
+ popovers: injector.get(EditorPopoverService),
+ columnAriaLabel: t('dot.block.editor.table.handle.column.aria-label'),
+ rowAriaLabel: t('dot.block.editor.table.handle.row.aria-label')
+ }
+ }),
+ TableActiveCellsPlugin
+ ]
+ : []),
...(has('image') ? [DotImage] : []),
...(has('link')
? [
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-active-cells.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-active-cells.plugin.ts
new file mode 100644
index 00000000000..c95ef60f164
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-active-cells.plugin.ts
@@ -0,0 +1,113 @@
+import { Extension } from '@tiptap/core';
+import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model';
+import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
+import { TableMap } from '@tiptap/pm/tables';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+
+const PLUGIN_KEY = new PluginKey('tableActiveCells');
+
+/**
+ * Marks the cells in the cursor's column and row with CSS classes so the
+ * {@link DotTableCell} / {@link DotTableHeader} NodeViews can show the column / row handles
+ * only on the right cells (first-row of active column for the column handle, first-cell of
+ * active row for the row handle).
+ *
+ * Decorations applied per cell:
+ * - `is-active-column` — every cell whose column matches the cursor's column
+ * - `is-active-row` — every cell whose row matches the cursor's row
+ *
+ * No floating-ui, no autoUpdate, no store. CSS does the rest:
+ *
+ * ```css
+ * .tiptap tbody > tr:first-child > .is-active-column > .dot-cell-handle--col { display: inline-flex; }
+ * .tiptap tbody > tr > .is-active-row:first-child > .dot-cell-handle--row { display: inline-flex; }
+ * ```
+ *
+ * `prosemirror-tables` already composes multiple `Decoration.node` classes on the same cell
+ * (e.g. `selectedCell`), so adding our own classes never clobbers theirs.
+ */
+export const TableActiveCellsPlugin = Extension.create({
+ name: 'tableActiveCells',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: PLUGIN_KEY,
+ state: {
+ init: (_, state) => buildDecorations(state),
+ apply: (tr, oldSet, _oldState, newState) => {
+ // Only recompute when the selection moved or the doc changed —
+ // skipping pure metadata transactions keeps this cheap.
+ if (!tr.docChanged && !tr.selectionSet) return oldSet;
+ return buildDecorations(newState);
+ }
+ },
+ props: {
+ decorations(state) {
+ return PLUGIN_KEY.getState(state) ?? null;
+ }
+ }
+ })
+ ];
+ }
+});
+
+function buildDecorations(state: EditorState): DecorationSet {
+ const tableInfo = findTableAt(state.selection.$from);
+ if (!tableInfo) return DecorationSet.empty;
+
+ const { tableNode, tablePos, cellPos } = tableInfo;
+ const map = TableMap.get(tableNode);
+ const cellOffset = cellPos - tablePos - 1;
+ const cellIndex = map.map.indexOf(cellOffset);
+ if (cellIndex < 0) return DecorationSet.empty;
+
+ const activeCol = cellIndex % map.width;
+ const activeRow = Math.floor(cellIndex / map.width);
+
+ const decorations: Decoration[] = [];
+
+ // Walk every cell in the table once, deciding which class set applies.
+ tableNode.forEach((row, rowOffset, rowIndex) => {
+ const rowStart = tablePos + 1 + rowOffset;
+ row.forEach((cell, cellOffsetInRow, colIndex) => {
+ const isActiveCol = colIndex === activeCol;
+ const isActiveRow = rowIndex === activeRow;
+ if (!isActiveCol && !isActiveRow) return;
+
+ const classes: string[] = [];
+ if (isActiveCol) classes.push('is-active-column');
+ if (isActiveRow) classes.push('is-active-row');
+
+ const cellStart = rowStart + 1 + cellOffsetInRow;
+ decorations.push(
+ Decoration.node(cellStart, cellStart + cell.nodeSize, {
+ class: classes.join(' ')
+ })
+ );
+ });
+ });
+
+ return DecorationSet.create(state.doc, decorations);
+}
+
+interface TableInfo {
+ tableNode: PMNode;
+ tablePos: number;
+ cellPos: number;
+}
+
+/** Walks up from a resolved position to find the surrounding table + cell. */
+function findTableAt($pos: ResolvedPos): TableInfo | null {
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const node = $pos.node(depth);
+ if (node.type.name === 'table') {
+ const tablePos = $pos.before(depth);
+ const cellDepth = depth + 2;
+ if ($pos.depth < cellDepth) return null;
+ const cellPos = $pos.before(cellDepth);
+ return { tableNode: node, tablePos, cellPos };
+ }
+ }
+ return null;
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-extensions.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-extensions.ts
new file mode 100644
index 00000000000..878ec07f888
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-extensions.ts
@@ -0,0 +1,250 @@
+import { NodeViewRenderer } from '@tiptap/core';
+import { Table, TableCell, TableHeader, TableKit } from '@tiptap/extension-table';
+import { Node as PMNode } from '@tiptap/pm/model';
+
+import { TableScopeAutoAssign } from './table-scope-auto-assign.plugin';
+
+import type { EditorPopoverService } from '../services/editor-popover.service';
+
+// ── DotTable ───────────────────────────────────────────────────────────────────────
+
+const DotTable = Table.extend({
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ caption: {
+ default: null,
+ parseHTML: (element) => {
+ const captionEl = element.querySelector(':scope > caption');
+ return captionEl?.textContent?.trim() || null;
+ },
+ renderHTML: () => ({})
+ },
+ ariaLabel: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('aria-label'),
+ renderHTML: (attributes) => {
+ const value = attributes['ariaLabel'];
+ if (value == null || value === '') return {};
+ return { 'aria-label': value };
+ }
+ },
+ ariaLabelledby: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('aria-labelledby'),
+ renderHTML: (attributes) => {
+ const value = attributes['ariaLabelledby'];
+ if (value == null || value === '') return {};
+ return { 'aria-labelledby': value };
+ }
+ }
+ };
+ }
+});
+
+// ── DotTableCell / DotTableHeader (NodeViews with embedded handles) ────────────────
+
+interface CellExtensionOptions {
+ HTMLAttributes: Record;
+ /** Injected by the editor component scope so click handlers can open the scoped popovers. */
+ popovers: EditorPopoverService | null;
+ /** i18n labels for the handle buttons; supplied at extension-construction time. */
+ columnAriaLabel: string;
+ rowAriaLabel: string;
+}
+
+const CELL_ATTRS_TO_SYNC = ['colspan', 'rowspan', 'colwidth', 'align', 'scope'] as const;
+
+/**
+ * Renders a `` / ` ` with two child buttons (column handle + row handle) plus a
+ * content container. The buttons live inside the cell DOM, so positioning is pure CSS:
+ *
+ * - `--col` → `top: -12px; left: 50%; transform: translateX(-50%)`
+ * - `--row` → `top: 50%; left: -12px; transform: translateY(-50%)`
+ *
+ * The {@link TableActiveCellsPlugin} adds `.is-active-column` / `.is-active-row` classes to
+ * cells in the cursor's column / row; CSS first-child selectors then show the handle only on
+ * the first-row cell of the active column (and the first-cell of the active row).
+ */
+function makeCellNodeViewFactory(
+ tag: 'td' | 'th',
+ options: CellExtensionOptions
+): NodeViewRenderer {
+ return ({ node, getPos, HTMLAttributes }) => {
+ const popovers = options.popovers;
+ const cell = document.createElement(tag);
+ applyHTMLAttributes(cell, HTMLAttributes);
+
+ const colHandle = makeHandleButton('column', 'more_horiz', options.columnAriaLabel);
+ const rowHandle = makeHandleButton('row', 'more_vert', options.rowAriaLabel);
+
+ const content = document.createElement('div');
+ content.className = 'dot-cell-content';
+
+ cell.append(colHandle, rowHandle, content);
+
+ const resolveCellPos = (): number | null => {
+ const pos = typeof getPos === 'function' ? getPos() : null;
+ return typeof pos === 'number' ? pos : null;
+ };
+
+ if (popovers) {
+ colHandle.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const pos = resolveCellPos();
+ if (pos == null) return;
+ popovers.openTableColumn(() => colHandle.getBoundingClientRect(), {
+ cellPos: pos,
+ isHeader: tag === 'th',
+ headerScope: (currentNode.attrs['scope'] as string | null) ?? ''
+ });
+ });
+ rowHandle.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const pos = resolveCellPos();
+ if (pos == null) return;
+ popovers.openTableRow(() => rowHandle.getBoundingClientRect(), {
+ cellPos: pos
+ });
+ });
+ }
+
+ // The closure captures `node` at creation time. We update this reference in
+ // `update()` so the click handlers always see the latest attrs (e.g. scope).
+ let currentNode: PMNode = node;
+
+ return {
+ dom: cell,
+ contentDOM: content,
+ update: (newNode) => {
+ if (newNode.type.name !== node.type.name) return false;
+ currentNode = newNode;
+ // Sync the known attrs onto the cell element. We can't just call
+ // `applyHTMLAttributes` again because the new HTMLAttributes aren't
+ // passed to `update` — we resolve from `newNode.attrs` directly.
+ for (const attr of CELL_ATTRS_TO_SYNC) {
+ const value = newNode.attrs[attr];
+ if (value == null || value === '') cell.removeAttribute(attr);
+ else cell.setAttribute(attr, String(value));
+ }
+ return true;
+ },
+ // The handle buttons + their icon spans are NodeView-owned DOM. Prevent
+ // ProseMirror from re-parsing them on every mutation inside.
+ ignoreMutation: (mutation) => {
+ const target = mutation.target as Element | null;
+ if (!target) return false;
+ if (target instanceof HTMLElement && target.closest('.dot-cell-handle')) {
+ return true;
+ }
+ return false;
+ }
+ };
+ };
+}
+
+function makeHandleButton(
+ kind: 'column' | 'row',
+ icon: string,
+ ariaLabel: string
+): HTMLButtonElement {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = `dot-cell-handle dot-cell-handle--${kind === 'column' ? 'col' : 'row'}`;
+ button.setAttribute('contenteditable', 'false');
+ button.setAttribute('tabindex', '-1');
+ button.setAttribute('aria-label', ariaLabel);
+ button.dataset['testid'] = `table-${kind}-handle`;
+
+ const iconSpan = document.createElement('span');
+ iconSpan.className = 'material-symbols-outlined';
+ iconSpan.setAttribute('aria-hidden', 'true');
+ iconSpan.textContent = icon;
+ button.appendChild(iconSpan);
+ return button;
+}
+
+function applyHTMLAttributes(el: HTMLElement, attrs: Record): void {
+ for (const [key, value] of Object.entries(attrs)) {
+ if (value == null || value === '' || value === false) continue;
+ el.setAttribute(key, String(value));
+ }
+}
+
+const DotTableCell = TableCell.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ popovers: null,
+ columnAriaLabel: 'Column actions',
+ rowAriaLabel: 'Row actions'
+ };
+ },
+ addNodeView() {
+ return makeCellNodeViewFactory('td', this.options);
+ }
+});
+
+const DotTableHeader = TableHeader.extend({
+ addOptions() {
+ return {
+ ...this.parent?.(),
+ popovers: null,
+ columnAriaLabel: 'Column actions',
+ rowAriaLabel: 'Row actions'
+ };
+ },
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ scope: {
+ default: null,
+ parseHTML: (element) => element.getAttribute('scope'),
+ renderHTML: (attributes) => {
+ const value = attributes['scope'];
+ if (value == null || value === '') return {};
+ return { scope: value };
+ }
+ }
+ };
+ },
+ addNodeView() {
+ return makeCellNodeViewFactory('th', this.options);
+ }
+});
+
+// ── Bundle ─────────────────────────────────────────────────────────────────────────
+
+interface DotTableKitOptions {
+ table?: Parameters[0];
+ /** Cell + header NodeView options — used to inject the popover service + aria labels. */
+ cell?: Partial;
+ header?: Partial;
+}
+
+/**
+ * Returns the full set of table-related TipTap extensions. The cell + header NodeViews
+ * each receive an {@link EditorPopoverService} via options so their click handlers can open
+ * the column / row popovers without going through Angular DI.
+ *
+ * - `DotTable` — adds caption + aria-label + aria-labelledby attributes.
+ * - `DotTableCell` / `DotTableHeader` — NodeView renders handle buttons inside the cell.
+ * - `TableCell` + `TableRow` come from `TableKit` (cell is overridden here; row stays default).
+ * - `TableScopeAutoAssign` — fills `scope` on header cells based on position.
+ */
+export function createDotTableExtensions(options: DotTableKitOptions = {}) {
+ return [
+ // We provide custom Table, TableCell and TableHeader; disable the kit's versions.
+ TableKit.configure({
+ table: false,
+ tableCell: false,
+ tableHeader: false
+ }),
+ DotTable.configure(options.table ?? {}),
+ DotTableCell.configure(options.cell ?? {}),
+ DotTableHeader.configure(options.header ?? {}),
+ TableScopeAutoAssign
+ ];
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts
new file mode 100644
index 00000000000..b581e1fe704
--- /dev/null
+++ b/core-web/libs/new-block-editor/src/lib/editor/extensions/table-scope-auto-assign.plugin.ts
@@ -0,0 +1,106 @@
+import { Extension } from '@tiptap/core';
+import { Node as PMNode } from '@tiptap/pm/model';
+import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state';
+
+const PLUGIN_KEY = new PluginKey('tableScopeAutoAssign');
+
+/**
+ * Walks every `` in the document and stages `scope` attribute updates on `` cells
+ * whose `scope` is still unset. Returns the transaction when at least one cell was updated,
+ * or `null` when the doc is already in a consistent state (so callers can skip dispatching).
+ */
+function buildAutoScopeTransaction(state: EditorState): Transaction | null {
+ const tr = state.tr;
+ let changed = false;
+
+ state.doc.descendants((node, pos): boolean => {
+ if (node.type.name !== 'table') return true;
+
+ node.forEach((row, rowOffset, rowIndex) => {
+ const isFirstRow = rowIndex === 0;
+ const rowStart = pos + 1 + rowOffset;
+
+ row.forEach((cell, cellOffset, cellIndex) => {
+ if (cell.type.name !== 'tableHeader') return;
+
+ const existing = cell.attrs['scope'];
+ if (existing != null && existing !== '') return;
+
+ const desired = isFirstRow ? 'col' : cellIndex === 0 ? 'row' : null;
+ if (!desired) return;
+
+ const cellPos = rowStart + 1 + cellOffset;
+ tr.setNodeAttribute(cellPos, 'scope', desired);
+ changed = true;
+ });
+ });
+
+ // Children visited via node.forEach above — no need to descend further.
+ return false;
+ });
+
+ if (!changed) return null;
+ tr.setMeta(PLUGIN_KEY, true);
+ return tr;
+}
+
+/**
+ * Auto-fills `scope` on ` ` cells based on their position so screen readers can
+ * resolve column-vs-row header relationships (WCAG 1.3.1).
+ *
+ * - first row → `scope="col"`
+ * - first column (when the cell is a ` `) → `scope="row"`
+ *
+ * Runs in `appendTransaction` only after doc-changing transactions, and is **idempotent**:
+ * it never touches a cell whose `scope` is already a non-null string. That preserves any
+ * value the author set explicitly (e.g. `colgroup`, `rowgroup`) via the toolbar select.
+ *
+ * Skipped when the previous transaction was generated by this plugin (`tr.getMeta` guard)
+ * to avoid recursive amplification when several tables exist in the same doc.
+ */
+export const TableScopeAutoAssign = Extension.create({
+ name: 'tableScopeAutoAssign',
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: PLUGIN_KEY,
+ /**
+ * Runs once when the editor view mounts. ProseMirror builds the initial
+ * state directly from the schema — that build does not flow through any
+ * transaction, so `appendTransaction` would otherwise never see legacy
+ * tables already in the doc.
+ */
+ view: (view) => {
+ const tr = buildAutoScopeTransaction(view.state);
+ if (tr) view.dispatch(tr);
+ return {};
+ },
+ appendTransaction: (transactions, _oldState, newState) => {
+ if (!transactions.some((tr) => tr.docChanged)) return null;
+ if (transactions.some((tr) => tr.getMeta(PLUGIN_KEY))) return null;
+ return buildAutoScopeTransaction(newState);
+ }
+ })
+ ];
+ }
+});
+
+/**
+ * Exposed for unit-testing: returns the position-derived scope for a given cell
+ * inside a table node, mirroring the plugin's logic. `null` means leave untouched.
+ */
+export function computeAutoScope(
+ table: PMNode,
+ rowIndex: number,
+ cellIndex: number
+): 'col' | 'row' | null {
+ if (rowIndex < 0 || rowIndex >= table.childCount) return null;
+ const row = table.child(rowIndex);
+ if (cellIndex < 0 || cellIndex >= row.childCount) return null;
+ const cell = row.child(cellIndex);
+ if (cell.type.name !== 'tableHeader') return null;
+ if (rowIndex === 0) return 'col';
+ if (cellIndex === 0) return 'row';
+ return null;
+}
diff --git a/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts b/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts
index ee2799eeb2c..20fd8064c66 100644
--- a/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts
+++ b/core-web/libs/new-block-editor/src/lib/editor/services/editor-popover.service.ts
@@ -1,12 +1,52 @@
import { Injectable, NgZone, inject, signal } from '@angular/core';
-export type PopoverId = 'image-properties' | 'link' | 'table' | 'emoji' | 'asset-by-url';
+export type PopoverId =
+ | 'image-properties'
+ | 'link'
+ | 'table'
+ | 'table-column'
+ | 'table-row'
+ | 'table-properties'
+ | 'emoji'
+ | 'asset-by-url';
/** Prefill payload for the {@link ImagePropertiesPopoverComponent} (edit-mode). */
export interface ImagePropertiesPayload {
initialValues: { src: string; title: string; alt: string };
}
+/**
+ * Prefill payload for the `TablePropertiesPopoverComponent` (caption + aria-label +
+ * aria-labelledby on the active table). Captures the current values so the form opens
+ * populated.
+ */
+export interface TablePropertiesPayload {
+ initialValues: {
+ caption: string;
+ hasCaption: boolean;
+ ariaLabel: string;
+ ariaLabelledby: string;
+ };
+}
+
+/**
+ * Prefill payload for the column-scoped popover. `cellPos` anchors edits to a specific
+ * cell so the popover keeps operating on the same cell even if the user clicks away while
+ * it's open.
+ */
+export interface TableColumnPayload {
+ cellPos: number;
+ /** True when the anchor cell is a ` ` — controls visibility of the scope select. */
+ isHeader: boolean;
+ /** Current `scope` attribute of the anchor cell, or `''` when unset. */
+ headerScope: string;
+}
+
+/** Prefill payload for the row-scoped popover. */
+export interface TableRowPayload {
+ cellPos: number;
+}
+
export interface LinkPopoverPayload {
initialValues?: {
href?: string;
@@ -28,9 +68,10 @@ interface ActivePopover {
}
/**
- * Owns the state for caret-anchored editor popovers (link, image-properties, table, emoji).
- * Each popover is rendered through the shared `` shell, which subscribes
- * to {@link activePopover} and positions itself against the trigger rect via floating-ui.
+ * Owns the state for caret-anchored editor popovers (link, image-properties, table, emoji,
+ * and the three table-handle popovers). Each popover is rendered through the shared
+ * `` shell, which subscribes to {@link activePopover} and positions
+ * itself against the trigger rect via floating-ui.
*
* Sibling to {@link EditorModalService}, which owns centered modal dialogs (AI content,
* AI image, image picker, video picker).
@@ -42,6 +83,9 @@ export class EditorPopoverService {
readonly activePopover = signal(null);
readonly imagePropertiesPayload = signal(null);
readonly linkPayload = signal(null);
+ readonly tablePropertiesPayload = signal(null);
+ readonly tableColumnPayload = signal(null);
+ readonly tableRowPayload = signal(null);
/**
* **Reactive:** reads {@link activePopover}, so calling this from inside an `effect()`
@@ -79,6 +123,37 @@ export class EditorPopoverService {
});
}
+ /**
+ * Opens the table-properties popover (caption + aria-label + aria-labelledby) anchored
+ * to the toolbar's `table_edit` button. Populated with the active table's current values.
+ */
+ openTableProperties(clientRectFn: () => DOMRect | null, payload: TablePropertiesPayload): void {
+ this.zone.run(() => {
+ this.tablePropertiesPayload.set(payload);
+ this.activePopover.set({ id: 'table-properties', clientRectFn });
+ });
+ }
+
+ /**
+ * Opens the column-scoped popover (insert L/R, toggle col header, header scope, delete
+ * column) anchored to the column handle. `cellPos` snapshot keeps the popover acting on
+ * the same column even if the user clicks elsewhere while it's open.
+ */
+ openTableColumn(clientRectFn: () => DOMRect | null, payload: TableColumnPayload): void {
+ this.zone.run(() => {
+ this.tableColumnPayload.set(payload);
+ this.activePopover.set({ id: 'table-column', clientRectFn });
+ });
+ }
+
+ /** Opens the row-scoped popover (insert above/below, toggle row header, delete row). */
+ openTableRow(clientRectFn: () => DOMRect | null, payload: TableRowPayload): void {
+ this.zone.run(() => {
+ this.tableRowPayload.set(payload);
+ this.activePopover.set({ id: 'table-row', clientRectFn });
+ });
+ }
+
close(): void {
this.zone.run(() => this.activePopover.set(null));
}
diff --git a/core-web/libs/new-block-editor/todo.md b/core-web/libs/new-block-editor/todo.md
new file mode 100644
index 00000000000..cf785d88c40
--- /dev/null
+++ b/core-web/libs/new-block-editor/todo.md
@@ -0,0 +1,4 @@
+# Table Accessibility (#35720) — Follow-ups
+
+- [ ] **Update the VTL file** to render the new table attributes in the table node — `caption`, `aria-label`, `aria-labelledby` (on ``), and `scope` (on ``) — so server-rendered (non-headless) pages match the editor output and meet WCAG 1.3.1 / 4.1.2.
+- [ ] **Add a merge-cells button** to the table handles to support merge / split actions. Merge / split / delete-table were dropped from the Phase 3 a11y popover; they still need a home for parity with the legacy table-editing surface.
diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
index 36809315a24..dcf014c296b 100644
--- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
+++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties
@@ -5976,6 +5976,30 @@ dot.block.editor.toolbar.table.toggle-column-header=Toggle column header
dot.block.editor.toolbar.table.delete-row=Delete row
dot.block.editor.toolbar.table.delete-column=Delete column
dot.block.editor.toolbar.table.delete-table=Delete table
+dot.block.editor.toolbar.table.properties=Table properties
+dot.block.editor.toolbar.table.header-scope=Header scope
+dot.block.editor.toolbar.table.scope.auto=Auto
+dot.block.editor.toolbar.table.scope.col=Column
+dot.block.editor.toolbar.table.scope.row=Row
+dot.block.editor.toolbar.table.scope.colgroup=Column group
+dot.block.editor.toolbar.table.scope.rowgroup=Row group
+
+# Table contextual handles (column / row / table-actions popovers)
+dot.block.editor.table.handle.column.aria-label=Column actions
+dot.block.editor.table.handle.row.aria-label=Row actions
+dot.block.editor.table.handle.table.aria-label=Table actions
+dot.block.editor.table.column.insert-left=Insert column left
+dot.block.editor.table.column.insert-right=Insert column right
+dot.block.editor.table.column.toggle-header=Toggle column header
+dot.block.editor.table.column.delete=Delete column
+dot.block.editor.table.row.insert-above=Insert row above
+dot.block.editor.table.row.insert-below=Insert row below
+dot.block.editor.table.row.toggle-header=Toggle row header
+dot.block.editor.table.row.delete=Delete row
+dot.block.editor.table.actions.merge-cells=Merge cells
+dot.block.editor.table.actions.split-cell=Split cell
+dot.block.editor.table.actions.delete=Delete table
+
dot.block.editor.toolbar.insert-emoji=Insert emoji
dot.block.editor.toolbar.full-screen=Full screen
dot.block.editor.toolbar.exit-full-screen=Exit full screen
@@ -6017,6 +6041,13 @@ dot.block.editor.dialog.table.field.rows=Rows
dot.block.editor.dialog.table.field.columns=Columns
dot.block.editor.dialog.table.field.header-row=Include header row
+# Table properties dialog
+dot.block.editor.dialog.table-properties.title=Table properties
+dot.block.editor.dialog.table-properties.caption=Caption
+dot.block.editor.dialog.table-properties.aria-label=Accessible name (aria-label)
+dot.block.editor.dialog.table-properties.aria-labelledby=Labelled by (aria-labelledby)
+dot.block.editor.dialog.table-properties.add-caption=Add caption
+
# Asset by URL dialog
dot.block.editor.dialog.asset-by-url.aria-label=Insert asset by URL
dot.block.editor.dialog.asset-by-url.title=Insert asset by URL