<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-ask-my-blog-bubble
  published:     2026-10-05
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait

> Une fonctionnalité que vos visiteurs ne trouvent pas n'existe pas. Cet article ajoute une bulle flottante « Ask my blog » à un site Docusaurus — un troisième point d'entrée vers le même index de questions, et le premier qui ne demande aucune connaissance préalable. Avec l'astuce d'exclusion mutuelle qui empêche deux dialogues flottants indépendants de s'ouvrir en même temps.

---
![La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait](/img/v2/ask-my-blog-chatbox.webp)

**TL;DR**

J'avais construit une carte du corpus, un index de 2 050 questions et une palette de commandes à six modes — puis j'ai remarqué que chaque accès à l'index de questions supposait de savoir qu'il existait. Cet article est la moitié bon marché de cette histoire : une bulle de chat flottante qui ouvre la recherche de questions depuis n'importe quelle page, et un indice de première visite qui mentionne le raccourci clavier une seule fois. Plus la petite astuce pub-sub qui garantit que deux dialogues flottants indépendants ne sont jamais ouverts en même temps.

Il existe un embarras particulier, réservé aux gens qui construisent seuls. J'avais passé des semaines sur des fonctionnalités dont j'étais sincèrement fier : une [carte interactive de tout le blog](/blog/docusaurus-blog-map), un [index de questions](/blog/docusaurus-ask-my-blog) généré par un modèle local, une [palette de commandes](/blog/?q=docker) à six modes.

Puis je me suis posé une question plus simple que d'habitude : comment un lecteur qui n'est jamais venu ici trouverait-il l'index de questions ? Il y avait exactement deux portes d'entrée. Appuyer sur `Ctrl`+`K`, puis `?` pour basculer la palette en mode question — ce qui suppose de connaître à la fois le raccourci et l'existence du mode. Ou aller sur `/faq`, ce qui suppose de savoir que la page existe.

Les deux portes étaient ouvertes. Les deux ne fonctionnaient que pour quelqu'un qui avait déjà lu l'article les annonçant, c'est-à-dire : moi.

