primitives icon indicating copy to clipboard operation
primitives copied to clipboard

Click twice to get focus in side dialog with react-select used.

Open JunYang-tes opened this issue 1 year ago • 9 comments

Bug report

Current Behavior

Use react-select within dialog of shadcn-ui, after react-select get focused, I need to click other form item (input) twice to focus that.

Expected behavior

Click one time to focus it

Suggested solution

image

here

When react-select lost focus, it's sub-dom changed, this makes container get focused, so it steal focus from the one I clicked.Can we just ignore the removal inside container?

// When the focused element gets removed from the DOM, browsers move focus
// back to the document.body. In this case, we move focus to the container
// to keep focus trapped correctly.
function handleMutations(mutations: MutationRecord[]) {
  const focusedElement = document.activeElement as HTMLElement | null;
  if (focusedElement !== document.body) return;
  for (const mutation of mutations) {
    if (mutation.removedNodes.length > 0) focus(container);
  }
}

I had some time to look at this issue again today and realized my previous analysis was incorrect. Let's re-examine it using the following DialogDemo.

import * as React from "react";
import { Dialog } from "radix-ui";
import { Cross2Icon } from "@radix-ui/react-icons";
import styles from "./styles.module.css";
import Select from 'react-select';

const options = [
	{ value: 'chocolate', label: 'Chocolate' },
	{ value: 'strawberry', label: 'Strawberry' },
	{ value: 'vanilla', label: 'Vanilla' },
];

document.addEventListener('focusin', e => {
	console.log('focusin', document.activeElement)
})


const DialogDemo = () => {
	const [selectedOption, setSelectedOption] = React.useState(null);

	return <Dialog.Root>
		<Dialog.Trigger asChild>
			<button className={`${styles.Button} violet`}>Edit profile</button>
		</Dialog.Trigger>
		<Dialog.Portal>
			<Dialog.Overlay className={styles.Overlay} />
			<Dialog.Content className={styles.Content}>
				<Dialog.Title className={styles.Title}>Edit profile</Dialog.Title>
				<input
					className={styles.Input}
					id="name"
					onClick={e => {
						console.log('clicked', document.activeElement)
					}}
					defaultValue="Pedro Duarte"
				/>
				<Select
					defaultValue={selectedOption}
					onChange={setSelectedOption}
					options={options}
				/>
				<div
					style={{ display: "flex", marginTop: 25, justifyContent: "flex-end" }}
				>
					<Dialog.Close asChild>
						<button className={`${styles.Button} green`}>Save changes</button>
					</Dialog.Close>
				</div>
				<Dialog.Close asChild>
					<button className={styles.IconButton} aria-label="Close">
						<Cross2Icon />
					</button>
				</Dialog.Close>
			</Dialog.Content>
		</Dialog.Portal>
	</Dialog.Root>
};

export default DialogDemo;

Reproduction steps:

  1. Click on Select

  2. Click on the input field

If we didn't have handleMutations, the focus change process would be:

Select input loses focus -> document.body gains focus -> input gains focus (because it was clicked) -> (input.onClick is triggered, at this point the focused element is input)

But since handleMutations detects that document.body has gained focus, it moves focus to the container. So the focus change process becomes:

Select input loses focus -> document.body gains focus -> container gains focus -> (input.onClick is triggered, at this point the focused element is container)

In other words, the focus call in handleMutations interrupts the original focus change process. The original intention of this function was to ensure focus stays within the container, but it didn't account for cases where focus first moves to body and then to an element inside the container.

Perhaps we could modify handleMutations like this:

      function handleMutations(mutations: MutationRecord[]) {
          
        const focusedElement = document.activeElement;
        if (focusedElement !== document.body) return;

        const shouldFocus = mutations.some(m => m.removedNodes.length > 0);
        if (shouldFocus) {
          setTimeout(() => {
            // double check 👇👇👇
            if (document.activeElement && !container.contains(document.activeElement)) {
              focus(container)
            }
          })
        }
      }

Your environment

Software Name(s) Version
Radix Package(s) @radix-ui/react-focus-scope ^1.1.0
React n/a 17
Browser Chrome 121.0.6167.139
tech
Node n/a 16
npm/yarn
Operating System linux

JunYang-tes avatar Nov 03 '24 15:11 JunYang-tes

My workaround for now was focusing programmatically on click on the input.

          <input
            onClick={() => {
              if (inputRef.current) {
                inputRef.current?.focus();
              }
            }}
            ref={inputRef}
          />

kawamurakazushi avatar Jan 07 '25 11:01 kawamurakazushi

I have the same issue. Having a onClick to focus for every input is not ideal UI. Especially since, for a text input, it doesn't focus on where you click but always puts you at the end of the line.

mihaifloca-civalgo avatar Jan 24 '25 15:01 mihaifloca-civalgo

Is there any update on this issue?

danggearment avatar Apr 08 '25 10:04 danggearment

What is the real solution to this issue? This makes dialog unusable :(

eddielu avatar Apr 12 '25 19:04 eddielu

I have faced the same issue and have found that the problem occurs when using ScrollArea inside the Dialog. Try to remove the ScrollArea or replace it with another lib, it'll work well

khanhspring avatar May 06 '25 16:05 khanhspring

Is there any update on this issue?

Abdelhadi92 avatar May 14 '25 22:05 Abdelhadi92

Hi radix team, any updates on this? This is becoming an issue for us.

stevenwang223 avatar Jul 24 '25 21:07 stevenwang223