1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-21 22:29:38 +02:00

Consolidate dropdown controllers (#600)

* Basic listbox and popover controllers with temporary example

* Separate select and menu controllers
This commit is contained in:
Zach Gollwitzer 2024-04-03 17:32:27 -04:00 committed by GitHub
parent 0a0289846e
commit 4f0b2de4ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 298 additions and 150 deletions

View file

@ -2,8 +2,4 @@ module FormsHelper
def form_field_tag(&)
tag.div class: "form-field", &
end
def currency_dropdown(f: nil, options: [])
render partial: "shared/currency_dropdown", locals: { f: f, options: options }
end
end

View file

@ -1,58 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dropdown"
export default class extends Controller {
static targets = ["menu", "input", "label", "option"]
toggleMenu(event) {
event.stopPropagation(); // Prevent event from closing the menu immediately
this.repositionDropdown();
this.menuTarget.classList.toggle("hidden");
}
hideMenu = () => {
this.menuTarget.classList.add("hidden");
}
connect() {
document.addEventListener("click", this.hideMenu);
}
disconnect() {
document.removeEventListener("click", this.hideMenu);
}
repositionDropdown () {
const button = this.menuTarget.previousElementSibling;
const menu = this.menuTarget;
// Calculate position
const buttonRect = button.getBoundingClientRect();
menu.style.top = `${buttonRect.bottom + window.scrollY}px`;
menu.style.left = `${buttonRect.left + window.scrollX}px`;
}
selectOption (e) {
const value = e.target.getAttribute('data-value');
if (value) {
// Remove active option background and tick
this.optionTargets.forEach((element) => {
element.classList.remove('bg-gray-100');
element.children[0].classList.add('hidden');
});
// Set currency value and label
if (this.hasInputTarget) {
this.inputTarget.value = value;
}
if (this.hasLabelTarget) {
this.labelTarget.innerHTML = value;
}
// Reassign active option background and tick
e.currentTarget.classList.add('bg-gray-100')
e.currentTarget.children[0].classList.remove('hidden');
}
}
}

View file

@ -1,36 +0,0 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dropdown"
export default class extends Controller {
static targets = ["menu"]
static values = { closeOnClick: { type: Boolean, default: true } }
toggleMenu = (e) => {
e.stopPropagation(); // Prevent event from closing the menu immediately
this.menuTarget.classList.contains("hidden") ? this.showMenu() : this.hideMenu();
}
showMenu = () => {
document.addEventListener("click", this.onDocumentClick);
this.menuTarget.classList.remove("hidden");
}
hideMenu = () => {
document.removeEventListener("click", this.onDocumentClick);
this.menuTarget.classList.add("hidden");
}
disconnect = () => {
this.hideMenu();
}
onDocumentClick = (e) => {
if (this.menuTarget.contains(e.target) && !this.closeOnClickValue ) {
// user has clicked inside of the dropdown
e.stopPropagation();
return;
}
this.hideMenu();
}
}

View file

@ -0,0 +1,75 @@
import { Controller } from "@hotwired/stimulus";
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
*
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
*/
export default class extends Controller {
static targets = ["button", "content"];
connect() {
this.show = false;
this.contentTarget.classList.add("hidden"); // Initially hide the popover
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
this.buttonTarget.removeEventListener("click", this.toggle);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
this.close();
}
// If turbo reloads, we maintain the state of the menu
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) {
this.close();
}
};
handleKeydown = (event) => {
switch (event.key) {
case " ":
event.preventDefault(); // Prevent the default action to avoid scrolling
if (document.activeElement === this.buttonTarget) {
this.toggle();
}
case "Escape":
this.close();
this.buttonTarget.focus(); // Bring focus back to the button
break;
}
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
if (this.show) {
this.focusFirstElement();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
}
}

View file

