Skip to content

feat(BackToTop): add the BackToTop component - #516

Open
kevbarns wants to merge 2 commits into
mainfrom
feat/back-to-top
Open

feat(BackToTop): add the BackToTop component#516
kevbarns wants to merge 2 commits into
mainfrom
feat/back-to-top

Conversation

@kevbarns

@kevbarns kevbarns commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Reprise de #139, ouverte par @sylvainlg en juin 2023. Il a confirmé le 31/08 que le travail pouvait être repris (#139 (comment)). La structure, le nommage des props et les stories viennent de sa PR.

Closes #139

Ce que la revue de #139 laissait ouvert

La seule remarque non traitée était #139 (comment), que @garronej avait marquée comme bloquante. Elle demandait trois choses :

  1. un scroll fluide vers le haut de page comme comportement par défaut quand aucune ancre n'est fournie ;
  2. dans ce cas un <button> stylé fr-link, parce qu'un <a> sans href valide n'est pas accessible ;
  3. une ref plutôt qu'un sélecteur CSS pour désigner la cible, « Dsfr components do not allow to provide html id but all enable to provide ref ».

Les points 2 et 3 sont implémentés tels quels. Le point 1 est implémenté mais pas en défaut, et c'est la seule divergence assumée de cette PR.

API

<BackToTop />                        // <a href="#top">, forme canonique DSFR
<BackToTop anchor="#header" />       // ancre explicite
<BackToTop targetRef={topRef} />     // <button>, scroll + focus scriptés
<BackToTop right />                  // aligné à droite

anchor et targetRef sont mutuellement exclusives au niveau du type.

Pourquoi l'ancre reste le défaut

La doc DSFR est explicite sur ce composant (src/dsfr/component/link/example/back-to-top/index.ejs dans le paquet @gouvfr/dsfr) :

Le lien de "retour en haut de page" est une ancre vers un élément dont l'id est "top". Afin de le faire fonctionner correctement, il est nécessaire d'ajouter l'attribut id (id="top") sur l'élément le plus haut de la page comme le body (<body id="top">) ou les liens d'évitement (<div class="fr-skiplinks" id="top">), afin que le focus de navigation soit lui aussi replacé en haut de page.

C'est le point qui tranche : un window.scrollTo déplace le viewport mais pas le focus. Vérifié dans un navigateur, sur une page avec <body id="top"> : le focus posé sur un lien en milieu de page, un clic sur <a href="#top"> fait passer document.activeElement de ce lien à body#top. Un scroll scripté ne produit pas ce déplacement.

La variante targetRef couvre donc le besoin de #139 sans ce défaut : elle fait scrollIntoView et focus({ preventScroll: true }) sur la cible, en posant tabindex="-1" si l'élément n'est pas déjà atteignable.

@garronej si tu préfères malgré tout targetRef en défaut, ou une prop smooth supplémentaire, dis-le et je change.

prefers-reduced-motion

La première version de cette PR passait behavior: "auto" quand prefers-reduced-motion: reduce est actif. C'était faux et la description l'annonçait à tort comme réglé. "auto" ne veut pas dire « ne pas animer », il veut dire « prends la valeur CSS de scroll-behavior » : sur une page qui pose scroll-behavior: smooth, l'animation avait lieu quand même.

Mesuré en lisant scrollTop de façon synchrone juste après l'appel, sur un conteneur dont la feuille de style pose scroll-behavior: smooth — un scroll instantané a déjà atterri, un scroll animé non :

behavior passé scroll-behavior CSS scrollTop avant → après animé
"auto" smooth 2845 → 2845 oui
"instant" smooth 2845 → 0 non
"smooth" smooth 2845 → 2845 oui
"auto" auto 2863 → 0 non

"instant" n'est pas le correctif pour autant : il est absent de ScrollBehavior dans le lib.dom.d.ts de TypeScript 4.9, la version du dépôt, et une valeur d'enum WebIDL inconnue lève un TypeError, ce qui casserait le handler après que le focus a déjà bougé. Le CSS est donc neutralisé en inline puis restauré, ce que fait déjà le DSFR autour de son propre scroll lock (dsfr.module.js, ScrollLock.lock). Corrigé en 125a422.

Rendu vérifié

Markup obtenu via renderToStaticMarkup, à comparer au sample DSFR link-back-to-top.ejs (label: 'Haut de page', href: '#top', icon: 'arrow-up-fill', iconPlace: 'left') :

<!-- <BackToTop /> -->
<div id="fr-back-to-top-:R0:"><a class="fr-link fr-link--icon-left fr-icon-arrow-up-fill" href="#top">Haut de page</a></div>

<!-- <BackToTop right /> -->
<div id="fr-back-to-top-:R0:" class="fr-grid-row fr-grid-row--right"><a class="fr-link fr-link--icon-left fr-icon-arrow-up-fill" href="#top">Haut de page</a></div>

<!-- <BackToTop targetRef={topRef} /> -->
<div id="fr-back-to-top-:R0:"><button type="button" class="fr-link fr-link--icon-left fr-icon-arrow-up-fill">Haut de page</button></div>

Optimiseur CSS

"BackToTop": ["link"] est ajouté à REACT_DSFR_MODULE_TO_DSFR_COMPONENTS. Vérifié de bout en bout avec npx react-dsfr optimize-css sur un projet jetable qui n'importe que BackToTop :

