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
259 lines
8.6 KiB
JavaScript
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();
|