feat(select): improve merchant dropdown behavior and placement controls (#1364)

* feat(select): improve merchant dropdown behavior and placement controls

 - add configurable menu_placement strategy to DS::Select (auto/down/up) with safe normalization
forward menu_placement through StyledFormBuilder#collection_select
 - force Merchant dropdown to open downward in transaction create and editor forms
 - fix select option/search text contrast by applying text-primary in DS select menu
 - prevent form jump on open by scrolling only inside dropdown content instead of using scrollIntoView
 - clamp internal dropdown scroll to valid bounds for stability
- refactor select controller placement logic for readability (placementMode, clamp) without changing behavior

* set menu_placement=auto for metchant selector
This commit is contained in:
Tao Chen
2026-04-08 02:52:14 +08:00
committed by GitHub
parent be42988adf
commit 2658c36b05
6 changed files with 41 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
<%# locals: form:, method:, collection:, options: {} %>
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-action="dropdown:select->form-dropdown#onSelect">
<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" if options[:label].present? %>
@@ -27,7 +27,7 @@
<input type="search"
placeholder="<%= t("helpers.select.search_placeholder") %>"
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] %>
<div class="filterable-item 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 %>"
<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="0"
aria-selected="<%= is_selected %>"

View File

@@ -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

View File

@@ -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],

View File

@@ -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%"

View File

@@ -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),
{

View File

@@ -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),