Skip to content

Commit 4f88620

Browse files
oandregalclaude
andcommitted
fix: keep table toolbar over an offset header and below page layers
The toolbar now sits outside the table, so a sticky offset that consumers give the header no longer moved it: it stayed at the top of the scroll area while the header sat lower. Copy the header's computed inset-block-start onto the toolbar when it is placed. Its z-index of 2 also lifted it above page elements at z-index 2 (such as a sticky heading), which the header-contained toolbar stayed under. Isolate the table so the header's z-index stays inside it, and drop the toolbar back to z-index 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4750208 commit 4f88620

3 files changed

Lines changed: 94 additions & 7 deletions

File tree

‎packages/dataviews/src/components/dataviews-layouts/table/index.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -318,28 +318,32 @@ function ViewTable< Item >( {
318318
const tableSelectionRef = useRef< HTMLSpanElement >( null );
319319
const bulkSelectionRef = useRef< HTMLSpanElement >( null );
320320
const bulkActionsRef = useRef< HTMLDivElement >( null );
321-
// Size the bulk actions toolbar to the header by writing to the DOM
322-
// directly: it is correct on first paint and skips a re-render.
323-
const syncBulkActionsHeight = () => {
321+
// Place the bulk actions toolbar over the header by writing to the DOM
322+
// directly: it is correct on first paint and skips a re-render. The sticky
323+
// offset is copied so the toolbar follows a header that consumers move.
324+
const syncBulkActionsPosition = () => {
324325
const header = tableHeaderRef.current;
325326
const overlay = bulkActionsRef.current;
326-
if ( header && overlay ) {
327+
const win = header?.ownerDocument.defaultView;
328+
if ( header && overlay && win ) {
327329
overlay.style.setProperty(
328330
'--wp-dataviews-table-header-height',
329331
`${ header.getBoundingClientRect().height }px`
330332
);
333+
overlay.style.insetBlockStart =
334+
win.getComputedStyle( header ).insetBlockStart;
331335
}
332336
};
333337
const resizeHeaderRef = useResizeObserver< HTMLTableSectionElement >(
334-
syncBulkActionsHeight
338+
syncBulkActionsPosition
335339
);
336340
const tableHeaderRefs = useMergeRefs( [ tableHeaderRef, resizeHeaderRef ] );
337341
const [ isActionInProgress, setIsActionInProgress ] = useState( false );
338342
const hasBulkActions = useSomeItemHasAPossibleBulkAction( actions, data );
339343
const showBulkActions =
340344
( hasBulkActions && selection.length > 0 ) || isActionInProgress;
341345
// The resize observer reports after the first paint, so measure on show.
342-
useLayoutEffect( syncBulkActionsHeight, [ showBulkActions ] );
346+
useLayoutEffect( syncBulkActionsPosition, [ showBulkActions ] );
343347
const hadSelectionRef = useRef( false );
344348
useEffect( () => {
345349
const tableHead = tableHeaderRef.current;

‎packages/dataviews/src/components/dataviews-layouts/table/style.scss‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
border-color: inherit;
88
border-collapse: collapse;
99
position: relative;
10+
// Keep the sticky header's z-index inside the table, so the bulk actions
11+
// toolbar (a sibling) covers it without rising above the page's own layers.
12+
isolation: isolate;
1013
color: var(--wpds-color-foreground-content-neutral-weak);
1114
margin-bottom: auto;
1215
background-color: inherit;
@@ -350,7 +353,7 @@
350353
position: sticky;
351354
inset-block-start: 0;
352355
inset-inline-start: 0;
353-
z-index: 2;
356+
z-index: 1;
354357
height: 0;
355358
flex-shrink: 0;
356359

‎packages/dataviews/src/dataviews/test/bulk-selection.browser.test.tsx‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,86 @@ it.each( [ 'balanced', 'compact', 'comfortable' ] as const )(
310310
}
311311
);
312312

313+
it( 'keeps the table toolbar over a header that consumers offset', async () => {
314+
const data = Array.from( { length: 20 }, ( _, index ) => ( {
315+
...posts[ 0 ],
316+
id: String( index ),
317+
title: `Post ${ index }`,
318+
} ) );
319+
await render(
320+
<div style={ { height: 300 } }>
321+
<style>
322+
{ '.offset-layout thead { inset-block-start: 40px; }' }
323+
</style>
324+
<DataViews
325+
data={ data }
326+
fields={ fields }
327+
view={ { type: 'table', titleField: 'title', fields: [] } }
328+
onChangeView={ () => {} }
329+
actions={ actions }
330+
paginationInfo={ { totalItems: data.length, totalPages: 1 } }
331+
defaultLayouts={ { table: true } }
332+
>
333+
<DataViews.Layout className="offset-layout" />
334+
</DataViews>
335+
</div>
336+
);
337+
await userEvent.click(
338+
page.getByRole( 'checkbox', { name: 'Select all', exact: true } )
339+
);
340+
const header = document.querySelector( 'thead' )!;
341+
const toolbar = document.querySelector( '.dataviews-bulk-actions' )!;
342+
const scroller = document.querySelector( '.dataviews-layout__container' )!;
343+
for ( const scrollTop of [ 0, scroller.scrollHeight ] ) {
344+
scroller.scrollTop = scrollTop;
345+
await expect
346+
.poll( () => toolbar.getBoundingClientRect().top )
347+
.toBe( header.getBoundingClientRect().top );
348+
}
349+
} );
350+
351+
it( 'keeps the table toolbar below sticky page elements', async () => {
352+
const data = Array.from( { length: 20 }, ( _, index ) => ( {
353+
...posts[ 0 ],
354+
id: String( index ),
355+
title: `Post ${ index }`,
356+
} ) );
357+
await render(
358+
<>
359+
<div
360+
data-testid="page-heading"
361+
style={ {
362+
position: 'sticky',
363+
top: 0,
364+
zIndex: 2,
365+
height: 60,
366+
background: '#fff',
367+
} }
368+
/>
369+
<DataViews
370+
data={ data }
371+
fields={ fields }
372+
view={ { type: 'table', titleField: 'title', fields: [] } }
373+
onChangeView={ () => {} }
374+
actions={ actions }
375+
paginationInfo={ { totalItems: data.length, totalPages: 1 } }
376+
defaultLayouts={ { table: true } }
377+
/>
378+
</>
379+
);
380+
await userEvent.click(
381+
page.getByRole( 'checkbox', { name: 'Select all', exact: true } )
382+
);
383+
const toolbar = document.querySelector( '.dataviews-bulk-actions' )!;
384+
// Scroll the page so the toolbar passes under the sticky heading.
385+
window.scrollBy( 0, toolbar.getBoundingClientRect().top - 20 );
386+
const { left, width } = toolbar.getBoundingClientRect();
387+
await expect
388+
.poll( () => document.elementFromPoint( left + width / 2, 40 ) )
389+
.toBe( page.getByTestId( 'page-heading' ).element() );
390+
window.scrollTo( 0, 0 );
391+
} );
392+
313393
it( 'keeps a grid item checkbox below the sticky toolbar after arrow navigation and Tab', async () => {
314394
const data = Array.from( { length: 8 }, ( _, index ) => ( {
315395
...posts[ 0 ],

0 commit comments

Comments
 (0)