mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-22 22:59:39 +02:00
Consolidate dropdown controllers (#600)
* Basic listbox and popover controllers with temporary example * Separate select and menu controllers
This commit is contained in:
parent
0a0289846e
commit
4f0b2de4ef
14 changed files with 298 additions and 150 deletions
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
75
app/javascript/controllers/menu_controller.js
Normal file
75
app/javascript/controllers/menu_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
141
app/javascript/controllers/select_controller.js
Normal file
141
app/javascript/controllers/select_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue