diff --git a/src/BackToTop.tsx b/src/BackToTop.tsx new file mode 100644 index 000000000..879368b84 --- /dev/null +++ b/src/BackToTop.tsx @@ -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>; + /** 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, `` or the skip links container + * (` + ); + }) +); + +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 `` 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; diff --git a/src/bin/only-include-css-of-used-components.ts b/src/bin/only-include-css-of-used-components.ts index 00773b1a4..f113324a9 100644 --- a/src/bin/only-include-css-of-used-components.ts +++ b/src/bin/only-include-css-of-used-components.ts @@ -125,6 +125,7 @@ export const REACT_DSFR_MODULE_TO_DSFR_COMPONENTS: Record +\`\`\` + +When no \`id\` can be set on the top of the page, pass a \`targetRef\` instead. A +\`