Repository: algolia/autocomplete (TypeScript)

Issue:
input doesn't get focus when entering detached mode
## Description

I'm not sure if I used correct terms in the title, but anyway when clicking a search box, it doesn't get focused even in our example:

https://xbnmy.csb.app/

## Reproduction

https://user-images.githubusercontent.com/499898/131669733-dcca5dea-e44a-4459-ba52-9fc395c4ebe6.MP4

[**Preview →**](https://codesandbox.io/s/github/algolia/autocomplete/tree/next/examples/playground?file=/app.tsx)

**Steps**

1. Open the page on mobile
2. Click on the search box
3. Click on the search box again
4. Now you see it is focused

## Expected behavior

It should've been focused after the first click.

## Environment

- iOS 15.0 (dev beta 7) on iPhone 12 with Safari and Chrome
- iOS 13.0 on iPhone SE with Safari and Chrome
- Autocomplete 1.3.0


Discussion:
Interestingly this doesn't happen on desktop Safari (even in detached mode). I wonder if this could be related to hover styles?
It would be great if it could be an option exposed to the developer who could then choose between the two behaviours
- By default: it focuses
- It doesn't focus, and the end-user need to click in the input

I don't think this needs an option, not focusing is simply a bug @seafoox 
The input element _does_ receive focus (notice the purple outline which comes from a `:focus` style), what we don't have is the keyboard popping up.

This is probably related to the fact that iPhones don't let you programmatically assign focus (at least that was the case [in the past](http://blog.pixelastic.com/2015/07/13/trigger-focus-on-input-on-iphone-programmatically/)), just like they don't allow you to autoplay videos, etc. We can set focus if this is done within a user event callback (which is the case here and it works) but we can't open the keyboard, this only happens when the user deliberately taps the input (we can't even simulate it). As far as I know there's no way around it.

~~I guess this is a wontfix situation.~~

@FabienMotte might have found a dark magic [way around it](https://codesandbox.io/s/z33rnoy1nm) 🔥 will try it!
We've investigated this behavior and unfortunately, we can't work around it without introducing a hack. This hack forces us to change how we schedule DOM updates, which has unintended side-effects such as reopening the search modal on close when the query has been updated.

We'll close it for now, as this is intentional browser behavior and there's no clean, non-invasive way around it.
This is sad, but I understand why you don't want to fight the browser here... Would a better fix be to have the search box that a user sees on mobile that is actually a button right now, be the `<input>` that gets moved up at the top of detached mode? That way the user clicked an `input` and we're not trying to trick anyone.

I don't know if it's possible, and if it is, it's probably a bunch of work. As of right now the search feels kind of broken for mobile users since it requires that extra tap.
We did consider that approach, but it would require faking the input to look like a button, which likely is a nightmare for accessibility behaviour. 

I definitely see what you mean with it feeling a little broken, but what we found is the best fix is showing some UI on empty query, either query suggestions or static content, which makes that screen more interactive, and also makes more sense not to immediately cover it up with the keyboard.

You can see a similar pattern in many mobile applications (twitter, reddit), where the search button goes to an explore page, which allows people to find that immediately, but also requires two taps to get to the actual search bar.

We might reconsider this in the future, but for the time being this resolution makes sense to us.
@Haroenv could we have a demo of how to work around this problem? 😃
I think it is useful for everyone because we all will have this problem in iOS anyway.
Has there been a change? Or is it still this way
@amirhega the problem isn't solved yet.
With all due respect to the maintainers' decision, I disagree with this being closed with "wontfix". In our use case, the majority of users are using mobile and the majority of those iOS devices, resulting in a poor user experience for at least half of our users. In fact this is even broken on [Algolia's own website](https://www.algolia.com/doc/ui-libraries/autocomplete/api-reference/autocomplete-js/autocomplete/).

> This is probably related to the fact that iPhones don't let you programmatically assign focus (at least that was the case [in the past](http://blog.pixelastic.com/2015/07/13/trigger-focus-on-input-on-iphone-programmatically/)), just like they don't allow you to autoplay videos, etc. We can set focus if this is done within a user event callback (which is the case here and it works) but we can't open the keyboard, this only happens when the user deliberately taps the input (we can't even simulate it). As far as I know there's no way around it.

iOS Safari does allow programmatic `focus()` including activating the on-screen keyboard, as long as the focus call is a result of a user interaction. Which appears to be the case when opening the detached panel by clicking on the fake search input (which is a button) with one catch. 

