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: ` + + + + `, + 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: ` + + + + `, + 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: ` + +
+
+

+ {{ 'dot.block.editor.dialog.table-properties.title' | dm }} +

+ + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ ` +}) +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)"> - } - - @if (isAllowed('table')) { - - - - - - - - - - - - - - - - - - - - - - - - - } + + @if (isAllowed('emoji')) {
` / `` 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