Found fr-icon-arrow-up-fill in node_modules/@codegouvfr/react-dsfr/BackToTop.js
Found import of BackToTop in src/App.tsx
Including the CSS of 1 DSFR components (out of 45).

dsfr.min.css tombe à 200 664 octets, fr-link conservé, fr-table et fr-accordion absents. Sans l'entrée dans la map, le même projet donne :

[react-dsfr] Unknown react-dsfr module "BackToTop" imported in src/App.tsx: no CSS is trimmed at all for this run, every component is included.
Including the CSS of 45 DSFR components (out of 45).

soit 591 050 octets. L'entrée vaut donc 390 ko de CSS sur ce projet, et son absence est bien le fail-safe. Le test publicModuleCoverage échoue également en nommant BackToTop (from src/BackToTop): resolves to undefined.

L'icône n'est pas rognée non plus : npm pack @codegouvfr/react-dsfr@1.33.0 montre que le paquet publié embarque src/ (386 fichiers), et le scanner d'icônes descend dans node_modules/@codegouvfr/react-dsfr/src, où le littéral fr-icon-arrow-up-fill est présent.

Vérifications lancées

Commande Résultat
yarn build OK
tsc -p src --noEmit OK
tsc -p src/bin --noEmit OK (src/tsconfig.json exclut ./bin, donc couvert séparément)
yarn test 27 fichiers, 108 tests
yarn format:check OK
eslint sur les fichiers touchés OK
npx react-dsfr optimize-css sur projet jetable 1 composant sur 45

Limites

  • Le retrait du tabindex au blur n'est pas vérifié. Le composant pose tabindex="-1" sur la cible si elle n'est pas déjà atteignable, et le retire sur blur. Retirer l'attribut juste après focus() ne marche pas, c'est mesuré : l'élément est blurré et document.activeElement retombe sur <body>. Le blur lui-même n'a pas pu être testé, l'environnement de test ne dispatche aucun événement de focus (document.hasFocus() est false). Si le blur ne vient jamais, ce qui reste est un tabindex="-1", qui par définition garde l'élément hors de l'ordre de tabulation : le mode dégradé est inerte.
  • Pas de story live pour targetRef : getStory ne passe que des props sérialisables au composant, donc la variante est documentée par un bloc de code dans la description, comme le fait déjà Accordion pour son mode contrôlé.
  • Pas de test unitaire : le dépôt n'a aucune infra de rendu (ni jsdom ni @testing-library/react). Le markup ci-dessus a été vérifié via react-dom/server dans un fichier jetable, et scrollBackTo dans un navigateur en injectant la fonction telle que compilée dans dist/BackToTop.js.

Picks up the work started in #139 by @sylvainlg, who confirmed it could be
taken over, and addresses the review left open there.

The default is the DSFR canonical form: an `<a href="#top">` carrying `fr-link`,
the `arrow-up-fill` icon on the left and the "Haut de page" label. The anchor is
what moves the navigation focus back to the top of the page, not just the
scroll, so it stays the default rather than a scripted scroll.

For pages that cannot set `id="top"` on their topmost element, `targetRef`
renders a `<button type="button">` styled as a link, which scrolls the element
into view, moves the focus onto it and honours `prefers-reduced-motion`.
`anchor` and `targetRef` are mutually exclusive at the type level.

Registers the component in REACT_DSFR_MODULE_TO_DSFR_COMPONENTS so the
component CSS optimizer keeps trimming when a consumer imports it.
Copilot AI lite review requested due to automatic review settings August 31, 2026 09:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a new BackToTop component to the React DSFR component set, aligned with DSFR guidance and the prior discussion in #139 (including an accessible targetRef variant that restores focus when scrolling programmatically).

Changes:

  • Added BackToTop component with two mutually exclusive APIs: anchor (default #top) or targetRef (renders a <button> and performs scroll + focus).
  • Added Storybook stories and documentation for the component and its prop semantics.
  • Updated the CSS optimizer mapping so importing BackToTop resolves to the DSFR link CSS component.

Reviewed changes

Copilot reviewed 2 out of 3 changed files in this pull request and generated no comments.

File Description
src/BackToTop.tsx Implements the new BackToTop component (anchor default + accessible targetRef behavior) and its i18n strings.
stories/BackToTop.stories.tsx Adds Storybook documentation/stories and prop typing assertions for BackToTop.
src/bin/only-include-css-of-used-components.ts Ensures CSS optimizer includes DSFR link styles when BackToTop is used.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@kevbarns kevbarns self-assigned this Aug 31, 2026
…scroll

`behavior: "auto"` does not mean "do not animate", it means "use the CSS
scroll-behavior". On a page that sets `scroll-behavior: smooth`, the
prefers-reduced-motion branch animated the scroll anyway, so it did nothing.

Measured in a browser by reading scrollTop synchronously right after the call,
on a container whose stylesheet sets `scroll-behavior: smooth`: `behavior:"auto"`
leaves scrollTop untouched (animated), `"instant"` lands it immediately.

`"instant"` is not the fix: it is absent from TypeScript 4.9's ScrollBehavior and
throws a TypeError on browsers that predate it, which would break the handler
after the focus had already moved. The CSS is neutralised inline and restored
instead, which is what the DSFR does around its own scroll lock.

Also stop leaving a tabindex behind on the consumer's node, drop the story that
pointed at an anchor absent from the Storybook canvas, and drop the keyof assert
that had no precedent in stories/.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants