Skip to content

feat(YfmTable): support header rows in yfm-table#1132

Merged
d3m1d0v merged 12 commits into
mainfrom
d3m1d0v/table-header-rows
May 18, 2026
Merged

feat(YfmTable): support header rows in yfm-table#1132
d3m1d0v merged 12 commits into
mainfrom
d3m1d0v/table-header-rows

Conversation

@d3m1d0v
Copy link
Copy Markdown
Member

@d3m1d0v d3m1d0v commented May 12, 2026

Summary by Sourcery

Add support for configurable header rows in YFM tables, including parsing/serialization, editor controls, and visual behavior, and update related tooling and tests.

New Features:

  • Allow YFM tables to declare header rows via a header-rows attribute that is parsed from markup and rendered back to markup and DOM.
  • Expose a header rows option in the YfmTable extension and demos to enable header row controls in the editor UI, including toggling rows as headers from the row menu.

Bug Fixes:

  • Align YfmTabs ARIA ids and aria-controls values by prefixing generated panel ids consistently for regular and radio-style tabs.

Enhancements:

  • Decorate header rows in tables with data-header attributes and columnheader roles for improved visuals and accessibility, and prevent drag-and-drop operations that would break header sections.
  • Adjust table row insertion, deletion, and drag-and-drop commands to keep the header-rows count in sync when header rows are added, removed, or moved.
  • Refactor non-structural transaction detection into a shared utility and reuse it in the folding plugin to avoid unnecessary recomputation.
  • Extend table focus, DnD styling, and cell querying logic to support both th and td elements as first-column controls.

Build:

  • Bump @diplodoc/transform dependency to 4.75.1 in the workspace.

Tests:

  • Add unit and visual tests covering header-rows parsing/serialization, header toggling behavior, and its interaction with row insertion, deletion, and drag-and-drop.
  • Add Playwright helpers and stories to exercise header row behavior in the table DnD demo, and adjust the Playwright Docker script and package scripts for running tests with UI.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 12, 2026

Reviewer's Guide

Adds configurable header row support to YFM tables across parsing, schema, serialization, node views, controls, DnD, and decoration plugins, exposes it via the YfmTable extension and demos, and introduces shared transaction-shape utilities used by both header-row decoration and folding logic, plus small accessibility and test updates unrelated to tables.

File-Level Changes

