From 8e444ff98b7c5a6e45ecfede7b80efd961d06c66 Mon Sep 17 00:00:00 2001 From: Guillem Arias Fauste Date: Thu, 21 May 2026 12:54:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(design-system):=20add=20DS::SearchInput=20?= =?UTF-8?q?primitive=20(closes=20#1715=20=C2=A73)=20(#1853)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(design-system): add DS::SearchInput + migrate 2 broken-focus callsites Resolves #1715 §3. Two standalone search-field callsites — `/settings/preferences` currency filter and `/settings/providers` filter row — had a hand- rolled markup that ended in `focus:ring-gray-500`. That utility has no backing token in the design system (`ring-gray-500` isn't in Tailwind's default + Sure doesn't register a gray ring color), so the input rendered with zero focus indicator on a bordered bg-container surface. Keyboard users couldn't tell when the field was focused. Introduce `DS::SearchInput` — icon-on-left, bordered, token-backed focus ring matching the DS::Button pattern landing in #1840 (`outline-2 outline-offset-2 outline-gray-900` with the dark-mode override). API: DS::SearchInput.new( name: "...", placeholder: "...", value: ..., aria_label: "...", # defaults to placeholder class: "...", # passed to the wrapper **opts # spread onto the , e.g. data-* ) Migrate the two broken callsites. Three other "search" patterns stay as-is (out of scope for this PR): - `form.search_field :search` inside `styled_form_with` blocks (accounts/show/_activity.html.erb, UI::Account::ActivityFeed) — already routes through StyledFormBuilder's form-field CSS. - Embedded-dropdown search input inside DS::Select, DS::Menu, and the splits/category-select panels — uses a different shape (no border, no ring) because the parent panel provides the chrome. - Category dropdown's combobox search input (app/views/category/dropdowns/show.html.erb) — has a custom `role=combobox` flow and stays intentionally distinct. * feat(design-system): add embedded variant to DS::SearchInput, migrate 2 more callsites Adds `variant: :embedded` to `DS::SearchInput` for search inputs that live *inside* another DS panel (DS::Select dropdown, splits category filter, future DS::Popover-hosted filters). No own border / no own focus ring — the parent panel provides the chrome, so adding ring + outline competes with its `focus-within` state. API: DS::SearchInput.new(variant: :embedded, placeholder: "...", data: {...}) The `:standalone` default (from the previous commit) stays unchanged and remains the right choice for top-of-list filter inputs. Migrated: - `app/components/DS/select.html.erb` — the in-dropdown search input for `DS::Select.new(searchable: true)`. Was the only remaining internal raw markup in the component. - `app/views/splits/_category_select.html.erb` — split-transaction category picker filter. Same shape as DS::Select's search but hand-rolled because the picker isn't a vanilla DS::Select. Three other search patterns stay out of scope (intentionally, per the previous commit): - `form.search_field :search` inside `styled_form_with` — uses form-field CSS, different visual contract. - `app/views/category/dropdowns/show.html.erb` — bespoke `role="combobox"` flow with `aria-expanded` / `aria-autocomplete` semantics that don't belong in this primitive. * fix(review): mobile font + embedded variant focus-within ring - DS::SearchInput: switch text-sm -> text-base sm:text-sm on both variants so the input keeps its 16px base size on mobile. iOS Safari zooms the viewport when a focused input is below 16px, which the unconditional text-sm was triggering on the Settings Preferences currency search and Settings Bank Sync provider search. - DS::Select (searchable variant) + splits/_category_select: add focus-within:ring-4 focus-within:ring-alpha-black-200 (with theme-dark variant) on the wrapper around the embedded search input. The embedded variant intentionally has no own focus ring so it inherits chrome from its parent panel — but the two current parent panels were not providing one, so keyboard focus on the dropdown search box rendered with no visible indicator. Ring matches the .form-field token used across the design system. * fix(merge): repair DS::Select search input merge resolution The previous merge of main left invalid Ruby inside the DS::SearchInput `data:` hash: aria-label="<%= t("helpers.select.search_placeholder") %>" This is an ERB string assignment masquerading as a hash entry — it does not parse and would have raised SyntaxError at render. Two follow-ups: - Drop the `aria-label` entry entirely. `DS::SearchInput` already defaults `aria_label` to `placeholder`, and `placeholder` is set on the call, so the resulting already carries `aria-label="<%= t(...) %>"`. - Restore the `input->select#syncTabindex` action that main #1848 added on the embedded search input. It keeps the roving tabindex on the listbox in sync as filtered results change. Original PR branch had only `list-filter#filter`; reintegrate both with explicit `input->` event prefixes for parity with main. --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- app/components/DS/search_input.html.erb | 17 ++++ app/components/DS/search_input.rb | 78 +++++++++++++++++++ app/components/DS/select.html.erb | 18 ++--- app/views/settings/preferences/show.html.erb | 19 ++--- .../providers/_search_filters.html.erb | 21 +++-- app/views/splits/_category_select.html.erb | 17 ++-- 6 files changed, 129 insertions(+), 41 deletions(-) create mode 100644 app/components/DS/search_input.html.erb create mode 100644 app/components/DS/search_input.rb diff --git a/app/components/DS/search_input.html.erb b/app/components/DS/search_input.html.erb new file mode 100644 index 000000000..e79fd6715 --- /dev/null +++ b/app/components/DS/search_input.html.erb @@ -0,0 +1,17 @@ +<%= tag.div class: container_classes do %> + <%= tag.input type: "search", + name: name, + value: value, + placeholder: placeholder, + "aria-label": aria_label, + autocomplete: "off", + class: input_classes, + **opts %> + <% if variant == :embedded %> + <%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %> + <% else %> +
+ <%= helpers.icon("search", class: "text-secondary") %> +
+ <% end %> +<% end %> diff --git a/app/components/DS/search_input.rb b/app/components/DS/search_input.rb new file mode 100644 index 000000000..51c3dc9ca --- /dev/null +++ b/app/components/DS/search_input.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# `DS::SearchInput` is the search-field primitive. +# +# Two variants: +# +# - `:standalone` (default) — top-of-list filter inputs (Preferences +# currency search, Settings/Bank Sync provider filter). Bordered +# bg-container surface, icon-on-left, full token-backed focus ring. +# +# - `:embedded` — search-inside-a-panel (DS::Select internal search, +# splits category filter, any future DS::Popover that hosts a filter). +# No border / no own focus ring — the parent panel provides the +# chrome, so adding ring + outline here would compete with the +# parent's focus-within state. +# +# For `form.search_field :foo` inside a `styled_form_with` block, +# keep using the form helper — it routes through `StyledFormBuilder`'s +# form-field CSS, which is a different visual contract. +class DS::SearchInput < DesignSystemComponent + VARIANTS = %i[standalone embedded].freeze + + attr_reader :variant, :name, :placeholder, :value, :aria_label, :extra_classes, :opts + + def initialize(variant: :standalone, name: nil, placeholder: nil, value: nil, aria_label: nil, class: nil, **opts) + @variant = variant.to_sym + @name = name + @placeholder = placeholder + @value = value + @aria_label = aria_label || placeholder + @extra_classes = binding.local_variable_get(:class) + @opts = opts + + raise ArgumentError, "Invalid variant: #{@variant}. Must be one of #{VARIANTS.inspect}" unless VARIANTS.include?(@variant) + end + + def container_classes + class_names("relative", extra_classes) + end + + def input_classes + # `text-base sm:text-sm` — keep the base font at 16px so iOS Safari + # does not zoom the viewport when the input is focused. Shrink to + # 14px from `sm:` upward. The previous unconditional `text-sm` + # triggered the mobile zoom regression. + case variant + when :embedded + # No own focus ring — the parent panel handles focus chrome via + # `focus-within`. `focus:outline-hidden focus:ring-0` neutralizes + # the browser default so it doesn't compete with the panel's + # state. + "bg-container text-primary text-base sm:text-sm placeholder:text-secondary font-normal " \ + "h-10 pl-10 w-full border-none rounded-lg " \ + "focus:outline-hidden focus:ring-0" + else + # `focus-visible:outline-*` matches the focus-ring pattern from + # DS::Button (base.css) so every interactive surface in the design + # system uses the same ring token. Replaces the broken + # `focus:ring-gray-500` from the inline callsites — that utility + # had no backing token and rendered invisibly on the bordered + # bg-container surface. + "block w-full border border-secondary rounded-md py-2.5 pl-10 pr-3 bg-container text-base sm:text-sm " \ + "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900 " \ + "theme-dark:focus-visible:outline-white" + end + end + + def icon_classes + variant == :embedded ? "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2" : "text-secondary" + end + + def icon_wrapper_classes + # Standalone variant wraps the icon in a positioned div; embedded + # places the icon as an absolutely-positioned sibling so the parent + # panel can stay in control of vertical alignment. + variant == :embedded ? nil : "absolute inset-0 ml-2 top-1/2 -translate-y-1/2 pointer-events-none" + end +end diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb index 6c06d83f1..1ac9b027f 100644 --- a/app/components/DS/select.html.erb +++ b/app/components/DS/select.html.erb @@ -33,15 +33,15 @@