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:
parent
1aafed5f8b
commit
90a9546f32
291 changed files with 4143 additions and 3104 deletions
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
56
app/javascript/controllers/app_layout_controller.js
Normal file
56
app/javascript/controllers/app_layout_controller.js
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
59
app/javascript/controllers/confirm_dialog_controller.js
Normal file
59
app/javascript/controllers/confirm_dialog_controller.js
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
8
app/javascript/controllers/intercom_controller.js
Normal file
8
app/javascript/controllers/intercom_controller.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="intercom"
|
||||
export default class extends Controller {
|
||||
show() {
|
||||
Intercom("show");
|
||||
}
|
||||
}
|
|
@ -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`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue