Files
sure/app/components/DS/menu_controller.js
Serge L 5aa808e668 Feat: Add default user account and consolidate account actions in menu (#1130)
* 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>
2026-03-15 21:26:26 +01:00

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: "",
});
}
});
}
}