mirror of
https://github.com/we-promise/sure.git
synced 2026-04-09 15:24:48 +00:00
* feat: Add default account for manual transaction entries (#1061) Allow users to designate a default account that auto-selects in the transaction creation form. Also consolidates account list actions (edit, link/unlink, enable/disable) into a meatball menu. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * - handle context menu width on mobile - restrict default account to depository types only - added FR, ES and DE i18n files * - Add credit card accounts can also be used as default - Moved logic into controller * Scope context menu max-width to accounts menu only - decouples the width constraint from the shared DS::Menu component by introducing an optional max_width param * fix ci test and address issues raised by coderabbit and codex * Address CodeRabbit review feedback - Use .present? for institution_name guards to avoid empty UI artifacts - Align "Set default" menu visibility with actual preselection eligibility (active + unlinked + supports_default?) to prevent drift between UI and model - Keep disabled star visible when account is already default but now ineligible Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add eligible_for_transaction_default? predicate to Account model Consolidates active + unlinked + supports_default? checks into a single shared predicate used by the controller, view, and user model guard, preventing a direct PATCH from bypassing UI eligibility rules. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Added "Unset default" option Added negative test for default account Removed duplicated logic for account.eligible_for_transaction_default --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
134 lines
3.4 KiB
JavaScript
134 lines
3.4 KiB
JavaScript
import {
|
|
autoUpdate,
|
|
computePosition,
|
|
offset,
|
|
shift,
|
|
} from "@floating-ui/dom";
|
|
import { Controller } from "@hotwired/stimulus";
|
|
|
|
/**
|
|
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
|
|
*/
|
|
export default class extends Controller {
|
|
static targets = ["button", "content"];
|
|
|
|
static values = {
|
|
show: Boolean,
|
|
placement: { type: String, default: "bottom-end" },
|
|
offset: { type: Number, default: 6 },
|
|
mobileFullwidth: { type: Boolean, default: true },
|
|
};
|
|
|
|
connect() {
|
|
this.show = this.showValue;
|
|
this.boundUpdate = this.update.bind(this);
|
|
this.addEventListeners();
|
|
this.startAutoUpdate();
|
|
}
|
|
|
|
disconnect() {
|
|
this.removeEventListeners();
|
|
this.stopAutoUpdate();
|
|
this.close();
|
|
}
|
|
|
|
addEventListeners() {
|
|
this.buttonTarget.addEventListener("click", this.toggle);
|
|
this.element.addEventListener("keydown", this.handleKeydown);
|
|
document.addEventListener("click", this.handleOutsideClick);
|
|
document.addEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
removeEventListeners() {
|
|
this.buttonTarget.removeEventListener("click", this.toggle);
|
|
this.element.removeEventListener("keydown", this.handleKeydown);
|
|
document.removeEventListener("click", this.handleOutsideClick);
|
|
document.removeEventListener("turbo:load", this.handleTurboLoad);
|
|
}
|
|
|
|
handleTurboLoad = () => {
|
|
if (!this.show) this.close();
|
|
};
|
|
|
|
handleOutsideClick = (event) => {
|
|
if (this.show && !this.element.contains(event.target)) this.close();
|
|
};
|
|
|
|
handleKeydown = (event) => {
|
|
if (event.key === "Escape") {
|
|
this.close();
|
|
this.buttonTarget.focus();
|
|
}
|
|
};
|
|
|
|
toggle = () => {
|
|
this.show = !this.show;
|
|
this.contentTarget.classList.toggle("hidden", !this.show);
|
|
if (this.show) {
|
|
this.update();
|
|
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({ preventScroll: true });
|
|
}
|
|
}
|
|
|
|
startAutoUpdate() {
|
|
if (!this._cleanup) {
|
|
this._cleanup = autoUpdate(
|
|
this.buttonTarget,
|
|
this.contentTarget,
|
|
this.boundUpdate,
|
|
);
|
|
}
|
|
}
|
|
|
|
stopAutoUpdate() {
|
|
if (this._cleanup) {
|
|
this._cleanup();
|
|
this._cleanup = null;
|
|
}
|
|
}
|
|
|
|
update() {
|
|
if (!this.buttonTarget || !this.contentTarget) return;
|
|
|
|
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
|
|
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
|
|
|
|
computePosition(this.buttonTarget, this.contentTarget, {
|
|
placement: useMobileFullwidth ? "bottom" : this.placementValue,
|
|
middleware: [offset(this.offsetValue), shift({ padding: 5 })],
|
|
strategy: "fixed",
|
|
}).then(({ x, y }) => {
|
|
if (useMobileFullwidth) {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: "0px",
|
|
width: "100vw",
|
|
top: `${y}px`,
|
|
});
|
|
} else {
|
|
Object.assign(this.contentTarget.style, {
|
|
position: "fixed",
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
width: "",
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|