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
6 changes: 6 additions & 0 deletions .changeset/five-carpets-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@radix-ui/react-scroll-area": patch
"radix-ui": patch
---

Stabilize viewport style tag unless nonce changes.
6 changes: 6 additions & 0 deletions .changeset/tiny-groups-behave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@radix-ui/react-password-toggle-field": patch
"radix-ui": patch
---

Fixed prop type definitions to include `asChild` for all component parts
49 changes: 49 additions & 0 deletions apps/storybook/stories/dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ export const NoPointerDownOutsideDismiss = () => (
</Dialog.Root>
);

export const EventPropagation = () => {
const count = React.useRef(0);
const [stopPropagation, setStopPropagation] = React.useState(true);
return (
<>
<div
style={{ padding: '2rem', border: '1px solid black' }}
onClick={() => {
count.current++;
console.log(`clicked the card ${count.current} time${count.current === 1 ? '' : 's'}!`);
}}
>
<Dialog.Root>
<Dialog.Trigger onClick={(event) => event.stopPropagation()}>open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay
className={styles.overlay}
onClick={(event) => {
if (stopPropagation) {
event.stopPropagation();
}
console.log('clicked the dialog overlay!');
}}
/>
<Dialog.Content
className={styles.contentDefault}
onClick={(event) => {
if (stopPropagation) {
event.stopPropagation();
}
console.log('clicked the dialog content!');
}}
>
<ExternalOverlayTrigger />
<Dialog.Close className={styles.close}>close</Dialog.Close>
<InsideShadowSuggestion />
<Dialog.Title>Title</Dialog.Title>
<Dialog.Description>You can close me now!</Dialog.Description>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
<button type="button" onClick={() => setStopPropagation(!stopPropagation)}>
Stop propagation: {stopPropagation ? 'on' : 'off'}
</button>
</>
);
};

