Skip to content
Merged
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
32 changes: 31 additions & 1 deletion src/components/Lightbox/Lightbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ function getMediaViewport(element: HTMLElement): HTMLElement {
return parent;
}

function getBackdropContainer(): HTMLElement {
// eslint-disable-next-line testing-library/no-node-access -- the layout container is the lightbox's backdrop hit target
const container = screen.getByRole('dialog').firstElementChild;
if (!(container instanceof HTMLElement)) {
throw new Error('Expected the lightbox dialog to have a layout container.');
}
return container;
}

beforeAll(() => {
Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
configurable: true,
Expand Down Expand Up @@ -124,7 +133,9 @@ describe('Lightbox', () => {
const onOpenChange = vi.fn();
render(<Lightbox isOpen media={media[0]} onOpenChange={onOpenChange} />);

fireEvent.click(screen.getByRole('dialog', {name: 'Media lightbox'}));
const backdropContainer = getBackdropContainer();
fireEvent.pointerDown(backdropContainer);
fireEvent.click(backdropContainer, {detail: 1});
expect(onOpenChange).toHaveBeenCalledWith(false);

const cancelEvent = new Event('cancel', {cancelable: true});
Expand All @@ -136,6 +147,25 @@ describe('Lightbox', () => {
expect(onOpenChange).toHaveBeenCalledWith(false);
});

it('does not close when a pan drag ends on the backdrop', () => {
const onOpenChange = vi.fn();
render(
<Lightbox hasZoom isOpen media={media[0]} onOpenChange={onOpenChange} />,
);

const viewport = getMediaViewport(
screen.getByRole('img', {name: 'First image'}),
);
const backdropContainer = getBackdropContainer();
fireEvent.doubleClick(viewport);
fireEvent.pointerDown(viewport, {clientX: 100, clientY: 100});
fireEvent.pointerMove(window, {clientX: 125, clientY: 125});
fireEvent.pointerUp(backdropContainer, {clientX: 150, clientY: 150});
fireEvent.click(backdropContainer, {clientX: 150, clientY: 150, detail: 1});

expect(onOpenChange).not.toHaveBeenCalled();
});

it('navigates galleries with arrow keys and buttons while respecting bounds', async () => {
const user = userEvent.setup();
const onIndexChange = vi.fn();
Expand Down
17 changes: 10 additions & 7 deletions src/components/Lightbox/Lightbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
useRef,
useState,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type ReactNode,
type Ref,
} from 'react';
Expand All @@ -19,6 +18,7 @@ import {LayerContext} from 'internal/LayerContext';
import {LogicalChevronEnd, LogicalChevronStart} from 'internal/LogicalChevron';
import isNonEmptyReactNode from 'internal/isNonEmptyReactNode';
import {mergeRefs} from 'internal/mergeRefs';
import {useBackdropDismiss} from 'internal/useBackdropDismiss';
import {useEscapeDismiss} from 'internal/useEscapeDismiss';
import {useIsomorphicLayoutEffect} from 'internal/useIsomorphicLayoutEffect';
import {useScrollLock} from 'internal/useScrollLock';
Expand Down Expand Up @@ -128,6 +128,7 @@ export function Lightbox({
style,
}: LightboxProps): React.JSX.Element {
const dialogRef = useRef<HTMLDialogElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<Element | null>(null);
const [uncontrolledIndex, setUncontrolledIndex] = useState(defaultIndex);
const [zoom, setZoom] = useState(1);
Expand Down Expand Up @@ -240,6 +241,11 @@ export function Lightbox({
const close = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const backdropDismiss = useBackdropDismiss<HTMLDialogElement>({
isBackdropEvent: event => event.target === containerRef.current,
isEnabled: isOpen,
onDismiss: close,
});
const escapeDismiss = useEscapeDismiss({
getElement: () => dialogRef.current,
isEnabled: isOpen,
Expand All @@ -266,11 +272,7 @@ export function Lightbox({
event.preventDefault();
close();
}}
onClick={(event: ReactMouseEvent<HTMLDialogElement>) => {
if (event.target === event.currentTarget) {
close();
}
}}
onClick={backdropDismiss.onClick}
onKeyDown={event => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
Expand All @@ -280,10 +282,11 @@ export function Lightbox({
goNext();
}
}}
onPointerDown={backdropDismiss.onPointerDown}
ref={mergeRefs(ref, dialogRef)}
style={style}>
<LayerContext value={layerContextValue}>
<div className={classes.container}>
<div className={classes.container} ref={containerRef}>
<div className={classes.close}>
<Button
className={classes.controlButton}
Expand Down
12 changes: 9 additions & 3 deletions src/internal/useBackdropDismiss.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ function isPointerOutsideElement(
* box, can have the same outside coordinates but retain the child as their
* target while bubbling through the dialog.
*/
function isBackdropPointerEvent<T extends HTMLElement>(
function isNativeBackdropPointerEvent<T extends HTMLElement>(
event: MouseEvent<T> | PointerEvent<T>,
): boolean {
return (
Expand All @@ -49,9 +49,15 @@ export interface BackdropDismissHandlers<T extends HTMLElement> {
* (e.g. text selection that overshoots onto the backdrop) are ignored.
*/
export function useBackdropDismiss<T extends HTMLElement>({
isBackdropEvent = isNativeBackdropPointerEvent,
isEnabled,
onDismiss,
}: {
/**
* Identifies events that land on the visual backdrop. By default, native
* dialog backdrop events are detected from the dialog's border-box geometry.
*/
isBackdropEvent?: (event: MouseEvent<T> | PointerEvent<T>) => boolean;
isEnabled: boolean;
onDismiss: () => void;
}): BackdropDismissHandlers<T> {
Expand All @@ -67,13 +73,13 @@ export function useBackdropDismiss<T extends HTMLElement>({
if (event.detail === 0) {
return;
}
const releasedOnBackdrop = isBackdropPointerEvent(event);
const releasedOnBackdrop = isBackdropEvent(event);
if (startedOnBackdrop && releasedOnBackdrop && isEnabled) {
onDismiss();
}
},
onPointerDown: event => {
pointerDownOnBackdropRef.current = isBackdropPointerEvent(event);
pointerDownOnBackdropRef.current = isBackdropEvent(event);
},
};
}