Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	app/javascript/controllers/sankey_chart_controller.js
This commit is contained in:
Guillem Arias
2026-06-02 22:37:39 +02:00
532 changed files with 33001 additions and 1741 deletions

View File

@@ -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) {

View File

@@ -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) {

View File

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

View 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);
}
}

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

View 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;
}
}

View 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);
});
}
}

View File

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

View File

@@ -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++;

View 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;
}
}

View 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;
}
}