Skip to content

[BUG] - Dropdown stays open when opening AlertDialog from onAction on large screens #6361

@MoalosiLiteboho

Description

@MoalosiLiteboho

HeroUI Version

@heroui/react@3.0.1

Describe the bug

When a controlled Dropdown is closed inside onAction and an AlertDialog is opened immediately after, the dropdown remains visible on large/desktop screens. The issue does not reproduce consistently on mobile or small screens.
All straightforward approaches to close the dropdown before opening the dialog fail on large screens. The suspected root cause is React Aria's async focus restoration — when the dropdown closes, React Aria restores focus to the trigger asynchronously, which appears to interfere with or re-open the controlled dropdown before the AlertDialog mounts and traps focus.

Your Example Website or App

No response

Steps to Reproduce the Bug or Issue

  1. Create a controlled Dropdown using isOpen / onOpenChange
  2. Inside onAction, call dropdown.close() then open an AlertDialog
  3. View on a large/desktop screen
  4. Click the dropdown trigger, then click the action item
"use client";

import {
	Description,
	Dropdown,
	Header,
	Label,
	type UseOverlayStateReturn,
	useOverlayState,
} from "@heroui/react";
import { CiTrash } from "react-icons/ci";
import { IoMdMore } from "react-icons/io";
import type { Client } from "@/features/clients/types/client";
import DeleteClientAlertDialog from "@/features/clients/ui/delete-client-alert-dialog";

type Props = {
	client: Client;
};

function ClientAction({ client }: Props) {
	const dropdown: UseOverlayStateReturn = useOverlayState();
	const state: UseOverlayStateReturn = useOverlayState();

	return (
		<>
			<Dropdown isOpen={dropdown.isOpen} onOpenChange={dropdown.setOpen}>
				<Dropdown.Trigger
					className="button button-md button--ghost button--icon-only data-[focus-visible=true]:status-focused"
					aria-label={`More actions for client ${client.email}`}
					aria-haspopup="menu"
				>
					<IoMdMore aria-hidden="true" />
				</Dropdown.Trigger>
				<Dropdown.Popover placement="bottom end">
					<Dropdown.Menu
						aria-label={`Actions for ${client.email}`}
						onAction={(key) => {
							if (key === "delete") {
								dropdown.close();
								state.open();
							}
						}}
					>
						<Dropdown.Section>
							<Header>Actions</Header>
							<Dropdown.Item
								id="delete"
								textValue="Delete Client"
								variant="danger"
								className="text-danger"
								aria-label={`Delete client ${client.email}`}
							>
								<CiTrash aria-hidden="true" />
								<div className="flex flex-col">
									<Label>Delete</Label>
									<Description className="text-xs">
										Permanently removes this client.
									</Description>
								</div>
							</Dropdown.Item>
						</Dropdown.Section>
					</Dropdown.Menu>
				</Dropdown.Popover>
			</Dropdown>
			<DeleteClientAlertDialog client={client} state={state} />
		</>
	);
}

export { ClientAction as default };
"use client";

import { AlertDialog, Button, type UseOverlayStateReturn } from "@heroui/react";
import { useDeleteClient } from "@/features/clients/queries/client.q";
import type { Client } from "@/features/clients/types/client";

type Props = {
	client: Client;
	state: UseOverlayStateReturn;
};

function DeleteClientAlertDialog({ client, state }: Props) {
	const { mutate: deleteClient, isPending } = useDeleteClient(() =>
		state.close(),
	);

	return (
		<AlertDialog.Backdrop isOpen={state.isOpen} onOpenChange={state.setOpen}>
			<AlertDialog.Container size="md">
				<AlertDialog.Dialog
					role="alertdialog"
					aria-labelledby="delete-dialog-heading"
					aria-describedby="delete-dialog-description"
				>
					<AlertDialog.CloseTrigger aria-label="Cancel deletion" />
					<AlertDialog.Header>
						<AlertDialog.Icon status="danger" aria-hidden="true" />
						<AlertDialog.Heading id="delete-dialog-heading">
							Delete Client
						</AlertDialog.Heading>
					</AlertDialog.Header>
					<AlertDialog.Body id="delete-dialog-description">
						<p>
							Are you sure you want to delete <strong>{client.email}</strong>?
							This will permanently remove their account and all associated
							data. This action cannot be undone.
						</p>
					</AlertDialog.Body>
					<AlertDialog.Footer>
						<Button
							slot="close"
							variant="tertiary"
							isDisabled={isPending}
							aria-label="Cancel and close dialog"
						>
							Cancel
						</Button>
						<Button
							variant="danger"
							isDisabled={isPending}
							isPending={isPending}
							aria-label={
								isPending
									? `Deleting ${client.email}, please wait`
									: `Confirm deletion of ${client.email}`
							}
							onPress={() => deleteClient(client.id)}
						>
							{isPending ? "Deleting..." : "Delete"}
						</Button>
					</AlertDialog.Footer>
				</AlertDialog.Dialog>
			</AlertDialog.Container>
		</AlertDialog.Backdrop>
	);
}

export { DeleteClientAlertDialog as default };

Expected behavior

When dropdown.close() is called inside onAction, the dropdown should fully close before the AlertDialog opens. Closing one overlay and immediately opening another should work without requiring any workarounds.

Screenshots or Videos

Image Image

Operating System Version

Linux

Browser

Chrome

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions