Skip to content
Open
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
182 changes: 182 additions & 0 deletions src/BackToTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"use client";

import React, { forwardRef, memo, type CSSProperties } from "react";
import type { Equals } from "tsafe";
import { assert } from "tsafe/assert";
import { symToStr } from "tsafe/symToStr";
import { fr } from "./fr";
import { createComponentI18nApi } from "./i18n";
import { cx } from "./tools/cx";
import { useAnalyticsId } from "./tools/useAnalyticsId";

export type BackToTopProps = {
id?: string;
className?: string;
style?: CSSProperties;
classes?: Partial<Record<"root" | "link", string>>;
/** Default: false (the link is aligned on the left of the content) */
right?: boolean;
} & (BackToTopProps.WithAnchor | BackToTopProps.WithTargetRef);

export namespace BackToTopProps {
export type WithAnchor = {
/**
* Anchor of the element to go back to. Default: `"#top"`.
*
* The DSFR expects the matching `id` to be set on the topmost element of the
* page, `<body id="top">` or the skip links container
* (`<div class="fr-skiplinks" id="top">`), so that the navigation focus is
* moved back to the top of the page along with the scroll.
*/
anchor?: string;
targetRef?: undefined;
};

export type WithTargetRef = {
anchor?: undefined;
/**
* Element to scroll back to, as an alternative to `anchor` when no `id` can be
* set on the top of the page. A `<button>` is rendered instead of a link: it
* scrolls the element into view and moves the focus onto it.
*
* Typed structurally rather than as `RefObject<HTMLElement>` so that refs
* created by both React 18 and React 19 are accepted.
*/
targetRef: { readonly current: HTMLElement | null };
};
}

/** @see <https://components.react-dsfr.codegouv.studio/?path=/docs/components-backtotop> */
export const BackToTop = memo(
forwardRef<HTMLDivElement, BackToTopProps>((props, ref) => {
const {
id: id_props,
className,
style,
classes = {},
right = false,
anchor,
targetRef,
...rest
} = props;

assert<Equals<keyof typeof rest, never>>();

const { t } = useTranslation();

const id = useAnalyticsId({
"defaultIdPrefix": "fr-back-to-top",
"explicitlyProvidedId": id_props
});

const linkClassName = cx(
fr.cx("fr-link", "fr-link--icon-left", "fr-icon-arrow-up-fill"),
classes.link
);

// The wrapper only carries a class when the link is aligned on the right or when
// the consumer provides one, `|| undefined` keeps an empty class="" out of the DOM.
const rootClassName =
cx(right && fr.cx("fr-grid-row", "fr-grid-row--right"), classes.root, className) ||
undefined;

return (
<div id={id} ref={ref} style={style} className={rootClassName}>
{targetRef === undefined ? (
<a className={linkClassName} href={anchor ?? "#top"}>
{t("back to top")}
</a>
) : (
<button
type="button"
className={linkClassName}
onClick={() => scrollBackTo(targetRef.current)}
>
{t("back to top")}
</button>
)}
</div>
);
})
);

function scrollBackTo(element: HTMLElement | null) {
if (element === null) {
return;
}

// An anchor gets the focus move for free from the browser, a scripted scroll does
// not: without this, keyboard and screen reader users stay where they were while the
// viewport jumps. An element that isn't already reachable has to be made
// programmatically focusable first.
//
// Three things this shape is deliberate about:
// - The attribute goes back on blur, not right after `focus()`. Removing it while the
// element still holds the focus blurs it, which defeats the whole point (measured:
// `document.activeElement` falls back to `<body>` on the same tick).
// - The guard tests the attribute and not only `tabIndex`, because an element carrying
// an explicit `tabindex="-1"` also reports -1 and its attribute is not ours to remove.
// - Should the blur never come, what is left behind is a `tabindex="-1"`, which by
// definition keeps the element out of the tab order. The failure mode is inert.
if (!element.hasAttribute("tabindex") && element.tabIndex < 0) {
element.setAttribute("tabindex", "-1");
element.addEventListener("blur", () => element.removeAttribute("tabindex"), {
"once": true
});
}

element.focus({ "preventScroll": true });

if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
element.scrollIntoView({ "behavior": "smooth", "block": "start" });

return;
}