@ -0,0 +1,141 @@
import { Controller } from "@hotwired/stimulus";
/**
* A custom "select" element that follows accessibility patterns of a native select element.
*
* - If you need to display arbitrary content including non-clickable items, links, buttons, and forms, use the "popover" controller instead.
*/
export default class extends Controller {
static classes = ["active"];
static targets = ["option", "button", "list", "input", "buttonText"];
connect() {
this.show = false;
this.syncButtonTextWithInput();
this.listTarget.classList.add("hidden");
this.buttonTarget.addEventListener("click", this.toggleList);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
this.element.addEventListener("turbo:load", this.handleTurboLoad);
}
disconnect() {
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
this.buttonTarget.removeEventListener("click", this.toggleList);
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
}
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) {
this.close();
}
};
handleTurboLoad = () => {
this.close();
this.syncButtonTextWithInput();
};
handleKeydown = (event) => {
switch (event.key) {
case " ":
case "Enter":
event.preventDefault(); // Prevent the default action to avoid scrolling
if (document.activeElement === this.buttonTarget) {
this.toggleList();
} else {
this.selectOption(event);
}
break;
case "ArrowDown":
event.preventDefault(); // Prevent the default action to avoid scrolling
this.focusNextOption();
break;
case "ArrowUp":
event.preventDefault(); // Prevent the default action to avoid scrolling
this.focusPreviousOption();
break;
case "Escape":
this.close();
this.buttonTarget.focus(); // Bring focus back to the button
break;
case "Tab":
this.close();
break;
}
};
focusNextOption() {
this.focusOptionInDirection(1);
}
focusPreviousOption() {
this.focusOptionInDirection(-1);
}
focusOptionInDirection(direction) {
const currentFocusedIndex = this.optionTargets.findIndex(
(option) => option === document.activeElement
);
const optionsCount = this.optionTargets.length;
const nextIndex =
(currentFocusedIndex + direction + optionsCount) % optionsCount;
this.optionTargets[nextIndex].focus();
}
toggleList = () => {
this.show = !this.show;
this.listTarget.classList.toggle("hidden", !this.show);
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
if (this.show) {
// Focus the first option or the selected option when the list is shown
const selectedOption = this.optionTargets.find(
(option) => option.getAttribute("aria-selected") === "true"
);
(selectedOption || this.optionTargets[0]).focus();
}
};
close() {
this.show = false;
this.listTarget.classList.add("hidden");
this.buttonTarget.setAttribute("aria-expanded", "false");
}
selectOption(event) {
const selectedOption =
event.type === "keydown" ? document.activeElement : event.currentTarget;
this.optionTargets.forEach((option) => {
option.setAttribute("aria-selected", "false");
option.setAttribute("tabindex", "-1");
option.classList.remove(...this.activeClasses);
});
selectedOption.classList.add(...this.activeClasses);
selectedOption.setAttribute("aria-selected", "true");
selectedOption.focus();
this.close(); // Close the list after selection
// Update the hidden input's value
const selectedValue = selectedOption.getAttribute("data-value");
this.inputTarget.value = selectedValue;
this.syncButtonTextWithInput();
// Auto-submit controller listens for this even to auto-submit
const inputEvent = new Event("input", {
bubbles: true,
cancelable: true,
});
this.inputTarget.dispatchEvent(inputEvent);
}
syncButtonTextWithInput() {
const matchingOption = this.optionTargets.find(
(option) => option.getAttribute("data-value") === this.inputTarget.value
);
if (matchingOption) {
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
}
}
}

View file

@ -24,16 +24,28 @@
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation.trend.percent %>%)</span>
<% end %>
</div>
<div class="relative w-[72px]" data-controller="dropdown">
<button data-action="click->dropdown#toggleMenu" class="ml-auto flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg">
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500" ) %>
<div class="relative w-[72px]" data-controller="menu">
<button
data-menu-target="button"
class="ml-auto flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg"
>
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
</button>
<div class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit" data-dropdown-target="menu">
<%= link_to edit_valuation_path(valuation.original), class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<div
data-menu-target="content"
class="absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit"
>
<%= link_to edit_valuation_path(valuation.original),
class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
<span class="text-gray-900 text-sm">Edit entry</span>
<% end %>
<%= link_to valuation_path(valuation.original), data: { turbo_method: :delete, turbo_confirm: { title: t('custom_turbo_confirm.history.title'), body: t('custom_turbo_confirm.history.body_html'), accept: t('custom_turbo_confirm.history.accept') } }, class: "text-red-600 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= link_to valuation_path(valuation.original),
data: { turbo_method: :delete,
turbo_confirm: { title: t('custom_turbo_confirm.history.title'),
body: t('custom_turbo_confirm.history.body_html'),
accept: t('custom_turbo_confirm.history.accept') } },
class: "text-red-600 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 shrink-0") %>
<span class="text-sm">Delete entry</span>
<% end %>

View file

@ -17,13 +17,22 @@
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
</div>
</div>
<div class="relative cursor-pointer" data-controller="dropdown">
<div class="flex hover:bg-gray-100 p-2 rounded" data-action="click->dropdown#toggleMenu">
<div class="relative cursor-pointer" data-controller="menu">
<button data-menu-target="button" class="flex hover:bg-gray-100 p-2 rounded">
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
</div>
<div class="absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden" data-dropdown-target="menu">
</button>
<div data-menu-target="content" class="absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs hidden">
<div class="w-48 px-3 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= button_to account_path(@account), method: :delete, class: "block w-full py-2 text-red-600 hover:text-red-800 flex items-center", data: { turbo_confirm: { title: t("custom_turbo_confirm.account_destroy.title"), body: t("custom_turbo_confirm.account_destroy.body_html"), accept: t("custom_turbo_confirm.account_destroy.accept", name: @account.name) } } do %>
<%= button_to account_path(@account),
method: :delete,
class: "block w-full py-2 text-red-600 hover:text-red-800 flex items-center",
data: {
turbo_confirm: {
title: t("custom_turbo_confirm.account_destroy.title"),
body: t("custom_turbo_confirm.account_destroy.body_html"),
accept: t("custom_turbo_confirm.account_destroy.accept", name: @account.name)
}
} do %>
<%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
<% end %>
</div>
@ -45,7 +54,9 @@
}
%>
</div>
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: account_path(@account) } %>
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
<% end %>
</div>
<div class="h-96 flex items-center justify-center text-2xl font-bold">
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>

View file

@ -29,17 +29,24 @@
<div class="flex-col p-5 min-w-80">
<div class="flex items-center justify-between">
<%= link_to root_path do %>
<%= image_tag 'logo.svg', alt: 'Maybe' %>
<%= image_tag 'logo.svg', alt: 'Maybe', class: "h-[22px]" %>
<% end %>
<div class="relative" data-controller="menu">
<button data-menu-target="button">
<div class="text-white w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.email.first %></div>
</button>
<div
data-menu-target="content"
class="absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit"
>
<%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
<span class="text-gray-900 text-sm">Settings</span>
<% end %>
<%= button_to session_path, method: :delete, class: "w-full text-gray-900 flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
<%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
<span class="text-sm">Logout</span>
<% end %>
<div class="relative cursor-pointer" data-controller="dropdown">
<div class="flex" data-action="click->dropdown#toggleMenu">
<div class="mr-1.5 text-white w-8 h-8 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.email.first %></div>
</div>
<div class="absolute z-10 hidden w-screen px-2 mt-2 -translate-x-1/2 left-1/2 max-w-min" data-dropdown-target="menu">
<div class="w-48 px-3 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= link_to "Settings", edit_settings_path, class: 'block p-2 hover:text-gray-600' %>
<%= button_to "Log Out", session_path, method: :delete, class: 'block p-2 hover:text-gray-600' %>
</div>
</div>
</div>
</div>
@ -79,7 +86,6 @@
</main>
</div>
<%= turbo_frame_tag "modal" %>
<%= render 'shared/custom_confirm_modal' %>
</body>
</html>

View file

@ -15,7 +15,9 @@
}
%>
</div>
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
<% end %>
</div>
<div class="h-96 flex items-center justify-center text-2xl font-bold">
<%= render partial: "shared/line_chart", locals: { series: @net_worth_series } %>
@ -70,7 +72,9 @@
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
<p><%= t('.new') %></p>
<% end %>
<%= render partial: "shared/period_dropdown", locals: { period: @period, path: root_path } %>
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
<% end %>
</div>
</div>
<div>

View file

