Files
pi-kit/pikit-web/assets/toast.js
2025-12-13 17:04:32 -05:00

259 lines
8.4 KiB
JavaScript

const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
const TOAST_SPEED_KEY = "pikit-toast-speed";
const TOAST_DURATION_KEY = "pikit-toast-duration";
const FONT_KEY = "pikit-font";
export const ALLOWED_TOAST_POS = [
"bottom-center",
"bottom-right",
"bottom-left",
"top-right",
"top-left",
"top-center",
];
export const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
export const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
export function createToastManager({
container,
posSelect,
animSelect,
speedInput,
durationInput,
fontSelect,
testBtn,
} = {}) {
const state = {
position: "bottom-center",
animation: "slide-in",
durationMs: 5000,
speedMs: 300,
font: "redhat",
};
function applyToastSettings() {
if (!container) return;
container.className = `toast-container pos-${state.position}`;
document.documentElement.style.setProperty("--toast-speed", `${state.speedMs}ms`);
const dir = state.position.startsWith("top") ? -1 : 1;
const isLeft = state.position.includes("left");
const isRight = state.position.includes("right");
const slideX = isLeft ? -26 : isRight ? 26 : 0;
const slideY = isLeft || isRight ? 0 : dir * 24;
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
if (durationInput) durationInput.value = state.durationMs;
}
function applyFontSetting() {
document.documentElement.setAttribute("data-font", state.font);
if (fontSelect) fontSelect.value = state.font;
}
function persistSettings() {
try {
localStorage.setItem(TOAST_POS_KEY, state.position);
localStorage.setItem(TOAST_ANIM_KEY, state.animation);
localStorage.setItem(TOAST_SPEED_KEY, String(state.speedMs));
localStorage.setItem(TOAST_DURATION_KEY, String(state.durationMs));
localStorage.setItem(FONT_KEY, state.font);
} catch (e) {
console.warn("Toast settings save failed", e);
}
}
function loadFromStorage() {
try {
const posSaved = localStorage.getItem(TOAST_POS_KEY);
if (ALLOWED_TOAST_POS.includes(posSaved)) state.position = posSaved;
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
const migrated =
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
? "slide-in"
: animSaved;
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) state.animation = migrated;
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
state.speedMs = savedSpeed;
}
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
state.durationMs = savedDur;
}
const savedFont = localStorage.getItem(FONT_KEY);
if (ALLOWED_FONTS.includes(savedFont)) state.font = savedFont;
} catch (e) {
console.warn("Toast settings load failed", e);
}
if (posSelect) posSelect.value = state.position;
if (animSelect) animSelect.value = state.animation;
if (speedInput) speedInput.value = state.speedMs;
if (durationInput) durationInput.value = state.durationMs;
if (fontSelect) fontSelect.value = state.font;
applyToastSettings();
applyFontSetting();
}
function showToast(message, type = "info") {
if (!container || !message) return;
const t = document.createElement("div");
t.className = `toast ${type} anim-${state.animation}`;
t.textContent = message;
container.appendChild(t);
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
if (!animOn) {
t.classList.add("show");
} else {
requestAnimationFrame(() => t.classList.add("show"));
}
const duration = state.durationMs;
setTimeout(() => {
const all = Array.from(container.querySelectorAll(".toast"));
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
const first = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
t.classList.add("leaving");
void t.offsetHeight;
requestAnimationFrame(() => {
const second = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
others.forEach((el) => {
const dy = first.get(el).top - second.get(el).top;
if (Math.abs(dy) > 0.5) {
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
el.style.transform = `translateY(${dy}px)`;
requestAnimationFrame(() => {
el.style.transform = "";
});
}
});
});
const removeDelay = animOn ? state.speedMs : 0;
setTimeout(() => {
t.classList.remove("show");
t.remove();
others.forEach((el) => (el.style.transition = ""));
}, removeDelay);
}, duration);
}
function wireControls() {
posSelect?.addEventListener("change", () => {
const val = posSelect.value;
if (ALLOWED_TOAST_POS.includes(val)) {
state.position = val;
applyToastSettings();
persistSettings();
} else {
posSelect.value = state.position;
showToast("Invalid toast position", "error");
}
});
animSelect?.addEventListener("change", () => {
let val = animSelect.value;
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in";
if (ALLOWED_TOAST_ANIM.includes(val)) {
state.animation = val;
persistSettings();
} else {
animSelect.value = state.animation;
showToast("Invalid toast animation", "error");
}
});
const clampSpeed = (val) => clamp(val, 100, 3000);
const clampDuration = (val) => clamp(val, 1000, 15000);
speedInput?.addEventListener("input", () => {
const raw = speedInput.value;
if (raw === "") return;
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) return;
state.speedMs = val;
applyToastSettings();
persistSettings();
});
speedInput?.addEventListener("blur", () => {
const raw = speedInput.value;
if (raw === "") {
speedInput.value = state.speedMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) {
state.speedMs = clampSpeed(state.speedMs);
speedInput.value = state.speedMs;
showToast("Toast speed must be 100-3000 ms", "error");
return;
}
state.speedMs = val;
speedInput.value = state.speedMs;
applyToastSettings();
persistSettings();
});
durationInput?.addEventListener("input", () => {
const raw = durationInput.value;
if (raw === "") return;
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) return;
state.durationMs = val;
persistSettings();
});
durationInput?.addEventListener("blur", () => {
const raw = durationInput.value;
if (raw === "") {
durationInput.value = state.durationMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) {
state.durationMs = clampDuration(state.durationMs);
durationInput.value = state.durationMs;
showToast("Toast duration must be 1000-15000 ms", "error");
return;
}
state.durationMs = val;
durationInput.value = state.durationMs;
persistSettings();
});
fontSelect?.addEventListener("change", () => {
const val = fontSelect.value;
if (!ALLOWED_FONTS.includes(val)) {
fontSelect.value = state.font;
showToast("Invalid font choice", "error");
return;
}
state.font = val;
applyFontSetting();
persistSettings();
});
testBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
}
loadFromStorage();
wireControls();
return {
state,
showToast,
applyToastSettings,
applyFontSetting,
persistSettings,
loadFromStorage,
};
}