Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: update drawer scroll state when content changes",
"packageName": "@fluentui/react-drawer",
"email": "[email protected]",
"dependentChangeType": "patch"
}
23 changes: 23 additions & 0 deletions packages/react-components/react-drawer/library/config/tests.js
Original file line number Diff line number Diff line change
@@ -1 +1,24 @@
/** Jest test setup file. */

/**
* Mock ResizeObserver for test environment
*
* Required because DrawerBody component depends on ResizeObserver, but jsdom doesn't provide this browser API
*/
global.ResizeObserver = class ResizeObserver {
constructor(callback) {
this.callback = callback;
}

observe() {
// Do nothing in tests - we're only testing component behavior, not resize functionality
}

unobserve() {
// Do nothing in tests
}

disconnect() {
// Do nothing in tests
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { webLightTheme } from '@fluentui/react-theme';

import { DrawerBody } from './DrawerBody';
import type { JSXElement } from '@fluentui/react-utilities';
import { DrawerProvider, useDrawerContextValue } from '../../contexts';

const mountFluent = (element: JSXElement) => {
mount(<FluentProvider theme={webLightTheme}>{element}</FluentProvider>);
Expand Down Expand Up @@ -39,4 +40,48 @@ describe('DrawerBody', () => {
.scrollTo('top')
.should($e => assertScrollPosition($e[0], 0));
});

it('updates scrollState when children change from short to long', () => {
const shortContent = 'Short content';
const longContent = Array(50)
.fill(
'lorem ipsum dolor sit amet consectetur, adipisicing elit. Corrupti, animi? Quos, eum pariatur. Labore magni vel doloremque reiciendis, consequatur porro explicabo similique harum illo, ad hic, earum nobis accusantium quasi?',
)
.join(' ');

const Example = () => {
const context = useDrawerContextValue();
const [showLong, setShowLong] = React.useState(false);

return (
<DrawerProvider value={context}>
<div id="scroll-state">{context.scrollState}</div>

<DrawerBody id="drawer-body" style={{ height: '200px' }}>
{showLong ? longContent : shortContent}
</DrawerBody>

<button id="toggle-content" onClick={() => setShowLong(s => !s)}>
Toggle
</button>
</DrawerProvider>
);
};

mountFluent(<Example />);

// Initially short content should result in 'none'
cy.get('#drawer-body').should('exist');
cy.get('#scroll-state').should('have.text', 'none');

// Toggle to long content and assert context scroll state updates to 'top'
cy.get('#toggle-content').click();
cy.get('#scroll-state').should('have.text', 'top');

// Scroll to bottom and assert it becomes 'bottom'
cy.get('#drawer-body')
.scrollTo('bottom')
// wait for any rAF-based updates and then assert the scrollState
.then(() => cy.get('#scroll-state').should('have.text', 'bottom'));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useDrawerContext_unstable } from '../../contexts/drawerContext';
import { DrawerScrollState } from '../../shared/DrawerBase.types';

import type { DrawerBodyProps, DrawerBodyState } from './DrawerBody.types';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';

/**
* @internal
Expand Down Expand Up @@ -48,10 +49,16 @@ const getScrollState = ({ scrollTop, scrollHeight, clientHeight }: HTMLElement):
* @param ref - reference to root HTMLElement of DrawerBody
*/
export const useDrawerBody_unstable = (props: DrawerBodyProps, ref: React.Ref<HTMLElement>): DrawerBodyState => {
const { targetDocument } = useFluent();
const win = targetDocument?.defaultView;

const { setScrollState } = useDrawerContext_unstable();

const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [setAnimationFrame, cancelAnimationFrame] = useAnimationFrame();
const mergedRef = useMergedRefs(ref, scrollRef);

const [setScrollAnimationFrame, cancelScrollAnimationFrame] = useAnimationFrame();
const [setResizeAnimationFrame, cancelResizeAnimationFrame] = useAnimationFrame();

const updateScrollState = React.useCallback(() => {
if (!scrollRef.current) {
Expand All @@ -62,23 +69,28 @@ export const useDrawerBody_unstable = (props: DrawerBodyProps, ref: React.Ref<HT
}, [setScrollState]);

const onScroll = React.useCallback(() => {
cancelAnimationFrame();
setAnimationFrame(() => updateScrollState());
}, [cancelAnimationFrame, setAnimationFrame, updateScrollState]);
cancelScrollAnimationFrame();
setScrollAnimationFrame(updateScrollState);
}, [cancelScrollAnimationFrame, setScrollAnimationFrame, updateScrollState]);

useIsomorphicLayoutEffect(() => {
cancelAnimationFrame();
setAnimationFrame(() => updateScrollState());
/* update scroll state when children changes */
return () => cancelAnimationFrame();
}, [props.children, cancelAnimationFrame, updateScrollState, setAnimationFrame]);
// Update scroll state on children change
useIsomorphicLayoutEffect(updateScrollState, [props.children, updateScrollState]);

// Update scroll state on mount and when resize occurs
useIsomorphicLayoutEffect(() => {
cancelAnimationFrame();
setAnimationFrame(() => updateScrollState());
if (!scrollRef.current || !win?.ResizeObserver) {
return;
}

const observer = new win.ResizeObserver(() => setResizeAnimationFrame(updateScrollState));

observer.observe(scrollRef.current);

return () => cancelAnimationFrame();
}, [cancelAnimationFrame, updateScrollState, setAnimationFrame]);
return () => {
observer.disconnect();
cancelResizeAnimationFrame();
};
}, [setResizeAnimationFrame, cancelResizeAnimationFrame, updateScrollState, win]);

return {
components: {
Expand All @@ -87,10 +99,7 @@ export const useDrawerBody_unstable = (props: DrawerBodyProps, ref: React.Ref<HT

root: slot.always(
getIntrinsicElementProps<DrawerBodyProps>('div', {
// FIXME:
// `ref` is wrongly assigned to be `HTMLElement` instead of `HTMLDivElement`
// but since it would be a breaking change to fix it, we are casting ref to it's proper type
ref: useMergedRefs<HTMLDivElement>(ref as React.Ref<HTMLDivElement>, scrollRef),
ref: mergedRef,
...props,
onScroll: mergeCallbacks(props.onScroll, onScroll),
}),
Expand Down