Open the phone search on a tap, not on focus
The folded search opened whenever its field had focus, so the
dashboard's autofocus landed every phone on an open search with the
account hidden behind it. It now opens on a tap of the glyph (or on
typing), folds when focus leaves with nothing typed, and folds at once
on a touch anywhere else rather than waiting on a blur the phone was
seen to send late. A page carrying a query still renders open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CxsxtnQrJKvwHGfRmdhR9F
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index ba57c5f..39768ed 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -106,12 +106,28 @@ module ProTacts
# rather than two.
#
# On a phone the search folds into its glyph and opens
- # across the whole header while it has focus or a query
- # (admin.css). The label is what makes the glyph open
- # it: a tap anywhere in a label focuses its field, so
- # the fold needs no script either.
- form(action: "/", method: "get", class: "search-form") do
- label do
+ # across the whole header (admin.css, keyed on
+ # data-open). A tap on the glyph opens it, and the label
+ # is what focuses the field from there. It folds again
+ # when focus leaves with nothing typed; a page carrying
+ # a query renders open, the query being what its results
+ # answer. Focus alone does not open it, because the
+ # dashboard's autofocus would then land every phone on
+ # an open search with the account hidden behind it. A
+ # touch anywhere else folds it at once rather than
+ # waiting on the blur, which a phone was seen to send
+ # late or not at all.
+ open = !@query.to_s.empty?
+ fold = "if ($el.querySelector('input').value === '') " \
+ "{ open = false; $el.querySelector('input').blur() }"
+ form(action: "/", method: "get", class: "search-form",
+ x_data: "{ open: #{open} }", "x-bind:data-open": "open",
+ "@input": "open = true",
+ "@focusout": "open = $el.contains($event.relatedTarget) || " \
+ "$el.querySelector('input').value !== ''",
+ "@pointerdown.outside": fold,
+ **(open ? {data: {open: true}} : {})) do
+ label("@click": "open = true") do
render Icon.new(:search)
input(type: "search", name: "q", value: @query,
placeholder: "Search contacts", autofocus: @autofocus)
diff --git a/public/admin.css b/public/admin.css
index b817959..64c8281 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -472,9 +472,9 @@ textarea:focus {
and home joins the destinations in the account menu (Layout
repeats them there). The search folds into its glyph at the
start of the row, pulled out by its padding so it stands on the
- edge the page's column does; a tap on it focuses the field its
- label holds, and while the field has focus, or a query to show,
- it opens across the whole header over the account, which is
+ edge the page's column does; a tap on it opens it (Layout says
+ when it folds again, and why focus alone does not open it), across
+ the whole header over the account, which is
hidden rather than removed so the header keeps its height. The
login is whatever the proxy vouches for, usually an email, so it
ends in an ellipsis when it runs out of room. The breakpoint is
@@ -511,8 +511,7 @@ textarea:focus {
opacity: 0;
clip-path: inset(50%);
}
- .admin-header .search-form:is(:focus-within,
- :has(input:not(:placeholder-shown))) {
+ .admin-header .search-form[data-open] {
position: absolute;
inset: 0;
display: flex;
@@ -521,30 +520,25 @@ textarea:focus {
margin: 0;
padding: 0 var(--gl-space-m);
}
- .admin-header .search-form:is(:focus-within,
- :has(input:not(:placeholder-shown))) label {
+ .admin-header .search-form[data-open] label {
flex: 1;
padding: 0;
cursor: auto;
}
- .admin-header .search-form:is(:focus-within,
- :has(input:not(:placeholder-shown))) label > svg {
+ .admin-header .search-form[data-open] label > svg {
display: none;
}
- .admin-header .search-form:is(:focus-within,
- :has(input:not(:placeholder-shown))) input[type="search"] {
+ .admin-header .search-form[data-open] input[type="search"] {
position: static;
width: 100%;
height: auto;
opacity: 1;
clip-path: none;
}
- .admin-header .search-form:is(:focus-within,
- :has(input:not(:placeholder-shown))) .search-clear {
+ .admin-header .search-form[data-open] .search-clear {
inset-inline-end: calc(var(--gl-space-m) + var(--gl-space-2xs));
}
- .admin-header:has(.search-form:focus-within,
- .search-form input:not(:placeholder-shown)) > :not(.search-form) {
+ .admin-header:has(.search-form[data-open]) > :not(.search-form) {
visibility: hidden;
}
.user-menu {
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index c49e35a..55f8318 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -361,11 +361,15 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes header, "search-form"
assert_includes header, "name=\"q\""
assert_includes header, "autofocus"
+ # Folded on a phone despite the focus (Layout says why).
+ refute_includes header, " data-open"
get "/", q: "ada"
header = last_response.body.split("<main").first
assert_includes header, "value=\"ada\""
refute_includes header, "autofocus"
+ # Open from the start, showing the query its results answer.
+ assert_includes header, " data-open"
end
end