From 4f0b2de4ef6a9547f190e103d8d49f44ca368708 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 3 Apr 2024 17:32:27 -0400 Subject: [PATCH] Consolidate dropdown controllers (#600) * Basic listbox and popover controllers with temporary example * Separate select and menu controllers --- app/helpers/forms_helper.rb | 4 - .../currency_dropdown_controller.js | 58 ------- .../controllers/dropdown_controller.js | 36 ----- app/javascript/controllers/menu_controller.js | 75 ++++++++++ .../controllers/select_controller.js | 141 ++++++++++++++++++ .../accounts/_account_valuation_list.html.erb | 24 ++- app/views/accounts/show.html.erb | 23 ++- app/views/layouts/application.html.erb | 28 ++-- app/views/pages/dashboard.html.erb | 8 +- app/views/shared/_currency_dropdown.html.erb | 15 -- app/views/shared/_period_dropdown.html.erb | 4 - app/views/shared/_period_select.html.erb | 16 ++ .../transactions/_category_dropdown.html.erb | 10 +- app/views/transactions/_search_form.html.erb | 6 +- 14 files changed, 298 insertions(+), 150 deletions(-) delete mode 100644 app/javascript/controllers/currency_dropdown_controller.js delete mode 100644 app/javascript/controllers/dropdown_controller.js create mode 100644 app/javascript/controllers/menu_controller.js create mode 100644 app/javascript/controllers/select_controller.js delete mode 100644 app/views/shared/_currency_dropdown.html.erb delete mode 100644 app/views/shared/_period_dropdown.html.erb create mode 100644 app/views/shared/_period_select.html.erb diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 4228b7bf..ac138976 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -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 diff --git a/app/javascript/controllers/currency_dropdown_controller.js b/app/javascript/controllers/currency_dropdown_controller.js deleted file mode 100644 index d07a7a0f..00000000 --- a/app/javascript/controllers/currency_dropdown_controller.js +++ /dev/null @@ -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'); - } - } -} diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js deleted file mode 100644 index a4ab376a..00000000 --- a/app/javascript/controllers/dropdown_controller.js +++ /dev/null @@ -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(); - } -} diff --git a/app/javascript/controllers/menu_controller.js b/app/javascript/controllers/menu_controller.js new file mode 100644 index 00000000..27a9d94e --- /dev/null +++ b/app/javascript/controllers/menu_controller.js @@ -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(); + } + } +} diff --git a/app/javascript/controllers/select_controller.js b/app/javascript/controllers/select_controller.js new file mode 100644 index 00000000..04578728 --- /dev/null +++ b/app/javascript/controllers/select_controller.js @@ -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(); + } + } +} diff --git a/app/views/accounts/_account_valuation_list.html.erb b/app/views/accounts/_account_valuation_list.html.erb index 42070e0a..f1e83caf 100644 --- a/app/views/accounts/_account_valuation_list.html.erb +++ b/app/views/accounts/_account_valuation_list.html.erb @@ -24,16 +24,28 @@ (<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= valuation.trend.percent %>%) <% end %> -
- - -
-
+
+
-
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index a98b5403..fb6bfe73 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -29,17 +29,24 @@
<%= link_to root_path do %> - <%= image_tag 'logo.svg', alt: 'Maybe' %> + <%= image_tag 'logo.svg', alt: 'Maybe', class: "h-[22px]" %> <% end %> -
-
-
<%= Current.user.email.first %>
-
- @@ -79,7 +86,6 @@
<%= turbo_frame_tag "modal" %> - <%= render 'shared/custom_confirm_modal' %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index af8a19b3..1705a4eb 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -15,7 +15,9 @@ } %>
- <%= 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 %>
<%= 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") %>

<%= t('.new') %>

<% 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 %>
diff --git a/app/views/shared/_currency_dropdown.html.erb b/app/views/shared/_currency_dropdown.html.erb deleted file mode 100644 index 124eef59..00000000 --- a/app/views/shared/_currency_dropdown.html.erb +++ /dev/null @@ -1,15 +0,0 @@ -
- - -
diff --git a/app/views/shared/_period_dropdown.html.erb b/app/views/shared/_period_dropdown.html.erb deleted file mode 100644 index dd759fb6..00000000 --- a/app/views/shared/_period_dropdown.html.erb +++ /dev/null @@ -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 %> \ No newline at end of file diff --git a/app/views/shared/_period_select.html.erb b/app/views/shared/_period_select.html.erb new file mode 100644 index 00000000..0281ba62 --- /dev/null +++ b/app/views/shared/_period_select.html.erb @@ -0,0 +1,16 @@ +<%# locals: (value: 'all') -%> +<% options = [['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']] %> +
+ + + +
diff --git a/app/views/transactions/_category_dropdown.html.erb b/app/views/transactions/_category_dropdown.html.erb index 528e872f..1cbb8f6e 100644 --- a/app/views/transactions/_category_dropdown.html.erb +++ b/app/views/transactions/_category_dropdown.html.erb @@ -1,14 +1,14 @@ <%# locals: (transaction:) %> -
-
+
+
-