**Quick Jump:** [Ce qui a changé](#what-changed) | [Construire la bulle](#building-the-bubble)

## Ce qui a changé {#what-changed}

Deux choses, dont aucune n'est une nouvelle fonctionnalité :

```plaintext
1. A floating bubble              2. A first-visit pill
   ┌────────────────┐               ┌─────────────────┐
   │ 🦫 Ask my blog │               │ Press ⌘K to     │
   ├────────────────┤               │ search      ✕   │
   │ [Ask a questi…]│               └─────────────────┘
   │  › How do I …  │                 once per browser,
   │  › What is …   │                 then never again
   └────────────────┘
     bottom-right, every page
```

La bulle est la plus intéressante, parce qu'elle est la **troisième** voie vers exactement le même index de questions : `Ctrl+K` puis `?`, la page autonome `/faq`, et maintenant un bouton en forme de chat qui suit le lecteur partout. Même corpus, même classement, mêmes résultats — trois portes.

## Pourquoi trois portes, ce n'est pas de la redondance {#why-three-doors-is-not-redundancy}

J'ai résisté à la bulle un moment, au motif que la palette faisait déjà le travail. C'était une erreur, et voici le raisonnement qui m'a fait changer d'avis :

* **Chaque porte sert un lecteur différent.** La palette sert ceux qui savent déjà qu'elle existe. `/faq` sert les moteurs de recherche et les gens qui veulent parcourir un sujet. La bulle sert le lecteur bloqué au milieu d'un article, qui n'aurait jamais l'idée d'appuyer sur un raccourci clavier.
* **Un bouton flottant est la seule affordance que tout le monde comprend déjà.** Personne n'a besoin qu'on lui explique ce que fait une bulle de chat dans un coin. C'est précisément pour ça qu'elle mérite un coin — et pourquoi l'en-tête du panneau arbore la même [mascotte suricate](/blog/docusaurus-easter-eggs) qu'on retrouve partout ailleurs sur ce site.
* **Ça ne coûte presque rien à ajouter.** La bulle ne réimplémente pas la recherche — elle monte le composant de recherche existant dans un panneau. Environ 150 lignes, dont l'essentiel est de la gestion du focus.
* **La partie chère était déjà payée.** Le corpus de questions, le classement, le chargement paresseux : tout existait. Ajouter un point d'entrée à une fonctionnalité déjà construite est le travail au meilleur rapport effort/résultat, et c'est celui qu'on saute.

Ce raisonnement a une limite, et il vaut mieux l'énoncer avant d'aller ajouter une quatrième porte. Le test n'est pas « combien de points d'entrée puis-je caser ? » mais « celui-ci atteint-il un lecteur que les autres ratent ? ». J'avais aussi prévu une tuile en page d'accueil pointant vers la palette, et je l'ai abandonnée : la barre de navigation affiche déjà un champ de recherche visible en permanence, avec le raccourci imprimé dedans, sur chaque page. Trois portes pour trois lecteurs différents, oui. Trois rappels de la même porte, non.

## Construire la bulle {#building-the-bubble}

Le composant est volontairement mince. Quand le panneau est fermé, rien de lié à la recherche n'est monté — le corpus de questions de 468 Ko n'est donc téléchargé que par un lecteur qui l'ouvre vraiment :

**Project: src/components/AskMyBlogWidget**

**src/components/AskMyBlogWidget/index.tsx**

```typescript
/**
 * AskMyBlogWidget — a third, always-visible entry point into "Ask my blog" (alongside
 * Ctrl+K → `?` and the standalone /faq page): a floating bubble, bottom-right, that opens a
 * small panel wrapping the existing <AskMyBlog> search box (src/components/AskMyBlog). It
 * doesn't reimplement search — <AskMyBlog> is only mounted once the panel is actually open,
 * so the question corpus fetch stays on-demand exactly as it already is for CommandPalette's
 * `?` mode (see AskMyBlog/questionsIndex.js for why that fetch is lazy).
 *
 * Reserves a fixed slot stacked above the scroll-to-top button's corner
 * (src/components/ScrollToTopButton, mounted on every page by src/theme/Layout) rather than
 * detecting its presence at runtime — that button stays hidden until the reader scrolls, and the
 * empty space above it meanwhile is harmless.
 */

import { useCallback, useEffect, useRef, useState, type JSX } from "react";
import { translate } from "@docusaurus/Translate";
import AskMyBlog from "@site/src/components/AskMyBlog";
import { setActiveOverlay } from "@site/src/components/CommandPalette/paletteBus";
import mascot from "@site/static/img/ask_my_blog.webp";
import styles from "./styles.module.css";

const HINT_STORAGE_KEY = "askmyblog_bubble_hint_shown";
const HINT_DELAY_MS = 15_000;

export default function AskMyBlogWidget(): JSX.Element {
  const [open, setOpen] = useState(false);
  const [showHint, setShowHint] = useState(false);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const bubbleRef = useRef<HTMLButtonElement | null>(null);
  const clearOverlayRef = useRef<(() => void) | null>(null);

  // First-visit discoverability, shown at most once ever — same mechanic as
  // CommandPalette/Hint.tsx, offset from its 10s delay so the two don't animate in together.
  useEffect(() => {
    if (localStorage.getItem(HINT_STORAGE_KEY)) return undefined;
    const timer = setTimeout(() => {
      localStorage.setItem(HINT_STORAGE_KEY, "1");
      setShowHint(true);
    }, HINT_DELAY_MS);
    return () => clearTimeout(timer);
  }, []);

  const close = useCallback(() => {
    setOpen(false);
    clearOverlayRef.current?.();
    bubbleRef.current?.focus();
  }, []);

  const toggle = () => {
    setShowHint(false);
    if (open) {
      close();
      return;
    }
    setOpen(true);
    clearOverlayRef.current = setActiveOverlay(close);
  };

  useEffect(() => {
    if (!open) return undefined;

    panelRef.current?.querySelector("input")?.focus();

    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.preventDefault();
        close();
      }
    }

    function onTab(event: KeyboardEvent) {
      if (event.key !== "Tab" || !panelRef.current) return;
      const focusable = panelRef.current.querySelectorAll<HTMLElement>(
        'button, [href], input, [tabindex]:not([tabindex="-1"])',
      );
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    document.addEventListener("keydown", onKeyDown);
    document.addEventListener("keydown", onTab);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.removeEventListener("keydown", onTab);
    };
  }, [open, close]);

  return (
    <>
      {open && (
        <div
          ref={panelRef}
          className={styles.panel}
          role="dialog"
          aria-modal="true"
          aria-label={translate({ id: "askMyBlogWidget.title", message: "Ask my blog" })}
        >
          <div className={styles.panelHead}>
            <span className={styles.panelHeadTitle}>
              <img
                src={mascot}
                alt=""
                className={styles.panelHeadMascot}
                width="40"
                height="40"
              />
              {translate({ id: "askMyBlogWidget.title", message: "Ask my blog" })}
            </span>
            <button
              type="button"
              className={styles.panelClose}
              aria-label={translate({
                id: "theme.common.askMyBlogClose",
                message: "Close",
                description: "The aria label for closing the Ask my blog chat panel",
              })}
              onClick={close}
            >
              &times;
            </button>
          </div>
          <div className={styles.panelBody}>
            <AskMyBlog
              placeholder={translate({
                id: "blog.askMyBlog.placeholder",
                message: "Ask a question…",
              })}
              maxResults={5}
              showLabel={false}
            />
          </div>
        </div>
      )}
      <button
        ref={bubbleRef}
        type="button"
        className={styles.bubble}
        onClick={toggle}
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-label={translate({
          id: "theme.common.askMyBlog",
          message: "Ask my blog",
          description: "The aria label for the floating Ask my blog chat bubble",
        })}
      >
        {showHint && <span className={styles.pulseRing} aria-hidden="true" />}
        <svg
          className={styles.bubbleIcon}
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
        </svg>
      </button>
    </>
  );
}
```

**src/components/AskMyBlogWidget/styles.module.css**

```css
.bubble {
  position: fixed;
  right: 30px;
  /* 16px clear of the scroll-to-top button below it (ScrollToTopButton: bottom 30px, 60px
     tall → top edge at 90px) — the two used to sit 10px apart, easy to mis-tap in the
     bottom-right thumb zone. */
  bottom: 106px;
  z-index: 999;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 60px;
  height: 60px;
  padding: 0;
  cursor: pointer;
  background: var(--mk-primary);
  border: none;
  border-radius: 50%;
  box-shadow: var(--mk-shadow-3);
  transition:
    transform 0.2s,
    background 0.2s;
}

.bubble:hover {
  background: var(--ifm-color-primary-dark);
  transform: scale(1.06);
}

.bubble:focus-visible {
  outline: 2px solid var(--mk-primary);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .bubble,
  .bubble:hover {
    transform: none;
    transition: none;
  }
}

.bubbleIcon {
  width: 30px;
  height: 30px;
  color: var(--mk-on-line);
}

.pulseRing {
  position: absolute;
  inset: -4px;
  border: 2px solid var(--mk-primary);
  border-radius: 50%;
  animation: askPulse 2.2s ease-out 3;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .pulseRing {
    display: none;
    animation: none;
  }
}

@keyframes askPulse {
  0% {
    opacity: 0.7;
    transform: scale(1);
  }

  100% {
    opacity: 0;
    transform: scale(1.7);
  }
}

@media (max-width: 600px) {
  .bubble {
    right: 16px;
    /* Same 16px clearance above the mobile scroll-to-top button (bottom 16px, 44px tall →
       top edge at 60px). */
    bottom: 76px;
    width: 44px;
    height: 44px;
  }

  .bubbleIcon {
    width: 22px;
    height: 22px;
  }
}

.panel {
  position: fixed;
  right: 30px;
  bottom: 176px;
  z-index: 999;
  display: flex;
  flex-direction: column;
  width: min(360px, calc(100vw - 2rem));
  max-height: min(480px, calc(100vh - 200px));
  overflow: hidden;
  background: var(--mk-surface);
  border: 1px solid var(--mk-border-strong);
  border-radius: var(--mk-radius-lg);
  box-shadow: var(--mk-shadow-3);
  animation: panelIn 0.15s ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .panel {
    animation: none;
  }
}

@keyframes panelIn {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }

  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.panelHead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0.55rem 1rem;
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--mk-on-line);
  background: var(--mk-primary);
}

.panelHeadTitle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.panelHeadMascot {
  flex-shrink: 0;
  object-fit: cover;
  border: 2px solid var(--mk-on-line);
  border-radius: 50%;
}

.panelClose {
  padding: 0.1rem 0.3rem;
  font-size: 1.3rem;
  line-height: 1;
  color: var(--mk-on-line);
  opacity: 0.85;
  background: none;
  border: none;
  cursor: pointer;
}

.panelClose:hover {
  opacity: 1;
}

.panelBody {
  padding: 0.75rem 1rem 1rem;
  overflow-y: auto;
}

@media (max-width: 600px) {
  .panel {
    right: 16px;
    left: 16px;
    bottom: 128px;
    width: auto;
  }
}
```

Il est monté une fois par page, dans le même swizzle `Layout` qui monte la palette :

```javascript title="src/theme/Layout/index.js"
export default function LayoutWrapper(props) {
  return (
    <Layout {...props}>
      {props.children}
      <CommandPalette />
      <CommandPaletteHint />
      <AskMyBlogWidget />
    </Layout>
  );
}
```

Une décision de mise en page à copier : la bulle réserve un **emplacement fixe empilé au-dessus** du coin occupé par le [bouton de retour en haut](/blog/docusaurus-go-top), plutôt que de détecter la présence de ce bouton à l'exécution. Le retour en haut n'est pas monté sur toutes les pages, et un peu d'espace vide au-dessus quand il est absent est totalement inoffensif — bien plus inoffensif que deux boutons flottants se disputant les mêmes 60 pixels.

## Le problème des deux overlays {#the-two-overlays-problem}

La palette et le panneau de la bulle sont des dialogues flottants indépendants, chacun ouvrable depuis plusieurs endroits, et aucun ne sait que l'autre existe. Ouvrez l'un pendant que l'autre est ouvert et vous obtenez deux modales empilées, deux pièges de focus qui se battent, et deux gestionnaires `Esc`.

La correction tient en huit lignes, et elle vit dans le module de bus de la palette — comme ça, aucun composant n'a besoin d'importer l'autre :

```typescript title="src/components/CommandPalette/paletteBus.ts"
let activeCloser = null;

export function setActiveOverlay(closeFn) {
  if (activeCloser && activeCloser !== closeFn) activeCloser();
  activeCloser = closeFn;
  return () => {
    if (activeCloser === closeFn) activeCloser = null;
  };
}
```

Chaque dialogue appelle `setActiveOverlay(close)` à son ouverture, et appelle la fonction de nettoyage qu'il récupère à sa fermeture. Celui qui s'ouvre en second ferme celui qui était ouvert en premier. Ajouter un troisième overlay plus tard ne demande de changer absolument rien.

## Un indice autorisé à apparaître une seule fois (passez si vous ne voulez que la bulle) {#a-hint-that-is-allowed-to-appear-exactly-once-skip-this-if-you-just-want-the-bubble}

La pastille de la palette et l'anneau pulsant de la bulle sont des indices de première visite, et tous deux suivent les trois mêmes règles :

* Le flag `localStorage` est écrit **quand l'indice est affiché**, pas quand il est fermé. Un visiteur qui l'ignore ne le revoit pas à la page suivante.
* Les délais sont décalés — la pastille de la palette à 4 ou 10 secondes, le pulse de la bulle à 15 — pour que les deux ne s'animent jamais ensemble et ne passent pas pour un pop-up.
* L'anneau pulsant est entièrement supprimé sous `prefers-reduced-motion`, pas seulement ralenti.

Le délai de la palette est volontairement différent selon le type de page, et la raison mérite d'être dite : 10 secondes sur un article, pour que la pastille n'interrompe jamais quelqu'un qui vient de commencer à lire. La page d'accueil n'a aucune lecture à interrompre et c'est là que les visiteurs rebondissent le plus vite, donc elle a 4 secondes — assez long pour ne pas ressembler à un pop-up au chargement.

```typescript title="src/components/CommandPalette/Hint.tsx"
function delayFor(pathname) {
  if (HOME_PATH.test(pathname)) return HOME_DELAY_MS;
  if (ARTICLE_PATH.test(pathname)) return ARTICLE_DELAY_MS;
  return null;
}
```

Renvoyer `null` — plutôt qu'un grand nombre — fait de « ce chemin n'affiche rien » un état distinct et lisible, au lieu d'un délai qu'il faut déchiffrer en plissant les yeux.

## Conclusion {#conclusion}

Les fonctionnalités de cette série ont pris des semaines. Les rendre trouvables a pris un après-midi, et il est tout à fait possible que cet après-midi ait compté davantage.

Il y a un mode d'échec propre à la construction de ses propres outils : vous êtes le seul utilisateur incapable d'évaluer la découvrabilité, parce que vous ne pouvez pas désapprendre où se trouvent les choses. De l'intérieur, chaque porte paraît évidente. Le seul remède que j'ai trouvé est mécanique : listez les points d'entrée d'une fonctionnalité, et pour chacun, notez ce que le lecteur doit déjà savoir pour l'utiliser. Tout ce dont la réponse est « que cette fonctionnalité existe » n'est pas un point d'entrée du tout.

Et en cas de doute, mettez un bouton dans le coin. Tout le monde sait ce qu'il fait.
