Skip to content

Commit 94ca592

Browse files
authored
fix(ObjectPage): prevent jumping of active tab when selecting section (#7395)
Fixes #7038
1 parent 933e172 commit 94ca592

File tree

6 files changed

+141
-17
lines changed

6 files changed

+141
-17
lines changed

cypress/support/commands.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,19 @@ declare global {
3737
position?: Cypress.PositionType;
3838
},
3939
): Chainable<Element>;
40+
/**
41+
* Asserts that the element never gains the given attribute.
42+
*
43+
* __Note:__ An error is thrown if the attribute is not found, therefore it does not block the test if the subject
44+
* never includes the given attribute.
45+
*
46+
*
47+
* @param attributeName - The name of the attribute which must not appear.
48+
* @param observerTime - How long (in ms) to watch for mutations (default: 500).
49+
* @example
50+
* cy.get('button').shouldNeverHaveAttribute('disabled', 1000);
51+
*/
52+
shouldNeverHaveAttribute(attributeName: string, observerTime?: number): Chainable<JQuery<HTMLElement>>;
4053
}
4154
}
4255
}
@@ -66,3 +79,36 @@ Cypress.Commands.add(
6679
chainable.then(() => done(new Error('Expected element NOT to be clickable, but click() succeeded')));
6780
},
6881
);
82+
83+
Cypress.Commands.add(
84+
'shouldNeverHaveAttribute',
85+
{ prevSubject: 'element' },
86+
(subject, attributeName, observerTime = 500) => {
87+
cy.wrap(subject).then(($el) => {
88+
const el = $el[0];
89+
const observer = new MutationObserver((mutations) => {
90+
for (const mutation of mutations) {
91+
if (mutation.attributeName === attributeName) {
92+
Cypress.log({
93+
name: 'shouldNeverHaveAttribute',
94+
message: `${attributeName} was found!`,
95+
consoleProps: () => ({
96+
attributeName,
97+
element: el,
98+
}),
99+
});
100+
101+
observer.disconnect();
102+
throw new Error(`${attributeName} was found!`);
103+
}
104+
}
105+
});
106+
107+
observer.observe(el, { attributes: true });
108+
109+
setTimeout(() => {
110+
observer.disconnect();
111+
}, observerTime);
112+
});
113+
},
114+
);

packages/main/src/components/ObjectPage/ObjectPage.cy.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import {
4646
} from '../..';
4747
import { cypressPassThroughTestsFactory } from '@/cypress/support/utils';
4848

