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"> +
+
+ <%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %> + <%= form.hidden_field method, + value: @selected_value, + data: { + "form-dropdown-target": "input", + "auto-submit-target": "auto" + } %> + +
+
+ +
\ 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