diff --git a/components/blocks/spacer/spacer.tsx b/components/blocks/spacer/spacer.tsx index d50974152c..c6718f4458 100644 --- a/components/blocks/spacer/spacer.tsx +++ b/components/blocks/spacer/spacer.tsx @@ -17,7 +17,7 @@ export function Spacer({ data }: { data: Consultingv2BlocksSpacer }) { ); const spacer = isDecorated ? ( - +
); - return hideClasses ?
{spacer}
: spacer; + return hideClasses ? ( + + ) : ( + spacer + ); } diff --git a/components/blocks/v3/heroBox/heroBox.tsx b/components/blocks/v3/heroBox/heroBox.tsx index bf5b3bca0f..d47e045075 100644 --- a/components/blocks/v3/heroBox/heroBox.tsx +++ b/components/blocks/v3/heroBox/heroBox.tsx @@ -4,6 +4,7 @@ import ButtonRow from "@/components/blocksSubtemplates/buttonRow"; import { backgroundOptions } from "@/components/blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions"; import V2ComponentWrapper from "@/components/layout/v2ComponentWrapper"; import { Container } from "@/components/util/container"; +import { findNextBlock, scrollToBlockContent } from "@/lib/scrollToBlock"; import { cn } from "@/lib/utils"; import Image from "next/image"; import { useState, type CSSProperties } from "react"; @@ -59,13 +60,8 @@ export const V3HeroBox = ({ data, priority = false }) => { type="button" aria-label="Scroll to content" onClick={(e) => { - // Scroll so the arrow ends up 10% of the viewport height down from - // the top of the screen, pulling the hero up out of the way. - const { top } = e.currentTarget.getBoundingClientRect(); - window.scrollTo({ - top: window.scrollY + top - window.innerHeight * 0.1, - behavior: "smooth", - }); + const nextBlock = findNextBlock(e.currentTarget); + if (nextBlock) scrollToBlockContent(nextBlock); }} className="pointer-events-auto flex size-11 items-center justify-center rounded-full border border-black/70 text-black transition-colors hover:bg-black hover:text-white dark:border-white/80 dark:text-white dark:hover:bg-white dark:hover:text-black" > diff --git a/components/layout/v2ComponentWrapper.schema.tsx b/components/layout/v2ComponentWrapper.schema.tsx index f4c2d5762e..04b2412cee 100644 --- a/components/layout/v2ComponentWrapper.schema.tsx +++ b/components/layout/v2ComponentWrapper.schema.tsx @@ -72,7 +72,7 @@ export const anchorIdSchema: TinaField = { }, }, description: - "Optional id for in-page links. Set this on a section, then point a button's link at # to jump here (the offset for the sticky header is handled automatically).", + "Optional id for in-page links. Set this on a section, then point a button's link at # to jump here (a little breathing room above the section is added automatically).", }; // Background + anchor fields every V2 block shares — spread once via `...wrapperBaseFields`. diff --git a/components/layout/v2ComponentWrapper.tsx b/components/layout/v2ComponentWrapper.tsx index e7e6280e48..5dad0093a6 100644 --- a/components/layout/v2ComponentWrapper.tsx +++ b/components/layout/v2ComponentWrapper.tsx @@ -23,11 +23,13 @@ const V2ComponentWrapper = ({ children, fadeInMargin = "-100px", className, + ariaHidden, }: { data: BackgroundData; children: React.ReactNode; fadeInMargin?: UseInViewOptions["margin"]; className?: string; + ariaHidden?: boolean; }) => { //Bleed effect setup const bleed = useRef(null); @@ -58,12 +60,13 @@ const V2ComponentWrapper = ({ return (
{ return value.reference === data.background?.backgroundColour; })?.classes, "relative w-full overflow-visible", - // Offset in-page anchor scrolling so the target clears the sticky header + // Breathing room above a section jumped to by an in-page anchor link. data.anchorId && "scroll-mt-24", className )} @@ -110,8 +113,12 @@ const V2ComponentWrapper = ({ className="pointer-events-none absolute inset-0 z-25 bg-dot-grid bg-dots" /> )} + {/* data-block-content marks where this block's content starts, past the + wrapper's padding and the decorative layers above. lib/scrollToBlock + targets it. */}
` by +// blocks-renderer.tsx; that wrapper is the block boundary here. +const BLOCK_WRAPPER = "[data-tinafield]"; + +export function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** The next block after `fromEl`'s own that renders a visible box. */ +export function findNextBlock(fromEl: Element): Element | null { + let sibling = fromEl.closest(BLOCK_WRAPPER)?.nextElementSibling ?? null; + while (sibling) { + const root = sibling.firstElementChild; + if ( + root && + root.getClientRects().length > 0 && + root.getAttribute("aria-hidden") !== "true" + ) { + return root; + } + sibling = sibling.nextElementSibling; + } + return null; +} + +function contentSection(blockRoot: Element): Element { + return blockRoot.querySelector(":scope > [data-block-content]") ?? blockRoot; +} + +/** Document offset where a block's content starts, past its container padding. */ +export function getBlockContentTop(blockRoot: Element): number { + const content = contentSection(blockRoot); + const container = content.firstElementChild; + const laidOut = !!container && container.getClientRects().length > 0; + const el = laidOut ? container : content; + const padding = laidOut + ? parseFloat(getComputedStyle(container).paddingTop) || 0 + : 0; + return window.scrollY + el.getBoundingClientRect().top + padding; +} + +export function scrollToBlockContent(blockRoot: Element): void { + window.scrollTo({ + top: getBlockContentTop(blockRoot), + // "instant" not "auto" — auto defers to the global scroll-smooth CSS. + behavior: prefersReducedMotion() ? "instant" : "smooth", + }); + // Move the reading position too, so screen readers and the keyboard follow. + const target = contentSection(blockRoot) as HTMLElement; + if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1"); + target.focus({ preventScroll: true }); +}