Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
23 changes: 23 additions & 0 deletions packages/react/src/dialog/popup/DialogPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,29 @@ describe('<Dialog.Popup />', () => {
});
});

it('focuses the popup itself rather than inner content when opened by touch', async () => {
await render(
<Dialog.Root modal={false}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Popup data-testid="dialog">
<input data-testid="input" />
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>,
);

const trigger = screen.getByText('Open');
fireEvent.pointerDown(trigger, { pointerType: 'touch' });
fireEvent.click(trigger, { detail: 1 });

// On touch the default focuses the popup to avoid opening the virtual keyboard.
await waitFor(() => {
expect(screen.getByTestId('dialog')).toHaveFocus();
});
expect(screen.getByTestId('input')).not.toHaveFocus();
});

it('should not move focus when initialFocus is false', async () => {
function TestComponent() {
return (
Expand Down
15 changes: 3 additions & 12 deletions packages/react/src/dialog/popup/DialogPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DialogPopupDataAttributes } from './DialogPopupDataAttributes';
import { useDialogPortalContext } from '../portal/DialogPortalContext';
import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete';
import { COMPOSITE_KEYS } from '../../internals/composite/composite';
import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups';
import { FOCUSABLE_POPUP_PROPS, createDefaultInitialFocus } from '../../utils/popups';

const stateAttributesMapping: StateAttributesMapping<DialogPopupState> = {
...baseMapping,
Expand Down Expand Up @@ -67,17 +67,8 @@ export const DialogPopup = React.forwardRef(function DialogPopup(
},
});

// Default initial focus logic:
// If opened by touch, focus the popup element to prevent the virtual keyboard from opening
// (this is required for Android specifically as iOS handles this automatically).
function defaultInitialFocus(interactionType: InteractionType) {
if (interactionType === 'touch') {
return store.context.popupRef.current;
}
return true;
}

const resolvedInitialFocus = initialFocus === undefined ? defaultInitialFocus : initialFocus;
const resolvedInitialFocus =
initialFocus === undefined ? createDefaultInitialFocus(store.context.popupRef) : initialFocus;

const nestedDialogOpen = nestedOpenDialogCount > 0;

Expand Down
42 changes: 12 additions & 30 deletions packages/react/src/dialog/root/useDialogRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,21 @@
import * as React from 'react';
import { useScrollLock } from '@base-ui/utils/useScrollLock';
import { EMPTY_OBJECT } from '@base-ui/utils/empty';
import { mergeProps } from '../../merge-props';
import { useDismiss } from '../../floating-ui-react';
import { contains, getTarget } from '../../floating-ui-react/utils';
import { createChangeEventDetails } from '../../internals/createBaseUIEventDetails';
import { REASONS } from '../../internals/reasons';
import { type DialogRoot } from './DialogRoot';
import { DialogStore } from '../store/DialogStore';
import {
FOCUSABLE_POPUP_PROPS,
useImplicitActiveTrigger,
useOpenStateTransitions,
usePopupInteractionProps,
usePopupRootSync,
} from '../../utils/popups';

export function useDialogRoot(params: UseDialogRootParameters): UseDialogRootReturnValue {
const { store, parentContext, actionsRef, isDrawer } = params;
export function useDialogRoot(params: UseDialogRootParameters): void {
const { store, actionsRef } = params;

const open = store.useState('open');
usePopupRootSync(store, open);
Expand All @@ -34,35 +32,22 @@ export function useDialogRoot(params: UseDialogRootParameters): UseDialogRootRet
() => ({ unmount: forceUnmount, close: handleImperativeClose }),
[forceUnmount, handleImperativeClose],
);

return { parentContext, isDrawer };
}

export interface UseDialogRootSharedParameters {}

export interface UseDialogRootParameters {
store: DialogStore<any>;
actionsRef?: DialogRoot.Props['actionsRef'] | undefined;
parentContext?: DialogStore<unknown>['context'] | undefined;
isDrawer: boolean;
}

export interface UseDialogRootReturnValue {
parentContext: DialogStore<unknown>['context'] | undefined;
isDrawer: boolean;
}

export interface UseDialogRootState {}

export function DialogInteractions({
store,
dialogRoot,
parentContext,
isDrawer,
}: {
store: DialogStore<any>;
dialogRoot: UseDialogRootReturnValue;
parentContext: DialogStore<unknown>['context'] | undefined;
isDrawer: boolean;
}) {
const { parentContext, isDrawer } = dialogRoot;

const open = store.useState('open');
const disablePointerDismissal = store.useState('disablePointerDismissal');
const modal = store.useState('modal');
Expand Down Expand Up @@ -101,16 +86,14 @@ export function DialogInteractions({

const target = getTarget(event) as Element | null;
if (isTopmost && !disablePointerDismissal) {
const eventTarget = target as Element | null;
// Only close if the click occurred on the dialog's owning backdrop.
// This supports multiple modal dialogs that aren't nested in the React tree:
// https://github.com/mui/base-ui/issues/1320
if (modal) {
return store.context.internalBackdropRef.current || store.context.backdropRef.current
? store.context.internalBackdropRef.current === eventTarget ||
store.context.backdropRef.current === eventTarget ||
(contains(eventTarget, popupElement) &&
!eventTarget?.hasAttribute('data-base-ui-portal'))
? store.context.internalBackdropRef.current === target ||
store.context.backdropRef.current === target ||
(contains(target, popupElement) && !target?.hasAttribute('data-base-ui-portal'))
: true;
}
return true;
Expand Down Expand Up @@ -153,10 +136,9 @@ export function DialogInteractions({

const activeTriggerProps = dismiss.reference ?? EMPTY_OBJECT;
const inactiveTriggerProps = dismiss.trigger ?? EMPTY_OBJECT;
const popupProps = React.useMemo(
() => mergeProps(FOCUSABLE_POPUP_PROPS, dismiss.floating),
[dismiss.floating],
);
// Consumers (DialogPopup/DrawerPopup) already spread `FOCUSABLE_POPUP_PROPS`
// directly, so the popup props only need to carry the dismiss handlers.
const popupProps = dismiss.floating ?? EMPTY_OBJECT;

usePopupInteractionProps(store, {
activeTriggerProps,
Expand Down
15 changes: 8 additions & 7 deletions packages/react/src/dialog/root/useRenderDialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,7 @@ export function useRenderDialogRoot<Payload>(
const mounted = store.useState('mounted');
const payload = store.useState('payload') as Payload | undefined;

const dialogRoot = useDialogRoot({
store,
actionsRef,
parentContext: parentDialogRootContext?.store.context,
isDrawer,
});
useDialogRoot({ store, actionsRef });

const shouldRenderInteractions = open || mounted;

Expand All @@ -82,7 +77,13 @@ export function useRenderDialogRoot<Payload>(
return (
<IsDrawerContext.Provider value={false}>
<DialogRootContext.Provider value={contextValue as DialogRootContext}>
{shouldRenderInteractions && <DialogInteractions store={store} dialogRoot={dialogRoot} />}
{shouldRenderInteractions && (
<DialogInteractions
store={store}
parentContext={parentDialogRootContext?.store.context}
isDrawer={isDrawer}
/>
)}
{typeof children === 'function' ? children({ payload }) : children}
</DialogRootContext.Provider>
</IsDrawerContext.Provider>
Expand Down
15 changes: 3 additions & 12 deletions packages/react/src/popover/popup/PopoverPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { COMPOSITE_KEYS } from '../../internals/composite/composite';
import { useToolbarRootContext } from '../../toolbar/root/ToolbarRootContext';
import { getDisabledMountTransitionStyles } from '../../utils/getDisabledMountTransitionStyles';
import { ClosePartProvider, useClosePartCount } from '../../utils/closePart';
import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups';
import { FOCUSABLE_POPUP_PROPS, createDefaultInitialFocus } from '../../utils/popups';

const stateAttributesMapping: StateAttributesMapping<PopoverPopupState> = {
...baseMapping,
Expand Down Expand Up @@ -74,17 +74,8 @@ export const PopoverPopup = React.forwardRef(function PopoverPopup(

useHoverFloatingInteraction(floatingContext, { enabled: openOnHover && !disabled, closeDelay });

// Default initial focus logic:
// If opened by touch, focus the popup element to prevent the virtual keyboard from opening
// (this is required for Android specifically as iOS handles this automatically).
function defaultInitialFocus(interactionType: InteractionType) {
if (interactionType === 'touch') {
return store.context.popupRef.current;
}
return true;
}

const resolvedInitialFocus = initialFocus === undefined ? defaultInitialFocus : initialFocus;
const resolvedInitialFocus =
initialFocus === undefined ? createDefaultInitialFocus(store.context.popupRef) : initialFocus;

const focusManagerModal = modal !== false && hasClosePart;
store.useSyncedValue('focusManagerModal', focusManagerModal);
Expand Down
10 changes: 10 additions & 0 deletions packages/react/src/utils/popups/popupStoreUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export const FOCUSABLE_POPUP_PROPS = {
[FOCUSABLE_ATTRIBUTE]: '',
} satisfies HTMLProps<HTMLElement> & Record<typeof FOCUSABLE_ATTRIBUTE, string>;

/**
* Returns the default `initialFocus` resolver for a popup. When opened by touch it focuses the
* popup element itself to prevent the virtual keyboard from opening (required for Android
* specifically; iOS handles this automatically). Otherwise it falls back to the default behavior.
*/
export function createDefaultInitialFocus(popupRef: React.RefObject<HTMLElement | null>) {
return (interactionType: InteractionType) =>
interactionType === 'touch' ? popupRef.current : true;
}

type PopupStoreWithOpen<
State extends PopupStoreState<unknown>,
SetOpenEventDetails extends BaseUIChangeEventDetails<string>,
Expand Down
Loading