mirror of
https://github.com/we-promise/sure.git
synced 2026-06-04 10:19:03 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # app/javascript/controllers/sankey_chart_controller.js
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["messages", "form", "input"];
|
||||
static targets = ["messages", "form", "input", "submit"];
|
||||
|
||||
connect() {
|
||||
this.#configureAutoScroll();
|
||||
this.#updateSubmitState();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -22,10 +23,13 @@ export default class extends Controller {
|
||||
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
|
||||
input.style.overflowY =
|
||||
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
|
||||
|
||||
this.#updateSubmitState();
|
||||
}
|
||||
|
||||
submitSampleQuestion(e) {
|
||||
this.inputTarget.value = e.target.dataset.chatQuestionParam;
|
||||
this.#updateSubmitState();
|
||||
|
||||
setTimeout(() => {
|
||||
this.formTarget.requestSubmit();
|
||||
@@ -36,10 +40,21 @@ export default class extends Controller {
|
||||
handleInputKeyDown(e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.formTarget.requestSubmit();
|
||||
if (this.#hasContent()) {
|
||||
this.formTarget.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#hasContent() {
|
||||
return this.inputTarget.value.trim().length > 0;
|
||||
}
|
||||
|
||||
#updateSubmitState() {
|
||||
if (!this.hasSubmitTarget) return;
|
||||
this.submitTarget.disabled = !this.#hasContent();
|
||||
}
|
||||
|
||||
#configureAutoScroll() {
|
||||
this.messagesObserver = new MutationObserver((_mutations) => {
|
||||
if (this.hasMessagesTarget) {
|
||||
|
||||
@@ -22,31 +22,42 @@ export default class extends Controller {
|
||||
presetColors: Array,
|
||||
};
|
||||
|
||||
initialize() {
|
||||
this.pickerBtnTarget.addEventListener("click", () => {
|
||||
this.showPaletteSection();
|
||||
});
|
||||
|
||||
this.colorInputTarget.addEventListener("input", (e) => {
|
||||
this.picker.setColor(e.target.value);
|
||||
});
|
||||
|
||||
this.detailsTarget.addEventListener("toggle", (e) => {
|
||||
connect() {
|
||||
// Bound references stored on the instance so disconnect() can remove
|
||||
// them. Without this, every Turbo navigation that re-renders the
|
||||
// picker stacks another listener on the same node.
|
||||
this._onPickerBtnClick = () => this.showPaletteSection();
|
||||
this._onColorInputInput = (e) => this.picker?.setColor(e.target.value);
|
||||
this._onDetailsToggle = (e) => {
|
||||
if (!this.colorInputTarget.checkValidity()) {
|
||||
e.preventDefault();
|
||||
this.colorInputTarget.reportValidity();
|
||||
e.target.open = true;
|
||||
}
|
||||
this.updatePopupPosition()
|
||||
});
|
||||
this.updatePopupPosition();
|
||||
};
|
||||
|
||||
this.pickerBtnTarget.addEventListener("click", this._onPickerBtnClick);
|
||||
this.colorInputTarget.addEventListener("input", this._onColorInputInput);
|
||||
this.detailsTarget.addEventListener("toggle", this._onDetailsToggle);
|
||||
document.addEventListener("mousedown", this.handleOutsideClick);
|
||||
|
||||
this.selectedIcon = null;
|
||||
|
||||
if (!this.presetColorsValue.includes(this.colorInputTarget.value)) {
|
||||
this.colorPickerRadioBtnTarget.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", this.handleOutsideClick);
|
||||
disconnect() {
|
||||
this.pickerBtnTarget.removeEventListener("click", this._onPickerBtnClick);
|
||||
this.colorInputTarget.removeEventListener("input", this._onColorInputInput);
|
||||
this.detailsTarget.removeEventListener("toggle", this._onDetailsToggle);
|
||||
document.removeEventListener("mousedown", this.handleOutsideClick);
|
||||
if (this.picker) {
|
||||
this.picker.destroyAndRemove();
|
||||
this.picker = null;
|
||||
}
|
||||
}
|
||||
|
||||
initPicker() {
|
||||
@@ -85,8 +96,12 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateAvatarColors(color) {
|
||||
this.avatarTarget.style.backgroundColor = `${this.#backgroundColor(color)}`;
|
||||
this.avatarTarget.style.color = color;
|
||||
// Update the `--avatar-color` CSS variable instead of overriding
|
||||
// `style.color` / `style.backgroundColor` directly. The `.goal-avatar`
|
||||
// class does theme-aware `color-mix` work off the variable (light mode
|
||||
// darkens the letter, dark mode uses the full color) — overriding the
|
||||
// resolved values inline killed that contrast logic.
|
||||
this.avatarTarget.style.setProperty("--avatar-color", color);
|
||||
}
|
||||
|
||||
handleIconColorChange(e) {
|
||||
@@ -3,7 +3,7 @@ import * as d3 from "d3";
|
||||
|
||||
// Connects to data-controller="donut-chart"
|
||||
export default class extends Controller {
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent"];
|
||||
static targets = ["chartContainer", "contentContainer", "defaultContent", "amount"];
|
||||
static values = {
|
||||
segments: { type: Array, default: [] },
|
||||
unusedSegmentId: { type: String, default: "unused" },
|
||||
@@ -21,12 +21,27 @@ export default class extends Controller {
|
||||
#minSegmentAngle = 0.02; // Minimum angle in radians (~1.15 degrees)
|
||||
#padAngle = 0.005; // Spacing between segments (~0.29 degrees)
|
||||
#visiblePaths = null;
|
||||
#resizeObserver = null;
|
||||
#measureCanvas = null;
|
||||
// Largest square inscribed in a circle has side D/√2 ≈ 0.707·D. A single
|
||||
// line of text only needs horizontal room, so 0.78 leaves a touch of
|
||||
// padding without being overly conservative.
|
||||
#innerRingTextWidthRatio = 0.78;
|
||||
// ~text-sm (0.875rem at the default 16px root). Acceptance criterion is
|
||||
// "shrink proportionally, never below text-sm".
|
||||
#minAmountFontSizePx = 14;
|
||||
|
||||
connect() {
|
||||
this.#draw();
|
||||
this.#fitAmountTargets();
|
||||
document.addEventListener("turbo:load", this.#redraw);
|
||||
this.element.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
this.contentContainerTarget.addEventListener("mouseleave", this.#clearSegmentHover);
|
||||
|
||||
if (typeof ResizeObserver !== "undefined" && this.hasChartContainerTarget) {
|
||||
this.#resizeObserver = new ResizeObserver(() => this.#fitAmountTargets());
|
||||
this.#resizeObserver.observe(this.chartContainerTarget);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -34,6 +49,11 @@ export default class extends Controller {
|
||||
document.removeEventListener("turbo:load", this.#redraw);
|
||||
this.element.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
this.contentContainerTarget.removeEventListener("mouseleave", this.#clearSegmentHover);
|
||||
|
||||
if (this.#resizeObserver) {
|
||||
this.#resizeObserver.disconnect();
|
||||
this.#resizeObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
get #data() {
|
||||
@@ -202,6 +222,10 @@ export default class extends Controller {
|
||||
|
||||
this.defaultContentTarget.classList.add("hidden");
|
||||
template.classList.remove("hidden");
|
||||
|
||||
// The newly-visible amount is now in flow; re-fit in case the container
|
||||
// size has changed since initial draw.
|
||||
this.#fitAmountTargets(template);
|
||||
}
|
||||
|
||||
// Restores original segment colors and hides segment specific content
|
||||
@@ -254,4 +278,72 @@ export default class extends Controller {
|
||||
|
||||
paths.style("opacity", null); // Clear inline opacity style
|
||||
}
|
||||
|
||||
// Shrinks amount text down so it never overflows the inner ring of the
|
||||
// donut. Re-runs on draw, on resize, and when a segment template becomes
|
||||
// visible. Optional `scope` limits the work to a subtree (e.g. the segment
|
||||
// that just appeared).
|
||||
#fitAmountTargets(scope = null) {
|
||||
if (!this.hasChartContainerTarget || !this.hasAmountTarget) return;
|
||||
|
||||
// The donut SVG uses `preserveAspectRatio="xMidYMid meet"`, so the actual
|
||||
// rendered diameter is the *smaller* of the container's width and height.
|
||||
// Using width alone over-estimates available room in non-square cells
|
||||
// (e.g. the budget show page renders the donut inside a grid column that
|
||||
// grows wider than tall on large viewports).
|
||||
const rect = this.chartContainerTarget.getBoundingClientRect();
|
||||
const containerSize = Math.min(rect.width, rect.height);
|
||||
if (containerSize <= 0) return;
|
||||
|
||||
const innerDiameterRatio =
|
||||
(this.#viewBoxSize - 2 * this.segmentHeightValue) / this.#viewBoxSize;
|
||||
const availableWidth = containerSize * innerDiameterRatio * this.#innerRingTextWidthRatio;
|
||||
if (availableWidth <= 0) return;
|
||||
|
||||
const targets = scope
|
||||
? this.amountTargets.filter((el) => scope.contains(el))
|
||||
: this.amountTargets;
|
||||
|
||||
targets.forEach((el) => this.#fitAmountElement(el, availableWidth));
|
||||
}
|
||||
|
||||
#fitAmountElement(element, availableWidth) {
|
||||
// Reset previous inline sizing so we measure at the source size each pass.
|
||||
element.style.fontSize = "";
|
||||
|
||||
const text = element.textContent.trim();
|
||||
if (!text) return;
|
||||
|
||||
const computed = window.getComputedStyle(element);
|
||||
const baseFontSize = Number.parseFloat(computed.fontSize);
|
||||
if (!baseFontSize) return;
|
||||
|
||||
// Canvas-based measurement works for hidden elements (segment_<id>
|
||||
// templates start with `display: none`), where scrollWidth would be 0.
|
||||
const intrinsicWidth = this.#measureTextWidth(text, computed, baseFontSize);
|
||||
if (intrinsicWidth <= 0 || intrinsicWidth <= availableWidth) return;
|
||||
|
||||
const scaled = Math.max(
|
||||
this.#minAmountFontSizePx,
|
||||
Math.floor((availableWidth / intrinsicWidth) * baseFontSize),
|
||||
);
|
||||
|
||||
if (Math.abs(scaled - baseFontSize) >= 1) {
|
||||
element.style.fontSize = `${scaled}px`;
|
||||
}
|
||||
}
|
||||
|
||||
#measureTextWidth(text, computed, fontSize) {
|
||||
if (!this.#measureCanvas) {
|
||||
this.#measureCanvas = document.createElement("canvas");
|
||||
}
|
||||
const ctx = this.#measureCanvas.getContext("2d");
|
||||
if (!ctx) return 0;
|
||||
|
||||
const fontStyle = computed.fontStyle || "normal";
|
||||
const fontWeight = computed.fontWeight || "400";
|
||||
const fontFamily = computed.fontFamily || "sans-serif";
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
}
|
||||
|
||||
141
app/javascript/controllers/goal_form_controller.js
Normal file
141
app/javascript/controllers/goal_form_controller.js
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Single-form controller for the goal create / edit modal.
|
||||
//
|
||||
// Replaces the 2-step stepper: the form is short enough that all fields
|
||||
// fit on one panel, so the previous review step (which only showed a
|
||||
// derived "Save $X/mo to hit it on time" hint) collapses into an inline
|
||||
// live hint below the target date. Validation + avatar preview from the
|
||||
// name field still live here.
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"nameInput",
|
||||
"amountInput",
|
||||
"dateInput",
|
||||
"avatarPreview",
|
||||
"nameError",
|
||||
"amountError",
|
||||
"accountsError",
|
||||
"linkedAccountCheckbox",
|
||||
"suggested",
|
||||
];
|
||||
|
||||
static INVALID_INPUT_CLASSES = ["ring-2", "ring-destructive", "border-destructive"];
|
||||
|
||||
static values = {
|
||||
currency: { type: String, default: "USD" },
|
||||
suggestedWithDate: { type: String, default: "Save {monthly}/mo across {accounts} to hit it on time." },
|
||||
suggestedNoDate: { type: String, default: "Set a target date to project a finish line." },
|
||||
};
|
||||
|
||||
connect() {
|
||||
// Capture the default avatar contents (the "target" icon SVG) so we
|
||||
// can restore it when the user clears the name field after typing.
|
||||
if (this.hasAvatarPreviewTarget) {
|
||||
this._defaultAvatarHTML = this.avatarPreviewTarget.innerHTML;
|
||||
}
|
||||
this.updateSuggested();
|
||||
}
|
||||
|
||||
nameChanged() {
|
||||
if (this.hasNameInputTarget) {
|
||||
this.clearFieldError(this.nameInputTarget, this.hasNameErrorTarget ? this.nameErrorTarget : null);
|
||||
}
|
||||
if (!this.hasAvatarPreviewTarget || !this.hasNameInputTarget) return;
|
||||
|
||||
// If the user has explicitly picked an icon, leave it alone. Name
|
||||
// changes shouldn't undo an explicit choice.
|
||||
const iconPicked = this.element.querySelector('input[name="goal[icon]"]:checked');
|
||||
if (iconPicked) return;
|
||||
|
||||
const name = this.nameInputTarget.value.trim();
|
||||
if (name) {
|
||||
this.avatarPreviewTarget.textContent = name.charAt(0).toUpperCase();
|
||||
} else if (this._defaultAvatarHTML) {
|
||||
// Captured at connect. Restore the default "target" icon from the
|
||||
// server-rendered template, not a "?" character.
|
||||
this.avatarPreviewTarget.innerHTML = this._defaultAvatarHTML;
|
||||
}
|
||||
}
|
||||
|
||||
amountChanged() {
|
||||
if (this.hasAmountInputTarget) {
|
||||
this.clearFieldError(this.amountInputTarget, this.hasAmountErrorTarget ? this.amountErrorTarget : null);
|
||||
}
|
||||
}
|
||||
|
||||
linkedAccountChanged() {
|
||||
this.updateSuggested();
|
||||
if (this.linkedAccountCheckboxTargets.some((cb) => cb.checked) && this.hasAccountsErrorTarget) {
|
||||
this.accountsErrorTarget.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for any input that influences the suggested-pace hint
|
||||
// (target_amount, target_date). Also re-evaluates as accounts toggle.
|
||||
suggestedChanged() {
|
||||
this.amountChanged();
|
||||
this.updateSuggested();
|
||||
}
|
||||
|
||||
updateSuggested() {
|
||||
if (!this.hasSuggestedTarget) return;
|
||||
|
||||
const amount = this.hasAmountInputTarget ? Number.parseFloat(this.amountInputTarget.value) : Number.NaN;
|
||||
const dateValue = this.hasDateInputTarget ? this.dateInputTarget.value : null;
|
||||
const checkedCount = this.linkedAccountCheckboxTargets.filter((cb) => cb.checked).length;
|
||||
|
||||
const amountValid = Number.isFinite(amount) && amount > 0;
|
||||
if (!amountValid || checkedCount === 0) {
|
||||
this.suggestedTarget.classList.add("hidden");
|
||||
this.suggestedTarget.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let text;
|
||||
if (dateValue) {
|
||||
const months = this.#monthsBetween(new Date(), new Date(dateValue));
|
||||
if (months <= 0) {
|
||||
this.suggestedTarget.classList.add("hidden");
|
||||
this.suggestedTarget.textContent = "";
|
||||
return;
|
||||
}
|
||||
const perMonth = Math.ceil(amount / months);
|
||||
const accountLabel = `${checkedCount} ${checkedCount === 1 ? "account" : "accounts"}`;
|
||||
text = this.suggestedWithDateValue
|
||||
.replace("{monthly}", this.#money(perMonth))
|
||||
.replace("{accounts}", accountLabel);
|
||||
} else {
|
||||
text = this.suggestedNoDateValue;
|
||||
}
|
||||
|
||||
this.suggestedTarget.textContent = text;
|
||||
this.suggestedTarget.classList.remove("hidden");
|
||||
}
|
||||
|
||||
showFieldError(input, errorEl) {
|
||||
if (input) input.classList.add(...this.constructor.INVALID_INPUT_CLASSES);
|
||||
if (errorEl) errorEl.classList.remove("hidden");
|
||||
}
|
||||
|
||||
clearFieldError(input, errorEl) {
|
||||
if (input) input.classList.remove(...this.constructor.INVALID_INPUT_CLASSES);
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
}
|
||||
|
||||
#money(value) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: this.currencyValue || "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
#monthsBetween(from, to) {
|
||||
return (to - from) / (1000 * 60 * 60 * 24 * 30.44);
|
||||
}
|
||||
}
|
||||
90
app/javascript/controllers/goal_pledge_preview_controller.js
Normal file
90
app/javascript/controllers/goal_pledge_preview_controller.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Live impact preview for the record-pledge modal. Reads current balance +
|
||||
// target amount from values and updates a preview sentence each keystroke.
|
||||
// Template strings come from ERB so the wording stays localized.
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"amountInput",
|
||||
"preview",
|
||||
"accountSelect",
|
||||
"helperConnected",
|
||||
"helperManual",
|
||||
];
|
||||
static values = {
|
||||
currentBalance: Number,
|
||||
targetAmount: Number,
|
||||
currency: String,
|
||||
templateZero: String,
|
||||
templateNonzero: String,
|
||||
templateReached: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.update();
|
||||
this.accountChanged();
|
||||
}
|
||||
|
||||
// Helper text reacts to the currently-selected account, not the goal as a
|
||||
// whole. A mixed-funding goal (one connected account + one manual) used to
|
||||
// paint the "connected" helper even if the user then picked the manual
|
||||
// account from the dropdown; the saved pledge would be `kind: manual_save`
|
||||
// (correct, per `kind_for_account` in the controller) but the helper read
|
||||
// "transfer-style" copy until submission.
|
||||
accountChanged() {
|
||||
if (!this.hasAccountSelectTarget) return;
|
||||
if (!this.hasHelperConnectedTarget || !this.hasHelperManualTarget) return;
|
||||
const opt = this.accountSelectTarget.selectedOptions[0];
|
||||
const isManual = opt?.dataset.manual === "true";
|
||||
this.helperConnectedTarget.hidden = isManual;
|
||||
this.helperManualTarget.hidden = !isManual;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.hasPreviewTarget) return;
|
||||
|
||||
const amount = this.#amountValue();
|
||||
const newTotal = this.currentBalanceValue + amount;
|
||||
const target = this.targetAmountValue;
|
||||
const reached = newTotal >= target && target > 0;
|
||||
const percent = target > 0 ? Math.min(100, Math.round((newTotal / target) * 100)) : 0;
|
||||
|
||||
let text;
|
||||
if (reached) {
|
||||
text = this.templateReachedValue.replace("{target}", this.#money(target));
|
||||
} else if (amount === 0) {
|
||||
text = this.templateZeroValue
|
||||
.replaceAll("{percent}", percent.toString())
|
||||
.replaceAll("{current}", this.#money(this.currentBalanceValue))
|
||||
.replaceAll("{target}", this.#money(target));
|
||||
} else {
|
||||
text = this.templateNonzeroValue
|
||||
.replaceAll("{percent}", percent.toString())
|
||||
.replaceAll("{newTotal}", this.#money(newTotal))
|
||||
.replaceAll("{target}", this.#money(target));
|
||||
}
|
||||
|
||||
this.previewTarget.textContent = text;
|
||||
}
|
||||
|
||||
#amountValue() {
|
||||
if (!this.hasAmountInputTarget) return 0;
|
||||
const parsed = Number.parseFloat(this.amountInputTarget.value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
#money(value) {
|
||||
try {
|
||||
// Let Intl pick the currency-specific default fraction digits so
|
||||
// USD/EUR previews show cents while JPY/KRW stay whole-unit. The
|
||||
// server saves the user-entered amount verbatim; the preview must
|
||||
// not silently round it.
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: this.currencyValue || "USD",
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `${this.currencyValue || "$"}${value.toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
572
app/javascript/controllers/goal_projection_chart_controller.js
Normal file
572
app/javascript/controllers/goal_projection_chart_controller.js
Normal file
@@ -0,0 +1,572 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
import * as d3 from "d3";
|
||||
|
||||
// Projection chart for a goal. Renders:
|
||||
// - Saved area + line from goal creation → today (solid)
|
||||
// - Dashed projection line from today → target date (yellow if behind,
|
||||
// green if on track)
|
||||
// - Horizontal dashed target line with label
|
||||
// - Today marker (vertical line + dot)
|
||||
//
|
||||
// Data shape passed via `data-goal-projection-chart-data-value`
|
||||
// matches Goal#projection_payload.
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
data: Object,
|
||||
ariaLabel: String,
|
||||
ariaDescription: String,
|
||||
todayLabel: { type: String, default: "Today" },
|
||||
projectedTemplate: { type: String, default: "Projected: {amount}" },
|
||||
savedTemplate: { type: String, default: "Saved: {amount}" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this._resize = this._draw.bind(this);
|
||||
window.addEventListener("resize", this._resize);
|
||||
// Container may have 0 width on initial connect (Turbo restoration,
|
||||
// hidden parent, etc). Re-draw whenever the box settles into a real
|
||||
// size. The first observer callback also performs the initial paint.
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
this._observer = new ResizeObserver(() => this._draw());
|
||||
this._observer.observe(this.element);
|
||||
} else {
|
||||
this._draw();
|
||||
}
|
||||
// Repaint when the user toggles theme so SVG attributes (which bake
|
||||
// light/dark hex values at draw time) follow data-theme. Lives here
|
||||
// until theme_controller broadcasts a theme:change event upstream.
|
||||
if (typeof MutationObserver !== "undefined") {
|
||||
this._themeObserver = new MutationObserver((mutations) => {
|
||||
if (mutations.some((m) => m.attributeName === "data-theme")) this._draw();
|
||||
});
|
||||
this._themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
}
|
||||
// After a Turbo render (eg. after saving the goal from the edit modal
|
||||
// and redirecting back to show), the chart container can be left empty
|
||||
// its children may be wiped by the morph even though connect() was
|
||||
// already called, and ResizeObserver doesn't fire because the size
|
||||
// didn't change. Listen for the render event so we redraw when needed.
|
||||
this._onTurboRender = () => {
|
||||
if (!this.element.querySelector("svg")) this._draw();
|
||||
};
|
||||
document.addEventListener("turbo:render", this._onTurboRender);
|
||||
document.addEventListener("turbo:frame-load", this._onTurboRender);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
window.removeEventListener("resize", this._resize);
|
||||
this._observer?.disconnect();
|
||||
this._themeObserver?.disconnect();
|
||||
if (this._onTurboRender) {
|
||||
document.removeEventListener("turbo:render", this._onTurboRender);
|
||||
document.removeEventListener("turbo:frame-load", this._onTurboRender);
|
||||
}
|
||||
}
|
||||
|
||||
_draw() {
|
||||
const root = this.element;
|
||||
root.innerHTML = "";
|
||||
|
||||
const data = this.dataValue || {};
|
||||
const width = root.clientWidth || 720;
|
||||
const height = root.clientHeight || 240;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
const textPrimary = isDark ? "#ffffff" : "#171717";
|
||||
const textSecondary = isDark ? "#cfcfcf" : "#737373";
|
||||
const borderSubdued = isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.10)";
|
||||
const containerBg = isDark ? "#0a0a0a" : "#ffffff";
|
||||
|
||||
// Reserve gutter for y-axis labels when there's room. Mobile (< 320)
|
||||
// keeps the tighter left margin and skips the y-axis entirely.
|
||||
const yAxisVisible = width - 16 - 24 >= 320;
|
||||
const margin = { top: 28, right: 24, bottom: 28, left: yAxisVisible ? 44 : 16 };
|
||||
const innerWidth = width - margin.left - margin.right;
|
||||
const innerHeight = height - margin.top - margin.bottom;
|
||||
|
||||
// Date-only payload strings ("YYYY-MM-DD") parse as UTC midnight in
|
||||
// `new Date(str)`, which shifts displayed days back one for users west
|
||||
// of Greenwich. Parse components so today/target/saved_series sit on
|
||||
// local-midnight.
|
||||
const parseLocalDate = (s) => {
|
||||
if (!s) return null;
|
||||
const [ y, m, d ] = s.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
};
|
||||
const start = parseLocalDate(data.start_date);
|
||||
const today = parseLocalDate(data.today);
|
||||
const target = parseLocalDate(data.target_date);
|
||||
const targetAmount = data.target_amount || 0;
|
||||
const currentAmount = data.current_amount || 0;
|
||||
const avgMonthly = data.avg_monthly || 0;
|
||||
|
||||
// Past-due goals: pin endDate at today so the "today" marker stays inside
|
||||
// the x-domain instead of clipping right at the edge.
|
||||
const endDate = target
|
||||
? new Date(Math.max(target.getTime(), today.getTime()))
|
||||
: new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Drop any same-day-or-later points from the balance series: we own the
|
||||
// endpoint with `currentAmount` (live `linked_accounts.sum(:balance)`)
|
||||
// so the saved line meets the projection's starting point with no gap.
|
||||
// Without this, the snapshot in `balances` for today could differ from
|
||||
// the live read (sync timing) and the chart showed a vertical jump.
|
||||
const rawSavedSeries = (data.saved_series || [])
|
||||
.map((p) => ({ date: parseLocalDate(p.date), value: p.value }))
|
||||
.filter((p) => p.date < today);
|
||||
const firstContribDate = rawSavedSeries[0]?.date;
|
||||
const savedSeries = [];
|
||||
// Only seed a (start, 0) point when start_date predates the first
|
||||
// contribution. Otherwise the line draws a vertical jump up at the
|
||||
// chart's left edge.
|
||||
if (!firstContribDate || firstContribDate.getTime() > start.getTime()) {
|
||||
savedSeries.push({ date: start, value: 0 });
|
||||
}
|
||||
savedSeries.push(...rawSavedSeries);
|
||||
// Always close the saved line at (today, currentAmount) — the projection
|
||||
// line starts here too, guaranteeing visual continuity at the today
|
||||
// marker.
|
||||
savedSeries.push({ date: today, value: currentAmount });
|
||||
|
||||
const projectionEnd = target
|
||||
? Math.max(currentAmount, currentAmount + avgMonthly * Math.max(0, this._monthsBetween(today, target)))
|
||||
: currentAmount;
|
||||
const projectionSeries = target
|
||||
? [
|
||||
{ date: today, value: currentAmount },
|
||||
{ date: target, value: projectionEnd },
|
||||
]
|
||||
: [];
|
||||
|
||||
const requiredMonthly = data.required_monthly || 0;
|
||||
const requiredEnd = target && requiredMonthly > 0
|
||||
? currentAmount + requiredMonthly * Math.max(0, this._monthsBetween(today, target))
|
||||
: currentAmount;
|
||||
const requiredSeries = target && requiredMonthly > 0 && requiredEnd > currentAmount
|
||||
? [
|
||||
{ date: today, value: currentAmount },
|
||||
{ date: target, value: requiredEnd },
|
||||
]
|
||||
: [];
|
||||
|
||||
const yMax = Math.max(targetAmount * 1.05, projectionEnd, requiredEnd, currentAmount, 1);
|
||||
|
||||
const x = d3.scaleTime().domain([start, endDate]).range([margin.left, margin.left + innerWidth]);
|
||||
const y = d3.scaleLinear().domain([0, yMax]).range([margin.top + innerHeight, margin.top]);
|
||||
|
||||
const svg = d3
|
||||
.select(root)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
// Drop the <title> child; browsers render it as a native hover tooltip
|
||||
// that fights with our own crosshair tooltip. aria-label gives the same
|
||||
// SR accessible name without the tooltip side-effect.
|
||||
const descId = `chart-desc-${this._id()}`;
|
||||
svg.attr("role", "img").attr("aria-label", this.ariaLabelValue || "Goal projection");
|
||||
svg.append("desc").attr("id", descId).text(this.ariaDescriptionValue || "");
|
||||
svg.attr("aria-describedby", descId);
|
||||
|
||||
const defs = svg.append("defs");
|
||||
const gradient = defs
|
||||
.append("linearGradient")
|
||||
.attr("id", `saved-fill-${this._id()}`)
|
||||
.attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 1);
|
||||
gradient.append("stop").attr("offset", "0%").attr("stop-color", textPrimary).attr("stop-opacity", 0.22);
|
||||
gradient.append("stop").attr("offset", "100%").attr("stop-color", textPrimary).attr("stop-opacity", 0);
|
||||
|
||||
const COLLISION_PX = 18;
|
||||
const targetY = targetAmount > 0 ? y(targetAmount) : null;
|
||||
const yTicks = yAxisVisible ? y.ticks(3) : [];
|
||||
const targetCollidesWithTick =
|
||||
targetY !== null && yTicks.some((tv) => Math.abs(y(tv) - targetY) < COLLISION_PX);
|
||||
|
||||
if (yAxisVisible) {
|
||||
yTicks.forEach((tickValue) => {
|
||||
svg
|
||||
.append("line")
|
||||
.attr("x1", margin.left)
|
||||
.attr("x2", margin.left + innerWidth)
|
||||
.attr("y1", y(tickValue))
|
||||
.attr("y2", y(tickValue))
|
||||
.attr("stroke", borderSubdued)
|
||||
.attr("stroke-width", 1);
|
||||
// Skip the y-axis label when its row is close to the target line.
|
||||
// The target's own label will take over that y-slot below.
|
||||
if (targetY !== null && Math.abs(y(tickValue) - targetY) < COLLISION_PX) return;
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", margin.left - 6)
|
||||
.attr("y", y(tickValue) + 3)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textSecondary)
|
||||
.text(this._fmtMoneyShort(tickValue, data.currency));
|
||||
});
|
||||
}
|
||||
|
||||
if (targetAmount > 0) {
|
||||
svg
|
||||
.append("line")
|
||||
.attr("x1", margin.left)
|
||||
.attr("x2", margin.left + innerWidth)
|
||||
.attr("y1", y(targetAmount))
|
||||
.attr("y2", y(targetAmount))
|
||||
.attr("stroke", borderSubdued)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "3 3");
|
||||
|
||||
if (targetCollidesWithTick) {
|
||||
// Merge target label into the y-axis column at the target's y-row.
|
||||
// The collided y-axis tick was suppressed above so this label takes
|
||||
// over that slot cleanly.
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", margin.left - 6)
|
||||
.attr("y", targetY + 3)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textPrimary)
|
||||
.text(`Target · ${data.target_amount_short_label}`);
|
||||
} else {
|
||||
// Plenty of room: keep the right-side full-format label.
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", margin.left + innerWidth - 4)
|
||||
.attr("y", targetY - 6)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textPrimary)
|
||||
.text(`Target · ${data.target_amount_label}`);
|
||||
}
|
||||
}
|
||||
|
||||
const area = d3
|
||||
.area()
|
||||
.x((d) => x(d.date))
|
||||
.y0(margin.top + innerHeight)
|
||||
.y1((d) => y(d.value))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
const line = d3
|
||||
.line()
|
||||
.x((d) => x(d.date))
|
||||
.y((d) => y(d.value))
|
||||
.curve(d3.curveMonotoneX);
|
||||
|
||||
svg
|
||||
.append("path")
|
||||
.datum(savedSeries)
|
||||
.attr("fill", `url(#saved-fill-${this._id()})`)
|
||||
.attr("d", area);
|
||||
|
||||
svg
|
||||
.append("path")
|
||||
.datum(savedSeries)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", textPrimary)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke-linejoin", "round")
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("d", line);
|
||||
|
||||
if (requiredSeries.length) {
|
||||
// Light dashed reference line: the path needed to hit the target.
|
||||
// Neutral stroke (text-secondary) instead of green: both the
|
||||
// projection and the required line are otherwise green when the
|
||||
// goal is on track, and the two would visually merge.
|
||||
svg
|
||||
.append("path")
|
||||
.datum(requiredSeries)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", textSecondary)
|
||||
.attr("stroke-width", 1.2)
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke-dasharray", "2 4")
|
||||
.attr("opacity", 0.5)
|
||||
.attr("d", line);
|
||||
}
|
||||
|
||||
if (projectionSeries.length) {
|
||||
const willHit = projectionEnd >= targetAmount;
|
||||
const projColor = willHit ? "var(--color-green-600)" : "var(--color-yellow-600)";
|
||||
svg
|
||||
.append("path")
|
||||
.datum(projectionSeries)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", projColor)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("stroke-linecap", "round")
|
||||
.attr("stroke-dasharray", "4 4")
|
||||
.attr("d", line);
|
||||
|
||||
svg
|
||||
.append("circle")
|
||||
.attr("cx", x(target))
|
||||
.attr("cy", y(projectionEnd))
|
||||
.attr("r", 4)
|
||||
.attr("fill", projColor)
|
||||
.attr("stroke", containerBg)
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
// Suppress the projection-end label when it would visually collide
|
||||
// with the target label above. In a barely-on-track case the dot
|
||||
// already conveys "you'll hit the target". duplicating "$2.4K"
|
||||
// beside "Target · $2,400" adds noise.
|
||||
const projDotY = y(projectionEnd);
|
||||
const collidesWithTargetLabel = targetAmount > 0 && Math.abs(projDotY - y(targetAmount)) < 18;
|
||||
|
||||
if (innerWidth >= 320 && !(willHit && collidesWithTargetLabel)) {
|
||||
// Server-rendered labels: projection_end_label is the full-format
|
||||
// currency for the on-track endpoint, projection_shortfall_label
|
||||
// is the "$X short" string when we fall short.
|
||||
const labelText = willHit
|
||||
? data.projection_end_label
|
||||
: (data.projection_shortfall_label ? `${data.projection_shortfall_label} short` : "");
|
||||
if (labelText) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", x(target) - 8)
|
||||
.attr("y", y(projectionEnd) - 8)
|
||||
.attr("text-anchor", "end")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textSecondary)
|
||||
.attr("paint-order", "stroke")
|
||||
.attr("stroke", containerBg)
|
||||
.attr("stroke-width", 4)
|
||||
.attr("stroke-linejoin", "round")
|
||||
.text(labelText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg
|
||||
.append("line")
|
||||
.attr("x1", x(today))
|
||||
.attr("x2", x(today))
|
||||
.attr("y1", margin.top)
|
||||
.attr("y2", margin.top + innerHeight)
|
||||
.attr("stroke", borderSubdued)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "2 4");
|
||||
|
||||
svg
|
||||
.append("circle")
|
||||
.attr("cx", x(today))
|
||||
.attr("cy", y(currentAmount))
|
||||
.attr("r", 4)
|
||||
.attr("fill", textPrimary)
|
||||
.attr("stroke", containerBg)
|
||||
.attr("stroke-width", 2);
|
||||
|
||||
if (innerWidth >= 320) {
|
||||
svg
|
||||
.append("text")
|
||||
.attr("x", x(today))
|
||||
.attr("y", margin.top - 4)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textSecondary)
|
||||
.text(this.todayLabelValue);
|
||||
}
|
||||
|
||||
// Full 4-digit year so the terminal "Jan 2027" reads as the year, not
|
||||
// as "Jan 27" (which scans as January 27th). Slightly wider per tick;
|
||||
// the de-dupe logic below keeps the count sane.
|
||||
const tickFmt = d3.timeFormat("%b %Y");
|
||||
const tickCount = Math.min(5, Math.max(2, Math.round(innerWidth / 80)));
|
||||
const ticks = x.ticks(tickCount);
|
||||
const tickGroup = svg.append("g");
|
||||
tickGroup
|
||||
.selectAll("text")
|
||||
.data(ticks)
|
||||
.enter()
|
||||
.append("text")
|
||||
.attr("x", (d) => x(d))
|
||||
.attr("y", height - 8)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("font-size", 12)
|
||||
.attr("fill", textSecondary)
|
||||
.text((d) => tickFmt(d));
|
||||
// De-dupe adjacent equal tick labels (e.g. multiple "May '26" on a
|
||||
// short window where d3.ticks oversamples).
|
||||
const tickNodes = tickGroup.selectAll("text").nodes();
|
||||
for (let i = tickNodes.length - 1; i > 0; i--) {
|
||||
if (tickNodes[i].textContent === tickNodes[i - 1].textContent) {
|
||||
tickNodes[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Hover interactivity: crosshair + dots + tooltip on pointermove.
|
||||
// Transparent rect catches pointer events across the plot area.
|
||||
const crosshair = svg
|
||||
.append("line")
|
||||
.attr("y1", margin.top)
|
||||
.attr("y2", margin.top + innerHeight)
|
||||
.attr("stroke", textSecondary)
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "2 2")
|
||||
.attr("pointer-events", "none")
|
||||
.style("display", "none");
|
||||
|
||||
const hoverSavedDot = svg
|
||||
.append("circle")
|
||||
.attr("r", 4)
|
||||
.attr("fill", textPrimary)
|
||||
.attr("stroke", containerBg)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("pointer-events", "none")
|
||||
.style("display", "none");
|
||||
|
||||
const hoverProjDot = svg
|
||||
.append("circle")
|
||||
.attr("r", 4)
|
||||
.attr("fill", projectionSeries.length && projectionEnd >= targetAmount ? "var(--color-green-600)" : "var(--color-yellow-600)")
|
||||
.attr("stroke", containerBg)
|
||||
.attr("stroke-width", 2)
|
||||
.attr("pointer-events", "none")
|
||||
.style("display", "none");
|
||||
|
||||
// Only promote root to a positioned ancestor when it currently has no
|
||||
// positioning context. Inline checks against `root.style.position`
|
||||
// miss positions set via CSS (the inline style is empty), so we'd
|
||||
// clobber a stylesheet `position: fixed/sticky/absolute` with our
|
||||
// own `relative`. Read the computed style instead.
|
||||
if (getComputedStyle(root).position === "static") root.style.position = "relative";
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "bg-container text-primary text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none z-50 privacy-sensitive";
|
||||
tooltip.style.display = "none";
|
||||
root.appendChild(tooltip);
|
||||
|
||||
const overlay = svg
|
||||
.append("rect")
|
||||
.attr("x", margin.left)
|
||||
.attr("y", margin.top)
|
||||
.attr("width", innerWidth)
|
||||
.attr("height", innerHeight)
|
||||
.attr("fill", "transparent")
|
||||
.style("cursor", "crosshair");
|
||||
|
||||
const bisectDate = d3.bisector((d) => d.date).left;
|
||||
const dateFmt = d3.timeFormat("%b %d, %Y");
|
||||
const todayTs = today.getTime();
|
||||
const targetTs = target ? target.getTime() : null;
|
||||
const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const showAt = (xPos, yPos) => {
|
||||
const xVal = x.invert(xPos);
|
||||
if (!savedSeries.length) return;
|
||||
|
||||
const future = xVal.getTime() > todayTs && projectionSeries.length && targetTs;
|
||||
|
||||
// Date the crosshair + the active dot snaps to. Past = nearest saved
|
||||
// contribution (sparse, monthly-ish). Future = weekly steps along the
|
||||
// projection segment so the cursor doesn't jitter pixel-by-pixel.
|
||||
let hoverDate;
|
||||
if (future) {
|
||||
const weeks = Math.round((xVal.getTime() - todayTs) / MS_PER_WEEK);
|
||||
let snapped = todayTs + weeks * MS_PER_WEEK;
|
||||
if (snapped > targetTs) snapped = targetTs;
|
||||
if (snapped < todayTs) snapped = todayTs;
|
||||
hoverDate = new Date(snapped);
|
||||
} else {
|
||||
const i = bisectDate(savedSeries, xVal);
|
||||
const a = savedSeries[Math.max(0, i - 1)];
|
||||
const b = savedSeries[Math.min(savedSeries.length - 1, i)];
|
||||
hoverDate = !a ? b.date : !b ? a.date : (xVal - a.date < b.date - xVal ? a.date : b.date);
|
||||
}
|
||||
|
||||
const hoverX = x(hoverDate);
|
||||
crosshair.attr("x1", hoverX).attr("x2", hoverX).style("display", null);
|
||||
|
||||
const lines = [dateFmt(hoverDate)];
|
||||
|
||||
if (future) {
|
||||
// Projection segment: interpolate along the dashed line; saved dot
|
||||
// stays hidden (no saved value in the future).
|
||||
const tFrac = (hoverDate.getTime() - todayTs) / (targetTs - todayTs);
|
||||
const projValue = currentAmount + tFrac * (projectionEnd - currentAmount);
|
||||
hoverProjDot.attr("cx", hoverX).attr("cy", y(projValue)).style("display", null);
|
||||
hoverSavedDot.style("display", "none");
|
||||
lines.push(this.projectedTemplateValue.replace("{amount}", this._fmtMoney(projValue, data.currency)));
|
||||
} else {
|
||||
// Saved segment: hoverDate is already snapped to nearest savedSeries
|
||||
// entry above, so reuse that entry directly instead of running
|
||||
// bisectDate a second time.
|
||||
const savedPoint = savedSeries.find((p) => p.date.getTime() === hoverDate.getTime()) || savedSeries[savedSeries.length - 1];
|
||||
hoverSavedDot.attr("cx", x(savedPoint.date)).attr("cy", y(savedPoint.value)).style("display", null);
|
||||
hoverProjDot.style("display", "none");
|
||||
lines.push(this.savedTemplateValue.replace("{amount}", this._fmtMoney(savedPoint.value, data.currency)));
|
||||
}
|
||||
|
||||
tooltip.textContent = lines.join("\n");
|
||||
tooltip.style.whiteSpace = "pre";
|
||||
tooltip.style.display = "block";
|
||||
const tipRect = tooltip.getBoundingClientRect();
|
||||
const left = Math.min(width - tipRect.width - 4, Math.max(4, xPos + 12));
|
||||
const top = Math.max(4, yPos - tipRect.height - 8);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
crosshair.style("display", "none");
|
||||
hoverSavedDot.style("display", "none");
|
||||
hoverProjDot.style("display", "none");
|
||||
tooltip.style.display = "none";
|
||||
};
|
||||
|
||||
overlay.on("pointermove", (event) => {
|
||||
const [mx, my] = d3.pointer(event);
|
||||
showAt(mx, my);
|
||||
});
|
||||
overlay.on("pointerleave", hide);
|
||||
}
|
||||
|
||||
_monthsBetween(a, b) {
|
||||
return (b - a) / (1000 * 60 * 60 * 24 * 30.44);
|
||||
}
|
||||
|
||||
_fmtMoney(amount, currency) {
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency || "USD",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// Same server-shipped symbol path as `_fmtMoneyShort`.
|
||||
const symbol = this.dataValue?.currency_symbol || "$";
|
||||
return `${symbol}${Math.round(amount).toLocaleString()}`;
|
||||
}
|
||||
}
|
||||
|
||||
_fmtMoneyShort(amount, _currency) {
|
||||
// The server ships `currency_symbol` via projection_payload (resolved
|
||||
// through Money.new(0, code).currency.symbol so EUR/GBP/JPY/etc. render
|
||||
// with the family-locale-correct glyph). Fall back to "$" if a stale
|
||||
// payload reaches us mid-deploy.
|
||||
const symbol = this.dataValue?.currency_symbol || "$";
|
||||
const abs = Math.abs(amount);
|
||||
if (abs >= 1_000_000) {
|
||||
return `${symbol}${(amount / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
if (abs >= 1_000) {
|
||||
return `${symbol}${(amount / 1_000).toFixed(1).replace(/\.0$/, "")}K`;
|
||||
}
|
||||
return `${symbol}${Math.round(amount).toLocaleString()}`;
|
||||
}
|
||||
|
||||
_id() {
|
||||
if (!this._cachedId) {
|
||||
this._cachedId = Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
return this._cachedId;
|
||||
}
|
||||
}
|
||||
164
app/javascript/controllers/goals_filter_controller.js
Normal file
164
app/javascript/controllers/goals_filter_controller.js
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Free-text + status-chip filter for the goals index grid.
|
||||
// Mirrors the providers-filter pattern. Each card has data-goal-name
|
||||
// and data-goal-status; the controller toggles `.hidden` on cards
|
||||
// based on the active query/chip.
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"input",
|
||||
"chip",
|
||||
"card",
|
||||
"empty",
|
||||
"emptyCopy",
|
||||
"emptyClearSearch",
|
||||
"emptyClearFilter",
|
||||
"grid",
|
||||
"count",
|
||||
];
|
||||
static values = {
|
||||
status: { type: String, default: "all" },
|
||||
emptyQuery: { type: String, default: "" },
|
||||
emptyFilter: { type: String, default: "" },
|
||||
emptyBoth: { type: String, default: "" },
|
||||
emptyDefault: { type: String, default: "" },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.#hydrateFromUrl();
|
||||
this.syncChipState();
|
||||
if (this.statusValue !== "all" || (this.hasInputTarget && this.inputTarget.value)) {
|
||||
this.filter();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this._urlSyncTimer);
|
||||
}
|
||||
|
||||
filter() {
|
||||
const query = this.hasInputTarget
|
||||
? this.inputTarget.value.toLocaleLowerCase().trim()
|
||||
: "";
|
||||
const active = this.statusValue;
|
||||
let visible = 0;
|
||||
|
||||
this.cardTargets.forEach((card) => {
|
||||
const name = (card.dataset.goalName || "").toLocaleLowerCase();
|
||||
const status = card.dataset.goalStatus || "";
|
||||
const matchesQuery = !query || name.includes(query);
|
||||
const matchesStatus = active === "all" || status === active;
|
||||
const show = matchesQuery && matchesStatus;
|
||||
card.classList.toggle("hidden", !show);
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
if (this.hasEmptyTarget) {
|
||||
this.emptyTarget.classList.toggle("hidden", visible > 0);
|
||||
}
|
||||
if (this.hasGridTarget) {
|
||||
this.gridTarget.classList.toggle("hidden", visible === 0);
|
||||
}
|
||||
if (this.hasCountTarget) {
|
||||
this.countTarget.textContent = visible;
|
||||
}
|
||||
|
||||
this.updateEmptyState(visible, query, active);
|
||||
this.#scheduleUrlSync();
|
||||
}
|
||||
|
||||
// Debounced wrapper. Firing replaceState on every keystroke is wasteful
|
||||
// and produced visible jank on slow CPUs; deferring 200 ms collapses a
|
||||
// typing burst into a single URL update without losing back-button
|
||||
// fidelity (replaceState doesn't create history entries anyway).
|
||||
#scheduleUrlSync() {
|
||||
clearTimeout(this._urlSyncTimer);
|
||||
this._urlSyncTimer = setTimeout(() => this.#syncUrl(), 200);
|
||||
}
|
||||
|
||||
#hydrateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const status = params.get("filter");
|
||||
if (status && this.chipTargets.some((c) => c.dataset.status === status)) {
|
||||
this.statusValue = status;
|
||||
}
|
||||
const q = params.get("q");
|
||||
if (q && this.hasInputTarget) {
|
||||
this.inputTarget.value = q;
|
||||
}
|
||||
}
|
||||
|
||||
#syncUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (this.statusValue && this.statusValue !== "all") {
|
||||
params.set("filter", this.statusValue);
|
||||
} else {
|
||||
params.delete("filter");
|
||||
}
|
||||
const q = this.hasInputTarget ? this.inputTarget.value.trim() : "";
|
||||
if (q) {
|
||||
params.set("q", q);
|
||||
} else {
|
||||
params.delete("q");
|
||||
}
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
}
|
||||
|
||||
updateEmptyState(visible, query, active) {
|
||||
if (visible > 0 || !this.hasEmptyCopyTarget) return;
|
||||
const rawQuery = this.hasInputTarget ? this.inputTarget.value.trim() : "";
|
||||
const hasQuery = rawQuery.length > 0;
|
||||
const hasFilter = active !== "all";
|
||||
let copy;
|
||||
if (hasQuery && hasFilter) {
|
||||
copy = this.emptyBothValue.replace("__QUERY__", rawQuery);
|
||||
} else if (hasQuery) {
|
||||
copy = this.emptyQueryValue.replace("__QUERY__", rawQuery);
|
||||
} else if (hasFilter) {
|
||||
copy = this.emptyFilterValue;
|
||||
} else {
|
||||
copy = this.emptyDefaultValue;
|
||||
}
|
||||
this.emptyCopyTarget.textContent = copy;
|
||||
if (this.hasEmptyClearSearchTarget) {
|
||||
this.emptyClearSearchTarget.classList.toggle("hidden", !hasQuery);
|
||||
}
|
||||
if (this.hasEmptyClearFilterTarget) {
|
||||
this.emptyClearFilterTarget.classList.toggle("hidden", !hasFilter);
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
if (this.hasInputTarget) {
|
||||
this.inputTarget.value = "";
|
||||
this.inputTarget.focus();
|
||||
}
|
||||
this.filter();
|
||||
}
|
||||
|
||||
clearFilter() {
|
||||
this.statusValue = "all";
|
||||
this.syncChipState();
|
||||
this.filter();
|
||||
}
|
||||
|
||||
selectChip(event) {
|
||||
this.statusValue = event.currentTarget.dataset.status || "all";
|
||||
this.syncChipState();
|
||||
this.filter();
|
||||
}
|
||||
|
||||
syncChipState() {
|
||||
if (!this.hasChipTarget) return;
|
||||
this.chipTargets.forEach((chip) => {
|
||||
const active = chip.dataset.status === this.statusValue;
|
||||
chip.setAttribute("aria-pressed", active);
|
||||
chip.classList.toggle("bg-container", active);
|
||||
chip.classList.toggle("shadow-border-xs", active);
|
||||
chip.classList.toggle("text-primary", active);
|
||||
chip.classList.toggle("text-secondary", !active);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -128,9 +128,32 @@ export default class extends Controller {
|
||||
};
|
||||
|
||||
handleExit = (err, metadata) => {
|
||||
// If there was an error during update mode, refresh the page to show latest status
|
||||
if (err && metadata.status === "requires_credentials") {
|
||||
// If there was an error during update mode, refresh the page to show
|
||||
// latest status. Guard `metadata` (Plaid can fire onExit with it
|
||||
// undefined when Link aborts very early) and gate the redirect on
|
||||
// `isUpdateValue` so first-time link failures don't bounce the user
|
||||
// away from whatever page they were on.
|
||||
if (
|
||||
err &&
|
||||
metadata &&
|
||||
metadata.status === "requires_credentials" &&
|
||||
this.isUpdateValue
|
||||
) {
|
||||
window.location.href = "/accounts";
|
||||
return;
|
||||
}
|
||||
|
||||
// Promote Plaid's own error payload to the console so a silent modal
|
||||
// close still leaves a breadcrumb (issue #1792). Plaid Link's own UI
|
||||
// is responsible for showing a message inside the modal when this
|
||||
// fires; backend link-token failures are handled server-side via the
|
||||
// PlaidItemsController rescue + flash.
|
||||
if (err?.error_code) {
|
||||
console.error(
|
||||
"Plaid Link exited with error",
|
||||
err.error_code,
|
||||
err.display_message || err.error_message
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,10 +22,10 @@ export default class extends Controller {
|
||||
this.cardTargets.forEach((card) => {
|
||||
const name = card.dataset.providerName ?? "";
|
||||
const region = card.dataset.providerRegion ?? "";
|
||||
const kind = card.dataset.providerKind ?? "";
|
||||
const haystack = `${name} ${region} ${kind}`;
|
||||
const kindTokens = (card.dataset.providerKind ?? "").split(/\s+/);
|
||||
const haystack = `${name} ${region} ${kindTokens.join(" ")}`;
|
||||
const matchesQuery = !query || haystack.includes(query);
|
||||
const matchesKind = activeKind === "all" || kind === activeKind;
|
||||
const matchesKind = activeKind === "all" || kindTokens.includes(activeKind);
|
||||
const visible = matchesQuery && matchesKind;
|
||||
card.classList.toggle("hidden", !visible);
|
||||
if (visible) visibleCount++;
|
||||
|
||||
33
app/javascript/controllers/sync_toast_controller.js
Normal file
33
app/javascript/controllers/sync_toast_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="sync-toast"
|
||||
//
|
||||
// Shown when a background sync completes and the family's data changes.
|
||||
// - If the user is not interacting with a form, auto-reloads after a short delay.
|
||||
// - If the user is mid-form, the toast stays visible so they can choose when to refresh.
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
autoRefreshDelay: { type: Number, default: 2000 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (!this.#userIsInteracting()) {
|
||||
this._timer = setTimeout(() => this.refresh(), this.autoRefreshDelayValue);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
|
||||
refresh() {
|
||||
clearTimeout(this._timer);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
#userIsInteracting() {
|
||||
const el = document.activeElement;
|
||||
if (!el || el === document.body || el === document.documentElement) return false;
|
||||
return el.isContentEditable || el.closest("form, dialog, [role='dialog']") !== null;
|
||||
}
|
||||
}
|
||||
485
app/javascript/controllers/tag_select_controller.js
Normal file
485
app/javascript/controllers/tag_select_controller.js
Normal file
@@ -0,0 +1,485 @@
|
||||
import { autoUpdate } from "@floating-ui/dom";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"button",
|
||||
"menu",
|
||||
"search",
|
||||
"option",
|
||||
"selectionContainer",
|
||||
"createForm",
|
||||
"createError",
|
||||
];
|
||||
|
||||
static values = {
|
||||
createUrl: String,
|
||||
fieldName: String,
|
||||
defaultColor: String,
|
||||
disabled: Boolean,
|
||||
autoSubmit: Boolean,
|
||||
updateUrl: String,
|
||||
menuPlacement: { type: String, default: "auto" },
|
||||
offset: { type: Number, default: 6 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.creating = false;
|
||||
this.isOpen = false;
|
||||
this.selectedIds = new Set(
|
||||
this.optionTargets
|
||||
.filter((option) => option.getAttribute("aria-selected") === "true")
|
||||
.map((option) => option.dataset.tagId),
|
||||
);
|
||||
this.renderSelection();
|
||||
this.observeMenuResize();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.submitAbortController) this.submitAbortController.abort();
|
||||
this.stopAutoUpdate();
|
||||
if (this.resizeObserver) this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
toggle(event) {
|
||||
event.preventDefault();
|
||||
if (this.disabledValue) return;
|
||||
|
||||
this.isOpen ? this.close() : this.open();
|
||||
}
|
||||
|
||||
open(focusOption = false) {
|
||||
this.isOpen = true;
|
||||
this.buttonTarget.setAttribute("aria-expanded", "true");
|
||||
this.menuTarget.classList.remove("hidden");
|
||||
this.searchTarget.value = "";
|
||||
this.filter();
|
||||
this.startAutoUpdate();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.menuTarget.classList.remove(
|
||||
"opacity-0",
|
||||
"-translate-y-1",
|
||||
"pointer-events-none",
|
||||
);
|
||||
this.menuTarget.classList.add("opacity-100", "translate-y-0");
|
||||
this.updatePosition();
|
||||
if (focusOption) {
|
||||
this.focusActiveOption();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.stopAutoUpdate();
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
this.menuTarget.classList.remove("opacity-100", "translate-y-0");
|
||||
this.menuTarget.classList.add(
|
||||
"opacity-0",
|
||||
"-translate-y-1",
|
||||
"pointer-events-none",
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isOpen) this.menuTarget.classList.add("hidden");
|
||||
}, 150);
|
||||
}
|
||||
|
||||
toggleTag(event) {
|
||||
event.preventDefault();
|
||||
const option = event.currentTarget;
|
||||
const id = option.dataset.tagId;
|
||||
|
||||
if (this.selectedIds.has(id)) {
|
||||
this.selectedIds.delete(id);
|
||||
} else {
|
||||
this.selectedIds.add(id);
|
||||
}
|
||||
|
||||
this.updateOption(option);
|
||||
this.renderSelection();
|
||||
this.submitForm();
|
||||
}
|
||||
|
||||
filter() {
|
||||
this.clearCreateError();
|
||||
|
||||
const query = this.searchTarget.value.trim().toLowerCase();
|
||||
let hasExactMatch = false;
|
||||
|
||||
this.optionTargets.forEach((option) => {
|
||||
const name = option.dataset.tagName.toLowerCase();
|
||||
const isMatch = name.includes(query);
|
||||
option.classList.toggle("hidden", !isMatch);
|
||||
|
||||
if (name === query) hasExactMatch = true;
|
||||
});
|
||||
|
||||
const canCreate = query.length > 0 && !hasExactMatch;
|
||||
this.createFormTarget.classList.toggle("hidden", !canCreate);
|
||||
this.createFormTarget.classList.toggle("flex", canCreate);
|
||||
this.createNameElement.textContent = this.searchTarget.value.trim();
|
||||
this.syncActiveOption();
|
||||
}
|
||||
|
||||
handleSearchKeydown(event) {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!this.createFormTarget.classList.contains("hidden") &&
|
||||
!this.creating
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.createTag();
|
||||
}
|
||||
}
|
||||
|
||||
async createTag() {
|
||||
if (this.creating) return;
|
||||
|
||||
const name = this.searchTarget.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
this.creating = true;
|
||||
this.createFormTarget.disabled = true;
|
||||
this.clearCreateError();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.createUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag: {
|
||||
name,
|
||||
color: this.defaultColorValue,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tag = await this.parseJson(response);
|
||||
|
||||
if (!response.ok) {
|
||||
this.showCreateError(tag.errors?.join(", ") || tag.error);
|
||||
return;
|
||||
}
|
||||
|
||||
this.createFormTarget.insertAdjacentHTML("beforebegin", tag.html);
|
||||
this.selectedIds.add(String(tag.id));
|
||||
this.renderSelection();
|
||||
this.searchTarget.value = "";
|
||||
this.filter();
|
||||
this.submitForm();
|
||||
} finally {
|
||||
this.creating = false;
|
||||
this.createFormTarget.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderSelection() {
|
||||
this.hiddenInputsElement.innerHTML = "";
|
||||
this.hiddenInputsElement.appendChild(this.buildHiddenInput(""));
|
||||
this.selectionContainerTarget.innerHTML = "";
|
||||
|
||||
const selectedOptions = this.optionTargets.filter((option) =>
|
||||
this.selectedIds.has(option.dataset.tagId),
|
||||
);
|
||||
|
||||
selectedOptions.forEach((option) => {
|
||||
this.hiddenInputsElement.appendChild(
|
||||
this.buildHiddenInput(option.dataset.tagId),
|
||||
);
|
||||
const badge = option.querySelector("[data-tag-select-badge]");
|
||||
if (badge) {
|
||||
this.selectionContainerTarget.appendChild(badge.cloneNode(true));
|
||||
}
|
||||
this.updateOption(option);
|
||||
});
|
||||
|
||||
if (selectedOptions.length === 0) {
|
||||
this.selectionContainerTarget.appendChild(this.buildPlaceholder());
|
||||
}
|
||||
}
|
||||
|
||||
updateOption(option) {
|
||||
const isSelected = this.selectedIds.has(option.dataset.tagId);
|
||||
option.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||||
option.classList.toggle("bg-container-inset", isSelected);
|
||||
|
||||
const icon = option.querySelector(".check-icon");
|
||||
if (icon) icon.classList.toggle("hidden", !isSelected);
|
||||
}
|
||||
|
||||
buildHiddenInput(id) {
|
||||
const input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = this.fieldNameValue;
|
||||
input.value = id;
|
||||
input.disabled = this.disabledValue;
|
||||
return input;
|
||||
}
|
||||
|
||||
handleOutsideClick(event) {
|
||||
if (this.isOpen && !this.element.contains(event.target)) this.close();
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
if (!this.autoSubmitValue) return;
|
||||
if (!this.hasUpdateUrlValue || !this.updateUrlValue) return;
|
||||
|
||||
if (this.submitAbortController) this.submitAbortController.abort();
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.submitAbortController = abortController;
|
||||
|
||||
try {
|
||||
await fetch(this.updateUrlValue, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.csrfToken,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tag_ids: Array.from(this.selectedIds),
|
||||
}),
|
||||
credentials: "same-origin",
|
||||
signal: abortController.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") throw error;
|
||||
} finally {
|
||||
if (this.submitAbortController === abortController) {
|
||||
this.submitAbortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleKeydown(event) {
|
||||
if (!this.isOpen && event.target === this.buttonTarget) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.open(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isOpen) return;
|
||||
|
||||
if (event.key === "Escape" && this.isOpen) {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
this.buttonTarget.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
this.moveActiveOption(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.moveActiveOption(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
this.focusOption(this.visibleOptions[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
this.focusOption(this.visibleOptions.at(-1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
event.target.getAttribute("role") === "option"
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.target.click();
|
||||
}
|
||||
}
|
||||
|
||||
syncActiveOption() {
|
||||
const options = this.visibleOptions;
|
||||
const current = this.activeOption;
|
||||
const selected = options.find((option) =>
|
||||
this.selectedIds.has(option.dataset.tagId),
|
||||
);
|
||||
|
||||
this.setActiveOption(
|
||||
options.includes(current) ? current : selected || options[0],
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
moveActiveOption(delta) {
|
||||
const options = this.visibleOptions;
|
||||
if (options.length === 0) return;
|
||||
|
||||
const currentIndex = options.indexOf(this.activeOption);
|
||||
const nextIndex =
|
||||
currentIndex === -1
|
||||
? delta > 0
|
||||
? 0
|
||||
: options.length - 1
|
||||
: (currentIndex + delta + options.length) % options.length;
|
||||
|
||||
this.focusOption(options[nextIndex]);
|
||||
}
|
||||
|
||||
focusActiveOption() {
|
||||
this.focusOption(this.activeOption || this.visibleOptions[0]);
|
||||
}
|
||||
|
||||
focusOption(option) {
|
||||
this.setActiveOption(option, true);
|
||||
}
|
||||
|
||||
setActiveOption(option, focus) {
|
||||
this.optionTargets.forEach((target) => {
|
||||
target.tabIndex = target === option ? 0 : -1;
|
||||
});
|
||||
|
||||
if (!option) return;
|
||||
|
||||
if (focus) {
|
||||
option.focus({ preventScroll: true });
|
||||
option.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
get activeOption() {
|
||||
return this.optionTargets.find((option) => option.tabIndex === 0);
|
||||
}
|
||||
|
||||
get visibleOptions() {
|
||||
return this.optionTargets.filter(
|
||||
(option) => !option.classList.contains("hidden"),
|
||||
);
|
||||
}
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup && this.hasButtonTarget && this.hasMenuTarget) {
|
||||
this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () =>
|
||||
this.updatePosition(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (!this._cleanup) return;
|
||||
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
|
||||
observeMenuResize() {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.isOpen) requestAnimationFrame(() => this.updatePosition());
|
||||
});
|
||||
this.resizeObserver.observe(this.menuTarget);
|
||||
}
|
||||
|
||||
getScrollParent(element) {
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
placementMode() {
|
||||
const mode = (this.menuPlacementValue || "auto").toLowerCase();
|
||||
return ["auto", "down", "up"].includes(mode) ? mode : "auto";
|
||||
}
|
||||
|
||||
updatePosition() {
|
||||
if (!this.hasButtonTarget || !this.hasMenuTarget || !this.isOpen) return;
|
||||
|
||||
const container = this.getScrollParent(this.element);
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const buttonRect = this.buttonTarget.getBoundingClientRect();
|
||||
const menuHeight = this.menuTarget.scrollHeight;
|
||||
|
||||
const spaceBelow = containerRect.bottom - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top - containerRect.top;
|
||||
const placement = this.placementMode();
|
||||
const shouldOpenUp =
|
||||
placement === "up" ||
|
||||
(placement === "auto" &&
|
||||
spaceBelow < menuHeight &&
|
||||
spaceAbove > spaceBelow);
|
||||
|
||||
this.menuTarget.style.left = "0";
|
||||
this.menuTarget.style.width = "100%";
|
||||
this.menuTarget.style.top = "";
|
||||
this.menuTarget.style.bottom = "";
|
||||
this.menuTarget.style.overflowY = "auto";
|
||||
|
||||
if (shouldOpenUp) {
|
||||
this.menuTarget.style.bottom = "100%";
|
||||
this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`;
|
||||
} else {
|
||||
this.menuTarget.style.top = "100%";
|
||||
this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
get csrfToken() {
|
||||
return document.querySelector("meta[name='csrf-token']")?.content;
|
||||
}
|
||||
|
||||
get hiddenInputsElement() {
|
||||
return this.element.querySelector("[data-tag-select-hidden-inputs]");
|
||||
}
|
||||
|
||||
get createNameElement() {
|
||||
return this.createFormTarget.querySelector("[data-tag-select-create-name]");
|
||||
}
|
||||
|
||||
showCreateError(message) {
|
||||
if (!this.hasCreateErrorTarget) return;
|
||||
|
||||
this.createErrorTarget.textContent = message || "Could not create tag";
|
||||
this.createErrorTarget.classList.remove("hidden");
|
||||
this.searchTarget.setAttribute("aria-invalid", "true");
|
||||
this.searchTarget.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
async parseJson(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
clearCreateError() {
|
||||
if (!this.hasCreateErrorTarget) return;
|
||||
|
||||
this.createErrorTarget.textContent = "";
|
||||
this.createErrorTarget.classList.add("hidden");
|
||||
this.searchTarget.removeAttribute("aria-invalid");
|
||||
}
|
||||
|
||||
buildPlaceholder() {
|
||||
const placeholder = document.createElement("span");
|
||||
placeholder.className = "text-secondary";
|
||||
placeholder.textContent = this.selectionContainerTarget.dataset.placeholder;
|
||||
return placeholder;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user