Skip to content

The on-screen keyboard never closes on search submit or navigation #164

Description

@OffCrazyFreak

What

The on-screen keyboard should close when the user commits a search, whether by pressing the search button or the keyboard's own Go/Search key, on every surface that has a search bar: products, watchlist, shopping lists, digital cards when they ship, suggestions, map, updates, plus the sidebar and the mobile products sheet.

It should also close when the user navigates, for example products list to product details, or watchlist to shopping lists.

Split out of #162, where this was first noted. Related but separate: #162 is about when the search fires, this is about what the keyboard does when it does.

Current state: it never closes, anywhere

There is not a single .blur() call in the repository. Confirmed by grep across .ts, .tsx and .md; the only hits are Tailwind's backdrop-blur and a blur-slide animation preset.

Every surface routes through one shared component, so they all inherit this. frontend/src/components/custom/search/search-bar.tsx:96:

function submit(data: { query: string }) {
  if (disabled) return;
  const query = data.query?.trim() ?? "";
  setOpen(false);   // this is the sidebar, not the input
  search(query);    // router.push
  onSubmitted?.(query);
}

The clear handler at :106 does the opposite on purpose, inputRef.current?.focus(), which is correct behaviour and should stay.

useSearchNavigation (frontend/src/hooks/use-search-navigation.ts) is the single choke point every surface goes through, and it does pure navigation with no focus work.

Why nothing dismisses it today

Three findings from the platform docs, all documented rather than folklore:

  1. A native form submit closes the keyboard only because the page navigates and tears down the focused element. A preventDefault()-ed submit does not close it on either Android or iOS. react-hook-form's handleSubmit prevents default, so we are squarely in that case. This is the single most common cause of a stuck keyboard in an SPA search box.
  2. A client-side router.push does not close the keyboard. Nothing in the History API touches focus. It closes only if the focused input happens to be unmounted by the following render, and even then the timing is engine-dependent. Our header and sidebar search bars live in the persistent layout, so they are never unmounted and the keyboard survives every navigation.
  3. enterKeyHint="search" (present at search-bar.tsx:143) only changes the label on the Enter key. It has no effect on dismissal. Same for inputMode.

Two places accidentally get it right today, and only by luck. The mobile products sheet passes onSubmitted={close}, and SheetShell.onCloseAutoFocus (frontend/src/components/custom/modal/sheet-shell.tsx:117) moves focus to the bottom-nav centre button. Focus landing on a <button> is what closes the keyboard, not the sheet closing. ModalShell rescues the modal forms the same way. The non-modal surfaces (header, sidebar, landing hero, products page) have no such rescue and are the real bug.

Worth noting this is not search-only: the contact form, the shopping-list create and copy modals, and the digital-card form all submit from a text input and none of them blur either. They are only saved by the modal return-focus behaviour above.

Android vs iOS

Both engines tie keyboard visibility to focus on an editable element, so blur() works on both. The differences that matter here:

  • Android Chrome: blur closes it reliably. The known asymmetry runs the other way, dismissing via the Android back button does not blur the input and fires no blur event (chromium/41177736, closed as intended behaviour). So we can close the keyboard, we just cannot detect the user closing it.
  • iOS Safari: blur closes it reliably for text inputs. iOS additionally wants a real <button type="submit"> in the form for the Go key to fire submit at all, which we already have on most surfaces but not where submitButtonLocation="none" (the header). Worth checking on device.
  • setTimeout around blur and the readOnly toggle are both folklore for this purpose. The documented iOS timing restriction applies to focus(), not blur(), and readOnly is a trick for preventing the keyboard opening, not dismissing it.

The VirtualKeyboard API is not the answer

navigator.virtualKeyboard.hide() is Chromium-only (94+), no Firefox, no Safari, and MDN marks it limited availability rather than Baseline. It also only works if the element carries virtualkeyboardpolicy="manual", which makes us responsible for calling show() on every focus. Large commitment for a one-platform benefit. Plain blur() is the cross-platform answer including on Chromium.

We already set interactiveWidget: "resizes-content" in frontend/src/app/layout.tsx:115, but that governs what the keyboard does to the viewport, not whether it closes. Safari has not implemented it (WebKit 259770).

Accessibility, which shapes the fix

A bare blur drops focus to <body>. That is a focus black hole: the next Tab restarts from the top of the document, and screen reader users lose their virtual cursor. For someone on an iPad with a Bluetooth keyboard there was no on-screen keyboard to dismiss and we just threw away their place.

So the better pattern is to move focus to the results region (a tabindex={-1} heading or container) rather than blurring into nothing. Focus lands on a non-editable element, which dismisses the keyboard, and it satisfies the focus-management requirement at the same time. This also pairs well with the announcement requirement in AGENTS.md, since the result count changes without a navigation.

Sketch of the work

  • On explicit submit, move focus to the results region, or (document.activeElement as HTMLElement | null)?.blur() as the simpler first cut. Use document.activeElement rather than inputRef, since focus may already have moved to the tapped button.
  • Do it in search-bar.tsx's submit(), or in useSearchNavigation.search() so every caller is covered.
  • Do not blur on the autoSearch surfaces while typing, only on an explicit commit. Blurring per keystroke would make those unusable.
  • Blur on navigation. Note a usePathname effect alone is not enough: search() often only changes ?q= on the same route, so it needs useSearchParams too, or the blur belongs at the commit site rather than a route listener. Existing route-change effects that could host it: app-sidebar.tsx:32 and products-sheet.tsx:55, both layout-level.
  • Check the header bar on iOS, where submitButtonLocation="none" means there may be no submit control for the Go key to trigger.
  • Extend to the non-search forms (contact, shopping-list modals, digital cards) or explicitly decide the modal return-focus behaviour is sufficient there.
  • docs/MOBILE-NAV.md covers raising the keyboard and sizing around it, never dismissing it. Add a section.

Interaction with live search (#162)

If live search ships, the products bar stops having a meaningful submit, which removes the natural moment to dismiss the keyboard. Tapping a result and navigating to the details page becomes the commit point instead. Worth deciding these two together.

Sources: MDN HTMLElement.blur(), MDN VirtualKeyboard.hide(), MDN viewport meta, Apple Designing Forms, chromium 41177736, WebKit 259770.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    accessibilityAccessibility (a11y) issuebugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions