mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
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:
@@ -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 %>"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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%"
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user