49+
const arbitraryCharsId = `~\`!1@#$%^&*()-_+={}[]:;"'z,<.>/?|♥`;
50+
4951
describe('ObjectPage', () => {
5052
[ObjectPageMode.Default, ObjectPageMode.IconTabBar].forEach((mode) => {
5153
it(`dynamic children selection (mode: ${mode})`, () => {
@@ -360,16 +362,10 @@ describe('ObjectPage', () => {
360362
it('scroll to sections - default mode', () => {
361363
document.body.style.margin = '0px';
362364
cy.mount(
363-
<ObjectPage
364-
data-testid="op"
365-
titleArea={DPTitle}
366-
headerArea={DPContent}
367-
style={{ height: '100vh', scrollBehavior: 'auto' }}
368-
>
365+
<ObjectPage data-testid="op" titleArea={DPTitle} headerArea={DPContent} style={{ height: '100vh' }}>
369366
{OPContent}
370367
</ObjectPage>,
371368
);
372-
373369
cy.wait(200);
374370

375371
// first titleText should never be displayed (not.be.visible doesn't work here - only invisible for sighted users)
@@ -382,6 +378,9 @@ describe('ObjectPage', () => {
382378
cy.findByText('Test').should('be.visible');
383379

384380
cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick();
381+
cy.get('[data-section-id="test"]').shouldNeverHaveAttribute('selected');
382+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
383+
cy.get(`[ui5-tab][data-index="3"]`).should('have.attr', 'selected');
385384
cy.findByText('Employment').should('be.visible');
386385

387386
cy.wait(200);
@@ -404,6 +403,8 @@ describe('ObjectPage', () => {
404403
cy.realPress('ArrowDown');
405404
cy.realPress('ArrowDown');
406405
cy.realPress('Enter');
406+
cy.get('[data-section-id="goals"]').shouldNeverHaveAttribute('selected');
407+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
407408
cy.findByText('Job Relationship').should('be.visible');
408409

409410
cy.mount(
@@ -412,7 +413,7 @@ describe('ObjectPage', () => {
412413
titleArea={DPTitle}
413414
headerArea={DPContent}
414415
footerArea={Footer}
415-
style={{ height: '100vh', scrollBehavior: 'auto' }}
416+
style={{ height: '100vh' }}
416417
>
417418
{OPContent}
418419
</ObjectPage>,
@@ -433,11 +434,15 @@ describe('ObjectPage', () => {
433434
cy.wait(200);
434435
//fallback click
435436
cy.get('[ui5-tabcontainer]').findUi5TabByText('Employment').realClick();
437+
cy.get('[data-section-id="test"]').shouldNeverHaveAttribute('selected');
438+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
436439
cy.findByTestId('footer').should('be.visible');
437440
cy.findByText('Employment').should('be.visible');
438441
cy.findByText('Job Information').should('be.visible');
439442

440443
cy.get('[ui5-tabcontainer]').findUi5TabByText('Goals').click();
444+
cy.get('[data-section-id="test"]').shouldNeverHaveAttribute('selected', 300);
445+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
441446
cy.findByText('Test').should('be.visible');
442447
cy.findByTestId('footer').should('be.visible');
443448

@@ -447,6 +452,8 @@ describe('ObjectPage', () => {
447452
cy.realPress('ArrowDown');
448453
cy.realPress('ArrowDown');
449454
cy.realPress('Enter');
455+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
456+
cy.get(`[ui5-tab][data-index="3"]`).should('have.attr', 'selected');
450457
// wait for scroll
451458
cy.wait(200);
452459
cy.findByText('Job Relationship').should('be.visible');
@@ -459,7 +466,7 @@ describe('ObjectPage', () => {
459466
titleArea={DPTitle}
460467
headerArea={DPContent}
461468
footerArea={Footer}
462-
style={{ height: '100vh', scrollBehavior: 'auto' }}
469+
style={{ height: '100vh' }}
463470
>
464471
{OPContent}
465472
<ObjectPageSection aria-label="Long Section" id="long-section" titleText="Long Section">
@@ -487,6 +494,9 @@ describe('ObjectPage', () => {
487494
cy.wait(50);
488495
cy.realPress('ArrowDown');
489496
cy.realPress('Enter');
497+
cy.get('[data-section-id="test"]').shouldNeverHaveAttribute('selected');
498+
cy.get('[data-section-id="personal"]').shouldNeverHaveAttribute('selected');
499+
cy.get(`[ui5-tab][data-index="3"]`).shouldNeverHaveAttribute('selected');
490500
// wait for scroll
491501
cy.wait(200);
492502
cy.findByText('Start SubSection2').should('be.visible');
@@ -501,7 +511,7 @@ describe('ObjectPage', () => {
501511
titleArea={DPTitle}
502512
headerArea={DPContent}
503513
footerArea={Footer}
504-
style={{ height: '100vh', scrollBehavior: 'auto' }}
514+
style={{ height: '100vh' }}
505515
>
506516
{OPContent}
507517
<ObjectPageSection aria-label="Long Section" id="long-section" titleText="Long Section">
@@ -1164,7 +1174,7 @@ describe('ObjectPage', () => {
11641174
.its('secondCall.args[0].detail')
11651175
.should('deep.equal', {
11661176
sectionIndex: 3,
1167-
sectionId: `~\`!1@#$%^&*()-_+={}[]:;"'z,<.>/?|♥`,
1177+
sectionId: arbitraryCharsId,
11681178
subSectionId: 'employment-job-information',
11691179
});
11701180
cy.get('@sectionChangeSpy').should('not.have.been.called');
@@ -1505,7 +1515,7 @@ const OPContent = [
15051515
</div>
15061516
</ObjectPageSubSection>
15071517
</ObjectPageSection>,
1508-
<ObjectPageSection key="3" titleText="Employment" id={`~\`!1@#$%^&*()-_+={}[]:;"'z,<.>/?|♥`} aria-label="Employment">
1518+
<ObjectPageSection key="3" titleText="Employment" id={arbitraryCharsId} aria-label="Employment">
15091519
<ObjectPageSubSection titleText="Job Information" id="employment-job-information" aria-label="Job Information">
15101520
<div style={{ height: '100px', width: '100%', background: 'orange' }}>
15111521
<span data-testid="employment-job-information-content">employment-job-information-content</span>

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

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ import type { ObjectPageSubSectionPropTypes } from '../ObjectPageSubSection/inde
2525
import { CollapsedAvatar } from './CollapsedAvatar.js';
2626
import { classNames, styleData } from './ObjectPage.module.css.js';
2727
import { getSectionById, getSectionElementById } from './ObjectPageUtils.js';
28-
import type { ObjectPageDomRef, ObjectPagePropTypes, ObjectPageTitlePropsWithDataAttributes } from './types/index.js';
28+
import type {
29+
HandleOnSectionSelectedType,
30+
ObjectPageDomRef,
31+
ObjectPagePropTypes,
32+
ObjectPageTitlePropsWithDataAttributes,
33+
} from './types/index.js';
2934
import { useHandleTabSelect } from './useHandleTabSelect.js';
35+
import { useOnScrollEnd } from './useOnScrollEnd.js';
3036

3137
const ObjectPageCssVariables = {
3238
headerDisplay: '--_ui5wcr_ObjectPage_header_display',
@@ -76,6 +82,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
7682
const [internalSelectedSectionId, setInternalSelectedSectionId] = useState<string | undefined>(
7783
selectedSectionId ?? firstSectionId,
7884
);
85+
const [tabSelectId, setTabSelectId] = useState<null | string>(null);
7986

8087
const isProgrammaticallyScrolled = useRef(false);
8188
const [componentRef, objectPageRef] = useSyncRef(ref);
@@ -98,6 +105,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
98105
const [currentTabModeSection, setCurrentTabModeSection] = useState(null);
99106
const [toggledCollapsedHeaderWasVisible, setToggledCollapsedHeaderWasVisible] = useState(false);
100107
const sections = mode === ObjectPageMode.IconTabBar ? currentTabModeSection : children;
108+
const scrollEndHandler = useOnScrollEnd({ objectPageRef, setTabSelectId });
101109

102110
useEffect(() => {
103111
const currentSection =
@@ -232,7 +240,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
232240
};
233241

234242
// section was selected by clicking on the tab bar buttons
235-
const handleOnSectionSelected = (targetEvent, newSelectionSectionId, index: number | string, section) => {
243+
const handleOnSectionSelected: HandleOnSectionSelectedType = (targetEvent, newSelectionSectionId, index, section) => {
236244
isProgrammaticallyScrolled.current = true;
237245
debouncedOnSectionChange.cancel();
238246
setSelectedSubSectionId(undefined);
@@ -242,6 +250,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
242250
}
243251
return newSelectionSectionId;
244252
});
253+
setTabSelectId(newSelectionSectionId);
245254
scrollEvent.current = targetEvent;
246255
fireOnSelectedChangedEvent(targetEvent, index, newSelectionSectionId, section);
247256
};
@@ -527,6 +536,7 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
527536
const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(
528537
(e) => {
529538
const target = e.target as HTMLDivElement;
539+
scrollEndHandler(e);
530540
if (!isToggledRef.current) {
531541
isToggledRef.current = true;
532542
}
@@ -583,8 +593,8 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
583593
debouncedOnSectionChange,
584594
scrollTimeout,
585595
setSelectedSubSectionId,
596+
setTabSelectId,
586597
});
587-
588598
const objectPageStyles: CSSProperties = {
589599
...style,
590600
};
@@ -691,7 +701,11 @@ const ObjectPage = forwardRef<ObjectPageDomRef, ObjectPagePropTypes>((props, ref
691701
data-index={index}
692702
data-section-id={section.props.id}
693703
text={section.props.titleText}
694-
selected={internalSelectedSectionId === section.props?.id || undefined}
704+
selected={
705+
(tabSelectId && tabSelectId === section.props?.id) ||
706+
(!tabSelectId && internalSelectedSectionId === section.props?.id) ||
707+
undefined
708+
}
695709
items={subTabs.map((item) => {
696710
if (!isValidElement(item)) {
697711
return null;

packages/main/src/components/ObjectPage/types/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,10 @@ export interface ObjectPageDomRef extends HTMLDivElement {
155155
*/
156156
toggleHeaderArea: (snapped?: boolean) => void;
157157
}
158+
159+
export type HandleOnSectionSelectedType = (
160+
targetEvent: Event | Record<string, never>,
161+
newSelectionSectionId: string,
162+
index: number | string,
163+
section: HTMLElement,
164+
) => void;

packages/main/src/components/ObjectPage/useHandleTabSelect.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface UseHandleTabSelectProps {
2222
debouncedOnSectionChange: ReturnType<typeof debounce>;
2323
scrollTimeout: RefObject<number>;
2424
setSelectedSubSectionId: Dispatch<SetStateAction<string>>;
25+
setTabSelectId: Dispatch<SetStateAction<string | null>>;
2526
}
2627

2728
export const useHandleTabSelect = ({
@@ -39,6 +40,7 @@ export const useHandleTabSelect = ({
3940
debouncedOnSectionChange,
4041
scrollTimeout,
4142
setSelectedSubSectionId,
43+
setTabSelectId,
4244
}: UseHandleTabSelectProps) => {
4345
const [onSectionSelectedArgs, setOnSectionSelectedArgs] = useState<
4446
| false
@@ -52,8 +54,8 @@ export const useHandleTabSelect = ({
5254

5355
const handleOnSubSectionSelected = (e) => {
5456
isProgrammaticallyScrolled.current = true;
57+
const sectionId = e.detail.sectionId;
5558
if (mode === ObjectPageMode.IconTabBar) {
56-
const sectionId = e.detail.sectionId;
5759
setInternalSelectedSectionId(sectionId);
5860
const sectionNodes = objectPageRef.current?.querySelectorAll('section[data-component-name="ObjectPageSection"]');
5961
const currentIndex = childrenArray.findIndex((objectPageSection) => {
@@ -67,6 +69,7 @@ export const useHandleTabSelect = ({
6769
const subSectionId = e.detail.subSectionId;
6870
scrollTimeout.current = performance.now() + 200;
6971
setSelectedSubSectionId(subSectionId);
72+
setTabSelectId(sectionId);
7073
};
7174

7275
const handleTabItemSelect: TabContainerPropTypes['onTabSelect'] = (event) => {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Dispatch, RefObject, SetStateAction, UIEventHandler } from 'react';
2+
import { useCallback, useEffect, useRef } from 'react';
3+
4+
const isScrollEndAvailable = typeof document !== 'undefined' && 'onscrollend' in document.createElement('div');
5+
6+
interface UseOnScrollEndProps {
7+
objectPageRef: RefObject<HTMLDivElement>;
8+
setTabSelectId: Dispatch<SetStateAction<string | null>>;
9+
}
10+
11+
export function useOnScrollEnd(props: UseOnScrollEndProps) {
12+
const { objectPageRef, setTabSelectId } = props;
13+
14+
const scrollEndTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
15+
16+
// Native scrollend listener
17+
useEffect(() => {
18+
const objectPage = objectPageRef.current;
19+
if (!objectPage || !isScrollEndAvailable) return;
20+
21+
const onNativeScrollEnd = () => {
22+
setTabSelectId(null);
23+
};
24+
25+
objectPage.addEventListener('scrollend', onNativeScrollEnd);
26+
return () => {
27+
objectPage.removeEventListener('scrollend', onNativeScrollEnd);
28+
};
29+
}, [objectPageRef, setTabSelectId]);
30+
31+
// Fallback in onScroll
32+
const onObjectPageScroll: UIEventHandler<HTMLDivElement> = useCallback(() => {
33+
if (!isScrollEndAvailable) {
34+
if (scrollEndTimeout.current) {
35+
clearTimeout(scrollEndTimeout.current);
36+
}
37+
scrollEndTimeout.current = setTimeout(() => {
38+
setTabSelectId(null);
39+
}, 100);
40+
}
41+
}, [setTabSelectId]);
42+
43+
return onObjectPageScroll;
44+
}

0 commit comments

Comments
 (0)