// `behavior: "auto"` would not do: it means "use the CSS scroll-behavior", so it still
// animates on a page that sets `scroll-behavior: smooth`. `"instant"` is absent from
// TypeScript 4.9's ScrollBehavior and throws a TypeError on browsers that predate it.
// Neutralising the CSS is what the DSFR itself does around its scroll lock.
const { documentElement } = document;
const inlineScrollBehavior = documentElement.style.scrollBehavior;

documentElement.style.scrollBehavior = "auto";

element.scrollIntoView({ "block": "start" });

documentElement.style.scrollBehavior = inlineScrollBehavior;
}

BackToTop.displayName = symToStr({ BackToTop });

const { useTranslation, addBackToTopTranslations } = createComponentI18nApi({
"componentName": symToStr({ BackToTop }),
"frMessages": {
/* spell-checker: disable */
"back to top": "Haut de page"
/* spell-checker: enable */
}
});

addBackToTopTranslations({
"lang": "en",
"messages": {
"back to top": "Back to top"
}
});
addBackToTopTranslations({
"lang": "es",
"messages": {
"back to top": "Volver arriba"
}
});
addBackToTopTranslations({
"lang": "de",
"messages": {
"back to top": "Zum Seitenanfang"
}
});

export { addBackToTopTranslations };

export default BackToTop;
1 change: 1 addition & 0 deletions src/bin/only-include-css-of-used-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export const REACT_DSFR_MODULE_TO_DSFR_COMPONENTS: Record<string, DsfrComponentN
"Accordion": ["accordion"],
"AgentConnectButton": ["connect", "button"],
"Alert": ["alert", "link", "button"],
"BackToTop": ["link"],
"Badge": ["badge"],
"Breadcrumb": ["breadcrumb", "link"],
"Button": ["button", "link"],
Expand Down
68 changes: 68 additions & 0 deletions stories/BackToTop.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { BackToTop } from "../dist/BackToTop";
import { getStoryFactory } from "./getStory";
import { sectionName } from "./sectionName";

const { meta, getStory } = getStoryFactory({
sectionName,
"wrappedComponent": { BackToTop },
"description": `
- [See DSFR documentation](https://www.systeme-de-design.gouv.fr/composants-et-modeles/composants/lien#retour-en-haut-de-page)
- [See source code](https://github.com/codegouvfr/react-dsfr/blob/main/src/BackToTop.tsx)

By default the component renders an anchor pointing to \`#top\`. The DSFR expects the
matching \`id\` to be set on the topmost element of the page so that the navigation focus
moves back to the top along with the scroll:

\`\`\`html
<body id="top">
\`\`\`

When no \`id\` can be set on the top of the page, pass a \`targetRef\` instead. A
\`<button>\` is rendered, it scrolls the element into view and moves the focus onto it,
honouring \`prefers-reduced-motion\`:

\`\`\`tsx
function Page() {
const topRef = useRef<HTMLDivElement>(null);

return (
<>
<div ref={topRef} />
{/* ... */}
<BackToTop targetRef={topRef} right />
</>
);
}
\`\`\`

\`anchor\` and \`targetRef\` are mutually exclusive.
`,
"argTypes": {
"anchor": {
"control": { "type": "text" },
"description":
'Anchor of the element to go back to. Default: `"#top"`. Mutually exclusive with `targetRef`.'
},
"targetRef": {
"control": { "type": null },
"description":
"Element to scroll back to, as an alternative to `anchor`. Renders a `<button>` instead of a link."
},
"right": {
"control": "boolean",
"description": "Align the link on the right of the content. Default: `false`"
}
},
"disabledProps": ["lang"]
});

export default meta;

export const Default = getStory({});

export const BackToTopOnRight = getStory(
{
"right": true
},
{ "description": "Aligned on the right, wrapped in a `fr-grid-row fr-grid-row--right`." }
);
Loading