1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-22 22:59:39 +02:00

Pre-launch design sync with Figma spec (#2154)

* Add lookbook + viewcomponent, organize design system file

* Build menu component

* Button updates

* More button fixes

* Replace all menus with new ViewComponent

* Checkpoint: fix tests, all buttons and menus converted

* Split into Link and Button components for clarity

* Button cleanup

* Simplify custom confirmation configuration in views

* Finalize button, link component API

* Add toggle field to custom form builder + Component

* Basic tabs component

* Custom tabs, convert all menu / tab instances in app

* Gem updates

* Centralized icon helper

* Update all icon usage to central helper

* Lint fixes

* Centralize all disclosure instances

* Dialog replacements

* Consolidation of all dialog styles

* Test fixes

* Fix app layout issues, move to component with slots

* Layout simplification

* Flakey test fix

* Fix dashboard mobile issues

* Finalize homepage

* Lint fixes

* Fix shadows and borders in dark mode

* Fix tests

* Remove stale class

* Fix filled icon logic

* Move transparent? to public interface
This commit is contained in:
Zach Gollwitzer 2025-04-30 18:14:22 -04:00 committed by GitHub
parent 1aafed5f8b
commit 90a9546f32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
291 changed files with 4143 additions and 3104 deletions

View file

@ -1,51 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="account-collapse"
export default class extends Controller {
static values = { type: String };
initialToggle = false;
STORAGE_NAME = "accountCollapseStates";
connect() {
this.element.addEventListener("toggle", this.onToggle);
this.updateFromLocalStorage();
}
disconnect() {
this.element.removeEventListener("toggle", this.onToggle);
}
onToggle = () => {
if (this.initialToggle) {
this.initialToggle = false;
return;
}
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
items.delete(this.typeValue);
} else {
items.add(this.typeValue);
}
localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]));
};
updateFromLocalStorage() {
const items = this.getItemsFromLocalStorage();
if (items.has(this.typeValue)) {
this.initialToggle = true;
this.element.setAttribute("open", "");
}
}
getItemsFromLocalStorage() {
try {
const items = localStorage.getItem(this.STORAGE_NAME);
return new Set(items ? JSON.parse(items) : []);
} catch (error) {
console.error("Error parsing items from localStorage:", error);
return new Set();
}
}
}

View file

@ -0,0 +1,56 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="dialog"
export default class extends Controller {
static targets = ["leftSidebar", "rightSidebar", "mobileSidebar"];
static classes = [
"expandedSidebar",
"collapsedSidebar",
"expandedTransition",
"collapsedTransition",
];
openMobileSidebar() {
this.mobileSidebarTarget.classList.remove("hidden");
}
closeMobileSidebar() {
this.mobileSidebarTarget.classList.add("hidden");
}
toggleLeftSidebar() {
const isOpen = this.leftSidebarTarget.classList.contains("w-full");
this.#updateUserPreference("show_sidebar", !isOpen);
this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen);
}
toggleRightSidebar() {
const isOpen = this.rightSidebarTarget.classList.contains("w-full");
this.#updateUserPreference("show_ai_sidebar", !isOpen);
this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen);
}
#toggleSidebarWidth(el, isCurrentlyOpen) {
if (isCurrentlyOpen) {
el.classList.remove(...this.expandedSidebarClasses);
el.classList.add(...this.collapsedSidebarClasses);
} else {
el.classList.add(...this.expandedSidebarClasses);
el.classList.remove(...this.collapsedSidebarClasses);
}
}
#updateUserPreference(field, value) {
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
Accept: "application/json",
},
body: new URLSearchParams({
[`user[${field}]`]: value,
}).toString(),
});
}
}

View file

