One clear control for the search fields
Firefox draws no native cancel on a search field, and the one WebKit and Blink draw is suppressed in favor of ours, so the affordance is the same everywhere.

Assisted-by: GLM-5.3 via pi
change zvwylsquvlztunzorqlryrmswzvsvlsu
commit c53781938e02bdddd45134553bb2994925e17298
author Alpha Chen <alpha@kejadlen.dev>
date
parent ltluyrop
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index 61b9f0e..88759ff 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -1,5 +1,7 @@
 require "pro_tacts/admin/phlex"
 
+require "pro_tacts/admin/icons"
+
 module ProTacts
   module Admin
     # Picking from a list too long to read: the filter over
@@ -124,9 +126,20 @@ module ProTacts
       end
 
       def view_template
-        input(type: "search", placeholder: @placeholder,
-              aria_label: @placeholder, autofocus: @autofocus,
-              x_model: "filter", **enter)
+        # The filter and its clear in one box, so the button can be
+        # positioned at the field's end (admin.css). The click empties
+        # the state rather than the box alone — `filter` is what the
+        # rows, the cap, and the offer all read — and the reveal is
+        # CSS on the box, the header search's own (see Layout).
+        div(class: "filter") do
+          input(type: "search", placeholder: @placeholder,
+                aria_label: @placeholder, autofocus: @autofocus,
+                x_model: "filter", **enter)
+          button(type: "button", class: "icon-button search-clear", data_size: "sm",
+                 aria_label: "Clear filter", "@click": "filter = ''") do
+            render Icon.new(:x)
+          end
+        end
       end
 
       private
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 59e0252..54de6e5 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -95,9 +95,20 @@ module ProTacts
               # somewhere to be besides the input — and focuses only
               # where the screen asked for it.
               a(href: "/") { "pro-tacts" }
+              # The search and, in the field's end, its clear: a link,
+              # because clearing the dashboard's search is navigating
+              # to the page with no query — the form's own target with
+              # nothing submitted — landing focused on an empty input
+              # again. CSS reveals it the moment there is text to clear
+              # (admin.css), typed-but-unsubmitted included, so the
+              # header needs no script for it; the native cancel WebKit
+              # and Blink draw is suppressed there too, one affordance
+              # rather than two.
               form(action: "/", method: "get", class: "search-form") do
                 input(type: "search", name: "q", value: @query,
                       placeholder: "Search contacts", autofocus: @autofocus)
+                a(href: "/", class: "icon-button search-clear", data_size: "sm",
+                  aria_label: "Clear search") { render Icon.new(:x) }
               end
               # The destinations and the account, at the right edge:
               # the contacts list is the browse surface and the groups
diff --git a/public/admin.css b/public/admin.css
index 1bc4545..aec5141 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -635,6 +635,44 @@ body {
   padding-block: var(--gl-space-3xs);
 }
 
+/* Clearing a search field: one affordance for both kinds the app
+   has — the header's search and the lists' filter — Gloss's sm
+   icon-button carrying Lucide's x, overlaid at the field's end.
+   Revealed by :has(:not(:placeholder-shown)) on the box the input
+   and the control share, so it appears the moment there is text to
+   clear, typed-but-unsubmitted included, with no script — the
+   filter's button needs Alpine only for its click, which empties
+   the state the rows read (GroupFilter). The input's end padding
+   reserves the control's width by the sm icon-button's own
+   geometry (2× its 3xs padding + its 1.15×0.8125rem line), so text
+   never runs under it. */
+.search-form,
+.filter {
+  position: relative;
+}
+.search-clear {
+  position: absolute;
+  inset-block-start: 50%;
+  inset-inline-end: var(--gl-space-2xs);
+  translate: 0 -50%;
+  display: none;
+}
+.search-form:has(input:not(:placeholder-shown)) .search-clear,
+.filter:has(input:not(:placeholder-shown)) .search-clear {
+  display: inline-flex;
+}
+input[type="search"] {
+  padding-inline-end: calc(var(--gl-space-s) + 2 * var(--gl-space-2xs) +
+    2 * var(--gl-space-3xs) + 1.15 * 0.8125rem);
+}
+/* WebKit and Blink draw their own cancel on a search field; Firefox
+   draws none. Ours above is the one affordance everywhere, so the
+   native one goes — a field carrying both would clear twice over
+   the same corner. */
+input[type="search"]::-webkit-search-cancel-button {
+  display: none;
+}
+
 /* The account menu, the header's third region: the button naming the
    login and the popover it opens. The wrapper is the popover's
    positioning context — a popover is promoted to the top layer on
@@ -1014,10 +1052,10 @@ dialog .field-stack {
 dialog form.field-stack {
   gap: var(--gl-space-3xs);
 }
-dialog > .field-stack:has(> input[type="search"]) {
+dialog > .field-stack:has(> .filter) {
   gap: var(--gl-space-xs);
 }
-dialog > .field-stack > input[type="search"] {
+dialog > .field-stack > .filter > input[type="search"] {
   padding-block: var(--gl-space-3xs);
 }
 
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 09c9852..4b10f9e 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -324,6 +324,9 @@ class AdminGroupsPagesTest < Minitest::Test
       body = last_response.body
       assert_equal 200, last_response.status
       assert_includes body, %(placeholder="Filter contacts")
+      # The clear empties the state the rows read, not just the box.
+      assert_includes body, %(aria-label="Clear filter")
+      assert_includes body, %(@click="filter = ''")
       %w[george mary].each do |member|
         assert_includes body, %(name="members[]" value="#{member}" checked)
         assert_includes body, %(name="was[]" value="#{member}")
diff --git a/test/pro_tacts/admin/test_layout.rb b/test/pro_tacts/admin/test_layout.rb
index 6f00d63..e484814 100644
--- a/test/pro_tacts/admin/test_layout.rb
+++ b/test/pro_tacts/admin/test_layout.rb
@@ -49,6 +49,14 @@ class AdminLayoutTest < Minitest::Test
     assert_includes header_html, %(<ul id="user-menu" popover="auto")
   end
 
+  # A link, not a button: clearing the dashboard's search is navigating
+  # to the page with no query.
+  def test_the_search_carries_a_clear
+    with_env({})
+
+    assert_includes header_html, %(<a href="/" class="icon-button search-clear" data-size="sm" aria-label="Clear search">)
+  end
+
   def test_the_account_menu_links_to_the_import_screen
     with_env({})