From b19bdd570c26b125253a4b65b53ae7a16dda6160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Wed, 22 Jul 2026 00:39:48 -0700 Subject: [PATCH] fix(data-table): keep right-pinned action column header sticky on horizontal scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row-actions column is pinned right by injecting `sticky right-0` into the column className, which reaches both body cells and the header cell. Body cells stayed pinned, but the header appended a `relative` (for the resize handle) that — via tailwind-merge — clobbered the injected `sticky`, so the "操作" title scrolled away while its cells stayed frozen. Detect a right-pinned header (className carries `sticky`+`right-0`), skip `relative` for it (a sticky cell is its own positioning context, so the absolute resize handle still anchors), and re-assert `sticky right-0 z-20` after col.className so tailwind-merge keeps the pin above the body cells (z-10). Add a header-`th` sticky assertion to row-action-sticky (the test only covered body `td` — exactly the gap that let this through). Closes objectstack-ai/objectui#2784 Co-Authored-By: Claude Opus 4.8 --- .changeset/data-table-sticky-action-header.md | 19 +++++++++++++++++++ .../src/renderers/complex/data-table.tsx | 18 +++++++++++++++++- .../src/__tests__/row-action-sticky.test.tsx | 14 ++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 .changeset/data-table-sticky-action-header.md diff --git a/.changeset/data-table-sticky-action-header.md b/.changeset/data-table-sticky-action-header.md new file mode 100644 index 000000000..61ec6e002 --- /dev/null +++ b/.changeset/data-table-sticky-action-header.md @@ -0,0 +1,19 @@ +--- +"@object-ui/components": patch +--- + +fix(data-table): keep the right-pinned action column HEADER sticky on horizontal scroll (objectui#2784) + +The row-actions column is pinned to the right edge by injecting `sticky right-0` +into the column's `className`, which reaches both the body cells and the header +cell. Body cells stayed pinned, but the header cell unconditionally appended a +`relative` position utility (it anchors the column-resize handle) — and since +`cn` is `tailwind-merge`, the later `relative` won over the injected `sticky`. +So the "操作" title scrolled away while its body cells stayed frozen. + +The header now detects a right-pinned column (its `className` carries +`sticky` + `right-0`), skips `relative` for it (a sticky cell is already its own +positioning context, so the `absolute` resize handle still anchors correctly), +and re-asserts `sticky right-0 z-20` after `col.className` so tailwind-merge +keeps the pin and it stacks above the body's pinned cells (z-10). Left-frozen +columns, the resize handle, and non-pinned columns are unaffected. diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 938ef3b6b..41515460a 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -1333,6 +1333,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const isDragging = draggedColumn === index; const isDragOver = dragOverColumn === index; const isFrozen = frozenColumns > 0 && index < frozenColumns; + // Right-pinned columns (e.g. the auto-pinned row-actions column) + // carry their sticky class via `col.className`. The header cell + // otherwise appends a `relative` position utility below, which — + // because `cn` is tailwind-merge — would win over that `sticky` + // and let the header scroll away while its body cells stay pinned. + // Detect it here so we skip `relative` and re-assert the pin. + const isPinnedRight = typeof col.className === 'string' + && /\bsticky\b/.test(col.className) + && /\bright-0\b/.test(col.className); const frozenOffset = isFrozen ? measuredStickyLefts?.[(selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + index] ?? columns.slice(0, index).reduce((sum, c, i) => { @@ -1355,7 +1364,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { col.align === 'right' && 'text-right', col.align === 'center' && 'text-center', isFit && 'whitespace-nowrap', - 'relative group bg-background', + 'group bg-background', + // `relative` anchors the resize handle; a sticky cell is + // already its own positioning context, so only add it when + // the column isn't right-pinned (else it clobbers sticky). + !isPinnedRight && 'relative', + // Re-assert the pin AFTER col.className so tailwind-merge + // keeps it, and bump above body pinned cells (z-10). + isPinnedRight && 'sticky right-0 z-20', isFrozen && 'sticky z-20', isFrozen && index === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]', )} diff --git a/packages/plugin-grid/src/__tests__/row-action-sticky.test.tsx b/packages/plugin-grid/src/__tests__/row-action-sticky.test.tsx index 272168e76..1acbcfcff 100644 --- a/packages/plugin-grid/src/__tests__/row-action-sticky.test.tsx +++ b/packages/plugin-grid/src/__tests__/row-action-sticky.test.tsx @@ -68,6 +68,20 @@ describe('row-actions column sticky-right', () => { expect(container.querySelector('td.right-0')).not.toBeNull(); // the actions column }); + it('pins the actions COLUMN HEADER to the right, not just the body cells', async () => { + // Regression: the header's own `relative` position class (added for the + // resize handle) was clobbering the injected `sticky right-0` via + // tailwind-merge, so the title scrolled away while its cells stayed pinned. + renderGrid(); + await waitFor(() => expect(screen.getAllByTestId('row-action-inline-open').length).toBeGreaterThan(0)); + const headers = Array.from(document.querySelectorAll('th')); + const actionsHeader = headers[headers.length - 1]; + expect(actionsHeader).toBeTruthy(); + expect(actionsHeader.className).toContain('sticky'); + expect(actionsHeader.className).toContain('right-0'); + expect(actionsHeader.className).not.toContain('relative'); + }); + it('still surfaces the actions inline button (no regression from pinning)', async () => { renderGrid(); await waitFor(() => expect(screen.getAllByTestId('row-action-inline-open').length).toBe(ROWS.length));