@ -6,52 +6,14 @@ const application = Application.start();
application.debug = false;
window.Stimulus = application;
Turbo.config.forms.confirm = (message) => {
const dialog = document.getElementById("turbo-confirm");
try {
const { title, body, accept, acceptClass } = JSON.parse(message);
if (title) {
document.getElementById("turbo-confirm-title").innerHTML = title;
}
if (body) {
document.getElementById("turbo-confirm-body").innerHTML = body;
}
if (accept) {
document.getElementById("turbo-confirm-accept").innerHTML = accept;
}
if (acceptClass) {
document.getElementById("turbo-confirm-accept").className = acceptClass;
}
} catch (e) {
document.getElementById("turbo-confirm-title").innerText = message;
}
dialog.showModal();
return new Promise((resolve) => {
dialog.addEventListener(
"close",
() => {
const confirmed = dialog.returnValue === "confirm";
if (!confirmed) {
document.getElementById("turbo-confirm-title").innerHTML =
"Are you sure?";
document.getElementById("turbo-confirm-body").innerHTML =
"You will not be able to undo this decision";
document.getElementById("turbo-confirm-accept").innerHTML = "Confirm";
}
resolve(confirmed);
},
{ once: true },
Turbo.config.forms.confirm = (data) => {
const confirmDialogController =
application.getControllerForElementAndIdentifier(
document.getElementById("confirm-dialog"),
"confirm-dialog",
);
});
return confirmDialogController.handleConfirm(data);
};
export { application };

View file

@ -7,7 +7,7 @@ export default class extends Controller {
"group",
"selectionBar",
"selectionBarText",
"bulkEditDrawerTitle",
"bulkEditDrawerHeader",
];
static values = {
singularLabel: String,
@ -25,8 +25,9 @@ export default class extends Controller {
document.removeEventListener("turbo:load", this._updateView);
}
bulkEditDrawerTitleTargetConnected(element) {
element.innerText = `Edit ${
bulkEditDrawerHeaderTargetConnected(element) {
const headingTextEl = element.querySelector("h2");
headingTextEl.innerText = `Edit ${
this.selectedIdsValue.length
} ${this._pluralizedResourceName()}`;
}

View file

@ -21,8 +21,8 @@ export default class extends Controller {
handleColorChange(e) {
const color = e.currentTarget.value;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, transparent)`;
this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, transparent)`;
this.avatarTarget.style.color = color;
}
}

View file

@ -0,0 +1,59 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="confirm-dialog"
// See javascript/controllers/application.js for how this is wired up
export default class extends Controller {
static targets = ["title", "subtitle", "confirmButton"];
handleConfirm(rawData) {
const data = this.#normalizeRawData(rawData);
this.#prepareDialog(data);
this.element.showModal();
return new Promise((resolve) => {
this.element.addEventListener(
"close",
() => {
const isConfirmed = this.element.returnValue === "confirm";
resolve(isConfirmed);
},
{ once: true },
);
});
}
#prepareDialog(data) {
const variant = data.variant || "primary";
this.confirmButtonTargets.forEach((button) => {
if (button.dataset.variant === variant) {
button.removeAttribute("hidden");
} else {
button.setAttribute("hidden", true);
}
button.textContent = data.confirmText || "Confirm";
});
this.titleTarget.textContent = data.title || "Are you sure?";
this.subtitleTarget.innerHTML =
data.body || "This action cannot be undone.";
}
// If data is a string, it's the title. Otherwise, return the parsed object.
#normalizeRawData(rawData) {
try {
const parsed = JSON.parse(rawData);
if (typeof parsed === "boolean") {
return { title: "Are you sure?" };
}
return parsed;
} catch (e) {
return { title: rawData };
}
}
}

View file

@ -1,30 +1,28 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["replacementField", "submitButton"];
static classes = ["dangerousAction", "safeAction"];
static targets = [
"replacementField",
"destructiveSubmitButton",
"safeSubmitButton",
];
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String,
};
updateSubmitButton() {
chooseSubmitButton() {
if (this.replacementFieldTarget.value) {
this.submitButtonTarget.value = this.submitTextWhenReplacingValue;
this.#markSafe();
this.destructiveSubmitButtonTarget.hidden = true;
this.safeSubmitButtonTarget.textContent =
this.submitTextWhenReplacingValue;
this.safeSubmitButtonTarget.hidden = false;
} else {
this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue;
this.#markDangerous();
this.destructiveSubmitButtonTarget.textContent =
this.submitTextWhenNotReplacingValue;
this.destructiveSubmitButtonTarget.hidden = false;
this.safeSubmitButtonTarget.hidden = true;
}
}
#markSafe() {
this.submitButtonTarget.classList.remove(...this.dangerousActionClasses);
this.submitButtonTarget.classList.add(...this.safeActionClasses);
}
#markDangerous() {
this.submitButtonTarget.classList.remove(...this.safeActionClasses);
this.submitButtonTarget.classList.add(...this.dangerousActionClasses);
}
}

View file

@ -0,0 +1,8 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="intercom"
export default class extends Controller {
show() {
Intercom("show");
}
}

View file

@ -1,117 +0,0 @@
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
} from "@floating-ui/dom";
import { Controller } from "@hotwired/stimulus";
/**
* A "menu" can contain arbitrary content including non-clickable items, links, buttons, and forms.
*/
export default class extends Controller {
static targets = ["button", "content"];
static values = {
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
};
connect() {
this.show = this.showValue;
this.boundUpdate = this.update.bind(this);
this.addEventListeners();
this.startAutoUpdate();
}
disconnect() {
this.removeEventListeners();
this.stopAutoUpdate();
this.close();
}
addEventListeners() {
this.buttonTarget.addEventListener("click", this.toggle);
this.element.addEventListener("keydown", this.handleKeydown);
document.addEventListener("click", this.handleOutsideClick);
document.addEventListener("turbo:load", this.handleTurboLoad);
}
removeEventListeners() {
this.buttonTarget.removeEventListener("click", this.toggle);
this.element.removeEventListener("keydown", this.handleKeydown);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("turbo:load", this.handleTurboLoad);
}
handleTurboLoad = () => {
if (!this.show) this.close();
};
handleOutsideClick = (event) => {
if (this.show && !this.element.contains(event.target)) this.close();
};
handleKeydown = (event) => {
if (event.key === "Escape") {
this.close();
this.buttonTarget.focus();
}
};
toggle = () => {
this.show = !this.show;
this.contentTarget.classList.toggle("hidden", !this.show);
if (this.show) {
this.update();
this.focusFirstElement();
}
};
close() {
this.show = false;
this.contentTarget.classList.add("hidden");
}
focusFirstElement() {
const focusableElements =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const firstFocusableElement =
this.contentTarget.querySelectorAll(focusableElements)[0];
if (firstFocusableElement) {
firstFocusableElement.focus();
}
}
startAutoUpdate() {
if (!this._cleanup) {
this._cleanup = autoUpdate(
this.buttonTarget,
this.contentTarget,
this.boundUpdate,
);
}
}
stopAutoUpdate() {
if (this._cleanup) {
this._cleanup();
this._cleanup = null;
}
}
update() {
computePosition(this.buttonTarget, this.contentTarget, {
placement: this.placementValue,
middleware: [offset(this.offsetValue), flip(), shift({ padding: 5 })],
}).then(({ x, y }) => {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
});
});
}
}

View file

@ -1,28 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
static values = {
reloadOnClose: { type: Boolean, default: false },
};
connect() {
if (this.element.open) return;
this.element.showModal();
}
// Hide the dialog when the user clicks outside of it
clickOutside(e) {
if (e.target === this.element) {
this.close();
}
}
close() {
this.element.close();
if (this.reloadOnCloseValue) {
Turbo.visit(window.location.href);
}
}
}

View file

@ -1,39 +0,0 @@
/*
https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i
modified to add support for horizontal scrolling
*/
if (!window.scrollPositions) {
window.scrollPositions = {};
}
function preserveScroll() {
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
scrollPositions[element.id] = {
top: element.scrollTop,
left: element.scrollLeft
};
});
}
function restoreScroll(event) {
document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
if (scrollPositions[element.id]) {
element.scrollTop = scrollPositions[element.id].top;
element.scrollLeft = scrollPositions[element.id].left;
}
});
if (!event.detail.newBody) return;
// event.detail.newBody is the body element to be swapped in.
// https://turbo.hotwired.dev/reference/events
event.detail.newBody.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
if (scrollPositions[element.id]) {
element.scrollTop = scrollPositions[element.id].top;
element.scrollLeft = scrollPositions[element.id].left;
}
});
}
window.addEventListener("turbo:before-cache", preserveScroll);
window.addEventListener("turbo:before-render", restoreScroll);
window.addEventListener("turbo:render", restoreScroll);

View file

@ -8,6 +8,7 @@ export default class extends Controller {
remove(e) {
if (e.params.destroy) {
this.destroyFieldTarget.value = true;
this.element.classList.add("hidden");
} else {
this.element.remove();
}

View file

@ -1,86 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="sidebar"
export default class extends Controller {
static values = {
userId: String,
config: Object,
};
static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"];
initialize() {
this.leftPanelOpen = this.configValue.left_panel.is_open;
this.rightPanelOpen = this.configValue.right_panel.is_open;
}
toggleLeftPanel() {
this.leftPanelOpen = !this.leftPanelOpen;
this.#updatePanelWidths();
this.#persistPreference("show_sidebar", this.leftPanelOpen);
}
toggleLeftPanelMobile() {
if (this.leftPanelOpen) {
this.leftPanelMobileTarget.classList.remove("hidden");
this.leftPanelOpen = false;
} else {
this.leftPanelMobileTarget.classList.add("hidden");
this.leftPanelOpen = true;
}
}
toggleRightPanel() {
this.rightPanelOpen = !this.rightPanelOpen;
this.#updatePanelWidths();
this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
}
#updatePanelWidths() {
this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
this.rightPanelTarget.style.overflow = this.#rightPanelOverflow();
}
#leftPanelWidth() {
if (this.leftPanelOpen) {
return this.configValue.left_panel.min_width;
}
return 0;
}
#rightPanelWidth() {
if (this.rightPanelOpen) {
if (this.leftPanelOpen) {
return this.configValue.right_panel.min_width;
}
return this.configValue.right_panel.max_width;
}
return 0;
}
#rightPanelOverflow() {
if (this.rightPanelOpen) {
return "auto";
}
return "hidden";
}
#persistPreference(field, value) {
fetch(`/users/${this.userIdValue}`, {
method: "PATCH",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
Accept: "application/json",
},
body: new URLSearchParams({
[`user[${field}]`]: value,
}).toString(),
});
}
}

View file

@ -1,74 +0,0 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="tabs"
export default class extends Controller {
static classes = ["active", "inactive"];
static targets = ["btn", "tab"];
static values = { defaultTab: String, localStorageKey: String };
connect() {
const selectedTab = this.hasLocalStorageKeyValue
? this.getStoredTab() || this.defaultTabValue
: this.defaultTabValue;
this.updateClasses(selectedTab);
document.addEventListener("turbo:load", this.onTurboLoad);
}
disconnect() {
document.removeEventListener("turbo:load", this.onTurboLoad);
}
select(event) {
const element = event.target.closest("[data-id]");
if (element) {
const selectedId = element.dataset.id;
this.updateClasses(selectedId);
if (this.hasLocalStorageKeyValue) {
this.storeTab(selectedId);
}
}
}
onTurboLoad = () => {
const selectedTab = this.hasLocalStorageKeyValue
? this.getStoredTab() || this.defaultTabValue
: this.defaultTabValue;
this.updateClasses(selectedTab);
};
getStoredTab() {
const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
return tabs[this.localStorageKeyValue];
}
storeTab(selectedId) {
const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
tabs[this.localStorageKeyValue] = selectedId;
localStorage.setItem("tabs", JSON.stringify(tabs));
}
updateClasses = (selectedId) => {
this.btnTargets.forEach((btn) => {
btn.classList.remove(...this.activeClasses);
btn.classList.remove(...this.inactiveClasses);
});
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));
this.btnTargets.forEach((btn) => {
if (btn.dataset.id === selectedId) {
btn.classList.add(...this.activeClasses);
} else {
btn.classList.add(...this.inactiveClasses);
}
});
this.tabTargets.forEach((tab) => {
if (tab.id === selectedId) {
tab.classList.remove("hidden");
}
});
};
}

View file

@ -1,73 +1,87 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static values = { userPreference: String }
static values = { userPreference: String };
connect() {
this.applyTheme()
this.startSystemThemeListener()
this.applyTheme();
this.startSystemThemeListener();
}
disconnect() {
this.stopSystemThemeListener()
this.stopSystemThemeListener();
}
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
userPreferenceValueChanged() {
this.applyTheme()
this.applyTheme();
}
// Called when a theme radio button is clicked
updateTheme(event) {
const selectedTheme = event.currentTarget.value
const selectedTheme = event.currentTarget.value;
if (selectedTheme === "system") {
this.setTheme(this.systemPrefersDark())
this.setTheme(this.systemPrefersDark());
} else if (selectedTheme === "dark") {
this.setTheme(true)
this.setTheme(true);
} else {
this.setTheme(false)
this.setTheme(false);
}
}
// Applies theme based on the userPreferenceValue (from server)
applyTheme() {
if (this.userPreferenceValue === "system") {
this.setTheme(this.systemPrefersDark())
this.setTheme(this.systemPrefersDark());
} else if (this.userPreferenceValue === "dark") {
this.setTheme(true)
this.setTheme(true);
} else {
this.setTheme(false)
this.setTheme(false);
}
}
// Sets or removes the data-theme attribute
setTheme(isDark) {
if (isDark) {
document.documentElement.setAttribute("data-theme", "dark")
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.removeAttribute("data-theme")
document.documentElement.removeAttribute("data-theme");
}
}
systemPrefersDark() {
return window.matchMedia("(prefers-color-scheme: dark)").matches
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
handleSystemThemeChange = (event) => {
// Only apply system theme changes if the user preference is currently 'system'
if (this.userPreferenceValue === "system") {
this.setTheme(event.matches)
this.setTheme(event.matches);
}
};
toDark() {
this.setTheme(true);
}
toLight() {
this.setTheme(false);
}
startSystemThemeListener() {
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
this.darkMediaQuery.addEventListener(
"change",
this.handleSystemThemeChange,
);
}
stopSystemThemeListener() {
if (this.darkMediaQuery) {
this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
this.darkMediaQuery.removeEventListener(
"change",
this.handleSystemThemeChange,
);
}
}
}
}

View file

@ -95,7 +95,8 @@ export default class extends Controller {
.attr("cx", this._d3InitialContainerWidth / 2)
.attr("cy", this._d3InitialContainerHeight / 2)
.attr("r", 4)
.style("fill", "var(--color-gray-400)");
.attr("class", "fg-subdued")
.style("fill", "currentColor");
}
_drawChart() {
@ -151,7 +152,8 @@ export default class extends Controller {
.append("stop")
.attr("class", "end-color")
.attr("offset", "100%")
.attr("stop-color", "var(--color-gray-300)");
.attr("class", "fg-subdued")
.attr("stop-color", "currentColor");
}
_setTrendlineSplitAt(percent) {
@ -191,7 +193,7 @@ export default class extends Controller {
// Style ticks
this._d3Group
.selectAll(".tick text")
.style("fill", "var(--color-gray-500)")
.attr("class", "fg-gray")
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
@ -258,14 +260,10 @@ export default class extends Controller {
this._d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
.style("position", "absolute")
.style("padding", "8px")
.style("font", "14px Inter, sans-serif")
.style("background", "var(--color-white)")
.style("border", "1px solid var(--color-alpha-black-100)")
.style("border-radius", "10px")
.style("pointer-events", "none")
.style("opacity", 0); // Starts as hidden
.attr(
"class",
"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0",
);
}
_trackMouseForShowingTooltip() {
@ -273,6 +271,7 @@ export default class extends Controller {
this._d3Group
.append("rect")
.attr("class", "bg-container")
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("fill", "none")
@ -308,12 +307,12 @@ export default class extends Controller {
// Guideline
this._d3Group
.append("line")
.attr("class", "guideline")
.attr("class", "guideline fg-subdued")
.attr("x1", this._d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this._d3XScale(d.date))
.attr("y2", this._d3ContainerHeight)
.attr("stroke", "var(--color-gray-300)")
.attr("stroke", "currentColor")
.attr("stroke-dasharray", "4, 4");
// Big circle
@ -353,7 +352,6 @@ export default class extends Controller {
this._d3Group.selectAll(".guideline").remove();
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Tooltip.style("opacity", 0);
this._setTrendlineSplitAt(1);
}
});
@ -364,23 +362,17 @@ export default class extends Controller {
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
${datum.date_formatted}
</div>
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 0px;">
<div style="display: flex; align-items: center; gap: 8px; color: var(--color-black);">
<div style="display: flex; align-items: center; justify-content: center; height: 16px; width: 16px;">
${datum.trend.previous.amount === datum.trend.current.amount ? `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>
` : Number(datum.trend.previous.amount) < Number(datum.trend.current.amount) ? `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>
` : `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>
`}
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary">
<div class="flex items-center justify-center h-4 w-4">
${this._getTrendIcon(datum)}
</div>
${this._extractFormattedValue(datum.trend.current)}
</div>
${
datum.trend.value === 0
? `<span style="width: 80px;"></span>`
? `<span class="w-20"></span>`
: `
<span style="color: ${datum.trend.color};">
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
@ -391,6 +383,23 @@ export default class extends Controller {
`;
}
_getTrendIcon(datum) {
const isIncrease =
Number(datum.trend.previous.amount) < Number(datum.trend.current.amount);
const isDecrease =
Number(datum.trend.previous.amount) > Number(datum.trend.current.amount);
if (isIncrease) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>`;
}
if (isDecrease) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>`;
}
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>`;
}
_getDatumValue = (datum) => {
return this._extractNumericValue(datum.trend.current);
};