diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/editor-popover/editor-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/editor-popover/editor-popover.component.ts index 4b2ca8947231..dfdd8a6e5cec 100644 --- a/core-web/libs/new-block-editor/src/lib/editor/components/editor-popover/editor-popover.component.ts +++ b/core-web/libs/new-block-editor/src/lib/editor/components/editor-popover/editor-popover.component.ts @@ -44,16 +44,29 @@ import { EditorPopoverService, type PopoverId } from '../../services/editor-popo ` }) export class EditorPopoverComponent { - readonly popoverId = input.required(); + /** + * Accepts a single popover id or an array of ids. The shell is "open" when the + * service's active popover matches any of the listed ids — used by the unified + * `dot-table-handle-popover` to host the column / row / selection variants under + * a single shell. + */ + readonly popoverId = input.required(); - private readonly manager = inject(EditorPopoverService); - private readonly el = inject(ElementRef); - private readonly zone = inject(NgZone); - private readonly doc = inject(DOCUMENT); - private readonly injector = inject(Injector); + readonly #manager = inject(EditorPopoverService); + readonly #el = inject(ElementRef); + readonly #zone = inject(NgZone); + readonly #doc = inject(DOCUMENT); + readonly #injector = inject(Injector); - protected readonly isOpen = computed( - () => this.manager.activePopover()?.id === this.popoverId() + /** True when the service's active id matches this shell's id (or any of them). */ + #matchesActive(activeId: PopoverId | undefined): boolean { + if (activeId == null) return false; + const ids = this.popoverId(); + return Array.isArray(ids) ? ids.includes(activeId) : ids === activeId; + } + + protected readonly isOpen = computed(() => + this.#matchesActive(this.#manager.activePopover()?.id) ); protected readonly floatX = signal(0); protected readonly floatY = signal(0); @@ -65,8 +78,8 @@ export class EditorPopoverComponent { // so the popover stays anchored to its trigger rect (e.g. the cursor line) as the // user scrolls instead of getting stranded at its initial viewport spot. effect((onCleanup) => { - const active = this.manager.activePopover(); - if (!active || active.id !== this.popoverId()) { + const active = this.#manager.activePopover(); + if (!active || !this.#matchesActive(active.id)) { untracked(() => this.positioned.set(false)); return; } @@ -78,13 +91,13 @@ export class EditorPopoverComponent { }; const update = () => { - computePosition(virtualEl, this.el.nativeElement, { + computePosition(virtualEl, this.#el.nativeElement, { placement: 'bottom-start', strategy: 'fixed', middleware: [flip(), shift({ padding: 8 })] }).then(({ x, y }) => { const wasPositioned = untracked(() => this.positioned()); - this.zone.run(() => { + this.#zone.run(() => { untracked(() => { this.floatX.set(x); this.floatY.set(y); @@ -94,8 +107,8 @@ export class EditorPopoverComponent { if (!wasPositioned) { // Defer to next render so the visibility binding is painted // before .focus() runs — otherwise it no-ops on a hidden element. - afterNextRender(() => this.focusFirstInput(), { - injector: this.injector + afterNextRender(() => this.#focusFirstInput(), { + injector: this.#injector }); } }); @@ -106,12 +119,12 @@ export class EditorPopoverComponent { // never fires. Add a manual capture-phase scroll listener — same pattern the // slash menu uses — so the popover re-positions when the editor container scrolls. const onScroll = () => update(); - this.doc.addEventListener('scroll', onScroll, { passive: true, capture: true }); + this.#doc.addEventListener('scroll', onScroll, { passive: true, capture: true }); - const cleanup = autoUpdate(virtualEl, this.el.nativeElement, update); + const cleanup = autoUpdate(virtualEl, this.#el.nativeElement, update); onCleanup(() => { cleanup(); - this.doc.removeEventListener('scroll', onScroll, { capture: true }); + this.#doc.removeEventListener('scroll', onScroll, { capture: true }); }); }); @@ -120,29 +133,29 @@ export class EditorPopoverComponent { if (!this.isOpen()) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') this.zone.run(() => this.manager.close()); + if (e.key === 'Escape') this.#zone.run(() => this.#manager.close()); }; const onMouse = (e: MouseEvent) => { const target = e.target as Element | null; if (!target) return; - if (this.el.nativeElement.contains(target)) return; + if (this.#el.nativeElement.contains(target)) return; // PrimeNG overlay panels (e.g. with appendTo="body") render outside // this shell's DOM but logically belong to a control inside an open popover. // Treat clicks inside them as inside the popover so the popover stays open. if (target.closest('.p-overlay, .p-select-overlay')) return; - this.zone.run(() => this.manager.close()); + this.#zone.run(() => this.#manager.close()); }; - this.doc.addEventListener('keydown', onKey); - this.doc.addEventListener('mousedown', onMouse); + this.#doc.addEventListener('keydown', onKey); + this.#doc.addEventListener('mousedown', onMouse); onCleanup(() => { - this.doc.removeEventListener('keydown', onKey); - this.doc.removeEventListener('mousedown', onMouse); + this.#doc.removeEventListener('keydown', onKey); + this.#doc.removeEventListener('mousedown', onMouse); }); }); } - private focusFirstInput(): void { - const target = this.el.nativeElement.querySelector( + #focusFirstInput(): void { + const target = this.#el.nativeElement.querySelector( 'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled])' ) as HTMLElement | null; target?.focus(); diff --git a/core-web/libs/new-block-editor/src/lib/editor/components/table-popover/table-handle-popover.component.ts b/core-web/libs/new-block-editor/src/lib/editor/components/table-popover/table-handle-popover.component.ts new file mode 100644 index 000000000000..63fcea88dd03 --- /dev/null +++ b/core-web/libs/new-block-editor/src/lib/editor/components/table-popover/table-handle-popover.component.ts @@ -0,0 +1,354 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + Signal, + 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'; + +/** + * Unified popover for the three table handles (column / row / multi-cell selection). One + * `` shell is bound to all three popover ids; the rendered actions are + * picked from `COLUMN_ACTIONS`, `ROW_ACTIONS` or `SELECTION_ACTIONS` based on which id is + * currently active. Only one is open at a time — the `EditorPopoverService.activePopover` + * signal already enforces that — so a single component naturally owns all three variants + * with no behavior change versus the previous three-component setup. + * + * Adding a new action: drop an entry into {@link ACTIONS} and append the key to the right + * action list. The template's `@for` picks it up automatically. + * + * Two pieces of UI live outside the action loop: + * - The column variant's scope `` (only shown when the active cell is ``). + * - The selection variant's reactive `disabled` state, fed by `editor.can()`. + */ + +type ActionKey = + | 'columnInsertLeft' + | 'columnInsertRight' + | 'columnToggleHeader' + | 'columnDelete' + | 'rowInsertAbove' + | 'rowInsertBelow' + | 'rowToggleHeader' + | 'rowDelete' + | 'selectionMerge' + | 'selectionSplit'; + +interface ActionEntry { + testid: string; + icon: string; + labelKey: string; + handler: () => void; + variant?: 'danger'; + disabled?: Signal; +} + +@Component({ + selector: 'dot-table-handle-popover', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, Select, EditorPopoverComponent, DotMessagePipe], + template: ` + + + + `, + styles: [ + ` + /* + * PrimeNG escape hatch — can't be Tailwind. The component renders its own root + * with class .p-select; we need to stretch that element to fill the popover. + * styleClass="popover-field__select" lands on the .p-select root, then this + * rule pierces Angular view encapsulation to override PrimeNG's default width. + */ + :host ::ng-deep .popover-field__select.p-select, + :host ::ng-deep .popover-field__select .p-select { + width: 100%; + } + ` + ] +}) +export class TableHandlePopoverComponent implements OnDestroy { + readonly editor = input.required(); + protected readonly manager = inject(EditorPopoverService); + private readonly dotMessageService = inject(DotMessageService); + + protected readonly POPOVER_IDS = ['table-column', 'table-row', 'table-selection'] as const; + + // ── Action lists ───────────────────────────────────────────────────────── + // Plain lists of action keys per variant. The actual config lives in `ACTIONS` below. + + protected readonly COLUMN_ACTIONS: readonly ActionKey[] = [ + 'columnInsertLeft', + 'columnInsertRight', + 'columnToggleHeader', + 'columnDelete' + ]; + + protected readonly ROW_ACTIONS: readonly ActionKey[] = [ + 'rowInsertAbove', + 'rowInsertBelow', + 'rowToggleHeader', + 'rowDelete' + ]; + + protected readonly SELECTION_ACTIONS: readonly ActionKey[] = [ + 'selectionMerge', + 'selectionSplit' + ]; + + protected readonly currentActions = computed(() => { + const id = this.manager.activePopover()?.id; + if (id === 'table-column') return this.COLUMN_ACTIONS; + if (id === 'table-row') return this.ROW_ACTIONS; + if (id === 'table-selection') return this.SELECTION_ACTIONS; + return []; + }); + + protected readonly activeAriaLabel = computed(() => { + const id = this.manager.activePopover()?.id; + if (id === 'table-column') return 'dot.block.editor.table.handle.column.aria-label'; + if (id === 'table-row') return 'dot.block.editor.table.handle.row.aria-label'; + if (id === 'table-selection') return 'dot.block.editor.table.handle.selection.aria-label'; + return ''; + }); + + // ── Selection variant: reactive can-merge / can-split ──────────────────── + + protected readonly canMerge = signal(false); + protected readonly canSplit = signal(false); + private readonly canNotMerge = computed(() => !this.canMerge()); + private readonly canNotSplit = computed(() => !this.canSplit()); + + // ── Column variant: scope select state ─────────────────────────────────── + + protected readonly scope = signal(''); + protected readonly showScopeSelect = computed( + () => + this.manager.activePopover()?.id === 'table-column' && + (this.manager.tableColumnPayload()?.isHeader ?? false) + ); + protected readonly scopeOptions: ReadonlyArray<{ label: string; value: string }>; + + // ── Action registry ────────────────────────────────────────────────────── + + protected readonly ACTIONS: Record = { + columnInsertLeft: { + testid: 'col-insert-left', + icon: 'add_column_left', + labelKey: 'dot.block.editor.table.column.insert-left', + handler: () => this.runColumnCommand((ed) => ed.chain().focus().addColumnBefore().run()) + }, + columnInsertRight: { + testid: 'col-insert-right', + icon: 'add_column_right', + labelKey: 'dot.block.editor.table.column.insert-right', + handler: () => this.runColumnCommand((ed) => ed.chain().focus().addColumnAfter().run()) + }, + columnToggleHeader: { + testid: 'col-toggle-header', + icon: 'view_column', + labelKey: 'dot.block.editor.table.column.toggle-header', + handler: () => + this.runColumnCommand((ed) => ed.chain().focus().toggleHeaderColumn().run()) + }, + columnDelete: { + testid: 'col-delete', + icon: 'delete', + labelKey: 'dot.block.editor.table.column.delete', + variant: 'danger', + handler: () => this.runColumnCommand((ed) => ed.chain().focus().deleteColumn().run()) + }, + rowInsertAbove: { + testid: 'row-insert-above', + icon: 'add_row_above', + labelKey: 'dot.block.editor.table.row.insert-above', + handler: () => this.runRowCommand((ed) => ed.chain().focus().addRowBefore().run()) + }, + rowInsertBelow: { + testid: 'row-insert-below', + icon: 'add_row_below', + labelKey: 'dot.block.editor.table.row.insert-below', + handler: () => this.runRowCommand((ed) => ed.chain().focus().addRowAfter().run()) + }, + rowToggleHeader: { + testid: 'row-toggle-header', + icon: 'table_rows', + labelKey: 'dot.block.editor.table.row.toggle-header', + handler: () => this.runRowCommand((ed) => ed.chain().focus().toggleHeaderRow().run()) + }, + rowDelete: { + testid: 'row-delete', + icon: 'delete', + labelKey: 'dot.block.editor.table.row.delete', + variant: 'danger', + handler: () => this.runRowCommand((ed) => ed.chain().focus().deleteRow().run()) + }, + selectionMerge: { + testid: 'selection-merge', + icon: 'cell_merge', + labelKey: 'dot.block.editor.table.selection.merge-cells', + disabled: this.canNotMerge, + handler: () => this.editor().chain().focus().mergeCells().run() + }, + selectionSplit: { + testid: 'selection-split', + icon: 'call_split', + labelKey: 'dot.block.editor.table.selection.split-cell', + disabled: this.canNotSplit, + handler: () => this.editor().chain().focus().splitCell().run() + } + }; + + private cleanupCanListener: (() => void) | null = null; + + 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 scope from the column payload when the popover opens for the column variant. + effect(() => { + const payload = this.manager.tableColumnPayload(); + const open = this.manager.isOpen('table-column'); + untracked(() => { + if (open && payload) this.scope.set(payload.headerScope); + }); + }); + + // Mirror editor.can().mergeCells() / splitCell() to signals so the selection-variant + // action entries can bind a reactive `disabled` state. + effect(() => { + const ed = this.editor(); + this.cleanupCanListener?.(); + + const update = () => { + this.canMerge.set(ed.can().mergeCells()); + this.canSplit.set(ed.can().splitCell()); + }; + update(); + ed.on('transaction', update); + this.cleanupCanListener = () => ed.off('transaction', update); + }); + } + + ngOnDestroy(): void { + this.cleanupCanListener?.(); + } + + // ── Click + command helpers ────────────────────────────────────────────── + + /** + * Tailwind utility classes for a menu-item button. Splits the static base classes from + * the per-variant color set so the disabled / hover states cascade cleanly without + * fighting `:disabled` ordering rules. + */ + protected actionClass(action: ActionEntry): string { + const base = + 'flex w-full cursor-pointer items-center gap-2 border-0 bg-transparent px-3 py-2 text-left text-sm disabled:cursor-not-allowed disabled:hover:bg-transparent'; + const variant = + action.variant === 'danger' + ? 'text-red-700 hover:bg-red-100 disabled:text-red-300' + : 'text-gray-700 hover:bg-indigo-50 disabled:text-gray-400'; + return `${base} ${variant}`; + } + + protected run(event: MouseEvent, handler: () => void): void { + event.preventDefault(); + event.stopPropagation(); + handler(); + this.manager.close(); + } + + /** Place selection inside the column-payload's cell, then run the chain. */ + private runColumnCommand(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); + } + + /** Place selection inside the row-payload's cell, then run the chain. */ + private runRowCommand(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 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-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 000000000000..61a0fcb3328b --- /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 b038a46ddd95..4f7540c8410d 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 046fce0fe90c..a3f221ed841e 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 selectionHandle = makeHandleButton( + 'selection', + 'drag_indicator', + options.selectionAriaLabel + ); + + const content = document.createElement('div'); + content.className = 'dot-cell-content'; + + cell.append(colHandle, rowHandle, selectionHandle, 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 selection handle is only visible when this cell carries the + // `is-selection-anchor` decoration (multi-cell CellSelection). The CellSelection + // itself stays alive across the click via mousedown.preventDefault, so the merge + // / split commands invoked from the popover see the right selection. + selectionHandle.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopPropagation(); + popovers.openTableSelection(() => selectionHandle.getBoundingClientRect()); + }); + } + + // 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' | 'selection', + icon: string, + ariaLabel: string +): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + const modifier = kind === 'column' ? 'col' : kind === 'row' ? 'row' : 'selection'; + button.className = `dot-cell-handle dot-cell-handle--${modifier}`; + 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', + selectionAriaLabel: 'Selection actions' + }; + }, + addNodeView() { + return makeCellNodeViewFactory('td', this.options); + } +}); + +const DotTableHeader = TableHeader.extend({ + addOptions() { + return { + ...this.parent?.(), + popovers: null, + columnAriaLabel: 'Column actions', + rowAriaLabel: 'Row actions', + selectionAriaLabel: 'Selection 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 000000000000..b581e1fe704a --- /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 ` #* *##renderContentBlock($element.content) #* *# #* *##elseif( $element.type == "tableHeader" ) -#* *# +#* *# #* *##elseif( $element.type == "tableCell" ) #* *# #* *##elseif( $element.type == "hardBreak" )#* diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/table.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/table.vtl index 0018c6d325bf..df64291d96e3 100644 --- a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/table.vtl +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/table.vtl @@ -1,5 +1,6 @@ #parse( "static/storyblock/render.vtl" ) -
` 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 ee2799eeb2cd..a5cd1882d477 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,53 @@ 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-selection' + | '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 +69,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 +84,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 +124,48 @@ 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 }); + }); + } + + /** + * Opens the multi-cell selection popover (merge cells / split cell). Anchored to the + * selection handle (right edge of the bottom-right cell of the active CellSelection). + * No payload — the popover reads `editor.can().mergeCells()` / `splitCell()` directly. + */ + openTableSelection(clientRectFn: () => DOMRect | null): void { + this.zone.run(() => { + this.activePopover.set({ id: 'table-selection', clientRectFn }); + }); + } + close(): void { this.zone.run(() => this.activePopover.set(null)); } diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/table.component.ts b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/table.component.ts index f91d120e344f..26c3de1e48aa 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/table.component.ts +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/blocks/table.component.ts @@ -8,37 +8,43 @@ import { DotCMSBlockEditorItemComponent } from '../item/dotcms-block-editor-item selector: 'dotcms-block-editor-renderer-table', imports: [NgComponentOutlet], template: ` - - - @for (rowNode of content?.slice(0, 1); track rowNode.type) { - - @for (cellNode of rowNode.content; track cellNode.type) { - - } - - } - +
- -
+ @if (attrs?.['caption']) { + + } - @for (rowNode of content?.slice(1); track rowNode.type) { + @for (rowNode of content; track $index) { - @for (cellNode of rowNode.content; track cellNode.type) { - + @for (cellNode of rowNode.content; track $index) { + + @if (cellNode.type === 'tableHeader') { + + } @else { + + } } } @@ -48,5 +54,10 @@ import { DotCMSBlockEditorItemComponent } from '../item/dotcms-block-editor-item }) export class DotTableBlock { @Input() content: BlockEditorNode[] | undefined; + /** + * Table-node attributes (`caption`, `ariaLabel`, `ariaLabelledby`). Optional for + * back-compat with older payloads that don't carry these. + */ + @Input() attrs: BlockEditorNode['attrs']; blockEditorItem = DotCMSBlockEditorItemComponent; } diff --git a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html index 9e6c6df28d81..28cb63d9a2ae 100644 --- a/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html +++ b/core-web/libs/sdk/angular/src/lib/components/dotcms-block-editor-renderer/item/dotcms-block-editor-item.component.html @@ -86,7 +86,7 @@ } @case (BLOCKS.TABLE) { - + } @case (BLOCKS.GRID_BLOCK) { diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx index 7bb28b947826..00ea6cb921f5 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/BlockEditorBlock.tsx @@ -149,6 +149,7 @@ export const BlockEditorBlock = ({ ); diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Table.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Table.tsx index f90c1b30548e..0a58108d4c50 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Table.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSBlockEditorRenderer/components/blocks/Table.tsx @@ -4,54 +4,71 @@ import { BlockEditorNode } from '@dotcms/types'; interface TableRendererProps { content: BlockEditorNode[]; + /** + * Table-node attributes (`caption`, `ariaLabel`, `ariaLabelledby`). Optional for + * back-compat with older payloads that don't carry these. + */ + attrs?: BlockEditorNode['attrs']; blockEditorItem: React.FC<{ content: BlockEditorNode[]; }>; } /** - * Renders a table component for the Block Editor. + * Renders a table block for the Block Editor. * - * @param content - The content of the table. - * @param blockEditorItem - The Block Editor item component. + * **Cell-type-aware**: each cell is emitted as `#end#renderContentBlock($element.content)
{{ attrs?.['caption'] }}
- - + + + +
` or `` based on the node's + * `type` (`tableHeader` vs `tableCell`) rather than its row position — so column-header + * cells in any row (the result of "Toggle column header" in the editor) keep their + * semantic `` wrapper and the `scope` attribute reaches headless consumers. + * + * @param content - The table's child rows. + * @param attrs - Optional table-level attributes (`caption`, `ariaLabel`, `ariaLabelledby`). + * @param blockEditorItem - The Block Editor item component for nested content. */ export const TableRenderer: React.FC = ({ content, + attrs, blockEditorItem }: TableRendererProps) => { const BlockEditorItemComponent = blockEditorItem; - const renderTableContent = (node: BlockEditorNode) => { - return ; - }; + const renderCellContent = (node: BlockEditorNode) => ( + + ); + + const caption: string | undefined = attrs?.caption || undefined; + const ariaLabel: string | undefined = attrs?.ariaLabel || undefined; + const ariaLabelledBy: string | undefined = attrs?.ariaLabelledby || undefined; return ( - - - {content.slice(0, 1).map((rowNode, rowIndex) => ( - - {rowNode.content?.map((cellNode, cellIndex) => ( - - ))} - - ))} - +
- {renderTableContent(cellNode)} -
+ {caption ? : null} - {content.slice(1).map((rowNode, rowIndex) => ( - - {rowNode.content?.map((cellNode, cellIndex) => ( - - ))} + {content.map((rowNode, rowIndex) => ( + + {rowNode.content?.map((cellNode, cellIndex) => { + const colSpan = Number(cellNode.attrs?.colspan || 1); + const rowSpan = Number(cellNode.attrs?.rowspan || 1); + // Cell type — not row index — decides th vs td. Matches the + // VTL renderer (storyblock/render.vtl). + if (cellNode.type === 'tableHeader') { + return ( + + ); + } + return ( + + ); + })} ))} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 2af698fc7c78..0b263ab19aae 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -6033,6 +6033,33 @@ 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.handle.selection.aria-label=Selection actions +dot.block.editor.table.selection.merge-cells=Merge cells +dot.block.editor.table.selection.split-cell=Split cell +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 @@ -6074,6 +6101,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 diff --git a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl index b77d7d4ec801..165703066b3e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl +++ b/dotCMS/src/main/webapp/WEB-INF/velocity/static/storyblock/render.vtl @@ -49,13 +49,13 @@ *##renderContentBlock($element.content)#* *##* *##elseif( $element.type == "table" ) -#* *#
{caption}
- {renderTableContent(cellNode)} -
+ {renderCellContent(cellNode)} + + {renderCellContent(cellNode)} +
#renderContentBlock($element.content)
+#* *##if($!element.attrs.caption)
$esc.html($!element.attrs.caption)
#* *##elseif( $element.type == "tableRow" ) #* *#
#renderContentBlock($element.content)#renderContentBlock($element.content)#renderContentBlock($element.content)
+ +#if($!item.attrs.caption)#end #renderContentBlock($!item.content)
$esc.html($!item.attrs.caption)