1
0
Fork 0
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:
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

@ -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();
}
}
}