@ -1,15 +0,0 @@
<div data-controller="currency-dropdown" class="absolute right-1 bottom-[10px] flex items-end">
<button type="button" class="flex items-center justify-between w-20 px-2 py-1 text-sm rounded-lg hover:bg-gray-100 focus:bg-gray-100" data-action="click->currency-dropdown#toggleMenu">
<div data-currency-dropdown-target="label"><%= f.object.currency %></div>
<%# Example of how account currency value is updated %>
<%= f.hidden_field :currency, data: {currency_dropdown_target: "input"} %>
<%= lucide_icon("chevron-down", class: "text-gray-500 w-5 h-5" ) %>
</button>
<ul data-currency-dropdown-target="menu" class="hidden fixed p-1 bg-white rounded-[10px] min-w-[112px] z-50 translate-y-2 border border-alpha-black-100 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)]">
<% options.each do |option| %>
<li data-action="click->currency-dropdown#selectOption" data-currency-dropdown-target="option" data-value="<%= option %>" class="flex justify-between items-center p-2 text-sm text-gray-700 hover:bg-gray-100 cursor-pointer rounded-lg <%= "bg-gray-100" if option === f.object.currency %>"><%= option %>
<%= inline_svg_tag('icn-check.svg', class: "text-gray-500 fill-current #{'hidden'if option != f.object.currency}") %>
</li>
<% end %>
</ul>
</div>

View file

@ -1,4 +0,0 @@
<%# locals: (path:, period:) -%>
<%= form_with url: path, method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %>
<%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm py-2 pr-8 pl-2 cursor-pointer", onchange: "this.form.submit();" } %>
<% end %>

View file

@ -0,0 +1,16 @@
<%# locals: (value: 'all') -%>
<% options = [['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']] %>
<div data-controller="select" data-select-active-class="bg-alpha-black-50" class="relative">
<button type="button" data-select-target="button" class="flex items-center gap-1 w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm p-2 cursor-pointer">
<span data-select-target="buttonText" class="text-gray-900 text-sm"><%= options.find { |option| option[1] == value }[0] %></span>
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
</button>
<input type="hidden" name="period" value="<%= value %>" data-select-target="input" data-auto-submit-form-target="auto">
<ul data-select-target="list" class="hidden absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
<% options.each do |label, value| %>
<li tabindex="0" data-select-target="option" data-action="click->select#selectOption" data-value="<%= value %>" class="text-sm text-gray-900 rounded-lg cursor-pointer hover:bg-alpha-black-50 px-5 py-1">
<%= label %>
</li>
<% end %>
</ul>
</div>

View file

@ -1,14 +1,14 @@
<%# locals: (transaction:) %>
<div class="relative" data-controller="dropdown" data-dropdown-close-on-click-value="false">
<div class="flex cursor-pointer" data-action="click->dropdown#toggleMenu">
<div class="relative" data-controller="menu">
<button data-menu-target="button" class="flex">
<%= render partial: "shared/category_badge", locals: transaction.category.nil? ? {} : { name: transaction.category.name, color: transaction.category.color } %>
</div>
<div class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default" data-dropdown-target="menu">
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<div class="flex flex-col" data-controller="list-filter">
<div class="grow p-1.5">
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
<input placeholder="Search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter" />
<input placeholder="Search" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter" />
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
</div>
</div>

View file

@ -6,12 +6,12 @@
<div class="grow">
<%= render partial: "transactions/search_form/search_filter", locals: { form: form } %>
</div>
<div data-controller="dropdown" data-dropdown-close-on-click-value="false" class="relative">
<button type="button" data-action="dropdown#toggleMenu" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
<div data-controller="menu" class="relative">
<button data-menu-target="button" type="button" class="border border-gray-200 block h-full rounded-lg flex items-center gap-2 px-4">
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
<p class="text-sm font-medium text-gray-900">Filter</p>
</button>
<div class="hidden absolute z-10 top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs min-w-[450px]" data-dropdown-target="menu">
<div data-menu-target="content" class="absolute z-10 top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs min-w-[450px]">
<div data-controller="tabs" data-tabs-active-class="border-b-2 border-b-black text-gray-900" data-tabs-default-tab-value="txn-account-filter">
<div class="flex items-center px-3 text-sm font-medium text-gray-500 gap-4 border-b border-b-alpha-black-50">
<button class="py-2 border-b-2" type="button" data-id="txn-account-filter" data-tabs-target="btn" data-action="tabs#select">Account</button>