Skip to content

Commit

Permalink
Consolidate dropdown controllers (maybe-finance#600)
Browse files Browse the repository at this point in the history
* Basic listbox and popover controllers with temporary example

* Separate select and menu controllers
  • Loading branch information
zachgoll authored Apr 3, 2024
1 parent 0a02898 commit 4f0b2de
Show file tree
Hide file tree
Showing 14 changed files with 298 additions and 150 deletions.
4 changes: 0 additions & 4 deletions app/helpers/forms_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 0 additions & 58 deletions app/javascript/controllers/currency_dropdown_controller.js

This file was deleted.

36 changes: 0 additions & 36 deletions app/javascript/controllers/dropdown_controller.js

This file was deleted.

75 changes: 75 additions & 0 deletions app/javascript/controllers/menu_controller.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
141 changes: 141 additions & 0 deletions app/javascript/controllers/select_controller.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
24 changes: 18 additions & 6 deletions app/views/accounts/_account_valuation_list.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
23 changes: 17 additions & 6 deletions app/views/accounts/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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 } %>
Expand Down
Loading

0 comments on commit 4f0b2de

Please sign in to comment.