<%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %>
@@ -27,7 +27,7 @@
"
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"
+ class="bg-container text-primary 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") %>
@@ -39,7 +39,7 @@
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
-
"
+
"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
diff --git a/app/components/DS/select.rb b/app/components/DS/select.rb
index abbd48ada..b19166bfb 100644
--- a/app/components/DS/select.rb
+++ b/app/components/DS/select.rb
@@ -1,18 +1,20 @@
module DS
class Select < ViewComponent::Base
- attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options
+ attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :menu_placement, :options
VARIANTS = %i[simple logo badge].freeze
+ MENU_PLACEMENTS = %w[auto down up].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)
+ def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, menu_placement: :auto, **options)
@form = form
@method = method
@placeholder = placeholder
@variant = variant
@searchable = searchable
+ @menu_placement = normalize_menu_placement(menu_placement)
@options = options
normalized_items = normalize_items(items)
@@ -61,6 +63,11 @@ module DS
private
+ def normalize_menu_placement(value)
+ normalized = value.to_s.downcase
+ MENU_PLACEMENTS.include?(normalized) ? normalized : "auto"
+ end
+
def normalize_items(collection)
collection.map do |item|
case item
diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb
index bb51ab2ff..ba3b5f81e 100644
--- a/app/helpers/styled_form_builder.rb
+++ b/app/helpers/styled_form_builder.rb
@@ -44,6 +44,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
selected: selected_value,
placeholder: placeholder,
searchable: options.fetch(:searchable, false),
+ menu_placement: options[:menu_placement],
variant: options.fetch(:variant, :simple),
include_blank: options[:include_blank],
label: options[:label],
diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js
index 23b56051d..e4b07bb3b 100644
--- a/app/javascript/controllers/select_controller.js
+++ b/app/javascript/controllers/select_controller.js
@@ -2,9 +2,9 @@ import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
- static targets = ["button", "menu", "input"]
+ static targets = ["button", "menu", "input", "content"]
static values = {
- placement: { type: String, default: "bottom-start" },
+ menuPlacement: { type: String, default: "auto" },
offset: { type: Number, default: 6 }
}
@@ -103,7 +103,25 @@ export default class extends Controller {
scrollToSelected() {
const selected = this.menuTarget.querySelector(".bg-container-inset")
- if (selected) selected.scrollIntoView({ block: "center" })
+ if (!selected) return
+
+ const container = this.hasContentTarget ? this.contentTarget : this.menuTarget
+ const containerRect = container.getBoundingClientRect()
+ const selectedRect = selected.getBoundingClientRect()
+ const delta = selectedRect.top - containerRect.top - (container.clientHeight - selectedRect.height) / 2
+
+ const nextScrollTop = container.scrollTop + delta
+ const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
+ container.scrollTop = this.clamp(nextScrollTop, 0, maxScrollTop)
+ }
+
+ clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value))
+ }
+
+ placementMode() {
+ const mode = (this.menuPlacementValue || "auto").toLowerCase()
+ return ["auto", "down", "up"].includes(mode) ? mode : "auto"
}
handleOutsideClick(event) {
@@ -163,7 +181,8 @@ export default class extends Controller {
const spaceBelow = containerRect.bottom - buttonRect.bottom
const spaceAbove = buttonRect.top - containerRect.top
- const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
+ const placement = this.placementMode()
+ const shouldOpenUp = placement === "up" || (placement === "auto" && spaceBelow < menuHeight && spaceAbove > spaceBelow)
this.menuTarget.style.left = "0"
this.menuTarget.style.width = "100%"
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb
index 062d87c8b..2ff28e05e 100644
--- a/app/views/transactions/_form.html.erb
+++ b/app/views/transactions/_form.html.erb
@@ -34,7 +34,10 @@
Current.family.available_merchants_for(Current.user).alphabetically,
:id, :name,
{ include_blank: t(".none"),
- label: t(".merchant_label") } %>
+ label: t(".merchant_label"),
+ variant: :logo,
+ searchable: true,
+ menu_placement: :auto } %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
{
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 1893b3364..eb7dbb4cf 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -109,7 +109,7 @@
:id, :name,
{ include_blank: t(".none"),
label: t(".merchant_label"),
- class: "text-subdued", variant: :logo, searchable: true, disabled: @entry.split_child? || !can_annotate_entry? },
+ variant: :logo, searchable: true, menu_placement: :auto, disabled: @entry.split_child? || !can_annotate_entry? },
"data-auto-submit-form-target": "auto" %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),