Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ import {
getHashTarget,
userPrefersReducedMotion,
} from "./lib/navigationFocus";
import {
isLocaleSwitchNavigationState,
restoreLocaleScrollContext,
} from "./lib/localeScroll";

const PortfolioPage = lazy(() => import("./pages/PortfolioPage"));
const LemBoxCasePage = lazy(() => import("./pages/LemBoxCasePage"));
Expand Down Expand Up @@ -181,6 +185,7 @@ function NavigationFocusManager({ isNotFound }: { isNotFound: boolean }) {
};

if (location.hash === "") {
if (isLocaleSwitchNavigationState(location.state)) return;
focusElement(main);
scrollMainToTop();
return;
Expand Down Expand Up @@ -225,7 +230,39 @@ function NavigationFocusManager({ isNotFound }: { isNotFound: boolean }) {
observer.disconnect();
window.clearTimeout(timeoutId);
};
}, [isNotFound, location.hash, location.pathname, navigationType]);
}, [isNotFound, location.hash, location.pathname, location.state, navigationType]);

return null;
}

function LocaleSwitchScrollRestoration() {
const location = useLocation();
const navigationType = useNavigationType();
const frameIds = useRef<number[]>([]);

useEffect(() => {
frameIds.current.forEach((id) => window.cancelAnimationFrame(id));
frameIds.current = [];

if (navigationType === "POP") return;
if (location.hash !== "") return;
if (!isLocaleSwitchNavigationState(location.state)) return;

const { scrollContext } = location.state;

const firstFrame = window.requestAnimationFrame(() => {
const secondFrame = window.requestAnimationFrame(() => {
restoreLocaleScrollContext(scrollContext);
});
frameIds.current.push(secondFrame);
});
frameIds.current.push(firstFrame);

return () => {
frameIds.current.forEach((id) => window.cancelAnimationFrame(id));
frameIds.current = [];
};
}, [location.hash, location.key, location.state, navigationType]);

return null;
}
Expand Down Expand Up @@ -317,6 +354,7 @@ function App({
</main>

<NavigationFocusManager isNotFound={isNotFound} />
<LocaleSwitchScrollRestoration />

<Footer />
</div>
Expand Down
41 changes: 40 additions & 1 deletion src/Components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
// src/Components/Navbar.tsx

import { useState, useEffect, useRef } from "react";
import {
useCallback,
useState,
useEffect,
useRef,
type MouseEvent as ReactMouseEvent,
} from "react";
import translations from "../i18n";
import { useLanguage } from "../i18n/useLanguage";
import { FaBars, FaTimes } from "react-icons/fa";
Expand All @@ -11,6 +17,7 @@ import {
getLocalizedPath,
} from "../routes/siteRoutes";
import type { Language } from "../i18n/language";
import { userPrefersReducedMotion } from "../lib/navigationFocus";

const MOBILE_NAVIGATION_ID = "mobile-navigation-panel";

Expand Down Expand Up @@ -86,6 +93,37 @@ export default function Navbar() {
menuTriggerRef.current?.focus();
};

const handleBrandClick = useCallback(
(event: ReactMouseEvent<HTMLAnchorElement>) => {
setMenuOpen(false);

const isModifiedClick =
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey;
if (isModifiedClick) return;

const isAlreadyCleanHome =
location.pathname === homePath &&
location.search === "" &&
location.hash === "";
if (!isAlreadyCleanHome) return;

// Already at the target Home: there is no pathname/hash change for
// NavigationFocusManager to react to, so scroll here directly instead
// of letting Link push a redundant history entry to the same URL.
event.preventDefault();
window.scrollTo({
behavior: userPrefersReducedMotion() ? "auto" : "smooth",
left: 0,
top: 0,
});
},
[homePath, location.hash, location.pathname, location.search],
);

useEffect(() => {
if (!menuOpen) return;

Expand All @@ -109,6 +147,7 @@ export default function Navbar() {
{/* Nombre (link to Home) */}
<Link
to={homePath}
onClick={handleBrandClick}
className="text-lg font-medium text-white tracking-normal leading-snug hover:opacity-80 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black rounded"
aria-label={language === "es" ? "DEVRODRI - Inicio" : "DEVRODRI - Home"}
>
Expand Down
2 changes: 1 addition & 1 deletion src/Components/SeoHead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function SeoHead() {
: null;

return (
<Helmet>
<Helmet defer={false}>
<html lang={locale} />
<title>{metadata.title}</title>
<meta name="description" content={metadata.description} />
Expand Down
7 changes: 7 additions & 0 deletions src/i18n/LanguageProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { LanguageContext } from "./languageContext";
import {
getEquivalentLocalePath,
getLocaleForPathname,
getPublicRoute,
} from "../routes/siteRoutes";
import { captureLocaleScrollContext } from "../lib/localeScroll";

function detectBrowserLanguage(): Language {
if (typeof navigator === "undefined") return "es";
Expand Down Expand Up @@ -83,10 +85,15 @@ export function RoutedLanguageProvider({ children }: { children: ReactNode }) {
const language = getLocaleForPathname(location.pathname);

const handleLanguageChange = useCallback((nextLanguage: Language) => {
const currentPage = getPublicRoute(location.pathname)?.page ?? null;
const scrollContext = captureLocaleScrollContext(currentPage);

navigate({
pathname: getEquivalentLocalePath(location.pathname, nextLanguage),
search: location.search,
hash: location.hash,
}, {
state: { localeSwitch: true, scrollContext },
});
}, [location.hash, location.pathname, location.search, navigate]);

Expand Down
157 changes: 157 additions & 0 deletions src/lib/localeScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import type { PageKey } from "../routes/siteRoutes";

export type LocaleScrollContext = {
pageRatio: number;
sectionKey: string | null;
sectionProgress: number | null;
topAnchor: boolean;
};

export type LocaleSwitchNavigationState = {
localeSwitch: true;
scrollContext: LocaleScrollContext;
};

const TOP_ANCHOR_THRESHOLD_PX = 48;

const SECTION_KEYS_BY_PAGE: Record<PageKey, readonly string[]> = {
home: ["hero", "sobremi", "portfolio", "contacto", "faq"],
portfolio: [],
"lem-box": [
"lem-box-summary",
"lem-box-challenge",
"lem-box-role",
"lem-box-ecosystem",
"lem-box-audiences",
"lem-box-solution",
"lem-box-architecture",
"lem-box-markets",
"lem-box-evolution",
"lem-box-mobile-future",
"lem-box-public-links",
"lem-box-final-cta",
],
services: [
"services-choose",
"services-directory",
"services-method",
"services-coverage",
"services-proof",
"services-cta",
],
"business-websites": [
"business-websites-deliverables",
"business-websites-cases",
"business-websites-method",
"business-websites-crosslink",
"business-websites-coverage",
"business-websites-cta",
],
"custom-software": [
"custom-software-scope",
"custom-software-proof",
"custom-software-method",
"custom-software-crosslink",
"custom-software-coverage",
"custom-software-cta",
],
"thank-you": [],
};

function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}

function getNavbarHeight(): number {
const navbar = document.querySelector<HTMLElement>("[data-nojs-navbar]");
return navbar?.getBoundingClientRect().height ?? 0;
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude the open mobile panel from navbar height

During a mobile language switch, captureLocaleScrollContext runs while the dropdown is still open because the language handler calls setLanguage before closeMenu. The selected navbar element therefore includes the entire expanded panel, while restoration measures the collapsed navbar; the two viewport centers can differ by hundreds of pixels and shift the reader after switching languages. Measure only the persistent top bar or capture after the mobile panel has closed.

AGENTS.md reference: AGENTS.md:L4-L11

Useful? React with 👍 / 👎.

}

function getViewportCenter(navbarHeight: number): number {
return navbarHeight + (window.innerHeight - navbarHeight) / 2;
}

function getPageRatio(): number {
const scrollable = document.documentElement.scrollHeight - window.innerHeight;
if (scrollable <= 0) return 0;
return clamp(window.scrollY / scrollable, 0, 1);
}

export function captureLocaleScrollContext(
page: PageKey | null,
): LocaleScrollContext {
const navbarHeight = getNavbarHeight();
const pageRatio = getPageRatio();

if (window.scrollY <= navbarHeight + TOP_ANCHOR_THRESHOLD_PX) {
return { pageRatio, sectionKey: null, sectionProgress: null, topAnchor: true };
}

const sectionKeys = page === null ? [] : SECTION_KEYS_BY_PAGE[page];
const viewportCenter = getViewportCenter(navbarHeight);

let bestKey: string | null = null;
let bestProgress: number | null = null;
let bestDistance = Infinity;

for (const key of sectionKeys) {
const el = document.getElementById(key);
if (el === null) continue;
const rect = el.getBoundingClientRect();
Comment on lines +98 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Measure section containers instead of heading IDs

On the services, business-websites, custom-software, and LEM-BOX pages, the configured keys identify <h2> elements rather than their enclosing sections (for example, services-choose in ServicesHubPage.tsx). When the reader is below a heading, this calculation clamps sectionProgress to 1, and restoration centers the heading's bottom instead of preserving the position within the section, producing a potentially large jump toward the section start. Resolve each key to its enclosing section or place the IDs on the section containers before calculating progress.

Useful? React with 👍 / 👎.

if (rect.height <= 0) continue;

const distance =
viewportCenter < rect.top
? rect.top - viewportCenter
: viewportCenter > rect.bottom
? viewportCenter - rect.bottom
: 0;

if (distance < bestDistance) {
bestDistance = distance;
bestKey = key;
bestProgress = clamp((viewportCenter - rect.top) / rect.height, 0, 1);
}
if (distance === 0) break;
}

return {
pageRatio,
sectionKey: bestKey,
sectionProgress: bestProgress,
topAnchor: false,
};
}

export function isLocaleSwitchNavigationState(
state: unknown,
): state is LocaleSwitchNavigationState {
if (typeof state !== "object" || state === null) return false;
const candidate = state as Record<string, unknown>;
if (candidate.localeSwitch !== true) return false;
const scrollContext = candidate.scrollContext;
return typeof scrollContext === "object" && scrollContext !== null;
}

export function restoreLocaleScrollContext(context: LocaleScrollContext): void {
if (context.topAnchor) return;

const navbarHeight = getNavbarHeight();

if (context.sectionKey !== null && context.sectionProgress !== null) {
const el = document.getElementById(context.sectionKey);
if (el !== null) {
const rect = el.getBoundingClientRect();
const viewportCenter = getViewportCenter(navbarHeight);
const targetElementY = rect.top + context.sectionProgress * rect.height;
const delta = targetElementY - viewportCenter;
window.scrollTo({ left: 0, top: window.scrollY + delta });
return;
}
}

const scrollable = document.documentElement.scrollHeight - window.innerHeight;
if (scrollable > 0) {
window.scrollTo({ left: 0, top: context.pageRatio * scrollable });
}
}
Loading
Loading