Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix(Sticky): compute placeholder width from getBoundingClientRect when content does not overflow horizontally, preventing a phantom horizontal scrollbar under browser zoom",
"packageName": "@fluentui/react",
"email": "144495202+AKnassa@users.noreply.github.com",
"dependentChangeType": "patch"
}
74 changes: 74 additions & 0 deletions packages/react/src/components/Sticky/Sticky.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as React from 'react';
import { act, render } from '@testing-library/react';
import { Sticky } from './Sticky';
import { StickyPositionType } from './Sticky.types';
import { ScrollablePaneContext } from '../ScrollablePane/ScrollablePane.types';
import { isConformant } from '../../common/isConformant';
import type { IScrollablePaneContext } from '../ScrollablePane/ScrollablePane.types';

describe('Sticky', () => {
isConformant({
Expand All @@ -10,3 +15,72 @@ describe('Sticky', () => {
disabledTests: ['component-handles-ref', 'component-has-root-ref', 'component-handles-classname'],
});
});

describe('Sticky placeholder width', () => {
const createPaneContext = (): IScrollablePaneContext => ({
scrollablePane: {
subscribe: jest.fn(),
unsubscribe: jest.fn(),
addSticky: jest.fn(),
removeSticky: jest.fn(),
updateStickyRefHeights: jest.fn(),
sortSticky: jest.fn(),
notifySubscribers: jest.fn(),
syncScrollSticky: jest.fn(),
},
window: undefined,
});

const mockLayout = (
element: Element,
metrics: { scrollWidth: number; clientWidth: number; offsetWidth: number; rectWidth: number },
): void => {
Object.defineProperty(element, 'scrollWidth', { configurable: true, value: metrics.scrollWidth });
Object.defineProperty(element, 'clientWidth', { configurable: true, value: metrics.clientWidth });
Object.defineProperty(element, 'offsetWidth', { configurable: true, value: metrics.offsetWidth });
jest.spyOn(element, 'getBoundingClientRect').mockReturnValue({ width: metrics.rectWidth } as DOMRect);
};

const renderSticky = (): Sticky => {
const stickyRef = React.createRef<Sticky>();
render(
<ScrollablePaneContext.Provider value={createPaneContext()}>
<Sticky ref={stickyRef} stickyPosition={StickyPositionType.Header}>
<div>content</div>
</Sticky>
</ScrollablePaneContext.Provider>,
);
return stickyRef.current!;
};

it('uses the exact bounding-rect width when content does not overflow horizontally', () => {
const sticky = renderSticky();
const firstChild = sticky.nonStickyContent!.firstElementChild!;
// Fractional layout (e.g. browser zoom): true border-box width is 701.6px, but the rounded
// integer metrics report scrollWidth 700, clientWidth 700 and offsetWidth 702.
mockLayout(firstChild, { scrollWidth: 700, clientWidth: 700, offsetWidth: 702, rectWidth: 701.6 });
Object.defineProperty(sticky.nonStickyContent!, 'offsetHeight', { configurable: true, value: 30 });

act(() => {
sticky.setState({ isStickyTop: true });
});

// The placeholder must not exceed the true content width, otherwise ScrollablePane shows a
// phantom horizontal scrollbar (issue #29383).
expect(sticky.placeholder!.style.width).toBe('701.6px');
expect(sticky.placeholder!.style.height).toBe('30px');
});

it('keeps the scrollWidth-based width when content genuinely overflows horizontally', () => {
const sticky = renderSticky();
const firstChild = sticky.nonStickyContent!.firstElementChild!;
mockLayout(firstChild, { scrollWidth: 800, clientWidth: 700, offsetWidth: 702, rectWidth: 701.6 });

act(() => {
sticky.setState({ isStickyTop: true });
});

// scrollWidth + (offsetWidth - clientWidth) = 800 + (702 - 700)
expect(sticky.placeholder!.style.width).toBe('802px');
});
});
16 changes: 12 additions & 4 deletions packages/react/src/components/Sticky/Sticky.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export class Sticky extends React.Component<IStickyProps, IStickyState> {
// in the container, to get a horizontal scrollbar & be able to view the complete content of sticky component.
if (this.nonStickyContent && this.nonStickyContent.firstElementChild) {
height = this.nonStickyContent.offsetHeight;
const firstElementChild = this.nonStickyContent.firstElementChild;
// What value should be substituted for placeholder width?
// Assumptions:
// 1. Content inside <Sticky> should always be wrapped in a single div.
Expand All @@ -247,10 +248,17 @@ export class Sticky extends React.Component<IStickyProps, IStickyState> {
// 3. scrollWidth of a parent is greater than or equal to max of scrollWidths of its children, and same holds
// for children.
// placeholder width should be computed in the best possible way to prevent overscroll/underscroll.
width =
this.nonStickyContent.firstElementChild.scrollWidth +
((this.nonStickyContent.firstElementChild as HTMLElement).offsetWidth -
this.nonStickyContent.firstElementChild.clientWidth);
if (firstElementChild.scrollWidth <= firstElementChild.clientWidth) {
// The content does not overflow horizontally. scrollWidth, offsetWidth and clientWidth are all rounded to
// integers while the true layout width may be fractional (e.g. under browser zoom), so the rounded sum in
// the else-branch can be up to 1px wider or narrower than the content really is, which toggles a phantom
// horizontal scrollbar in ScrollablePane. Use the exact fractional border-box width instead.
width = firstElementChild.getBoundingClientRect().width;
} else {
width =
firstElementChild.scrollWidth +
((firstElementChild as HTMLElement).offsetWidth - firstElementChild.clientWidth);
}
}
return {
height,
Expand Down