Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,30 @@ import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import { portalLock } from '../../../bpk-react-utils';
import { focusScope } from '../../../bpk-scrim-utils';

import BpkModalV3 from './BpkModalV3';

jest.mock('../../../bpk-react-utils', () => ({
...jest.requireActual('../../../bpk-react-utils'),
portalLock: {
lock: jest.fn(),
unlock: jest.fn(),
isLocked: jest.fn(() => false),
},
}));

jest.mock('../../../bpk-scrim-utils', () => ({
__esModule: true,
focusScope: {
scopeFocus: jest.fn(),
unscopeFocus: jest.fn(),
pauseFocus: jest.fn(),
resumeFocus: jest.fn(),
},
}));

// ResizeObserver mock required for Ark UI / Zag.js
window.ResizeObserver =
window.ResizeObserver ||
Expand Down Expand Up @@ -75,6 +97,118 @@ describe('BpkModalV3', () => {
});

describe('Root', () => {
beforeEach(() => {
Comment on lines 99 to +100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests are correct for what they test. Three concerns:

  • There is no test for the close path: the tests verify unscopeFocus is called on open, but there's no test asserting behaviour when the modal closes while a drawer is active. This is precisely the gap where the a11y regression lives.

  • scopeFocus mock is registered but never asserted in any test — suggests the author considered testing restore behavior but didn't implement it.

  • The beforeEach clears only unscopeFocus but not scopeFocus. If a future test for restore is added, the mock should clear both:

beforeEach(() => {
  (focusScope.unscopeFocus as jest.Mock).mockClear();
  (focusScope.scopeFocus as jest.Mock).mockClear(); // add this
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three points fixed:

  • Added a test for the close path: should call focusScope.resumeFocus when modal transitions from open to closed
  • The scopeFocus mock is now also cleared in beforeEach alongside pauseFocus and resumeFocus
  • Updated all existing tests from unscopeFocus to pauseFocus / resumeFocus to match the new implementation

(focusScope.pauseFocus as jest.Mock).mockClear();
(focusScope.resumeFocus as jest.Mock).mockClear();
(focusScope.scopeFocus as jest.Mock).mockClear();
(portalLock.lock as jest.Mock).mockClear();
(portalLock.unlock as jest.Mock).mockClear();
});

it('should call focusScope.pauseFocus when the modal opens', () => {
renderModal({ open: true });
expect(focusScope.pauseFocus).toHaveBeenCalledTimes(1);
});

it('should not call focusScope.pauseFocus when the modal is closed', () => {
renderModal({ open: false });
expect(focusScope.pauseFocus).not.toHaveBeenCalled();
});

it('should call focusScope.pauseFocus when modal transitions from closed to open', () => {
const { rerender } = render(
<BpkModalV3.Root open={false} onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
expect(focusScope.pauseFocus).not.toHaveBeenCalled();

rerender(
<BpkModalV3.Root open onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
expect(focusScope.pauseFocus).toHaveBeenCalledTimes(1);
});

it('should call focusScope.resumeFocus when modal transitions from open to closed', () => {
jest.useFakeTimers();
const { rerender } = render(
<BpkModalV3.Root open onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
expect(focusScope.resumeFocus).not.toHaveBeenCalled();

rerender(
<BpkModalV3.Root open={false} onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
// Lock stays active during the exit animation — not released yet.
expect(focusScope.resumeFocus).not.toHaveBeenCalled();

act(() => { jest.runAllTimers(); });
expect(focusScope.resumeFocus).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});

it('should not call focusScope.resumeFocus when the modal is initially open', () => {
renderModal({ open: true });
expect(focusScope.resumeFocus).not.toHaveBeenCalled();
});

it('should call portalLock.lock when the modal opens', () => {
renderModal({ open: true });
expect(portalLock.lock).toHaveBeenCalledTimes(1);
});

it('should not call portalLock.lock when the modal is closed', () => {
renderModal({ open: false });
expect(portalLock.lock).not.toHaveBeenCalled();
});

it('should call portalLock.unlock when modal transitions from open to closed', () => {
jest.useFakeTimers();
const { rerender } = render(
<BpkModalV3.Root open onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
expect(portalLock.unlock).not.toHaveBeenCalled();

rerender(
<BpkModalV3.Root open={false} onOpenChange={jest.fn()}>
<BpkModalV3.Content>
<BpkModalV3.Title>Test</BpkModalV3.Title>
</BpkModalV3.Content>
</BpkModalV3.Root>,
);
// Lock stays active during the exit animation — not released yet.
expect(portalLock.unlock).not.toHaveBeenCalled();

act(() => { jest.runAllTimers(); });
expect(portalLock.unlock).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});

it('should call portalLock.unlock on unmount while open', () => {
const { unmount } = renderModal({ open: true });
expect(portalLock.unlock).not.toHaveBeenCalled();
unmount();
expect(portalLock.unlock).toHaveBeenCalledTimes(1);
});

it('should render wrapper div with default type', () => {
const { container } = renderModal();
const wrapper = container.querySelector('[data-type]');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { Dialog } from '@ark-ui/react';

import { durationBase } from '@skyscanner/bpk-foundations-web/tokens/base.es6';

import { getDataComponentAttribute, useBodyLock } from '../../../../bpk-react-utils';
import { getDataComponentAttribute, portalLock, useBodyLock } from '../../../../bpk-react-utils';
import { focusScope } from '../../../../bpk-scrim-utils';
import { ModalTypeProvider } from '../BpkModalV3Context';
import { MODAL_V3_TYPES, type BpkModalV3Type } from '../common-types';

Expand Down Expand Up @@ -57,6 +58,35 @@ const BpkModalV3Root = ({

useBodyLock(type === MODAL_V3_TYPES.chatbot && bodyLockOpen);

// When this modal opens, pause the active focusScope (used by legacy withScrim
// components such as BpkDrawer) and lock the Portal event handlers so legacy
// overlays do not respond to clicks/Esc while the modal is visible.
//
// focusScope: prevents an infinite focus-redirect loop — both systems listen
// to 'focusin' on document and would fight each other without this guard.
// portalLock: prevents legacy Portal (BpkDrawer/BpkModal/BpkDialog) from
// starting their close animation simultaneously, which causes a visible flash.
//
// bodyLockOpen (rather than isOpen) is used as the dependency so that the
// locks stay active for the full exit-animation duration (durationBase ms)
// after isOpen becomes false. Without the delay, events fired during the
// exit animation (e.g. the mousedown that triggered the close falling through
// to a BpkScrim below) would reach legacy overlays and cause a flash.
//
// The cleanup function runs both on bodyLockOpen→false AND on unmount-while-open,
// ensuring the locks are always released.
//
// TODO: CLOV-1643 Remove once BpkDrawer, BpkModal, BpkDialog are deprecated.
useEffect(() => {
if (!bodyLockOpen) return undefined;
focusScope.pauseFocus();
portalLock.lock();
return () => {
focusScope.resumeFocus();
portalLock.unlock();
};
}, [bodyLockOpen]);

const handleOpenChange = (details: { open: boolean }) => {
if (open === undefined) {
setInternalOpen(details.open);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@

&[data-state='closed'] {
opacity: 0;

// Keep pointer-events active during the close animation so events do NOT
// fall through to legacy overlays beneath (e.g. BpkDrawer's close button,
// which shares z-index 1100) and trigger an unintended close. The scrim
// absorbs all pointer events while it fades; once ark-ui removes the element
// the underlying content becomes interactive again.
// TODO: CLOV-1643 Remove once BpkDrawer, BpkModal, BpkDialog are deprecated.
pointer-events: auto;
}

// Auto-hide scrim for full variant
Expand Down
3 changes: 3 additions & 0 deletions packages/backpack-web/src/bpk-react-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import { getDataComponentAttribute } from './src/getDataComponentAttribute';
import isRTL from './src/isRTL';
import { setNativeValue } from './src/nativeEventHandler';
import portalLock from './src/portalLock';
import { SURFACE_COLORS } from './src/surfaceColors';
import useBodyLock from './src/useBodyLock';
import withDefaultProps from './src/withDefaultProps';
Expand All @@ -52,6 +53,7 @@ export {
getDataComponentAttribute,
SURFACE_COLORS,
useBodyLock,
portalLock,
};
export default {
Portal,
Expand All @@ -69,4 +71,5 @@ export default {
getDataComponentAttribute,
SURFACE_COLORS,
useBodyLock,
portalLock,
};
16 changes: 16 additions & 0 deletions packages/backpack-web/src/bpk-react-utils/src/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import { createPortal } from 'react-dom';
// @ts-expect-error Untyped import. See `decisions/imports-ts-suppressions.md`.
import assign from 'object-assign';

// TODO: CLOV-1643 Remove portalLock import once BpkDrawer, BpkModal, BpkDialog are deprecated.
import portalLock from './portalLock';

const KEYCODES = {
ESCAPE: 'Escape',
} as const;
Expand Down Expand Up @@ -134,6 +137,13 @@ class Portal extends Component<Props, State> {
}

onDocumentMouseDown(event: MouseEvent | TouchEvent) {
// A higher-priority overlay (e.g. BpkModalV3) is open — do not close.
// TODO: CLOV-1643 Remove once BpkDrawer, BpkModal, BpkDialog are deprecated.
if (portalLock.isLocked()) {
this.shouldClose = false;
return;
}

const clickEventProperties = this.getClickEventProperties(event);
if (
clickEventProperties.isNotLeftClick ||
Expand Down Expand Up @@ -167,6 +177,12 @@ class Portal extends Component<Props, State> {
}

onDocumentKeyDown(event: KeyboardEvent) {
// A higher-priority overlay (e.g. BpkModalV3) is open — do not intercept Esc.
// TODO: CLOV-1643 Remove once BpkDrawer, BpkModal, BpkDialog are deprecated.
if (portalLock.isLocked()) {
return;
}

if (
event.key === KEYCODES.ESCAPE &&
this.props.isOpen &&
Expand Down
61 changes: 61 additions & 0 deletions packages/backpack-web/src/bpk-react-utils/src/portalLock-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2016 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import portalLock from './portalLock';

describe('portalLock', () => {
beforeEach(() => {
portalLock.resetForTesting();
});

it('is not locked by default', () => {
expect(portalLock.isLocked()).toBe(false);
});

it('is locked after lock() is called', () => {
portalLock.lock();
expect(portalLock.isLocked()).toBe(true);
});

it('is not locked after lock() then unlock()', () => {
portalLock.lock();
portalLock.unlock();
expect(portalLock.isLocked()).toBe(false);
});

it('requires matching unlock() calls when locked multiple times (nested modals)', () => {
portalLock.lock();
portalLock.lock();
portalLock.unlock();
expect(portalLock.isLocked()).toBe(true);

portalLock.unlock();
expect(portalLock.isLocked()).toBe(false);
});

it('does not go below zero when unlock() is called without a prior lock()', () => {
portalLock.unlock();
expect(portalLock.isLocked()).toBe(false);

// A subsequent lock/unlock pair should still work correctly.
portalLock.lock();
expect(portalLock.isLocked()).toBe(true);
portalLock.unlock();
expect(portalLock.isLocked()).toBe(false);
});
});
50 changes: 50 additions & 0 deletions packages/backpack-web/src/bpk-react-utils/src/portalLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Backpack - Skyscanner's Design System
*
* Copyright 2016 Skyscanner Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// When a higher-priority overlay (e.g. BpkModalV3) is open, legacy Portal
// components (used by BpkDrawer, BpkModal, BpkDialog) should not respond to
// document click or keydown events — otherwise they start their close animation
// simultaneously with the overlay, causing a visible flash.
//
// A counter (not a boolean) is used so that nested BpkModalV3 instances each
// hold their own lock: opening a second modal increments to 2, closing it
// decrements back to 1, and the first modal's lock is still active.
//
// TODO: CLOV-1643 Remove once BpkDrawer, BpkModal, and BpkDialog are deprecated.

let lockCount = 0;

const portalLock = {
lock() {
lockCount += 1;
},
unlock() {
if (lockCount > 0) {
lockCount -= 1;
}
},
isLocked() {
return lockCount > 0;
},
// Exposed for testing only — do not use in production code.
resetForTesting() {
lockCount = 0;
},
};

export default portalLock;
3 changes: 2 additions & 1 deletion packages/backpack-web/src/bpk-scrim-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
* limitations under the License.
*/

import focusScope from './src/focusScope';
import withScrim from './src/withScrim';
import withScrimmedPortal from './src/withScrimmedPortal';

export { withScrim, withScrimmedPortal };
export { focusScope, withScrim, withScrimmedPortal };
export default {
withScrim,
};
Loading
Loading