However, the [code calling `focus()`](https://github.com/algolia/autocomplete/blob/next/packages/autocomplete-js/src/autocomplete.ts#L377-L402) is wrapped in `requestAnimationFrame`, which - in my theory - makes the `focus()` call look like not having been triggered by a user interaction. Whether this is a bug or a feature in Safari is another question - I have not found any authoritative documentation on this matter, only dozens of StackOverflow threads. (The fact that the bug does not occur in iOS simulator suggests that this is rather a bug in iOS Safari.)

I've tried [removing `requestAnimationFrame`](https://github.com/salomvary/autocomplete/commit/ec381d4873bc29eda26be18ac6c96a866b81ad58) to prove this theory, and it indeed fixed the issue on iOS. It is not obvious to me what purpose `requestAnimationFrame` serves. Removing it does not break any tests, and things seem to keep working (briefly tested on iOS Safari and various desktop browsers). The code including `requestAnimationFrame` was added in 1239b63e80dd2351771d74e91b275eda19fb997f by @francoischalifour, maybe he can explain?

For the impatient, I've published my fork on npm: https://www.npmjs.com/package/@salomvary/autocomplete-js

I believe the `requestAnimationFrame` was to ensure the panel exists and is ready to be shown, however that may not be required as you show in your fork. Are you interested in making a pull request to backport your change? It looks useful to me, this is indeed a recurring problem that we hadn't found a solution for.

Thanks a lot for your contribution already @salomvary 
I just spoke with @francoischalifour and the initial reasoning was to slow down the setters a little so "other work on the page" can happen too. As fixing this bug is clearly more important, we should definitely remove the `requestAnimationFrame`.
> Are you interested in making a pull request to backport your change? It looks useful to me, this is indeed a recurring problem that we hadn't found a solution for.

Here we go https://github.com/algolia/autocomplete/pull/1231
Thanks again @salomvary , the fix is now part of autocomplete [v1.14.0](https://github.com/algolia/autocomplete/releases/tag/v1.14.0).



Patch:
diff --git a/packages/autocomplete-js/src/autocomplete.ts b/packages/autocomplete-js/src/autocomplete.ts
index 132767328..4dfe310fd 100644
--- a/packages/autocomplete-js/src/autocomplete.ts
+++ b/packages/autocomplete-js/src/autocomplete.ts
@@ -375,30 +375,28 @@ export function autocomplete<TItem extends BaseItem>(
   }
 
   function setIsModalOpen(value: boolean) {
-    requestAnimationFrame(() => {
-      const prevValue = props.value.core.environment.document.body.contains(
-        dom.value.detachedOverlay
-      );
+    const prevValue = props.value.core.environment.document.body.contains(
+      dom.value.detachedOverlay
+    );
 
-      if (value === prevValue) {
-        return;
-      }
+    if (value === prevValue) {
+      return;
+    }
 
-      if (value) {
-        props.value.core.environment.document.body.appendChild(
-          dom.value.detachedOverlay
-        );
-        props.value.core.environment.document.body.classList.add('aa-Detached');
-        dom.value.input.focus();
-      } else {
-        props.value.core.environment.document.body.removeChild(
-          dom.value.detachedOverlay
-        );
-        props.value.core.environment.document.body.classList.remove(
-          'aa-Detached'
-        );
-      }
-    });
+    if (value) {
+      props.value.core.environment.document.body.appendChild(
+        dom.value.detachedOverlay
+      );
+      props.value.core.environment.document.body.classList.add('aa-Detached');
+      dom.value.input.focus();
+    } else {
+      props.value.core.environment.document.body.removeChild(
+        dom.value.detachedOverlay
+      );
+      props.value.core.environment.document.body.classList.remove(
+        'aa-Detached'
+      );
+    }
   }
 
   warn(


Test patch:
diff --git a/packages/autocomplete-js/src/__tests__/detached.test.ts b/packages/autocomplete-js/src/__tests__/detached.test.ts
index d39559210..af0b4cc0f 100644
--- a/packages/autocomplete-js/src/__tests__/detached.test.ts
+++ b/packages/autocomplete-js/src/__tests__/detached.test.ts
@@ -62,15 +62,16 @@ describe('detached', () => {
     // Open detached overlay
     searchButton.click();
 
-    await waitFor(() => {
-      const input = document.querySelector<HTMLInputElement>('.aa-Input')!;
+    const input = document.querySelector<HTMLInputElement>('.aa-Input')!;
 
-      expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument();
-      expect(document.body).toHaveClass('aa-Detached');
-      expect(input).toHaveFocus();
+    expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument();
+    expect(document.body).toHaveClass('aa-Detached');
 
-      fireEvent.input(input, { target: { value: 'a' } });
-    });
+    // Input should immediately be focused, to ensure the keyboard is shown on mobile
+    expect(input).toHaveFocus();
+
+    // Type a query in the focused input
+    fireEvent.input(input, { target: { value: 'a' } });
 
     // Wait for the panel to open
     await waitFor(() => {
@@ -391,16 +392,15 @@ describe('detached', () => {
     // Open detached overlay
     searchButton.click();
 
-    // Type a query in the focused input
-    await waitFor(() => {
-      const input = document.querySelector<HTMLInputElement>('.aa-Input')!;
+    const input = document.querySelector<HTMLInputElement>('.aa-Input')!;
 
-      expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument();
-      expect(document.body).toHaveClass('aa-Detached');
-      expect(input).toHaveFocus();
+    expect(document.querySelector('.aa-DetachedOverlay')).toBeInTheDocument();
+    expect(document.body).toHaveClass('aa-Detached');
+    // Input should immediately be focused, to ensure the keyboard is shown on mobile
+    expect(input).toHaveFocus();
 
-      fireEvent.input(input, { target: { value: 'a' } });
-    });
+    // Type a query in the focused input
+    fireEvent.input(input, { target: { value: 'a' } });
 
     // Wait for the panel to open
     await waitFor(() => {