Files
sure/app/views/settings/preferences/show.html.erb
Guillem Arias Fauste 8e444ff98b feat(design-system): add DS::SearchInput primitive (closes #1715 §3) (#1853)
* 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 <input>, 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 <input type="search"> 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 <input> 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 <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-05-21 12:54:54 +02:00

230 lines
12 KiB
Plaintext

<%= content_for :page_title, t(".page_title") %>
<%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
<div>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.select :locale,
language_options,
{ label: t(".language"), include_blank: t(".language_auto") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :date_format,
Family::DATE_FORMATS,
{ label: t(".date_format") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= form.select :default_account_order,
AccountOrder.all.map { |order| [ order.label, order.key ] },
{ label: t(".default_account_order") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :country,
country_options,
{ label: t(".country") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :month_start_day,
(1..28).map { |day| [localized_ordinal(day), day] },
{ label: t(".month_start_day"), hint: t(".month_start_day_hint") },
{ data: { auto_submit_form_target: "auto" } } %>
<% if @user.family.uses_custom_month_start? %>
<div class="text-sm text-warning bg-warning/10 p-3 rounded-lg">
<%= t(".month_start_day_warning") %>
</div>
<% end %>
<p class="text-xs italic pl-2 text-secondary"><%= t(".translations_notice") %></p>
<% end %>
<% end %>
</div>
<% end %>
<% if Current.user.admin? %>
<%= settings_section title: t(".currencies_title", moniker: family_moniker), subtitle: t(".currencies_subtitle", moniker: family_moniker_downcase) do %>
<% selected_count_translations = t(".selected_currencies_count") %>
<div class="space-y-4"
data-controller="currency-preferences"
data-currency-preferences-base-currency-value="<%= @user.family.primary_currency_code %>"
data-currency-preferences-locale-value="<%= I18n.locale %>"
data-currency-preferences-selected-count-translations-value="<%= selected_count_translations.to_json %>">
<% additional_preview_currencies = @user.family.secondary_enabled_currency_objects.first(6) %>
<% additional_currency_count = @user.family.secondary_enabled_currency_objects.count %>
<div class="rounded-xl border border-secondary p-4 bg-container-inset">
<div class="grid gap-4 md:grid-cols-2">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-secondary"><%= t(".base_currency_label") %></p>
<div class="mt-2">
<p class="text-sm font-medium text-primary"><%= @user.family.primary_currency_code %></p>
<p class="text-sm text-secondary"><%= currency_label(@user.family.primary_currency_code) %></p>
</div>
</div>
<div class="md:border-l md:border-secondary md:pl-4">
<p class="text-xs font-medium uppercase tracking-wide text-secondary"><%= t(".additional_currencies_label") %></p>
<% if additional_preview_currencies.any? %>
<div class="mt-2 flex flex-wrap gap-2">
<% additional_preview_currencies.each do |currency| %>
<span class="inline-flex items-center rounded-full bg-surface-inset px-2.5 py-1 text-xs font-medium text-primary">
<%= currency.iso_code %>
</span>
<% end %>
<% if additional_currency_count > additional_preview_currencies.count %>
<span class="inline-flex items-center rounded-full bg-surface-inset px-2.5 py-1 text-xs font-medium text-secondary">
<%= t(".currencies_more", count: additional_currency_count - additional_preview_currencies.count) %>
</span>
<% end %>
</div>
<% else %>
<p class="mt-2 text-sm text-secondary"><%= t(".no_additional_currencies") %></p>
<% end %>
</div>
</div>
</div>
<div class="flex justify-end">
<%= render DS::Button.new(
text: t(".manage_currencies"),
type: :button,
class: "md:w-auto w-full justify-center",
data: { action: "currency-preferences#open" }
) %>
</div>
<%= render DS::Dialog.new(
id: "currency-preferences-dialog",
auto_open: false,
disable_frame: true,
width: "md",
data: { currency_preferences_target: "dialog" }
) do |dialog| %>
<% dialog.with_header(title: t(".manage_currencies"), subtitle: t(".manage_currencies_subtitle")) %>
<% dialog.with_body do %>
<% primary_currency_code = @user.family.primary_currency_code %>
<% selected_currency_codes = @user.family.enabled_currency_codes %>
<% base_currency_rows, other_currency_rows = Money::Currency.as_options.partition { |currency| currency.iso_code == primary_currency_code } %>
<% currency_rows = base_currency_rows + other_currency_rows %>
<%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4", data: { action: "turbo:submit-end->currency-preferences#handleSubmitEnd" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= hidden_field_tag "user[family_attributes][id]", @user.family.id %>
<%= hidden_field_tag "user[family_attributes][enabled_currencies][]", primary_currency_code, id: nil %>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap gap-2">
<%= render DS::Button.new(
text: t(".select_all_currencies"),
type: :button,
variant: :ghost,
size: :sm,
data: { action: "currency-preferences#selectAll" }
) %>
<%= render DS::Button.new(
text: t(".select_base_only"),
type: :button,
variant: :ghost,
size: :sm,
data: { action: "currency-preferences#selectBaseOnly" }
) %>
</div>
<p class="text-sm font-medium text-secondary"
data-currency-preferences-target="selectedCount"
role="status"
aria-live="polite"
aria-atomic="true"></p>
</div>
<div data-controller="list-filter" class="space-y-3">
<%= render DS::SearchInput.new(
placeholder: t(".currency_search_placeholder"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter"
}
) %>
<div data-list-filter-target="list" class="max-h-80 overflow-auto rounded-xl border border-secondary divide-y divide-alpha-black-100 theme-dark:divide-alpha-white-100">
<p class="hidden px-4 py-3 text-sm text-secondary" data-list-filter-target="emptyMessage">
<%= t(".no_matching_currencies") %>
</p>
<% currency_rows.each do |currency| %>
<% checked = selected_currency_codes.include?(currency.iso_code) %>
<% base_currency = currency.iso_code == primary_currency_code %>
<label class="<%= class_names(
"filterable-item flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors hover:bg-surface-inset",
"bg-surface-inset": base_currency,
"cursor-default": base_currency
) %>"
data-filter-name="<%= "#{currency.iso_code} #{currency.name}" %>">
<%= check_box_tag "user[family_attributes][enabled_currencies][]",
currency.iso_code,
checked,
disabled: base_currency,
class: "checkbox checkbox--light mt-0.5",
data: {
currency_preferences_target: "checkbox",
action: "change->currency-preferences#updateSelectedCount"
} %>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-primary"><%= currency.iso_code %></span>
<% if base_currency %>
<span class="inline-flex items-center rounded-full bg-surface px-2 py-0.5 text-[11px] font-medium text-secondary">
<%= t(".base_currency_badge") %>
</span>
<% end %>
</div>
<p class="mt-0.5 text-sm text-secondary"><%= currency.name %></p>
</div>
</div>
</div>
</label>
<% end %>
</div>
</div>
<div class="flex justify-end gap-2">
<%= render DS::Button.new(text: t("shared.cancel"), type: :button, variant: :ghost, data: { action: "DS--dialog#close" }) %>
<%= render DS::Button.new(text: t(".save_currencies"), type: :submit) %>
</div>
<% end %>
<% end %>
<% end %>
</div>
<% end %>
<%= settings_section title: t(".sharing_title", moniker: family_moniker), subtitle: t(".sharing_subtitle", moniker: family_moniker_downcase) do %>
<div>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :default_account_sharing,
[
[t(".sharing_shared"), "shared"],
[t(".sharing_private"), "private"]
],
{ label: t(".sharing_default_label") },
{ data: { auto_submit_form_target: "auto" } } %>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
<%# Preview features toggle — visible to all users, not just admins. Lives at
the bottom of Preferences as a standalone card (no section header) so the
toggle row is the entire surface. Posts directly to
settings#preferences#update via the Settings::PreferencesController,
matching the auto-submit pattern used on the Appearance page. %>
<section class="bg-container shadow-border-xs rounded-xl p-4">
<%= form_with url: settings_preferences_path, method: :patch,
data: { controller: "auto-submit-form" } do |f| %>
<%# Wrapping the row in <label for=…> makes the title + description
themselves a click target for the toggle, so the cursor-pointer
affordance on the container is honest. %>
<label for="user_preview_features_enabled" class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".preview.title") %></h4>
<p class="text-secondary"><%= t(".preview.description") %></p>
</div>
<%= render DS::Toggle.new(
id: "user_preview_features_enabled",
name: "user[preview_features_enabled]",
checked: @user.preview_features_enabled?,
data: { auto_submit_form_target: "auto" }
) %>
</label>
<% end %>
</section>