mirror of
https://github.com/aphrody-labs/base-ui.git
synced 2026-10-17 12:46:28 +00:00
[all components] Correct effect timing (#5337)
This commit is contained in:
1 parent
5611d30083
commit
b38becd6ea
18 files changed
+753
-121
No files matched your search
@@ -54,7 +54,7 @@ export const AccordionPanel = React.forwardRef(function AccordionPanel(
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useIsoLayoutEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (keepMountedProp === false && hiddenUntilFound) {
|
||||
warn(
|
||||
'The `keepMounted={false}` prop on an `Accordion.Panel` is ignored when `hiddenUntilFound` is enabled on the panel or root, since the panel must remain mounted while closed.',
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import * as React from 'react';
|
||||
import { useControlled } from '@base-ui/utils/useControlled';
|
||||
import { useStableCallback } from '@base-ui/utils/useStableCallback';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { warn } from '@base-ui/utils/warn';
|
||||
import { BaseUIComponentProps, Orientation } from '../../internals/types';
|
||||
import { CompositeList } from '../../internals/composite/list/CompositeList';
|
||||
@@ -43,7 +42,7 @@ export const AccordionRoot = React.forwardRef(function AccordionRoot<Value = any
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useIsoLayoutEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (hiddenUntilFoundProp && keepMountedProp === false) {
|
||||
warn(
|
||||
'The `keepMounted={false}` prop on `Accordion.Root` is ignored when `hiddenUntilFound` is enabled, since panels must remain mounted while closed.',
|
||||
|
||||
@@ -34,7 +34,7 @@ export const CollapsiblePanel = React.forwardRef(function CollapsiblePanel(
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useIsoLayoutEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (hiddenUntilFoundProp && keepMountedProp === false) {
|
||||
warn(
|
||||
'The `keepMounted={false}` prop on `Collapsible.Panel` is ignored when `hiddenUntilFound` is enabled, since the panel must remain mounted while closed.',
|
||||
|
||||
@@ -9,9 +9,12 @@ import { Select } from '@base-ui/react/select';
|
||||
import { NumberField } from '@base-ui/react/number-field';
|
||||
import { ScrollArea } from '@base-ui/react/scroll-area';
|
||||
import { useRefWithInit } from '@base-ui/utils/useRefWithInit';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { useTimeout } from '@base-ui/utils/useTimeout';
|
||||
import { REASONS } from '../../internals/reasons';
|
||||
import { useDialogRootContext } from './DialogRootContext';
|
||||
import { DialogStore } from '../store/DialogStore';
|
||||
import { DialogInteractions } from './useDialogRoot';
|
||||
|
||||
describe('<Dialog.Root />', () => {
|
||||
const { render } = createRenderer();
|
||||
@@ -34,6 +37,75 @@ describe('<Dialog.Root />', () => {
|
||||
expectedPopupRole: 'dialog',
|
||||
});
|
||||
|
||||
it('reports nested drawer counts before passive effects', async () => {
|
||||
const childStore = new DialogStore(
|
||||
{
|
||||
open: true,
|
||||
mounted: true,
|
||||
modal: false,
|
||||
disablePointerDismissal: false,
|
||||
},
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
const parentStore = new DialogStore(undefined, undefined, false);
|
||||
let mountPassiveEffectFlushed = false;
|
||||
let teardownPassiveEffectFlushed = false;
|
||||
let mountedBeforePassiveEffect: boolean | null = null;
|
||||
let unmountedBeforePassiveEffect: boolean | null = null;
|
||||
let phase: 'mount' | 'teardown' = 'mount';
|
||||
|
||||
Object.assign(parentStore.context, {
|
||||
onNestedDialogOpen(dialogCount: number, drawerCount: number) {
|
||||
if (drawerCount > 0 && mountedBeforePassiveEffect === null) {
|
||||
mountedBeforePassiveEffect = !mountPassiveEffectFlushed;
|
||||
}
|
||||
if (
|
||||
phase === 'teardown' &&
|
||||
dialogCount === 0 &&
|
||||
drawerCount === 0 &&
|
||||
unmountedBeforePassiveEffect === null
|
||||
) {
|
||||
unmountedBeforePassiveEffect = !teardownPassiveEffectFlushed;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function PassiveEffectBoundary() {
|
||||
useIsoLayoutEffect(
|
||||
() => () => {
|
||||
teardownPassiveEffectFlushed = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
mountPassiveEffectFlushed = true;
|
||||
return () => {
|
||||
teardownPassiveEffectFlushed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const { unmount } = await render(
|
||||
<React.Fragment>
|
||||
<PassiveEffectBoundary />
|
||||
<DialogInteractions store={childStore} parentContext={parentStore.context} isDrawer />
|
||||
</React.Fragment>,
|
||||
);
|
||||
|
||||
expect(mountedBeforePassiveEffect).toBe(true);
|
||||
|
||||
phase = 'teardown';
|
||||
await act(async () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
expect(unmountedBeforePassiveEffect).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps trigger ownership when another trigger mounts while open', async () => {
|
||||
function Test() {
|
||||
const [showSecondTrigger, setShowSecondTrigger] = React.useState(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { useScrollLock } from '@base-ui/utils/useScrollLock';
|
||||
import { useDismiss } from '../../floating-ui-react';
|
||||
import { contains, getTarget } from '../../floating-ui-react/utils';
|
||||
@@ -92,7 +93,7 @@ export function DialogInteractions({
|
||||
});
|
||||
|
||||
// Notify parent of our open/close state using parent callbacks, if any
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (parentContext?.onNestedDialogOpen) {
|
||||
if (open) {
|
||||
parentContext.onNestedDialogOpen(
|
||||
|
||||
@@ -4,8 +4,10 @@ import { AlertDialog } from '@base-ui/react/alert-dialog';
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import { SafeReact } from '@base-ui/utils/safeReact';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
|
||||
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
|
||||
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
|
||||
import { useDrawerRootContext } from '../root/DrawerRootContext';
|
||||
|
||||
function setHeight(element: HTMLElement | null, getValue: () => number) {
|
||||
@@ -338,6 +340,112 @@ describe('<Drawer.Popup />', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('reports nested presence and frontmost height before passive effects', async () => {
|
||||
let passiveEffectFlushed = false;
|
||||
let heightReportedBeforePassiveEffect: boolean | null = null;
|
||||
let presenceReportedBeforePassiveEffect: boolean | null = null;
|
||||
const patchedDrawerContexts = new WeakSet<object>();
|
||||
|
||||
function PassiveEffectBoundary({ childOpen }: { childOpen: boolean }) {
|
||||
React.useEffect(() => {
|
||||
if (childOpen) {
|
||||
passiveEffectFlushed = true;
|
||||
}
|
||||
}, [childOpen]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChildDrawerMethodProbe() {
|
||||
const context = useDrawerRootContext();
|
||||
|
||||
if (!patchedDrawerContexts.has(context)) {
|
||||
patchedDrawerContexts.add(context);
|
||||
const notifyParentFrontmostHeight = context.notifyParentFrontmostHeight;
|
||||
const notifyParentHasNestedDrawer = context.notifyParentHasNestedDrawer;
|
||||
|
||||
context.notifyParentFrontmostHeight = (height) => {
|
||||
if (height > 0 && heightReportedBeforePassiveEffect === null) {
|
||||
heightReportedBeforePassiveEffect = !passiveEffectFlushed;
|
||||
}
|
||||
notifyParentFrontmostHeight?.(height);
|
||||
};
|
||||
context.notifyParentHasNestedDrawer = (present) => {
|
||||
if (present && presenceReportedBeforePassiveEffect === null) {
|
||||
presenceReportedBeforePassiveEffect = !passiveEffectFlushed;
|
||||
}
|
||||
notifyParentHasNestedDrawer?.(present);
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PopupPhaseBoundary() {
|
||||
useDialogRootContext().useState('open');
|
||||
useDrawerRootContext();
|
||||
passiveEffectFlushed = false;
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
passiveEffectFlushed = false;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
passiveEffectFlushed = true;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ childOpen }: { childOpen: boolean }) {
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<Drawer.Root defaultOpen modal={false}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup data-testid="parent-popup">
|
||||
<PassiveEffectBoundary childOpen={childOpen} />
|
||||
<Drawer.Root open={childOpen} modal={false}>
|
||||
<ChildDrawerMethodProbe />
|
||||
<Drawer.Portal keepMounted>
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup
|
||||
initialFocus={false}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopupPhaseBoundary />
|
||||
Child drawer
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
const { setProps } = await render(<TestCase childOpen={false} />);
|
||||
|
||||
await setProps({ childOpen: true });
|
||||
|
||||
expect(heightReportedBeforePassiveEffect).toBe(true);
|
||||
expect(presenceReportedBeforePassiveEffect).toBe(true);
|
||||
const parentPopup = screen.getByTestId('parent-popup');
|
||||
expect(parentPopup.style.getPropertyValue('--nested-drawers')).toBe('1');
|
||||
expect(parentPopup).toHaveAttribute('data-nested-drawer-open', '');
|
||||
expect(parentPopup.style.getPropertyValue('--drawer-frontmost-height')).toBe('100px');
|
||||
});
|
||||
|
||||
it('does not treat dialogs inside nested drawers as nested drawers', async () => {
|
||||
await render(
|
||||
<Drawer.Root open modal={false}>
|
||||
|
||||
@@ -267,7 +267,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
|
||||
};
|
||||
}, [nestedSwipeProgressStore, popupRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export const DrawerPopup = React.forwardRef(function DrawerPopup(
|
||||
};
|
||||
}, [frontmostHeight, open, notifyParentFrontmostHeight]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!notifyParentHasNestedDrawer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as React from 'react';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import { screen } from '@mui/internal-test-utils';
|
||||
import { createRenderer } from '#test-utils';
|
||||
import { useDrawerProviderContext } from './DrawerProviderContext';
|
||||
|
||||
const manualDrawer = {};
|
||||
const missingDrawer = {};
|
||||
|
||||
function ProviderControls() {
|
||||
const context = useDrawerProviderContext();
|
||||
|
||||
@@ -14,10 +17,10 @@ function ProviderControls() {
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<button onClick={() => context.setDrawerOpen('manual', true)}>Register open</button>
|
||||
<button onClick={() => context.setDrawerOpen('manual', false)}>Register closed</button>
|
||||
<button onClick={() => context.removeDrawer('manual')}>Remove registered</button>
|
||||
<button onClick={() => context.removeDrawer('missing')}>Remove missing</button>
|
||||
<button onClick={() => context.setDrawerOpen(manualDrawer, true)}>Register open</button>
|
||||
<button onClick={() => context.setDrawerOpen(manualDrawer, false)}>Register closed</button>
|
||||
<button onClick={() => context.removeDrawer(manualDrawer)}>Remove registered</button>
|
||||
<button onClick={() => context.removeDrawer(missingDrawer)}>Remove missing</button>
|
||||
<button
|
||||
onClick={() => context.visualStateStore.set({ swipeProgress: 0.5, frontmostHeight: 120 })}
|
||||
>
|
||||
@@ -106,6 +109,22 @@ describe('<Drawer.Provider />', () => {
|
||||
expect(background).toHaveAttribute('data-inactive', '');
|
||||
});
|
||||
|
||||
it('does not retain closed drawer registrations', async () => {
|
||||
const onRender = vi.fn();
|
||||
const { user } = await render(
|
||||
<React.Profiler id="provider" onRender={onRender}>
|
||||
<Drawer.Provider>
|
||||
<ProviderControls />
|
||||
</Drawer.Provider>
|
||||
</React.Profiler>,
|
||||
);
|
||||
|
||||
onRender.mockClear();
|
||||
await user.click(screen.getByRole('button', { name: 'Register closed' }));
|
||||
|
||||
expect(onRender).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('synchronizes and restores visual state on Drawer.Indent', async () => {
|
||||
const { user, setProps } = await render(<VisualStateCase showIndent />);
|
||||
const indent = screen.getByTestId('indent');
|
||||
|
||||
@@ -16,42 +16,30 @@ import {
|
||||
export function DrawerProvider(props: DrawerProvider.Props) {
|
||||
const { children } = props;
|
||||
|
||||
const [openById, setOpenById] = React.useState(() => new Map<string, boolean>());
|
||||
const [openDrawers, setOpenDrawers] = React.useState(() => new Set<object>());
|
||||
const [visualStateStore] = React.useState(createVisualStateStore);
|
||||
|
||||
const setDrawerOpen = useStableCallback((drawerId: string, open: boolean) => {
|
||||
setOpenById((prev) => {
|
||||
const prevOpen = prev.get(drawerId);
|
||||
if (prevOpen === open) {
|
||||
const setDrawerOpen = useStableCallback((drawer: object, open: boolean) => {
|
||||
setOpenDrawers((prev) => {
|
||||
if (prev.has(drawer) === open) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.set(drawerId, open);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
const removeDrawer = useStableCallback((drawerId: string) => {
|
||||
setOpenById((prev) => {
|
||||
if (!prev.has(drawerId)) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = new Map(prev);
|
||||
next.delete(drawerId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
const active = React.useMemo(() => {
|
||||
for (const open of openById.values()) {
|
||||
const next = new Set(prev);
|
||||
if (open) {
|
||||
return true;
|
||||
next.add(drawer);
|
||||
} else {
|
||||
next.delete(drawer);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [openById]);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
const removeDrawer = useStableCallback((drawer: object) => {
|
||||
setDrawerOpen(drawer, false);
|
||||
});
|
||||
|
||||
const active = openDrawers.size > 0;
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface DrawerProviderContext {
|
||||
setDrawerOpen: (drawerId: string, open: boolean) => void;
|
||||
removeDrawer: (drawerId: string) => void;
|
||||
setDrawerOpen: (drawer: object, open: boolean) => void;
|
||||
removeDrawer: (drawer: object) => void;
|
||||
active: boolean;
|
||||
visualStateStore: DrawerVisualStateStore;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Drawer } from '@base-ui/react/drawer';
|
||||
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
|
||||
import { createRenderer, isJSDOM, waitSingleFrame } from '#test-utils';
|
||||
import { REASONS } from '../../internals/reasons';
|
||||
import { useDrawerProviderContext } from '../provider/DrawerProviderContext';
|
||||
import { useDrawerRootContext } from './DrawerRootContext';
|
||||
|
||||
const useIdMockState = vi.hoisted(() => ({ returnUndefined: false }));
|
||||
@@ -1234,25 +1235,102 @@ describe('<Drawer.Root />', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('does not register an open drawer with its provider until an id is available', async () => {
|
||||
useIdMockState.returnUndefined = true;
|
||||
it.each([
|
||||
{
|
||||
label: 'a controlled drawer',
|
||||
rootProps: { open: true },
|
||||
strict: false,
|
||||
withoutUseId: false,
|
||||
},
|
||||
{
|
||||
label: 'a default-open drawer in Strict Mode',
|
||||
rootProps: { defaultOpen: true },
|
||||
strict: true,
|
||||
withoutUseId: false,
|
||||
},
|
||||
{
|
||||
label: 'the React 17 id fallback',
|
||||
rootProps: { defaultOpen: true },
|
||||
strict: true,
|
||||
withoutUseId: true,
|
||||
},
|
||||
])(
|
||||
'reports and unregisters $label before passive effects',
|
||||
async ({ rootProps, strict, withoutUseId }) => {
|
||||
let mountPassiveEffectFlushed = false;
|
||||
let teardownPassiveEffectFlushed = false;
|
||||
let mountedBeforePassiveEffect: boolean | null = null;
|
||||
let unmountedBeforePassiveEffect: boolean | null = null;
|
||||
let phase: 'mount' | 'teardown' = 'mount';
|
||||
const patchedContexts = new WeakSet<object>();
|
||||
|
||||
try {
|
||||
await render(
|
||||
<Drawer.Provider>
|
||||
<Drawer.IndentBackground data-testid="background" />
|
||||
<Drawer.Root open>Drawer</Drawer.Root>
|
||||
</Drawer.Provider>,
|
||||
);
|
||||
function PassiveEffectBoundary() {
|
||||
React.useEffect(() => {
|
||||
mountPassiveEffectFlushed = true;
|
||||
return () => {
|
||||
if (phase === 'teardown') {
|
||||
teardownPassiveEffectFlushed = true;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await waitSingleFrame();
|
||||
});
|
||||
expect(screen.getByTestId('background')).toHaveAttribute('data-inactive', '');
|
||||
} finally {
|
||||
useIdMockState.returnUndefined = false;
|
||||
}
|
||||
});
|
||||
function ProviderMethodProbe() {
|
||||
const providerContext = useDrawerProviderContext();
|
||||
|
||||
if (providerContext && !patchedContexts.has(providerContext)) {
|
||||
patchedContexts.add(providerContext);
|
||||
const { setDrawerOpen, removeDrawer } = providerContext;
|
||||
|
||||
providerContext.setDrawerOpen = (...args) => {
|
||||
if (mountedBeforePassiveEffect === null) {
|
||||
mountedBeforePassiveEffect = !mountPassiveEffectFlushed;
|
||||
}
|
||||
setDrawerOpen(...args);
|
||||
};
|
||||
providerContext.removeDrawer = (...args) => {
|
||||
if (phase === 'teardown' && unmountedBeforePassiveEffect === null) {
|
||||
unmountedBeforePassiveEffect = !teardownPassiveEffectFlushed;
|
||||
}
|
||||
removeDrawer(...args);
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ showDrawer }: { showDrawer: boolean }) {
|
||||
const content = (
|
||||
<Drawer.Provider>
|
||||
{showDrawer && <PassiveEffectBoundary />}
|
||||
{showDrawer && <ProviderMethodProbe />}
|
||||
{showDrawer && <Drawer.Root modal={false} {...rootProps} />}
|
||||
<Drawer.IndentBackground data-testid="background" />
|
||||
</Drawer.Provider>
|
||||
);
|
||||
|
||||
return strict ? <React.StrictMode>{content}</React.StrictMode> : content;
|
||||
}
|
||||
|
||||
useIdMockState.returnUndefined = withoutUseId;
|
||||
|
||||
try {
|
||||
const { setProps } = await render(<TestCase showDrawer />);
|
||||
|
||||
expect(mountedBeforePassiveEffect).toBe(true);
|
||||
expect(screen.getByTestId('background')).toHaveAttribute('data-active', '');
|
||||
|
||||
phase = 'teardown';
|
||||
await setProps({ showDrawer: false });
|
||||
|
||||
expect(unmountedBeforePassiveEffect).toBe(true);
|
||||
expect(screen.getByTestId('background')).toHaveAttribute('data-inactive', '');
|
||||
} finally {
|
||||
useIdMockState.returnUndefined = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(isJSDOM)('clears swipe-dismiss styles when swipe close is canceled', async () => {
|
||||
const env = setupSwipeTestEnv();
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import * as React from 'react';
|
||||
import { addEventListener } from '@base-ui/utils/addEventListener';
|
||||
import { useControlled } from '@base-ui/utils/useControlled';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { useStableCallback } from '@base-ui/utils/useStableCallback';
|
||||
import { ownerWindow } from '@base-ui/utils/owner';
|
||||
import { platform } from '@base-ui/utils/platform';
|
||||
import { useId } from '@base-ui/utils/useId';
|
||||
import {
|
||||
DrawerRootContext,
|
||||
type DrawerNestedSwipeProgressStore,
|
||||
@@ -417,10 +417,10 @@ function createNestedSwipeProgressStore(): NestedSwipeProgressStore {
|
||||
}
|
||||
|
||||
function DrawerProviderReporter() {
|
||||
const drawerId = useId();
|
||||
|
||||
const providerContext = useDrawerProviderContext();
|
||||
const store = useDialogRootContext(false);
|
||||
const setDrawerOpen = providerContext?.setDrawerOpen;
|
||||
const removeDrawer = providerContext?.removeDrawer;
|
||||
|
||||
const open = store.useState('open');
|
||||
const nestedOpenDialogCount = store.useState('nestedOpenDialogCount');
|
||||
@@ -428,23 +428,19 @@ function DrawerProviderReporter() {
|
||||
|
||||
const isTopmost = nestedOpenDialogCount === 0;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!providerContext || drawerId == null) {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!removeDrawer) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
providerContext.removeDrawer(drawerId);
|
||||
removeDrawer(store);
|
||||
};
|
||||
}, [drawerId, providerContext]);
|
||||
}, [removeDrawer, store]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (drawerId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
providerContext?.setDrawerOpen(drawerId, open);
|
||||
}, [drawerId, open, providerContext]);
|
||||
useIsoLayoutEffect(() => {
|
||||
setDrawerOpen?.(store, open);
|
||||
}, [open, setDrawerOpen, store]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// CloseWatcher enables the Android back gesture (Chromium-only).
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import * as React from 'react';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
|
||||
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
|
||||
import {
|
||||
type DrawerProviderContext,
|
||||
useDrawerProviderContext,
|
||||
} from '../provider/DrawerProviderContext';
|
||||
|
||||
const useIdMockState = vi.hoisted(() => ({ returnUndefined: false }));
|
||||
|
||||
@@ -518,39 +524,67 @@ describe('<Drawer.SwipeArea />', () => {
|
||||
});
|
||||
|
||||
it('cancels an active opening gesture when the swipe area becomes disabled', async () => {
|
||||
let disabledPassiveEffectFlushed = false;
|
||||
let providerContext: DrawerProviderContext | undefined;
|
||||
const cleanupPhases: boolean[] = [];
|
||||
|
||||
const SwipeAreaPhaseBoundary = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(function SwipeAreaPhaseBoundary(props, ref) {
|
||||
useIsoLayoutEffect(() => {
|
||||
disabledPassiveEffectFlushed = false;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
disabledPassiveEffectFlushed = true;
|
||||
});
|
||||
|
||||
return <div {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
function ProviderContextCapture() {
|
||||
providerContext = useDrawerProviderContext();
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ disabled }: { disabled: boolean }) {
|
||||
return (
|
||||
<Drawer.Root>
|
||||
<Drawer.SwipeArea
|
||||
data-testid="swipe-area"
|
||||
disabled={disabled}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup
|
||||
data-testid="popup"
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Drawer
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
<Drawer.Provider>
|
||||
<ProviderContextCapture />
|
||||
<Drawer.Root>
|
||||
<Drawer.SwipeArea
|
||||
data-testid="swipe-area"
|
||||
disabled={disabled}
|
||||
render={<SwipeAreaPhaseBoundary />}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup
|
||||
data-testid="popup"
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 100,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Drawer
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</Drawer.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -580,11 +614,25 @@ describe('<Drawer.SwipeArea />', () => {
|
||||
pointerType: 'mouse',
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(screen.getByTestId('popup')).toHaveAttribute('data-swiping', '');
|
||||
const popup = screen.getByTestId('popup');
|
||||
expect(popup).toHaveAttribute('data-swiping', '');
|
||||
|
||||
await setProps({ disabled: true });
|
||||
expect(screen.getByTestId('popup')).not.toHaveAttribute('data-swiping');
|
||||
expect(swipeArea).toHaveAttribute('data-disabled', '');
|
||||
const originalSetVisualState = providerContext!.visualStateStore.set;
|
||||
providerContext!.visualStateStore.set = (state) => {
|
||||
if (state.swipeProgress === 0) {
|
||||
cleanupPhases.push(!disabledPassiveEffectFlushed);
|
||||
}
|
||||
originalSetVisualState(state);
|
||||
};
|
||||
|
||||
try {
|
||||
await setProps({ disabled: true });
|
||||
expect(cleanupPhases.at(-1)).toBe(true);
|
||||
expect(screen.getByTestId('popup')).not.toHaveAttribute('data-swiping');
|
||||
expect(swipeArea).toHaveAttribute('data-disabled', '');
|
||||
} finally {
|
||||
providerContext!.visualStateStore.set = originalSetVisualState;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not prevent a non-cancelable pointer start', async () => {
|
||||
|
||||
@@ -103,6 +103,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
const dragDeltaRef = React.useRef({ x: 0, y: 0 });
|
||||
const closedOffsetRef = React.useRef<number | null>(null);
|
||||
const appliedSwipeStylesRef = React.useRef(false);
|
||||
const swipePopupElementRef = React.useRef<HTMLElement | null>(null);
|
||||
const swipeBackdropElementRef = React.useRef<HTMLElement | null>(null);
|
||||
const popupTransitionRef = React.useRef<string | null>(null);
|
||||
const releaseGuardCleanupRef = React.useRef<() => void>(NOOP);
|
||||
|
||||
@@ -228,6 +230,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementX, `${movementX}px`);
|
||||
popupElement.style.setProperty(DrawerPopupCssVars.swipeMovementY, `${movementY}px`);
|
||||
popupElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
|
||||
swipePopupElementRef.current = popupElement;
|
||||
if (popupTransitionRef.current === null) {
|
||||
popupTransitionRef.current = popupElement.style.transition;
|
||||
}
|
||||
@@ -236,6 +239,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
const backdropElement = store.context.backdropRef.current;
|
||||
if (backdropElement) {
|
||||
backdropElement.setAttribute(DrawerPopupDataAttributes.swiping, '');
|
||||
swipeBackdropElementRef.current = backdropElement;
|
||||
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, `${backdropProgress}`);
|
||||
if (openProgress > 0 && frontmostHeight > 0) {
|
||||
backdropElement.style.setProperty(DrawerPopupCssVars.height, `${frontmostHeight}px`);
|
||||
@@ -253,8 +257,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
}
|
||||
|
||||
const clearSwipeStyles = useStableCallback(() => {
|
||||
const popupElement = store.context.popupRef.current;
|
||||
if (popupElement && appliedSwipeStylesRef.current) {
|
||||
const popupElement = swipePopupElementRef.current;
|
||||
if (popupElement) {
|
||||
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementX);
|
||||
popupElement.style.removeProperty(DrawerPopupCssVars.swipeMovementY);
|
||||
popupElement.removeAttribute(DrawerPopupDataAttributes.swiping);
|
||||
@@ -265,7 +269,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
popupTransitionRef.current = null;
|
||||
}
|
||||
|
||||
const backdropElement = store.context.backdropRef.current;
|
||||
const backdropElement = swipeBackdropElementRef.current;
|
||||
if (backdropElement) {
|
||||
backdropElement.removeAttribute(DrawerPopupDataAttributes.swiping);
|
||||
backdropElement.style.setProperty(DrawerBackdropCssVars.swipeProgress, '0');
|
||||
@@ -274,6 +278,8 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
|
||||
providerContext?.visualStateStore.set({ swipeProgress: 0, frontmostHeight: 0 });
|
||||
appliedSwipeStylesRef.current = false;
|
||||
swipePopupElementRef.current = null;
|
||||
swipeBackdropElementRef.current = null;
|
||||
swipeAreaActiveRef.current = false;
|
||||
});
|
||||
|
||||
@@ -386,7 +392,7 @@ export const DrawerSwipeArea = React.forwardRef(function DrawerSwipeArea(
|
||||
}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
resetSwipe();
|
||||
resetDragDelta();
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import { Combobox } from '@base-ui/react/combobox';
|
||||
import { Drawer } from '@base-ui/react/drawer';
|
||||
import { Slider } from '@base-ui/react/slider';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { act, fireEvent, flushMicrotasks, screen, waitFor } from '@mui/internal-test-utils';
|
||||
import { createRenderer, describeConformance, isJSDOM } from '#test-utils';
|
||||
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
|
||||
import { useDrawerProviderContext } from '../provider/DrawerProviderContext';
|
||||
import { useDrawerRootContext } from '../root/DrawerRootContext';
|
||||
|
||||
describe('<Drawer.Viewport />', () => {
|
||||
beforeAll(function beforeHook() {
|
||||
@@ -3635,6 +3640,319 @@ describe('<Drawer.Viewport />', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('publishes resting snap progress before descendant layout effects', async () => {
|
||||
let snapPointPassiveEffectFlushed = false;
|
||||
let appliedBeforePassiveEffect: boolean | null = null;
|
||||
|
||||
function PassiveEffectBoundary({ snapPoint }: { snapPoint: string }) {
|
||||
useIsoLayoutEffect(() => {
|
||||
snapPointPassiveEffectFlushed = false;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (snapPoint === '200px') {
|
||||
snapPointPassiveEffectFlushed = true;
|
||||
}
|
||||
}, [snapPoint]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ snapPoint }: { snapPoint: string }) {
|
||||
return (
|
||||
<Drawer.Root open modal={false} snapPoints={['100px', '200px']} snapPoint={snapPoint}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Backdrop data-testid="backdrop" />
|
||||
<Drawer.Viewport
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PassiveEffectBoundary snapPoint={snapPoint} />
|
||||
<Drawer.Popup
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
Object.defineProperty(element, 'offsetHeight', {
|
||||
configurable: true,
|
||||
value: 200,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Drawer
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const { setProps } = await render(<TestCase snapPoint="100px" />);
|
||||
const backdrop = screen.getByTestId('backdrop');
|
||||
const originalSetProperty = backdrop.style.setProperty.bind(backdrop.style);
|
||||
const setPropertySpy = vi
|
||||
.spyOn(backdrop.style, 'setProperty')
|
||||
.mockImplementation((name, value, priority) => {
|
||||
if (
|
||||
name === '--drawer-swipe-progress' &&
|
||||
value === '0' &&
|
||||
appliedBeforePassiveEffect === null
|
||||
) {
|
||||
appliedBeforePassiveEffect = !snapPointPassiveEffectFlushed;
|
||||
}
|
||||
originalSetProperty(name, value, priority);
|
||||
});
|
||||
|
||||
try {
|
||||
await setProps({ snapPoint: '200px' });
|
||||
expect(appliedBeforePassiveEffect).toBe(true);
|
||||
expect(backdrop.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
|
||||
} finally {
|
||||
setPropertySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears kept-mounted swipe state before descendant layout effects on reopen', async () => {
|
||||
let openPassiveEffectFlushed = false;
|
||||
let resetBeforePassiveEffect: boolean | null = null;
|
||||
|
||||
function PassiveEffectBoundary({ open }: { open: boolean }) {
|
||||
useDialogRootContext().useState('open');
|
||||
|
||||
useIsoLayoutEffect(() => {
|
||||
openPassiveEffectFlushed = false;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
openPassiveEffectFlushed = true;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ open }: { open: boolean }) {
|
||||
return (
|
||||
<Drawer.Root open={open} modal={false}>
|
||||
<Drawer.Portal keepMounted>
|
||||
<Drawer.Viewport>
|
||||
<PassiveEffectBoundary open={open} />
|
||||
<Drawer.Popup data-testid="popup">Drawer</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const { setProps } = await render(<TestCase open={false} />);
|
||||
const popup = screen.getByTestId('popup');
|
||||
popup.style.setProperty('--drawer-swipe-movement-y', '50px');
|
||||
popup.setAttribute('data-swipe-dismiss', '');
|
||||
const originalToggleAttribute = popup.toggleAttribute.bind(popup);
|
||||
const toggleAttributeSpy = vi
|
||||
.spyOn(popup, 'toggleAttribute')
|
||||
.mockImplementation((name, force) => {
|
||||
if (name === 'data-swipe-dismiss' && force === false && resetBeforePassiveEffect === null) {
|
||||
resetBeforePassiveEffect = !openPassiveEffectFlushed;
|
||||
}
|
||||
return originalToggleAttribute(name, force);
|
||||
});
|
||||
|
||||
try {
|
||||
await setProps({ open: true });
|
||||
expect(resetBeforePassiveEffect).toBe(true);
|
||||
expect(popup.style.getPropertyValue('--drawer-swipe-movement-y')).toBe('0px');
|
||||
expect(popup).not.toHaveAttribute('data-swipe-dismiss');
|
||||
} finally {
|
||||
toggleAttributeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears nested progress before descendant layout effects when the child closes', async () => {
|
||||
let closePassiveEffectFlushed = false;
|
||||
let clearedBeforePassiveEffect: boolean | null = null;
|
||||
|
||||
function NestedProgressControl() {
|
||||
const { onNestedSwipeProgressChange } = useDrawerRootContext();
|
||||
|
||||
return <button onClick={() => onNestedSwipeProgressChange(0.5)}>Set nested progress</button>;
|
||||
}
|
||||
|
||||
function PassiveEffectBoundary({ childOpen }: { childOpen: boolean }) {
|
||||
useDialogRootContext().useState('open');
|
||||
closePassiveEffectFlushed = false;
|
||||
|
||||
useIsoLayoutEffect(
|
||||
() => () => {
|
||||
closePassiveEffectFlushed = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (childOpen) {
|
||||
closePassiveEffectFlushed = true;
|
||||
}
|
||||
},
|
||||
[childOpen],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ childOpen }: { childOpen: boolean }) {
|
||||
return (
|
||||
<Drawer.Root open modal={false}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup data-testid="parent-popup">
|
||||
<Drawer.Root open={childOpen} modal={false}>
|
||||
<Drawer.Portal keepMounted>
|
||||
<Drawer.Viewport>
|
||||
<PassiveEffectBoundary childOpen={childOpen} />
|
||||
<Drawer.Popup>Child drawer</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
<NestedProgressControl />
|
||||
</Drawer.Root>
|
||||
</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
|
||||
const { setProps, user } = await render(<TestCase childOpen />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Set nested progress' }));
|
||||
expect(
|
||||
screen.getByTestId('parent-popup').style.getPropertyValue('--drawer-swipe-progress'),
|
||||
).toBe('0.5');
|
||||
const parentPopup = screen.getByTestId('parent-popup');
|
||||
const originalSetProperty = parentPopup.style.setProperty.bind(parentPopup.style);
|
||||
const setPropertySpy = vi
|
||||
.spyOn(parentPopup.style, 'setProperty')
|
||||
.mockImplementation((name, value, priority) => {
|
||||
if (
|
||||
name === '--drawer-swipe-progress' &&
|
||||
value === '0' &&
|
||||
clearedBeforePassiveEffect === null
|
||||
) {
|
||||
clearedBeforePassiveEffect = !closePassiveEffectFlushed;
|
||||
}
|
||||
originalSetProperty(name, value, priority);
|
||||
});
|
||||
|
||||
try {
|
||||
await setProps({ childOpen: false });
|
||||
expect(clearedBeforePassiveEffect).toBe(true);
|
||||
expect(parentPopup.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
|
||||
} finally {
|
||||
setPropertySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears provider and backdrop swipe state during layout teardown', async () => {
|
||||
let teardownPassiveEffectFlushed = false;
|
||||
let clearedBeforePassiveEffect: boolean | null = null;
|
||||
|
||||
function ProviderStateControl() {
|
||||
const providerContext = useDrawerProviderContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
providerContext?.visualStateStore.set({
|
||||
swipeProgress: 0.5,
|
||||
frontmostHeight: 100,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Set provider state
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PassiveEffectBoundary() {
|
||||
useIsoLayoutEffect(
|
||||
() => () => {
|
||||
teardownPassiveEffectFlushed = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
teardownPassiveEffectFlushed = true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TestCase({ showViewport }: { showViewport: boolean }) {
|
||||
return (
|
||||
<Drawer.Provider>
|
||||
<Drawer.Indent data-testid="indent" />
|
||||
<ProviderStateControl />
|
||||
<Drawer.Root open modal={false}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Backdrop data-testid="backdrop" />
|
||||
{showViewport && (
|
||||
<React.Fragment>
|
||||
<PassiveEffectBoundary />
|
||||
<Drawer.Viewport>
|
||||
<Drawer.Popup>Drawer</Drawer.Popup>
|
||||
</Drawer.Viewport>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</Drawer.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const { setProps, user } = await render(<TestCase showViewport />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Set provider state' }));
|
||||
const indent = screen.getByTestId('indent');
|
||||
const backdrop = screen.getByTestId('backdrop');
|
||||
backdrop.setAttribute('data-swiping', '');
|
||||
expect(indent.style.getPropertyValue('--drawer-swipe-progress')).toBe('0.5');
|
||||
expect(indent.style.getPropertyValue('--drawer-height')).toBe('100px');
|
||||
const originalSetProperty = indent.style.setProperty.bind(indent.style);
|
||||
const setPropertySpy = vi
|
||||
.spyOn(indent.style, 'setProperty')
|
||||
.mockImplementation((name, value, priority) => {
|
||||
if (
|
||||
name === '--drawer-swipe-progress' &&
|
||||
value === '0' &&
|
||||
clearedBeforePassiveEffect === null
|
||||
) {
|
||||
clearedBeforePassiveEffect = !teardownPassiveEffectFlushed;
|
||||
}
|
||||
originalSetProperty(name, value, priority);
|
||||
});
|
||||
|
||||
try {
|
||||
await setProps({ showViewport: false });
|
||||
expect(clearedBeforePassiveEffect).toBe(true);
|
||||
expect(indent.style.getPropertyValue('--drawer-swipe-progress')).toBe('0');
|
||||
expect(indent.style.getPropertyValue('--drawer-height')).toBe('');
|
||||
expect(backdrop).not.toHaveAttribute('data-swiping');
|
||||
} finally {
|
||||
setPropertySpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves pinch-zoom touchmove to the browser', async () => {
|
||||
await render(
|
||||
<Drawer.Root open>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isElement } from '@floating-ui/utils/dom';
|
||||
import { addEventListener } from '@base-ui/utils/addEventListener';
|
||||
import { ownerDocument, ownerWindow } from '@base-ui/utils/owner';
|
||||
import { useAnimationFrame } from '@base-ui/utils/useAnimationFrame';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { useStableCallback } from '@base-ui/utils/useStableCallback';
|
||||
import { useDialogRootContext } from '../../dialog/root/DialogRootContext';
|
||||
import { DialogViewport } from '../../dialog/viewport/DialogViewport';
|
||||
@@ -764,7 +765,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
|
||||
virtualKeyboard,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!snapPointRange || swipe.swiping) {
|
||||
return;
|
||||
}
|
||||
@@ -783,7 +784,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
|
||||
visualStateStore,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (!notifyParentSwipeProgressChange) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -797,7 +798,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
|
||||
};
|
||||
}, [notifyParentSwipeProgressChange, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
if (open) {
|
||||
// Skip `resetSwipe` while `Drawer.SwipeArea` is driving the open: it zeroes the popup's
|
||||
// `--swipe-movement-*` (via `syncDragStyles(false)`), flashing it fully open for a frame.
|
||||
@@ -810,7 +811,7 @@ export const DrawerViewport = React.forwardRef(function DrawerViewport(
|
||||
}
|
||||
}, [clearSwipeRelease, open, resetSwipe, swipeAreaActiveRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useIsoLayoutEffect(() => {
|
||||
const backdropElement = backdropRef.current;
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { isElementDisabled } from '@base-ui/utils/isElementDisabled';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { warn } from '@base-ui/utils/warn';
|
||||
import { SafeReact } from '@base-ui/utils/safeReact';
|
||||
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
|
||||
@@ -93,7 +92,7 @@ export const MenuSubmenuTrigger = React.forwardRef(function MenuSubmenuTrigger(
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useIsoLayoutEffect(() => {
|
||||
React.useEffect(() => {
|
||||
const element = triggerElementRef.current;
|
||||
if (element && isElementDisabled(element) && !disabled) {
|
||||
const ownerStackMessage = SafeReact.captureOwnerStack?.() || '';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import * as React from 'react';
|
||||
import { useControlled } from '@base-ui/utils/useControlled';
|
||||
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
|
||||
import { error } from '@base-ui/utils/error';
|
||||
import { useBaseUiId } from '../internals/useBaseUiId';
|
||||
import { useRenderElement } from '../internals/useRenderElement';
|
||||
@@ -52,7 +51,7 @@ export const Toggle = React.forwardRef(function Toggle<Value extends string>(
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useIsoLayoutEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (groupContext && valueProp === undefined && groupContext.isValueInitialized) {
|
||||
error(
|
||||
'A `<Toggle>` component rendered in a `<ToggleGroup>` has no explicit `value` prop.',
|
||||
|
||||
Reference in new issue
Block a user