Skip to content

Commit 19af447

Browse files
authored
fix(ObjectPage): keep clicked tab selected with a tall expandable header (#8915)
Fixes #8906
1 parent 7d0f255 commit 19af447

3 files changed

Lines changed: 124 additions & 4 deletions

File tree

‎packages/main/src/components/ObjectPage/index.tsx‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
102102
const isProgrammaticallyScrolled = useRef(false);
103103
const [componentRef, objectPageRef] = useSyncRef(ref);
104104
const topHeaderRef = useRef<HTMLDivElement>(null);
105-
const prevTopHeaderHeight = useRef(0);
105+
const pendingScrollTargetRef = useRef<number | null>(null);
106106
// @ts-expect-error: useSyncRef will create a ref if not present
107107
const [componentRefHeaderContent, headerContentRef] = useSyncRef(headerArea?.ref);
108108
const scrollEvent = useRef(undefined);
@@ -278,7 +278,8 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
278278
return;
279279
}
280280

281-
const safeTopHeaderHeight = topHeaderHeight || prevTopHeaderHeight.current;
281+
// header collapses in this commit but topHeaderHeight state lags a tick, so measure live
282+
const safeTopHeaderHeight = topHeaderRef.current?.getBoundingClientRect().height || topHeaderHeight;
282283

283284
const scrollMargin =
284285
-1 /* reduce margin-block so that intersection observer detects correct section*/ +
@@ -295,10 +296,14 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
295296
const objectPageRect = objectPageElement.getBoundingClientRect();
296297

297298
// Calculate the top position of the section relative to the container
298-
objectPageElement.scrollTop =
299-
sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
299+
const targetScrollTop = sectionRect.top - objectPageRect.top + objectPageElement.scrollTop - scrollMargin;
300+
objectPageElement.scrollTop = targetScrollTop;
300301

301302
section.style.scrollMarginBlockStart = '';
303+
304+
// remember a target the browser clamped because the bottom spacer hasn't grown yet
305+
pendingScrollTargetRef.current =
306+
targetScrollTop > objectPageElement.scrollHeight - objectPageElement.clientHeight ? targetScrollTop : null;
302307
}
303308
};
304309
// In TabBar mode the section is only rendered when selected: delay scroll for subsection
@@ -311,6 +316,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
311316
[
312317
mode,
313318
objectPageRef,
319+
topHeaderRef,
314320
topHeaderHeight,
315321
tabContainerHeaderHeight,
316322
headerPinned,
@@ -407,6 +413,20 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
407413
}
408414
}, [selectedSubSectionId, sectionSpacer, scrollToSectionById]);
409415

