Files
MyWebsite/js/main.js
T
itziarZG 7dfe54318e chore(merge): integrate main perf optimizations into develop
Resolves merge conflicts when bringing origin/main into develop:
- css/styles.css: keep built/minified Tailwind output, append develop's
  layout-stability CSS (scrollbar-gutter + body.modal-open)
- js/main.js: keep main's aria-hidden + aria-selected a11y hardening, restore
  develop's tabsContainer.scrollIntoView and body.modal-open modal pattern
  (with preventScroll + getScrollbarWidth fallback for non-gutter browsers)
- index*.html / huella.html: take main's meta+analytics+defer heavy work as
  base, restore develop's inline SVG icons (replaces Material Symbols spans)
  and add favicon link
- privacidad*.html: take main's rewritten multi-language versions, add favicon
- keep develop's tab DOM fixes (each tab-pane shows its own content;
  Trabajo Anterior details nested inside tab-proyectos)
- keep develop's deletion of fonts/material-symbols-outlined.woff2
2026-07-28 19:56:31 +02:00

259 lines
8.6 KiB
JavaScript

// ============================================
// Theme Toggle - Dark/Light Mode
// ============================================
/**
* Initialize theme on page load
* Checks localStorage for saved preference, falls back to light mode
*/
function updateThemeControl(isDark) {
const themeToggle = document.getElementById("theme-toggle");
if (!themeToggle) return;
const language = document.documentElement.lang;
const labels = {
es: ["Activar modo claro", "Activar modo oscuro"],
ca: ["Activar el mode clar", "Activar el mode fosc"],
en: ["Enable light mode", "Enable dark mode"],
};
const currentLabels = labels[language] || labels.es;
themeToggle.setAttribute("aria-pressed", String(isDark));
themeToggle.setAttribute("aria-label", isDark ? currentLabels[0] : currentLabels[1]);
}
function initTheme() {
const savedTheme = localStorage.getItem("theme") || "light";
const isDark = savedTheme === "dark";
document.documentElement.classList.toggle("dark", isDark);
updateThemeControl(isDark);
}
/**
* Toggle between dark and light theme
* Saves preference to localStorage
*/
function toggleTheme() {
const html = document.documentElement;
const isDark = !html.classList.contains("dark");
html.classList.toggle("dark", isDark);
localStorage.setItem("theme", isDark ? "dark" : "light");
updateThemeControl(isDark);
}
// ============================================
// Smooth Scrolling
// ============================================
/**
* Scroll to projects section
*/
function scrollToProjects() {
const projectsSection = document.getElementById("projects");
if (projectsSection) {
projectsSection.scrollIntoView({ behavior: "smooth" });
}
}
/**
* Scroll to contact section
*/
function scrollToContact() {
const contactSection = document.getElementById("contact");
if (contactSection) {
contactSection.scrollIntoView({ behavior: "smooth" });
}
}
// ============================================
// Modal Management
// ============================================
/** Element that had focus before a modal was opened */
let _modalPreviousFocus = null;
/** Scrollbar width in px, measured on first open */
let _scrollbarWidth = null;
/**
* Measure and cache the browser scrollbar width so we can compensate
* for layout shift when body.overflow is toggled.
*/
function getScrollbarWidth() {
if (_scrollbarWidth !== null) return _scrollbarWidth;
const outer = document.createElement("div");
outer.style.cssText =
"visibility:hidden;position:absolute;overflow:scroll;width:100px;height:100px;";
document.body.appendChild(outer);
const inner = document.createElement("div");
inner.style.width = "100%";
outer.appendChild(inner);
const w = outer.offsetWidth - inner.offsetWidth;
outer.remove();
_scrollbarWidth = w;
return w;
}
/**
* Open modal by project ID.
* Saves the currently focused element and moves focus into the modal.
* Uses a CSS class (body.modal-open) so the scrollbar gutter stays reserved
* via html { scrollbar-gutter: stable }, preventing page jumps.
* @param {string} projectId - The project identifier
*/
function openModal(projectId) {
const modal = document.getElementById(projectId + "-modal");
if (modal) {
_modalPreviousFocus = document.activeElement;
modal.classList.remove("hidden");
modal.setAttribute("aria-hidden", "false");
document.body.classList.add("modal-open");
// Defensive fallback for browsers without scrollbar-gutter support
document.body.style.paddingRight = getScrollbarWidth() + "px";
// Move focus to the first focusable element inside the modal.
// PreventScroll keeps the page from jumping when the focused
// element would otherwise trigger a scroll-into-view.
const focusable = modal.querySelectorAll(
'button, [href], input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'
);
if (focusable.length > 0) {
focusable[0].focus({ preventScroll: true });
} else {
modal.setAttribute("tabindex", "-1");
modal.focus({ preventScroll: true });
}
}
}
/**
* Close modal by project ID.
* Restores focus to the element that was focused before the modal opened.
* @param {string} projectId - The project identifier
*/
function closeModal(projectId) {
const modal = document.getElementById(projectId + "-modal");
if (modal) {
modal.classList.add("hidden");
modal.setAttribute("aria-hidden", "true");
document.body.classList.remove("modal-open");
document.body.style.paddingRight = "";
// Return focus to the element that triggered the modal.
// preventScroll keeps the page from jumping back to that element.
if (_modalPreviousFocus) {
try {
_modalPreviousFocus.focus({ preventScroll: true });
} catch (e) {
_modalPreviousFocus.focus();
}
_modalPreviousFocus = null;
}
}
}
// ============================================
// Laboratorio Tabs Management
// ============================================
/**
* Initialize tabs behavior for the Laboratorio section
* When switching tabs, the page scrolls smoothly to the tab buttons container
* to prevent the "jump" caused by tab content height changes.
*/
function initTabs() {
const tabButtons = document.querySelectorAll(".tab-btn");
const tabPanes = document.querySelectorAll(".tab-pane");
tabButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const targetTab = btn.getAttribute("data-tab");
tabButtons.forEach((tabButton) => {
const selected = tabButton === btn;
tabButton.setAttribute("aria-selected", String(selected));
tabButton.tabIndex = selected ? 0 : -1;
tabButton.classList.toggle("bg-primary", selected);
tabButton.classList.toggle("text-white", selected);
tabButton.classList.toggle("shadow-md", selected);
tabButton.classList.toggle("shadow-primary/25", selected);
tabButton.classList.toggle("text-slate-600", !selected);
tabButton.classList.toggle("dark:text-slate-400", !selected);
tabButton.classList.toggle("hover:bg-slate-100", !selected);
tabButton.classList.toggle("dark:hover:bg-white/5", !selected);
});
tabPanes.forEach((pane) => {
const selected = pane.id === `tab-${targetTab}`;
pane.classList.toggle("hidden", !selected);
pane.classList.toggle("block", selected);
pane.setAttribute("aria-hidden", String(!selected));
});
// Scroll the tabs container into view after switching panes so the
// user stays anchored to the controls even when the new pane is much
// shorter than the previous one (prevents a "jump down" feeling).
const tabsContainer = btn.parentElement;
if (tabsContainer && tabsContainer.parentElement) {
tabsContainer.scrollIntoView({ behavior: "smooth", block: "start" });
}
});
btn.addEventListener("keydown", (event) => {
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
event.preventDefault();
const currentIndex = Array.from(tabButtons).indexOf(btn);
const direction = event.key === "ArrowRight" ? 1 : -1;
const nextIndex = (currentIndex + direction + tabButtons.length) % tabButtons.length;
tabButtons[nextIndex].focus();
tabButtons[nextIndex].click();
});
});
const selectedTab = document.querySelector('.tab-btn[aria-selected="true"]');
tabButtons.forEach((btn) => {
btn.tabIndex = btn === selectedTab ? 0 : -1;
});
}
// ============================================
// Event Listeners
// ============================================
/**
* Close modal when ESC key is pressed
*/
document.addEventListener("keydown", function (event) {
const modal = document.querySelector('[id$="-modal"]:not(.hidden)');
if (modal && event.key === "Tab") {
const focusable = Array.from(modal.querySelectorAll(
'button, [href], input, select, textarea, [contenteditable="true"], [tabindex]:not([tabindex="-1"])'
));
if (focusable.length > 0) {
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
if (event.key === "Escape") {
const modals = document.querySelectorAll(
'[id$="-modal"]:not(.hidden)'
);
modals.forEach((openModalElement) => {
const projectId = openModalElement.id.replace("-modal", "");
closeModal(projectId);
});
}
});
/**
* Initialize theme and tabs
*/
initTheme();
initTabs();