export const WithPortalContainer = () => {
const [portalContainer, setPortalContainer] = React.useState<HTMLDivElement | null>(null);
return (
Expand Down
18 changes: 15 additions & 3 deletions cypress/e2e/Select.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ describe('Select (shadow DOM)', () => {
.findByLabelText(/pick a food/i)
.realTouch();

// wait for the content to be open and settled before interacting with it
cy.get('.shadow-host').shadow().findByRole('listbox').should('be.visible');

// trigger a touch scroll, triggering the pointer move event and ensuring
// we do not preventDefault on the upcoming pointer up event
cy.get('.shadow-host').shadow().find('[data-radix-select-viewport]').realSwipe('toTop', {
Expand All @@ -103,14 +106,23 @@ describe('Select (shadow DOM)', () => {
// assert the select content is still open after swiping
cy.get('.shadow-host').shadow().findByRole('listbox').should('exist');

// select an item after scrolling
// select an item after scrolling, ensuring it is scrolled into view and
// visible so the touch reliably lands within the constrained viewport
cy.get('.shadow-host')
.shadow()
.findByRole('option', { name: /Grapes/i })
.scrollIntoView()
.should('be.visible')
.realTouch();

// assert the select value has been updated
cy.get('.shadow-host').shadow().findByText(/food:/).should('include.text', 'grapes');
// selecting an item should close the content, which confirms the
// selection registered before we assert on the bound value
cy.get('.shadow-host').shadow().findByRole('listbox').should('not.exist');

// assert the select value has been updated. We query the element directly
// rather than running `findByText` against the shadow root, which throws a
// confusing "got ShadowRoot" error while retrying when the node is absent.
cy.get('.shadow-host').shadow().find('p').should('include.text', 'food: grapes');
});
});
});
11 changes: 9 additions & 2 deletions packages/react/dialog/src/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { createContextScope } from '@radix-ui/react-context';
import { useId } from '@radix-ui/react-id';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { DismissableLayer } from '@radix-ui/react-dismissable-layer';
import { DismissableLayer, useDismissableLayerSurface } from '@radix-ui/react-dismissable-layer';
import { FocusScope } from '@radix-ui/react-focus-scope';
import { Portal as PortalPrimitive } from '@radix-ui/react-portal';
import { Presence } from '@radix-ui/react-presence';
Expand Down Expand Up @@ -200,14 +200,21 @@ const DialogOverlayImpl = React.forwardRef<DialogOverlayImplElement, DialogOverl
(props: ScopedProps<DialogOverlayImplProps>, forwardedRef) => {
const { __scopeDialog, ...overlayProps } = props;
const context = useDialogContext(OVERLAY_NAME, __scopeDialog);

// Register the overlay as a dismiss surface so a consumer calling
// `stopPropagation` on it (eg. to avoid triggering parent handlers) does not
// prevent the dialog from closing. See: https://github.com/radix-ui/primitives/issues/3346
const registerDismissableSurface = useDismissableLayerSurface();
const composedRefs = useComposedRefs(forwardedRef, registerDismissableSurface);

return (
// Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
// ie. when `Overlay` and `Content` are siblings
<RemoveScroll as={Slot} allowPinchZoom shards={[context.contentRef]}>
<Primitive.div
data-state={getState(context.open)}
{...overlayProps}
ref={forwardedRef}
ref={composedRefs}
// We re-enable pointer-events prevented by `Dialog.Content` to allow scrolling the overlay.
style={{ pointerEvents: 'auto', ...overlayProps.style }}
/>
Expand Down
94 changes: 94 additions & 0 deletions packages/react/dismissable-layer/src/dismissable-layer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,100 @@ describe('DismissableLayer', () => {
expect(onDismiss).not.toHaveBeenCalled();
});

it('dismisses when a registered dismiss surface stops propagation', async () => {
const onDismiss = vi.fn();

function Surface() {
const registerSurface = DismissableLayer.useDismissableLayerSurface();
return (
<div ref={registerSurface} onClick={(event) => event.stopPropagation()}>
surface
</div>
);
}

render(
<>
<DismissableLayer.Root deferPointerDownOutside onDismiss={onDismiss}>
<button type="button">inside</button>
</DismissableLayer.Root>
<Surface />
</>,
);
await waitForDocumentPointerDownListener();

firePointerMouseClick(screen.getByText('surface'));
await waitForDocumentPointerDownListener();
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('dismisses when a registered dismiss surface stops propagation without deferring', async () => {
const onDismiss = vi.fn();

function Surface() {
const registerSurface = DismissableLayer.useDismissableLayerSurface();
return (
<div ref={registerSurface} onClick={(event) => event.stopPropagation()}>
surface
</div>
);
}

render(
<>
<DismissableLayer.Root onDismiss={onDismiss}>
<button type="button">inside</button>
</DismissableLayer.Root>
<Surface />
</>,
);
await waitForDocumentPointerDownListener();

firePointerMouseClick(screen.getByText('surface'));
await waitForDocumentPointerDownListener();
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('only exempts registered dismiss surfaces from stopped propagation', async () => {
const onDismiss = vi.fn();

function Surface() {
const registerSurface = DismissableLayer.useDismissableLayerSurface();
return (
<div ref={registerSurface} onClick={(event) => event.stopPropagation()}>
surface
</div>
);
}

render(
<>
<DismissableLayer.Root deferPointerDownOutside onDismiss={onDismiss}>
<button type="button">inside</button>
</DismissableLayer.Root>
<Surface />
<button type="button">outside</button>
</>,
);
await waitForDocumentPointerDownListener();

// A non-surface outside element that stops propagation should still block
// dismissal.
const outside = screen.getByText('outside');
const stopPropagation = (event: Event) => event.stopPropagation();
outside.addEventListener('mousedown', stopPropagation);
outside.addEventListener('mouseup', stopPropagation);
outside.addEventListener('click', stopPropagation);

firePointerMouseClick(outside);
await waitForDocumentPointerDownListener();
expect(onDismiss).not.toHaveBeenCalled();

firePointerMouseClick(screen.getByText('surface'));
await waitForDocumentPointerDownListener();
expect(onDismiss).toHaveBeenCalledTimes(1);
});

it('does not dismiss when focus moves outside during a deferred stopped interaction', async () => {
const onDismiss = vi.fn();

Expand Down
57 changes: 49 additions & 8 deletions packages/react/dismissable-layer/src/dismissable-layer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ const DismissableLayerContext = React.createContext({
layers: new Set<DismissableLayerElement>(),
layersWithOutsidePointerEventsDisabled: new Set<DismissableLayerElement>(),
branches: new Set<DismissableLayerBranchElement>(),

// Outside elements that belong to a layer's own dismiss affordance (eg, a
// dialog overlay). Pressing them should dismiss the layer regardless of
// whether or not they stop propagation.
//
// See https://github.com/radix-ui/primitives/issues/3346
dismissableSurfaces: new Set<DismissableLayerBranchElement>(),
});

type DismissableLayerElement = React.ComponentRef<typeof Primitive.div>;
Expand Down Expand Up @@ -108,6 +115,7 @@ const DismissableLayer = React.forwardRef<DismissableLayerElement, DismissableLa
ownerDocument,
deferPointerDownOutside,
isDeferredPointerDownOutsideRef,
dismissableSurfaces: context.dismissableSurfaces,
},
);

Expand Down Expand Up @@ -241,6 +249,26 @@ DismissableLayerBranch.displayName = BRANCH_NAME;

/* -----------------------------------------------------------------------------------------------*/

/**
* Registers a node as a "dismiss surface" for the enclosing DismissableLayer
*/
function useDismissableLayerSurface(): React.RefCallback<DismissableLayerBranchElement> {
const context = React.useContext(DismissableLayerContext);
const [node, setNode] = React.useState<DismissableLayerBranchElement | null>(null);

React.useEffect(() => {
if (!node) {
return;
}
context.dismissableSurfaces.add(node);
return () => {
context.dismissableSurfaces.delete(node);
};
}, [node, context.dismissableSurfaces]);

return setNode;
}

type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>;
type FocusOutsideEvent = CustomEvent<{ originalEvent: FocusEvent }>;

Expand All @@ -255,12 +283,14 @@ function usePointerDownOutside(
ownerDocument: Document | undefined;
deferPointerDownOutside: boolean;
isDeferredPointerDownOutsideRef: React.RefObject<boolean>;
dismissableSurfaces: Set<DismissableLayerBranchElement>;
},
) {
const {
ownerDocument = globalThis?.document,
deferPointerDownOutside = false,
isDeferredPointerDownOutsideRef,
dismissableSurfaces,
} = args;
const handlePointerDownOutside = useCallbackRef(onPointerDownOutside) as EventListener;
const isPointerInsideReactTreeRef = React.useRef(false);
Expand All @@ -280,16 +310,25 @@ function usePointerDownOutside(
}

function handleInteractionCapture(event: Event) {
if (isPointerDownOutsideRef.current) {
if (!isPointerDownOutsideRef.current) {
return;
}

const target = event.target;
const isDismissableSurface =
target instanceof Node &&
[...dismissableSurfaces].some((surface) => surface.contains(target as Node));

if (!isDismissableSurface) {
interceptedOutsideInteractionEventsRef.current.set(event.type, true);
}

if (event.type === 'click') {
window.setTimeout(() => {
if (isPointerDownOutsideRef.current) {
handleClickRef.current();
}
}, 0);
}
if (event.type === 'click') {
window.setTimeout(() => {
if (isPointerDownOutsideRef.current) {
handleClickRef.current();
}
}, 0);
}
}

Expand Down Expand Up @@ -407,6 +446,7 @@ function usePointerDownOutside(
handlePointerDownOutside,
deferPointerDownOutside,
isDeferredPointerDownOutsideRef,
dismissableSurfaces,
]);

return {
Expand Down Expand Up @@ -473,6 +513,7 @@ const Branch = DismissableLayerBranch;
export {
DismissableLayer,
DismissableLayerBranch,
useDismissableLayerSurface,
//
Root,
Branch,
Expand Down
2 changes: 2 additions & 0 deletions packages/react/dismissable-layer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ export {
//
Root,
Branch,
/** @internal */
useDismissableLayerSurface,
} from './dismissable-layer';
export type { DismissableLayerProps } from './dismissable-layer';
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { flushSync } from 'react-dom';
import { composeEventHandlers } from '@radix-ui/primitive';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { Primitive } from '@radix-ui/react-primitive';
import { Primitive, type PrimitivePropsWithRef } from '@radix-ui/react-primitive';
import { useComposedRefs } from '@radix-ui/react-compose-refs';
import { useId } from '@radix-ui/react-id';
import { useIsHydrated } from '@radix-ui/react-use-is-hydrated';
Expand Down Expand Up @@ -102,7 +102,7 @@ PasswordToggleField.displayName = PASSWORD_TOGGLE_FIELD_NAME;

const PASSWORD_TOGGLE_FIELD_INPUT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Input';

type PrimitiveInputProps = React.ComponentPropsWithoutRef<'input'>;
type PrimitiveInputProps = PrimitivePropsWithRef<'input'>;

interface PasswordToggleFieldOwnProps {
autoComplete?: 'current-password' | 'new-password';
Expand Down Expand Up @@ -201,7 +201,7 @@ PasswordToggleFieldInput.displayName = PASSWORD_TOGGLE_FIELD_INPUT_NAME;

const PASSWORD_TOGGLE_FIELD_TOGGLE_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Toggle';

type PrimitiveButtonProps = React.ComponentPropsWithoutRef<'button'>;
type PrimitiveButtonProps = PrimitivePropsWithRef<'button'>;

interface PasswordToggleFieldToggleProps extends Omit<PrimitiveButtonProps, 'type'> {}

Expand Down Expand Up @@ -406,7 +406,7 @@ PasswordToggleFieldSlot.displayName = PASSWORD_TOGGLE_FIELD_SLOT_NAME;

const PASSWORD_TOGGLE_FIELD_ICON_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Icon';

type PrimitiveSvgProps = React.ComponentPropsWithoutRef<'svg'>;
type PrimitiveSvgProps = PrimitivePropsWithRef<'svg'>;

interface PasswordToggleFieldIconProps extends Omit<PrimitiveSvgProps, 'children'> {
visible: React.ReactElement;
Expand Down
Loading
Loading