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
16 changes: 16 additions & 0 deletions .yarn/versions/a70a7dfc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
releases:
"@radix-ui/react-alert-dialog": patch
"@radix-ui/react-context-menu": patch
"@radix-ui/react-dialog": patch
"@radix-ui/react-dismissable-layer": patch
"@radix-ui/react-dropdown-menu": patch
"@radix-ui/react-hover-card": patch
"@radix-ui/react-menu": patch
"@radix-ui/react-navigation-menu": patch
"@radix-ui/react-popover": patch
"@radix-ui/react-select": patch
"@radix-ui/react-toast": patch
"@radix-ui/react-tooltip": patch

declined:
- primitives
197 changes: 192 additions & 5 deletions cypress/integration/Dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,198 @@
describe('Dialog', () => {
beforeEach(() => {
cy.visitStory('dialog--styled');
cy.visitStory('dialog--cypress');
});

it('should open and close correctly', () => {
cy.findByText('open').click();
cy.findByText('close').click();
cy.findByText('close').should('not.exist');
function shouldBeOpen() {
cy.findByText('title').should('exist');
}
function shouldBeClosed() {
cy.findByText('title').should('not.exist');
}
function shouldNotAllowOutsideInteraction(action: 'realClick' | 'realTouch') {
cy.findByLabelText('count up')
.invoke('text')
.then((count) => {
if (action === 'realTouch') {
cy.findByLabelText('count up').realClick();
} else {
cy.findByLabelText('count up').realTouch();
}
cy.findByLabelText('count up').should('have.text', count);
});
}
function shouldAllowOutsideInteraction(action: 'realClick' | 'realTouch') {
cy.findByLabelText('count up')
.invoke('text')
.then((count) => {
if (action === 'realTouch') {
cy.findByLabelText('count up').realClick();
} else {
cy.findByLabelText('count up').realTouch();
}
cy.findByLabelText('count up').should('not.have.text', count);
});
}

describe('given a modal dialog', () => {
it('can be open/closed with a keyboard', () => {
// using keyboard on open/close buttons
cy.findByText('open').focus();
cy.realPress('Space');
shouldBeOpen();
cy.findByText('close').should('be.focused');
cy.realPress('Space');
shouldBeClosed();
cy.findByText('open').should('be.focused');

// using keyboard on open button and close with escape
cy.realPress('Space');
shouldBeOpen();
cy.realPress('Escape');
shouldBeClosed();
});

it('can be open/closed with a pointer', () => {
// using pointer on open/close buttons
cy.findByText('open').click();
shouldBeOpen();
cy.findByText('close').should('be.focused').click();
shouldBeClosed();
cy.findByText('open').should('be.focused');

// using mouse inside dialog, then on a button outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByText('title').click();
shouldBeOpen();
shouldNotAllowOutsideInteraction('realClick');
shouldBeClosed();

// using touch on a button outside
cy.findByText('open').click();
shouldBeOpen();
shouldNotAllowOutsideInteraction('realTouch');
shouldBeClosed();

// using mouse on an input outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realClick();
cy.findByPlaceholderText('name').should('not.be.focused');
shouldBeClosed();

// using touch on an input outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realTouch();
cy.findByPlaceholderText('name').should('not.be.focused');
shouldBeClosed();

// turn on animation
cy.findByLabelText('animated').click();

// using mouse on an input outside an animated dialog
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realClick();
cy.findByPlaceholderText('name').should('not.be.focused');
shouldBeClosed();

// finally, ensure that pointer-events have been reset and interactions restored
shouldAllowOutsideInteraction('realClick');

// using touch on an input outside an animated dialog
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realTouch();
cy.findByPlaceholderText('name').should('not.be.focused');
shouldBeClosed();

// finally, ensure that pointer-events have been reset and interactions restored
shouldAllowOutsideInteraction('realTouch');
});
});

describe('given a non-modal dialog', () => {
beforeEach(() => {
cy.findByLabelText('modal').click();
});

it('can be open/closed with a keyboard', () => {
// using keyboard on open/close buttons
cy.findByText('open').focus();
cy.realPress('Space');
shouldBeOpen();
cy.findByText('close').should('be.focused');
cy.realPress('Space');
shouldBeClosed();
cy.findByText('open').should('be.focused');

// using keyboard on open button and close with escape
cy.realPress('Space');
shouldBeOpen();
cy.realPress('Escape');
shouldBeClosed();
});

it('can be open/closed with a pointer', () => {
// using pointer on open/close buttons
cy.findByText('open').click();
shouldBeOpen();
cy.findByText('close').should('be.focused').click();
shouldBeClosed();
cy.findByText('open').should('be.focused');

// using mouse inside dialog, then on a button outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByText('title').click();
shouldBeOpen();
shouldAllowOutsideInteraction('realClick');
shouldBeClosed();

// using touch on a button outside
cy.findByText('open').click();
shouldBeOpen();
shouldAllowOutsideInteraction('realTouch');
shouldBeClosed();

// using mouse on an input outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realClick();
cy.findByPlaceholderText('name').should('be.focused');
shouldBeClosed();

// using touch on an input outside
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realTouch();
cy.findByPlaceholderText('name').should('be.focused');
shouldBeClosed();

// turn on animation
cy.findByLabelText('animated').click();

// using mouse on an input outside an animated dialog
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realClick();
cy.findByPlaceholderText('name').should('be.focused');
shouldBeClosed();

// finally, ensure that pointer-events have been reset and interactions restored
shouldAllowOutsideInteraction('realClick');

// using touch on an input outside an animated dialog
cy.findByText('open').click();
shouldBeOpen();
cy.findByPlaceholderText('name').realTouch();
cy.findByPlaceholderText('name').should('be.focused');
shouldBeClosed();

// finally, ensure that pointer-events have been reset and interactions restored
shouldAllowOutsideInteraction('realTouch');
});
});
});
65 changes: 65 additions & 0 deletions packages/react/dialog/src/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,71 @@ export const Chromatic = () => (
);
Chromatic.parameters = { chromatic: { disable: false } };

export const Cypress = () => {
const [modal, setModal] = React.useState(true);
const [animated, setAnimated] = React.useState(false);
const [count, setCount] = React.useState(0);

return (
<>
<Dialog.Root modal={modal}>
<Dialog.Trigger className={triggerClass()}>open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Content
className={
animated
? animatedContentClass({ css: { animationDuration: '50ms !important' } })
: contentDefaultClass()
}
>
<Dialog.Title>title</Dialog.Title>
<Dialog.Description>description</Dialog.Description>
<Dialog.Close className={closeClass()}>close</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

<br />
<br />

<label>
<input
type="checkbox"
checked={modal}
onChange={(event) => setModal(Boolean(event.target.checked))}
/>{' '}
modal
</label>

<br />

<label>
<input
type="checkbox"
checked={animated}
onChange={(event) => setAnimated(Boolean(event.target.checked))}
/>{' '}
animated
</label>

<br />

<label>
count up{' '}
<button type="button" onClick={() => setCount((count) => count + 1)}>
{count}
</button>
</label>

<br />

<label>
name: <input type="text" placeholder="name" />
</label>
</>
);
};

const triggerClass = css({});

const RECOMMENDED_CSS__DIALOG__OVERLAY: any = {
Expand Down
1 change: 0 additions & 1 deletion packages/react/dismissable-layer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"@radix-ui/primitive": "workspace:*",
"@radix-ui/react-compose-refs": "workspace:*",
"@radix-ui/react-primitive": "workspace:*",
"@radix-ui/react-use-body-pointer-events": "workspace:*",
"@radix-ui/react-use-callback-ref": "workspace:*",
"@radix-ui/react-use-escape-keydown": "workspace:*"
},
Expand Down
55 changes: 48 additions & 7 deletions packages/react/dismissable-layer/src/DismissableLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as React from 'react';
import { composeEventHandlers } from '@radix-ui/primitive';
import { Primitive, dispatchDiscreteCustomEvent } from '@radix-ui/react-primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { useBodyPointerEvents } from '@radix-ui/react-use-body-pointer-events';
import { useCallbackRef } from '@radix-ui/react-use-callback-ref';
import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown';

Expand All @@ -17,6 +16,8 @@ const CONTEXT_UPDATE = 'dismissableLayer.update';
const POINTER_DOWN_OUTSIDE = 'dismissableLayer.pointerDownOutside';
const FOCUS_OUTSIDE = 'dismissableLayer.focusOutside';

let originalBodyPointerEvents: string;

const DismissableLayerContext = React.createContext({
layers: new Set<DismissableLayerElement>(),
layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
Expand Down Expand Up @@ -106,13 +107,25 @@ const DismissableLayer = React.forwardRef<DismissableLayerElement, DismissableLa
if (!event.defaultPrevented) onDismiss?.();
});

useBodyPointerEvents({ disabled: disableOutsidePointerEvents });

React.useEffect(() => {
if (!node) return;
if (disableOutsidePointerEvents) context.layersWithOutsidePointerEventsDisabled.add(node);
if (disableOutsidePointerEvents) {
if (context.layersWithOutsidePointerEventsDisabled.size === 0) {
originalBodyPointerEvents = document.body.style.pointerEvents;
document.body.style.pointerEvents = 'none';
}
context.layersWithOutsidePointerEventsDisabled.add(node);
}
context.layers.add(node);
dispatchUpdate();
return () => {
if (
disableOutsidePointerEvents &&
context.layersWithOutsidePointerEventsDisabled.size === 1
) {
document.body.style.pointerEvents = originalBodyPointerEvents;
}
};
}, [node, disableOutsidePointerEvents, context]);

/**
Expand Down Expand Up @@ -206,14 +219,41 @@ type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>;
function usePointerDownOutside(onPointerDownOutside?: (event: PointerDownOutsideEvent) => void) {
const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener;
const isPointerInsideReactTreeRef = React.useRef(false);
const handleClickRef = React.useRef(() => {});

React.useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
if (event.target && !isPointerInsideReactTreeRef.current) {
const eventDetail = { originalEvent: event };
handleAndDispatchCustomEvent(POINTER_DOWN_OUTSIDE, handlePointerDownOutside, eventDetail, {
discrete: true,
});

function handleAndDispatchPointerDownOutsideEvent() {
handleAndDispatchCustomEvent(
POINTER_DOWN_OUTSIDE,
handlePointerDownOutside,
eventDetail,
{ discrete: true }
);
}

/**
* On touch devices, we need to wait for a click event because browsers implement
* a ~350ms delay between the time the user stops touching the display and when the
* browser executres events. We need to ensure we don't reactivate pointer-events within
* this timeframe otherwise the browser may execute events that should have been prevented.
*
* Additionally, this also lets us deal automatically with cancellations when a click event
* isn't raised because the page was considered scrolled/drag-scrolled, long-pressed, etc.
*
* This is why we also continuously remove the previous listener, because we cannot be
* certain that it was raised, and therefore cleaned-up.
*/
if (event.pointerType === 'touch') {
document.removeEventListener('click', handleClickRef.current);
handleClickRef.current = handleAndDispatchPointerDownOutsideEvent;
document.addEventListener('click', handleClickRef.current, { once: true });
} else {
handleAndDispatchPointerDownOutsideEvent();
}
}
isPointerInsideReactTreeRef.current = false;
};
Expand All @@ -236,6 +276,7 @@ function usePointerDownOutside(onPointerDownOutside?: (event: PointerDownOutside
return () => {
window.clearTimeout(timerId);
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('click', handleClickRef.current);
};
}, [handlePointerDownOutside]);

Expand Down
Loading