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
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({})