Files
sure/app/components/DS/select.html.erb
Claude b39a08be6a Add parent/child category hierarchy to all category selects; add search to account select
Category selection consistency:
- DS::Select (shared component powering the main transaction form,
  transaction edit, bulk-update, and transfer category pickers) now
  indents subcategories with a corner-down-right icon, matching the
  existing transaction-row category dropdown.
- Feed DS::Select-based category pickers with Category.alphabetically_by_hierarchy
  (parent name, then parent-before-children, then own name) so children
  render directly under their parent.
- Added Category::Group.select_options, a shared helper producing
  parent-then-child ordered options (with an indent marker) for plain
  HTML <select> elements. Used by:
  - Rule builder category condition/action selects
  - Bulk 'categorize transactions' select
  - CSV/QIF import category mapping select
- Grouped the splits category combobox and the transaction search
  category filter checklist the same way, both with the
  corner-down-right indent icon used elsewhere.

Account selection:
- Added searchable: true to the account select in the new/edit
  transaction form, matching the category and merchant selects next
  to it.
2026-07-28 16:44:20 +00:00

116 lines
5.2 KiB
Plaintext

<%# locals: form:, method:, collection:, options: {} %>
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-select-menu-placement-value="<%= menu_placement %>" data-action="dropdown:select->form-dropdown#onSelect">
<div class="form-field <%= options[:container_class] %>">
<div class="form-field__body">
<%= form.label method, options[:label], class: "form-field__label", id: "#{method}_label" if options[:label].present? %>
<%= form.hidden_field method,
value: @selected_value,
data: {
"form-dropdown-target": "input",
"auto-submit-target": "auto",
**(options.dig(:html_options, :data) || {})
} %>
<%# `aria-expanded` reflects MENU open/closed state — managed by the
select controller's openMenu/close. Init as "false"; previously
this incorrectly mirrored whether a value was selected.
`aria-labelledby` points at BOTH the visible label and the
trigger button itself so AT users hear "<label> <selected
value>" — referencing only the label would override the
button's text node and suppress the current value. %>
<button type="button"
id="<%= method %>_trigger"
class="form-field__input w-full"
data-select-target="button"
data-action="click->select#toggle focus->select#handleButtonFocus"
aria-haspopup="listbox"
aria-expanded="false"
tabindex="0"
<%= "aria-labelledby=\"#{method}_label #{method}_trigger\"".html_safe if options[:label].present? %>>
<%= selected_item&.dig(:label) || @placeholder %>
</button>
</div>
</div>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-border-lg bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<% if searchable %>
<div class="flex items-center bg-container rounded-lg mb-1 focus-ring-within transition-shadow">
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t("helpers.select.search_placeholder"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter input->select#syncTabindex"
}
) %>
</div>
<% end %>
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto"
role="listbox" tabindex="-1">
<% items.each do |item| %>
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
<% is_child = obj&.respond_to?(:parent_id) && obj.parent_id.present? %>
<%# Options use tabindex="-1" always — keyboard focus is managed
programmatically so listbox items never appear in the Tab
sequence and skip past the trigger to the next form field. %>
<div class="filterable-item text-primary text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="-1"
aria-selected="<%= is_selected %>"
data-select-target="option"
data-action="click->select#select"
data-value="<%= item[:value] %>"
data-filter-name="<%= item[:label] %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= helpers.icon("check") %>
</span>
<% if is_child %>
<%= helpers.icon("corner-down-right", size: "sm") %>
<% end %>
<% case variant %>
<% when :simple %>
<%= item[:label] %>
<% when :logo %>
<% unless item[:value].nil? %>
<% if logo_for(item) %>
<%= image_tag logo_for(item),
class: "w-6 h-6 rounded-full border border-secondary",
loading: "lazy" %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
text: item[:label],
size: "sm",
rounded: true
) %>
<% end %>
<% end %>
<%= item[:label] %>
<% when :badge %>
<% hex_color = color_for(item) %>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="
background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent);
border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent);
color: <%= hex_color %>;">
<% if icon_for(item) %>
<%= helpers.icon icon_for(item), size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= item[:label] %>
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>