row count, and serialize it back into YFM markup via a header-rows attribute line.
  • Update the YFM table parser to read headerRows metadata from tokens into node attrs.
  • Extend TableDesc to carry headerRows metadata and provide an isHeaderRow helper used by other components.
  • Add toggleHeaderRows and canMakeRowHeader helpers and wire header-rows preservation/shrinking into insertEmptyRow, removeRowRange, and row DnD logic.
  • Change Details Files
    Implement header rows as a first-class table attribute across schema, parser, serializer, table descriptor, and commands.
    • Extend YfmTable schema to include a HeaderRows attribute, parse it from data-header-rows and
    packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/schema.ts
    packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/parser.ts
    packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/serializer.ts
    packages/editor/src/extensions/yfm/YfmTable/YfmTableSpecs/const.ts
    packages/editor/src/table-utils/table-desc.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/insert-empty-row.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/remove-row-range.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/toggle-header-rows.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/toggle-header-rows.test.ts
    Add visual, behavioral, and editor-control support for header rows in the YFM table UI.
    • Augment the cell node view to know whether its row is a header, reflect that via role='columnheader', and expose isRowHeader/canToggleRowHeader to the floating row menu.
    • Extend the floating menu control to render a header-row toggle item with icon and switch, guarded by the canToggleRowHeader flag and test IDs for Playwright.
    • Introduce a header-rows decoration plugin that marks header rows with data-header and skips non-structural transactions using a shared utility.
    • Disallow DnD of header rows and adjust header-rows counts when dragging header blocks across the table, plus ensure focus and hover logic works for both td and th cells and header styling for selected cells.
    • Enable header rows in the YfmTable extension via a headerRows option passed into controls plugins, and wire the demo Playground and DnD story to turn this feature on and pass YFM mods through.
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.scss
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/components/FloatingMenuControl/FloatingMenuControl.tsx
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/header-rows-plugin.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/dnd/dnd.scss
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/index.ts
    packages/editor/src/extensions/yfm/YfmTable/index.ts
    demo/src/components/Playground.tsx
    demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.tsx
    demo/src/stories/examples/yfm-table-dnd/YfmTableDnD.stories.tsx
    Add tests and Playwright helpers to validate header row behavior and YFM table UI interactions.
    • Add parser tests covering tables with header-rows="1" and header-rows="2" attributes and correct mapping into table attrs and rows.
    • Create Playwright visual and interaction tests for toggling header rows, shrinking header blocks on row insertion/removal, and validating data-header attributes and screenshot output.
    • Extend the Playwright YfmTable helper to support a header-toggle action and to query both th and td cells, and pass YfmMods into the playground to control table striping for visual tests.
    packages/editor/src/extensions/yfm/YfmTable/YfmTable.test.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/toggle-header-rows.test.ts
    demo/tests/playwright/core/editor.ts
    demo/tests/visual-tests/playground/YfmTable.visual.test.tsx
    demo/src/components/Playground.tsx
    Introduce a reusable transaction-shape helper and consume it in folding and header-row decoration logic.
    • Add isTextInsertTr, isSingleCharDeleteTr, and isNonStructuralTr helpers that classify transactions touching only text/marks/doc attrs without changing block structure.
    • Refactor the folding plugin to use isNonStructuralTr instead of its local canSafelyIgnoreTr implementation, deleting duplicated step classification logic.
    • Use isNonStructuralTr in the header-rows plugin to avoid rebuilding decorations for non-structural transactions.
    packages/editor/src/utils/transaction.ts
    packages/editor/src/extensions/additional/FoldingHeading/plugins/Folding.ts
    packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/header-rows-plugin.ts
    Apply small accessibility and tooling updates unrelated to header rows.
    • Adjust YfmTabs tests to expect prefixed aria-controls and panel IDs, aligning with updated tab-panel ID generation for regular and radio/tab-group modes.
    • Update the Playwright Docker script and npm scripts to expose the Playwright UI over port 8082 without using host networking, and add a dedicated e2e UI test script.
    • Bump @diplodoc/transform in the workspace catalog to 4.75.1 to support header rows and update lockfile accordingly.
    packages/editor/src/extensions/yfm/YfmTabs/YfmTabs.test.ts
    demo/scripts/playwright-docker.sh
    demo/package.json
    package.json
    pnpm-workspace.yaml
    pnpm-lock.yaml

    Tips and commands

    Interacting with Sourcery

    • Trigger a new review: Comment @sourcery-ai review on the pull request.
    • Continue discussions: Reply directly to Sourcery's review comments.
    • Generate a GitHub issue from a review comment: Ask Sourcery to create an
      issue from a review comment by replying to it. You can also reply to a
      review comment with @sourcery-ai issue to create an issue from it.
    • Generate a pull request title: Write @sourcery-ai anywhere in the pull
      request title to generate a title at any time. You can also comment
      @sourcery-ai title on the pull request to (re-)generate the title at any time.
    • Generate a pull request summary: Write @sourcery-ai summary anywhere in
      the pull request body to generate a PR summary at any time exactly where you
      want it. You can also comment @sourcery-ai summary on the pull request to
      (re-)generate the summary at any time.
    • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
      request to (re-)generate the reviewer's guide at any time.
    • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
      pull request to resolve all Sourcery comments. Useful if you've already
      addressed all the comments and don't want to see them anymore.
    • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
      request to dismiss all existing Sourcery reviews. Especially useful if you
      want to start fresh with a new review - don't forget to comment
      @sourcery-ai review to trigger a new review!

    Customizing Your Experience

    Access your dashboard to:

    • Enable or disable review features such as the Sourcery-generated pull request
      summary, the reviewer's guide, and others.
    • Change the review language.
    • Add, remove or edit custom review instructions.
    • Adjust other review settings.

    Getting Help

    @gravity-ui
    Copy link
    Copy Markdown

    gravity-ui Bot commented May 12, 2026

    Storybook Deployed

    @gravity-ui
    Copy link
    Copy Markdown

    gravity-ui Bot commented May 12, 2026

    🎭 Playwright Report

    @d3m1d0v d3m1d0v force-pushed the d3m1d0v/table-header-rows branch from fd5adb4 to 9b9c573 Compare May 12, 2026 18:19
    @d3m1d0v d3m1d0v force-pushed the d3m1d0v/table-header-rows branch from 9b9c573 to 83303d2 Compare May 13, 2026 21:54
    @makhnatkin
    Copy link
    Copy Markdown
    Collaborator

    1. I suggest not using a switcher. Instead, change the icon and text: “Enable header” / “Disable header”.
    2. I suggest disabling drag for enabled headers.
    3. I would not add a header toggle for column selection, because users may think it affects the whole column, not only the first cell.

    @d3m1d0v d3m1d0v force-pushed the d3m1d0v/table-header-rows branch from 83303d2 to 1439cf4 Compare May 14, 2026 16:36
    @d3m1d0v
    Copy link
    Copy Markdown
    Member Author

    d3m1d0v commented May 14, 2026

    2. I suggest disabling drag for enabled headers.

    Done

    @d3m1d0v
    Copy link
    Copy Markdown
    Member Author

    d3m1d0v commented May 14, 2026

    3. I would not add a header toggle for column selection, because users may think it affects the whole column, not only the first cell.

    Done

    @d3m1d0v d3m1d0v force-pushed the d3m1d0v/table-header-rows branch from 12f214f to f75f0e2 Compare May 18, 2026 11:29
    @d3m1d0v d3m1d0v marked this pull request as ready for review May 18, 2026 11:52
    Copy link
    Copy Markdown

    @sourcery-ai sourcery-ai Bot left a comment

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Hey - I've found 4 issues, and left some high level feedback:

    • In yfm-table-cell-view.tsx, _computeIsHeader recomputes TableDesc via _getCellInfo() separately from the main _getCellInfo() call used to populate _cellInfo, which both duplicates work and risks _isHeader temporarily diverging from _cellInfo.isRowHeader; consider deriving _isHeader directly from the cellInfo/tableDesc you already have in update()/selectNode() instead of calling _getCellInfo() twice.
    • yfmTableFocusPlugin now accepts a headerRowsEnabled option but does not use it anywhere; if it’s not needed for focus/hover behavior, drop the parameter to avoid confusion, or wire it into the logic where header-specific focus should be treated differently.
    • The row DnD logic that computes insertIdx and adjusts headerRows in YfmTableRowDnDHandler is fairly dense and embedded in drop(), making it hard to reason about; consider extracting the insert-index calculation and header-rows update into small named helpers so the drag behavior and header adjustment rules are easier to read and maintain.
    Prompt for AI Agents
    Please address the comments from this code review:
    
    ## Overall Comments
    - In `yfm-table-cell-view.tsx`, `_computeIsHeader` recomputes `TableDesc` via `_getCellInfo()` separately from the main `_getCellInfo()` call used to populate `_cellInfo`, which both duplicates work and risks `_isHeader` temporarily diverging from `_cellInfo.isRowHeader`; consider deriving `_isHeader` directly from the `cellInfo`/`tableDesc` you already have in `update()`/`selectNode()` instead of calling `_getCellInfo()` twice.
    - `yfmTableFocusPlugin` now accepts a `headerRowsEnabled` option but does not use it anywhere; if it’s not needed for focus/hover behavior, drop the parameter to avoid confusion, or wire it into the logic where header-specific focus should be treated differently.
    - The row DnD logic that computes `insertIdx` and adjusts `headerRows` in `YfmTableRowDnDHandler` is fairly dense and embedded in `drop()`, making it hard to reason about; consider extracting the insert-index calculation and header-rows update into small named helpers so the drag behavior and header adjustment rules are easier to read and maintain.
    
    ## Individual Comments
    
    ### Comment 1
    <location path="packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/plugins/focus-plugin.ts" line_range="34" />
    <code_context>
         return true;
     }
    
    -export const yfmTableFocusPlugin = (opts: {dndEnabled: boolean}) => {
    +export const yfmTableFocusPlugin = (opts: {dndEnabled: boolean; headerRowsEnabled: boolean}) => {
         return new Plugin<PluginState>({
             key: pluginKey,
    </code_context>
    <issue_to_address>
    **nitpick:** `headerRowsEnabled` is added to the plugin options but not used.
    
    The plugin now accepts `headerRowsEnabled`, but the option is unused and the `closest('td,th')` behavior is always applied. To avoid confusion, either integrate `headerRowsEnabled` into the focus logic (e.g., only treat `th` specially when it’s true) or remove the option until it’s needed.
    </issue_to_address>
    
    ### Comment 2
    <location path="packages/editor/src/table-utils/table-desc.ts" line_range="135-138" />
    <code_context>
             // <--- validation
    
    -        const desc = new this(rows.length, rows[0].cells.length, rows, baseOffset);
    +        const rawHeaderRows = Number(table.attrs['data-header-rows']) || 0;
    +        const headerRows = Math.max(0, Math.min(rawHeaderRows, rows.length));
    +
    +        const desc = new this(rows.length, rows[0].cells.length, rows, baseOffset, headerRows);
             this.__cache.set(table, desc);
             return desc;
    </code_context>
    <issue_to_address>
    **suggestion:** Directly using the string `'data-header-rows'` risks drifting from the schema attribute definition.
    
    Since the schema already defines `HeaderRows` via `YfmTableAttr`, consider using that constant instead of `'data-header-rows'` here to keep the implementation aligned with the schema and avoid future drift if the attribute name changes.
    
    Suggested implementation:
    
    ```typescript
            const rawHeaderRows = Number(table.attrs[YfmTableAttr.HeaderRows]) || 0;
    
    ```
    
    1. Ensure `YfmTableAttr` is imported in this file from the module where the table schema is defined (likely something like `../schema` or `../schema/yfm-table`), e.g.:
       `import {YfmTableAttr} from '../schema';`
    2. If the file already imports `YfmTableAttr` under a different name or from a barrel file, reuse that existing import instead of adding a new one.
    </issue_to_address>
    
    ### Comment 3
    <location path="packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/commands/insert-empty-row.ts" line_range="53-55" />
    <code_context>
                 tr.insert(posToInsert, createSimpleRow(state.schema, newCellsCount));
                 tr.setSelection(TextSelection.near(tr.doc.resolve(posToInsert), 1));
    +
    +            // If the new row is inserted inside the header-rows block, shrink the block
    +            // so the new row and everything below it stop being header rows.
    +            if (tableDesc.base.isHeaderRow(rowIdx)) {
    +                tr.setNodeAttribute(params.tablePos, YfmTableAttr.HeaderRows, rowIdx);
    +            }
    </code_context>
    <issue_to_address>
    **question:** Shrinking the header block on insert may be surprising when inserting at the very first header row.
    
    For `rowIdx > 0`, shrinking `headerRows` to `rowIdx` matches the comment. But when `rowIdx === 0`, this sets `HeaderRows` to 0 and removes all header rows, which may not match the expected UX of inserting a body row above existing headers. Please confirm the intended behavior and consider special-casing `rowIdx === 0` if headers should be preserved.
    </issue_to_address>
    
    ### Comment 4
    <location path="packages/editor/src/extensions/yfm/YfmTable/plugins/YfmTableControls/nodeviews/yfm-table-cell-view.tsx" line_range="71" />
    <code_context>
         private readonly _dndEnabled: boolean;
    +    private readonly _headerRowsEnabled: boolean;
    
    +    private _isHeader: boolean;
         private _decoRowUniqKey: number | null = null;
         private _decoColumnUniqKey: number | null = null;
    </code_context>
    <issue_to_address>
    **issue (complexity):** Consider computing header state only once in update and inlining the header toggle logic to remove redundant work and indirection.
    
    You can simplify this change without losing functionality by:
    
    ### 1. Remove duplicated header-state computation
    
    You currently compute header state twice via `_computeIsHeader()` and again in `update()` via `desc.isHeaderRow(...)`, both going through `_getCellInfo`.
    
    You can keep a single source of truth by:
    
    - Dropping `_computeIsHeader` and the `node` parameter on `_getCellInfo`.
    - Computing `isHeader` once in `update()` from a single `cellInfo`.
    - Using that for both DOM updates and `_cellInfo.isRowHeader`.
    
    Example:
    
    ```ts
    // remove this entirely
    // private _computeIsHeader(): boolean {
    //     if (!this._headerRowsEnabled) return false;
    //     const info = this._getCellInfo();
    //     if (!info) return false;
    //     return info.tableDesc.base.isHeaderRow(info.cell.row);
    // }
    
    // keep only a narrow _getCellInfo for the current node
    private _getCellInfo() {
        const table = this._getParentTable();
        const tableDesc = table ? TableDesc.create(table.node)?.bind(table.pos) : undefined;
        const cellInfo = tableDesc?.base.getCellInfo(this._node);
        return cellInfo
            ? {pos: this._getPos()!, table: table!, tableDesc: tableDesc!, cell: cellInfo}
            : undefined;
    }
    ```
    
    Then in `update`:
    
    ```ts
    update(node: Node, decorations: readonly Decoration[]): boolean {
        const prev = this._node;
        this._node = node;
    
        const cellInfo = this._getCellInfo();
        const desc = cellInfo?.tableDesc.base;
    
        const isHeader =
            this._headerRowsEnabled &&
            !!desc &&
            desc.isHeaderRow(cellInfo!.cell.row);
    
        this._isHeader = isHeader; // or drop the field and pass isHeader into _updateDom
        this._updateDom(prev);
    
        if (cellInfo && (cellInfo.cell.row === 0 || cellInfo.cell.column === 0)) {
            const info: YfmTableCellView['_cellInfo'] = (this._cellInfo = {
                tablePos: cellInfo.table.pos,
                rowIndex: cellInfo.cell.row,
                columnIndex: cellInfo.cell.column,
                showRowControl: false,
                showColumnControl: false,
                rowRange: desc!.getRowRangeByRowIdx(cellInfo.cell.row),
                columnRange: desc!.getColumnRangeByColumnIdx(cellInfo.cell.column),
                isRowHeader: isHeader,
                canToggleRowHeader:
                    this._headerRowsEnabled &&
                    (isHeader || canMakeRowHeader(desc!, cellInfo.cell.row)),
            });
    
            // existing decorations loop...
        }
    
        return true;
    }
    ```
    
    And `\_updateDom` stays focused on a single boolean:
    
    ```ts
    private _updateDom(prev?: Node) {
        if (this._isHeader) {
            if (this.dom.getAttribute('role') !== 'columnheader') {
                this.dom.setAttribute('role', 'columnheader');
            }
        } else if (this.dom.hasAttribute('role')) {
            this.dom.removeAttribute('role');
        }
    
        // existing align/colspan/rowspan logic…
    }
    ```
    
    This removes the second `TableDesc`/cell-info recomputation and makes `isRowHeader` the single source of truth (for both DOM and menu).
    
    ### 2. Inline `_toggleHeaderRows` to avoid one-off abstraction
    
    `_toggleHeaderRows` is currently only used by `_onRowHeaderChange`, which makes the `event`, `source`, and `getValue` indirection unnecessary.
    
    You can inline it into `_onRowHeaderChange` and keep all behavior:
    
    ```ts
    // remove _toggleHeaderRows()
    
    private _onRowHeaderChange = (value: boolean) => {
        this._logger.event({
            event: value ? 'row-set-header' : 'row-unset-header',
            source: 'row-menu',
        });
    
        const info = this._getCellInfo();
        if (info) {
            const rowRange = info.tableDesc.base.getRowRangeByRowIdx(info.cell.row);
            const newValue = value ? rowRange.endIdx + 1 : rowRange.startIdx;
    
            toggleHeaderRows({
                tablePos: info.table.pos,
                value: newValue,
            })(this._view.state, this._view.dispatch);
        }
    
        this._view.focus();
    };
    ```
    
    This keeps the logging, range computation, and command dispatch identical, but removes one level of indirection and makes the header toggle flow easier to follow.
    </issue_to_address>

    Sourcery is free for open source - if you like our reviews please consider sharing them ✨
    Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

    Comment thread packages/editor/src/table-utils/table-desc.ts
    @d3m1d0v d3m1d0v merged commit 022fba6 into main May 18, 2026
    7 checks passed
    @d3m1d0v d3m1d0v deleted the d3m1d0v/table-header-rows branch May 18, 2026 13:16
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Labels

    None yet

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    2 participants