416+
// re-apply the clamped target once sectionSpacer has grown enough
417+
useEffect(() => {
418+
const target = pendingScrollTargetRef.current;
419+
if (target == null) {
420+
return;
421+
}
422+
pendingScrollTargetRef.current = null;
423+
const objectPage = objectPageRef.current;
424+
if (objectPage && objectPage.scrollHeight - objectPage.clientHeight + 1 >= target) {
425+
objectPage.scrollTop = target;
426+
}
427+
// eslint-disable-next-line react-hooks/exhaustive-deps -- refs are stable; only sectionSpacer should re-trigger
428+
}, [sectionSpacer]);
429+
410430
useEffect(() => {
411431
if (headerPinnedProp !== undefined) {
412432
setHeaderPinned(headerPinnedProp);
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Link } from '../../../webComponents/Link/index.js';
2+
import { MessageStrip } from '../../../webComponents/MessageStrip/index.js';
3+
import { Title } from '../../../webComponents/Title/index.js';
4+
import { FlexBox } from '../../FlexBox/index.js';
5+
import { ObjectPageHeader } from '../../ObjectPageHeader/index.js';
6+
import { ObjectPageSection } from '../../ObjectPageSection/index.js';
7+
import { ObjectPageTitle } from '../../ObjectPageTitle/index.js';
8+
import { ObjectPage } from '../index.js';
9+
10+
// long expandable header + a last section shorter than the viewport (relies on the bottom spacer)
11+
export const ObjectPageLongHeaderTestComp = () => {
12+
return (
13+
<ObjectPage
14+
style={{ height: '100vh' }}
15+
titleArea={
16+
<ObjectPageTitle
17+
header={<Title>Denise Smith</Title>}
18+
snappedHeader={<Title>Denise Smith (snapped)</Title>}
19+
subHeader="Senior UI Developer"
20+
snappedSubHeader="Senior UI Developer (snapped)"
21+
expandedContent={
22+
<MessageStrip hideCloseButton>
23+
{Array.from({ length: 18 }, () => 'Information (only visible if header content is expanded)').join(' ')}
24+
</MessageStrip>
25+
}
26+
snappedContent={
27+
<MessageStrip hideCloseButton>Information (only visible if header content is snapped)</MessageStrip>
28+
}
29+
/>
30+
}
31+
headerArea={
32+
<ObjectPageHeader>
33+
<FlexBox direction="Column">
34+
<Link>+33 6 4512 5158</Link>
35+
<Link href="mailto:ui5-webcomponents-react@sap.com">DeniseSmith@sap.com</Link>
36+
</FlexBox>
37+
</ObjectPageHeader>
38+
}
39+
>
40+
<ObjectPageSection titleText="Goals" id="goals" aria-label="Goals">
41+
<div style={{ height: '120px', width: '100%', background: 'lightblue' }} />
42+
</ObjectPageSection>
43+
<ObjectPageSection titleText="Personal" id="personal" aria-label="Personal">
44+
<div style={{ height: '400px', width: '100%', background: 'lightyellow' }} />
45+
</ObjectPageSection>
46+
<ObjectPageSection titleText="Employment" id="employment" aria-label="Employment">
47+
<div style={{ height: '120px', width: '100%', background: 'orange' }} />
48+
</ObjectPageSection>
49+
</ObjectPage>
50+
);
51+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Page } from '@playwright/test';
2+
import { expect, test } from '../../../../../../playwright/fixtures/gallery-fixtures.js';
3+
4+
// Poll until scrollTop has moved and gone stable, so we assert the final selection and not the tab lock held mid-scroll.
5+
async function waitForScrollSettled(page: Page) {
6+
await page.evaluate(() => ((window as unknown as { __opScroll: number[] }).__opScroll = []));
7+
await page.waitForFunction(
8+
() => {
9+
const op = document.querySelector('[data-component-name="ObjectPage"]');
10+
if (!op) {
11+
return false;
12+
}
13+
const hist = (window as unknown as { __opScroll: number[] }).__opScroll;
14+
hist.push(op.scrollTop);
15+
const moved = hist.some((value) => value > 0);
16+
const count = hist.length;
17+
return moved && count >= 2 && Math.abs(hist[count - 1] - hist[count - 2]) <= 1;
18+
},
19+
undefined,
20+
{ polling: 100 },
21+
);
22+
}
23+
24+
test.describe('ObjectPage', () => {
25+
test('selects last section with long header', async ({ mount, page }) => {
26+
await page.setViewportSize({ width: 950, height: 800 });
27+
await mount('ObjectPage/ObjectPageLongHeaderTestComp');
28+
29+
await page.getByRole('tab', { name: 'Employment' }).click();
30+
await waitForScrollSettled(page);
31+
32+
const geo = await page.evaluate(() => {
33+
const op = document.querySelector('[data-component-name="ObjectPage"]');
34+
const tabs = document.querySelector('[data-component-name="ObjectPageTabContainer"]');
35+
const opTop = op.getBoundingClientRect().top;
36+
const rect = (id: string) => document.getElementById(id).getBoundingClientRect();
37+
return {
38+
stickyBottom: tabs.getBoundingClientRect().bottom - opTop,
39+
employmentTop: rect('ObjectPageSection-employment').top - opTop,
40+
personalBottom: rect('ObjectPageSection-personal').bottom - opTop,
41+
};
42+
});
43+
44+
// Employment scrolled to just under the sticky header, Personal scrolled above it (out of the selection zone)
45+
expect(Math.abs(geo.employmentTop - geo.stickyBottom)).toBeLessThanOrEqual(4);
46+
expect(geo.personalBottom).toBeLessThanOrEqual(geo.stickyBottom);
47+
await expect(page.locator('[data-section-id="employment"]')).toHaveAttribute('selected');
48+
});
49+
});

0 commit comments

Comments
 (0)