diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css
index 184cbf3a6..f27b97078 100644
--- a/app/assets/tailwind/maybe-design-system.css
+++ b/app/assets/tailwind/maybe-design-system.css
@@ -368,12 +368,14 @@
text-overflow: clip;
}
- select.form-field__input {
+ select.form-field__input,
+ button.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
+ text-align: left;
}
.form-field__radio {
diff --git a/app/components/DS/select.html.erb b/app/components/DS/select.html.erb
new file mode 100644
index 000000000..4b07ccd7b
--- /dev/null
+++ b/app/components/DS/select.html.erb
@@ -0,0 +1,94 @@
+<%# locals: form:, method:, collection:, options: {} %>
+
+
form-dropdown" data-action="dropdown:select->form-dropdown#onSelect">
+
+
+ <% if searchable %>
+
+ "
+ autocomplete="off"
+ class="bg-container text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
+ data-list-filter-target="input"
+ data-action="list-filter#filter">
+ <%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
+
+ <% end %>
+
+ <% items.each do |item| %>
+ <% is_selected = item[:value] == selected_value %>
+ <% obj = item[:object] %>
+
+
"
+ role="option"
+ tabindex="0"
+ aria-selected="<%= is_selected %>"
+ data-action="click->select#select"
+ data-value="<%= item[:value] %>"
+ data-filter-name="<%= item[:label] %>">
+
+ ">
+ <%= helpers.icon("check") %>
+
+
+ <% 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) %>
+
+ <% if icon_for(item) %>
+ <%= helpers.icon icon_for(item), size: "sm", color: "current" %>
+ <% else %>
+
+ <% end %>
+ <%= item[:label] %>
+
+ <% end %>
+
+ <% end %>
+
+
+
\ No newline at end of file
diff --git a/app/components/DS/select.rb b/app/components/DS/select.rb
new file mode 100644
index 000000000..abbd48ada
--- /dev/null
+++ b/app/components/DS/select.rb
@@ -0,0 +1,83 @@
+module DS
+ class Select < ViewComponent::Base
+ attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options
+
+ VARIANTS = %i[simple logo badge].freeze
+ HEX_COLOR_REGEX = /\A#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\z/
+ RGB_COLOR_REGEX = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/
+ DEFAULT_COLOR = "#737373"
+
+ def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, **options)
+ @form = form
+ @method = method
+ @placeholder = placeholder
+ @variant = variant
+ @searchable = searchable
+ @options = options
+
+ normalized_items = normalize_items(items)
+
+ if include_blank
+ normalized_items.unshift({
+ value: nil,
+ label: include_blank,
+ object: nil
+ })
+ end
+
+ @items = normalized_items
+ @selected_value = selected
+ end
+
+ def selected_item
+ items.find { |item| item[:value] == selected_value }
+ end
+
+ # Returns the color for a given item (used in :badge variant)
+ def color_for(item)
+ obj = item[:object]
+ color = obj&.respond_to?(:color) ? obj.color : DEFAULT_COLOR
+
+ return DEFAULT_COLOR unless color.is_a?(String)
+
+ if color.match?(HEX_COLOR_REGEX) || color.match?(RGB_COLOR_REGEX)
+ color
+ else
+ DEFAULT_COLOR
+ end
+ end
+
+ # Returns the lucide_icon name for a given item (used in :badge variant)
+ def icon_for(item)
+ obj = item[:object]
+ obj&.respond_to?(:lucide_icon) ? obj.lucide_icon : nil
+ end
+
+ # Returns true if the item has a logo (used in :logo variant)
+ def logo_for(item)
+ obj = item[:object]
+ obj&.respond_to?(:logo_url) && obj.logo_url.present? ? Setting.transform_brand_fetch_url(obj.logo_url) : nil
+ end
+
+ private
+
+ def normalize_items(collection)
+ collection.map do |item|
+ case item
+ when Hash
+ {
+ value: item[:value],
+ label: item[:label],
+ object: item[:object]
+ }
+ else
+ {
+ value: item.id,
+ label: item.name,
+ object: item
+ }
+ end
+ end
+ end
+ end
+end
diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb
index f90888d72..ae81f2062 100644
--- a/app/helpers/styled_form_builder.rb
+++ b/app/helpers/styled_form_builder.rb
@@ -28,11 +28,25 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
- field_options = normalize_options(options, html_options)
+ selected_value = @object.public_send(method) if @object.respond_to?(method)
+ placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label")
- build_field(method, field_options, html_options) do |merged_html_options|
- super(method, collection, value_method, text_method, options, merged_html_options)
- end
+ @template.render(
+ DS::Select.new(
+ form: self,
+ method: method,
+ items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } },
+ selected: selected_value,
+ placeholder: placeholder,
+ searchable: options.fetch(:searchable, false),
+ variant: options.fetch(:variant, :simple),
+ include_blank: options[:include_blank],
+ label: options[:label],
+ container_class: options[:container_class],
+ label_tooltip: options[:label_tooltip],
+ html_options: html_options
+ )
+ )
end
def money_field(amount_method, options = {})
diff --git a/app/javascript/controllers/form_dropdown_controller.js b/app/javascript/controllers/form_dropdown_controller.js
new file mode 100644
index 000000000..d191106f8
--- /dev/null
+++ b/app/javascript/controllers/form_dropdown_controller.js
@@ -0,0 +1,18 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["input"]
+
+ onSelect(event) {
+ this.inputTarget.value = event.detail.value
+
+ const inputEvent = new Event("input", { bubbles: true })
+ this.inputTarget.dispatchEvent(inputEvent)
+
+ const form = this.element.closest("form")
+ const controllers = (form?.dataset.controller || "").split(/\s+/)
+ if (form && controllers.includes("auto-submit-form")) {
+ form.requestSubmit()
+ }
+ }
+}
diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js
new file mode 100644
index 000000000..23b56051d
--- /dev/null
+++ b/app/javascript/controllers/select_controller.js
@@ -0,0 +1,182 @@
+import { Controller } from "@hotwired/stimulus"
+import { autoUpdate } from "@floating-ui/dom"
+
+export default class extends Controller {
+ static targets = ["button", "menu", "input"]
+ static values = {
+ placement: { type: String, default: "bottom-start" },
+ offset: { type: Number, default: 6 }
+ }
+
+ connect() {
+ this.isOpen = false
+ this.boundOutsideClick = this.handleOutsideClick.bind(this)
+ this.boundKeydown = this.handleKeydown.bind(this)
+ this.boundTurboLoad = this.handleTurboLoad.bind(this)
+
+ document.addEventListener("click", this.boundOutsideClick)
+ document.addEventListener("turbo:load", this.boundTurboLoad)
+ this.element.addEventListener("keydown", this.boundKeydown)
+
+ this.observeMenuResize()
+ }
+
+ disconnect() {
+ document.removeEventListener("click", this.boundOutsideClick)
+ document.removeEventListener("turbo:load", this.boundTurboLoad)
+ this.element.removeEventListener("keydown", this.boundKeydown)
+ this.stopAutoUpdate()
+ if (this.resizeObserver) this.resizeObserver.disconnect()
+ }
+
+ toggle = () => {
+ this.isOpen ? this.close() : this.openMenu()
+ }
+
+ openMenu() {
+ this.isOpen = true
+ this.menuTarget.classList.remove("hidden")
+ this.buttonTarget.setAttribute("aria-expanded", "true")
+ this.startAutoUpdate()
+ this.clearSearch()
+ requestAnimationFrame(() => {
+ this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none")
+ this.menuTarget.classList.add("opacity-100", "translate-y-0")
+ this.updatePosition()
+ this.scrollToSelected()
+ })
+ }
+
+ close() {
+ this.isOpen = false
+ this.stopAutoUpdate()
+ this.menuTarget.classList.remove("opacity-100", "translate-y-0")
+ this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none")
+ this.buttonTarget.setAttribute("aria-expanded", "false")
+ setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150)
+ }
+
+ select(event) {
+ const selectedElement = event.currentTarget
+ const value = selectedElement.dataset.value
+ const label = selectedElement.dataset.filterName || selectedElement.textContent.trim()
+
+ this.buttonTarget.textContent = label
+ if (this.hasInputTarget) {
+ this.inputTarget.value = value
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
+ }
+
+ const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
+ if (previousSelected) {
+ previousSelected.setAttribute("aria-selected", "false")
+ previousSelected.classList.remove("bg-container-inset")
+ const prevIcon = previousSelected.querySelector(".check-icon")
+ if (prevIcon) prevIcon.classList.add("hidden")
+ }
+
+ selectedElement.setAttribute("aria-selected", "true")
+ selectedElement.classList.add("bg-container-inset")
+ const selectedIcon = selectedElement.querySelector(".check-icon")
+ if (selectedIcon) selectedIcon.classList.remove("hidden")
+
+ this.element.dispatchEvent(new CustomEvent("dropdown:select", {
+ detail: { value, label },
+ bubbles: true
+ }))
+
+ this.close()
+ this.buttonTarget.focus()
+ }
+
+ focusSearch() {
+ const input = this.menuTarget.querySelector('input[type="search"]')
+ if (input) { input.focus({ preventScroll: true }); return true }
+ return false
+ }
+
+ focusFirstElement() {
+ const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ const el = this.menuTarget.querySelector(selector)
+ if (el) el.focus({ preventScroll: true })
+ }
+
+ scrollToSelected() {
+ const selected = this.menuTarget.querySelector(".bg-container-inset")
+ if (selected) selected.scrollIntoView({ block: "center" })
+ }
+
+ handleOutsideClick(event) {
+ if (this.isOpen && !this.element.contains(event.target)) this.close()
+ }
+
+ handleKeydown(event) {
+ if (!this.isOpen) return
+ if (event.key === "Escape") { this.close(); this.buttonTarget.focus() }
+ if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() }
+ }
+
+ handleTurboLoad() { if (this.isOpen) this.close() }
+
+ clearSearch() {
+ const input = this.menuTarget.querySelector('input[type="search"]')
+ if (!input) return
+ input.value = ""
+ input.dispatchEvent(new Event("input", { bubbles: true }))
+ }
+
+ startAutoUpdate() {
+ if (!this._cleanup && this.buttonTarget && this.menuTarget) {
+ this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition())
+ }
+ }
+
+ stopAutoUpdate() {
+ if (this._cleanup) { this._cleanup(); this._cleanup = null }
+ }
+
+ observeMenuResize() {
+ this.resizeObserver = new ResizeObserver(() => {
+ if (this.isOpen) requestAnimationFrame(() => this.updatePosition())
+ })
+ this.resizeObserver.observe(this.menuTarget)
+ }
+
+ getScrollParent(element) {
+ let parent = element.parentElement
+ while (parent) {
+ const style = getComputedStyle(parent)
+ const overflowY = style.overflowY
+ if (overflowY === "auto" || overflowY === "scroll") return parent
+ parent = parent.parentElement
+ }
+ return document.documentElement
+ }
+
+ updatePosition() {
+ if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return
+
+ const container = this.getScrollParent(this.element)
+ const containerRect = container.getBoundingClientRect()
+ const buttonRect = this.buttonTarget.getBoundingClientRect()
+ const menuHeight = this.menuTarget.scrollHeight
+
+ const spaceBelow = containerRect.bottom - buttonRect.bottom
+ const spaceAbove = buttonRect.top - containerRect.top
+ const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
+
+ this.menuTarget.style.left = "0"
+ this.menuTarget.style.width = "100%"
+ this.menuTarget.style.top = ""
+ this.menuTarget.style.bottom = ""
+ this.menuTarget.style.overflowY = "auto"
+
+ if (shouldOpenUp) {
+ this.menuTarget.style.bottom = "100%"
+ this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`
+ } else {
+ this.menuTarget.style.top = "100%"
+ this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb
index 4375ae0d3..88cb5e0e3 100644
--- a/app/views/transactions/_form.html.erb
+++ b/app/views/transactions/_form.html.erb
@@ -18,13 +18,13 @@
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
- <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
+ <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), variant: :logo }, required: true, class: "form-field__input text-ellipsis" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= f.fields_for :entryable do |ef| %>
<% categories = params[:nature] == "inflow" ? income_categories : expense_categories %>
- <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
+ <%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category"), variant: :badge, searchable: true } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 3dc25b148..5b748bbba 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -78,7 +78,8 @@
Current.family.categories.alphabetically,
:id, :name,
{ label: t(".category_label"),
- class: "text-subdued", include_blank: t(".uncategorized") },
+ class: "text-subdued", include_blank: t(".uncategorized"),
+ variant: :badge, searchable: true },
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
@@ -104,7 +105,7 @@
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),
- class: "text-subdued" },
+ class: "text-subdued", variant: :logo, searchable: true },
"data-auto-submit-form-target": "auto" %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml
index f2caaa233..bf860dde4 100644
--- a/config/locales/defaults/en.yml
+++ b/config/locales/defaults/en.yml
@@ -153,6 +153,8 @@ en:
helpers:
select:
prompt: Please select
+ search_placeholder: "Search"
+ default_label: "Select..."
submit:
create: Create %{model}
submit: Save %{model}
diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb
index a2481b185..db6717ea4 100644
--- a/test/system/transfers_test.rb
+++ b/test/system/transfers_test.rb
@@ -12,20 +12,41 @@ class TransfersTest < ApplicationSystemTestCase
transfer_date = Date.current
click_on "New transaction"
-
- # Will navigate to different route in same modal
click_on "Transfer"
assert_text "New transfer"
- select checking_name, from: "From"
- select savings_name, from: "To"
+ # Select accounts using DS::Select
+ select_ds("From", checking_name)
+ select_ds("To", savings_name)
+
fill_in "transfer[amount]", with: 500
fill_in "Date", with: transfer_date
click_button "Create transfer"
- within "#entry-group-" + transfer_date.to_s do
+ within "#entry-group-#{transfer_date}" do
assert_text "Payment to"
end
end
+
+ private
+
+ def select_ds(label_text, option_text)
+ field_label = find("label", exact_text: label_text)
+ container = field_label.ancestor("div.relative")
+
+ # Click the button to open the dropdown
+ container.find("button").click
+
+ # If searchable, type in the search input
+ if container.has_selector?("input[type='search']", visible: true)
+ container.find("input[type='search']", visible: true).set(option_text)
+ end
+
+ # Wait for the listbox to appear inside the relative container
+ listbox = container.find("[role='listbox']", visible: true)
+
+ # Click the option inside the listbox
+ listbox.find("[role='option']", exact_text: option_text, visible: true).click
+ end
end