diff --git a/change/@fluentui-react-drawer-f4a6631d-4008-4940-935a-b7f13c49dd23.json b/change/@fluentui-react-drawer-f4a6631d-4008-4940-935a-b7f13c49dd23.json
new file mode 100644
index 00000000000000..18305f51f4b4c9
--- /dev/null
+++ b/change/@fluentui-react-drawer-f4a6631d-4008-4940-935a-b7f13c49dd23.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "fix: compare DrawerBody scroll positions with a tolerance so the footer divider is hidden when scrolled to the bottom at fractional browser zoom",
+ "packageName": "@fluentui/react-drawer",
+ "email": "fnambeh@gsumail.gram.edu",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-drawer/library/src/components/DrawerBody/DrawerBody.test.tsx b/packages/react-components/react-drawer/library/src/components/DrawerBody/DrawerBody.test.tsx
index 5b2a912d41d320..44faf81fa2e13b 100644
--- a/packages/react-components/react-drawer/library/src/components/DrawerBody/DrawerBody.test.tsx
+++ b/packages/react-components/react-drawer/library/src/components/DrawerBody/DrawerBody.test.tsx
@@ -1,8 +1,39 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { DrawerBody } from './DrawerBody';
+import { DrawerProvider, useDrawerContextValue } from '../../contexts';
import { isConformant } from '../../testing/isConformant';
+type ScrollMetrics = {
+ scrollTop: number;
+ clientHeight: number;
+ scrollHeight: number;
+};
+
+/**
+ * jsdom reports 0 for every layout measurement, so the values `getScrollState` reads are stubbed
+ * on the prototype. Fractional values reproduce what browsers report when zoom is not a whole
+ * number: `scrollTop` keeps its fraction while `clientHeight` and `scrollHeight` are rounded.
+ */
+const scrollMetrics: ScrollMetrics = { scrollTop: 0, clientHeight: 0, scrollHeight: 0 };
+
+const ScrollStateHarness: React.FC = () => {
+ const contextValue = useDrawerContextValue();
+
+ return (
+
+ Content
+
{contextValue.scrollState}
+
+ );
+};
+
+const renderWithScrollMetrics = (metrics: ScrollMetrics): string | null => {
+ Object.assign(scrollMetrics, metrics);
+
+ return render().getByTestId('scroll-state').textContent;
+};
+
describe('DrawerBody', () => {
isConformant({
Component: DrawerBody,
@@ -21,4 +52,66 @@ describe('DrawerBody', () => {
`);
});
+
+ describe('scroll state', () => {
+ const originalDescriptors = new Map();
+
+ beforeAll(() => {
+ (Object.keys(scrollMetrics) as (keyof ScrollMetrics)[]).forEach(key => {
+ originalDescriptors.set(key, Object.getOwnPropertyDescriptor(HTMLElement.prototype, key));
+ Object.defineProperty(HTMLElement.prototype, key, {
+ configurable: true,
+ get: () => scrollMetrics[key],
+ });
+ });
+ });
+
+ afterAll(() => {
+ originalDescriptors.forEach((descriptor, key) => {
+ if (descriptor) {
+ Object.defineProperty(HTMLElement.prototype, key, descriptor);
+ } else {
+ // These live on Element.prototype, so removing the stub restores the inherited accessor.
+ Reflect.deleteProperty(HTMLElement.prototype, key);
+ }
+ });
+ });
+
+ it('reports "none" when the content fits', () => {
+ expect(renderWithScrollMetrics({ scrollTop: 0, clientHeight: 300, scrollHeight: 300 })).toBe('none');
+ });
+
+ it('reports "top" when scrollable and not scrolled', () => {
+ expect(renderWithScrollMetrics({ scrollTop: 0, clientHeight: 300, scrollHeight: 500 })).toBe('top');
+ });
+
+ it('reports "middle" when scrolled between the ends', () => {
+ expect(renderWithScrollMetrics({ scrollTop: 100, clientHeight: 300, scrollHeight: 500 })).toBe('middle');
+ });
+
+ it('reports "bottom" when scrolled to the end', () => {
+ expect(renderWithScrollMetrics({ scrollTop: 200, clientHeight: 300, scrollHeight: 500 })).toBe('bottom');
+ });
+
+ // Regression: https://github.com/microsoft/fluentui/issues/36331
+ // At fractional browser zoom the scroll offset stops short of `scrollHeight - clientHeight`,
+ // which used to leave the state at 'middle' and kept the DrawerFooter divider visible.
+ // 198.5652 is the widest gap measured in Chromium, at 115% zoom.
+ const fractionalOffsets: Array<[string, number]> = [
+ ['110% zoom', 199.0909],
+ ['115% zoom', 198.5652],
+ ['125% zoom', 199.8],
+ ['150% zoom', 199.6667],
+ ];
+
+ it.each(fractionalOffsets)('reports "bottom" when scrolled to the end at %s', (_zoom, scrollTop) => {
+ expect(renderWithScrollMetrics({ scrollTop, clientHeight: 300, scrollHeight: 500 })).toBe('bottom');
+ });
+
+ // Fractional layout can round `scrollHeight` a pixel above `clientHeight` on an element that
+ // cannot actually be scrolled, which used to report 'top' and show the divider.
+ it('reports "none" when overflow is within rounding error of the client height', () => {
+ expect(renderWithScrollMetrics({ scrollTop: 0, clientHeight: 300, scrollHeight: 301 })).toBe('none');
+ });
+ });
});
diff --git a/packages/react-components/react-drawer/library/src/components/DrawerBody/useDrawerBody.ts b/packages/react-components/react-drawer/library/src/components/DrawerBody/useDrawerBody.ts
index 881fc003aa0066..d8a1443a10418b 100644
--- a/packages/react-components/react-drawer/library/src/components/DrawerBody/useDrawerBody.ts
+++ b/packages/react-components/react-drawer/library/src/components/DrawerBody/useDrawerBody.ts
@@ -16,6 +16,22 @@ import type { DrawerScrollState } from '../../shared/DrawerBase.types';
import type { DrawerBodyProps, DrawerBodyState } from './DrawerBody.types';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';
+/**
+ * `clientHeight` and `scrollHeight` are rounded to integers while `scrollTop` is fractional, so
+ * scroll positions have to be compared with a tolerance instead of for exact equality. Without it
+ * a fully scrolled body reports `middle` whenever the browser zoom is not a whole number.
+ *
+ * A non-scrollable element can report up to 1px of overflow that it cannot actually scroll, and
+ * both values are integers, so a single pixel is enough here.
+ */
+const OVERFLOW_TOLERANCE = 1;
+
+/**
+ * A fully scrolled element can sit short of its own maximum scroll offset because `scrollTop` is
+ * fractional while the values it is compared against are rounded.
+ */
+const SCROLL_BOTTOM_TOLERANCE = 2;
+
/**
* Get the current scroll state of the DrawerBody.
*
@@ -23,7 +39,7 @@ import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts
* @param element - HTMLElement to check scroll state of
*/
const getScrollState = ({ scrollTop, scrollHeight, clientHeight }: HTMLElement): DrawerScrollState => {
- if (scrollHeight <= clientHeight) {
+ if (scrollHeight - clientHeight <= OVERFLOW_TOLERANCE) {
return 'none';
}
@@ -31,7 +47,7 @@ const getScrollState = ({ scrollTop, scrollHeight, clientHeight }: HTMLElement):
return 'top';
}
- if (scrollTop + clientHeight === scrollHeight) {
+ if (scrollHeight - clientHeight - scrollTop <= SCROLL_BOTTOM_TOLERANCE) {
return 'bottom';
}