


CSS logical properties : la révolution pour l’internationalisation

Lors d’une mission récente j’ai récemment dû implémenter un chatbot. C’était une première et je me dis que c’est l’occasion parfaite pour vous partager mon expérience et des conseils si vous aussi passez par là.
Avoir un chatbot facilement utilisable sur n’importe quelle page, léger, performant et personnalisable, selon les besoins du client.
En terme d’usage voici ce que je visais :
<body> <chatbot toggle-button-text="Fragrance advisor" chatbot-header-title-text="Fragrance advisor" /> <script type="module" src="./dist/chatbot.js"></script> </body>
Mon seul objectif : avoir un chatbot fonctionnel sur n’importe quelle page du site. Je me suis donc dirigé vers un web component. Me restait maintenant à décider comment j’allais créer ce web component. Tout faire nativement ? Utiliser un framework comme Lit ou Svelte ?
J’ai opté pour le combo Svelte 5 + SvelteKit puisque j’avais déjà eu une bonne expérience avec Svelte 4 l’année passée pour un projet personnel.
Une API REST et son schema OpenAPI étaient à ma disposition. Je pouvais donc facilement accéder à la session de l’utilisateur, aux conversations, aux messages de la conversation et surtout le endpoint /chat
(en server-sent events).
Cet endpoint /chat
acceptait uniquement les données dans le body
d’une requête en POST. Malheureusement, l’API EventSource qui nous permet d’utiliser les server-sent events dans le navigateur ne prend pas de paramètre dans le body. J’ai donc dû utiliser ce package maintenu par Microsoft : fetch-event-source.
Le schema OpenAPI étant disponible, j’ai utilisé openapi-fetch et openapi-typescript pour typer tous mes endpoints et bénéficier de l’autocomplétion Intellisense dans VS Code.
Je vous recommande Bruno, une alternative open-source à Postman, que j’utilise pour facilement tester mes requêtes ailleurs que dans mon environnement de dev.
Je voulais fournir un chatbot aussi isolé et autonome que possible. Quand on développe un web component il faut garder en tête que son CSS est presque entièrement isolé du reste de la page. Presque vous dites ? Et oui, il existe une liste d’irréductibles propriétés qui fuitent encore et toujours dans notre Shadow DOM (Source). C’est d’ailleurs une bonne chose puisqu’un web component doit normalement hériter du branding et donc des font-family
définies sur la page.
Si on a ce DOM…
<div class="chatbot-wrapper"> <chatbot /> </div>
… alors une variable CSS --padding
définie en global et une propriété color
définie sur l’élément parent seront héritées par notre <chatbot>
:
/* CSS from the page containing the web component */ :root { --padding: 1rem; } .chatbot-wrapper { color: red; } /* CSS of our web component */ :host { /* Reset all CSS properties. In our case to prevent the color of our web component to be `red`; */ all: initial; padding: var(--padding); /* 1rem */ }
all: initial
au style de reset de votre web componentEn ajoutant all: initial
aux styles de base de notre Shadow DOM on s’assure de ne pas avoir de mauvaise surprise une fois notre composant en production.
Utilisez un textarea
au lieu d’un input
pour pouvoir écrire du texte formaté avec des sauts de ligne (Shift + Enter).
<textarea bind:this={ui.textarea} bind:value={ui.inputValue} placeholder={translate(userLanguage).placeholder} rows={1} disabled={isLoading} oninput={autoResize} onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onsubmit(e); } }} id="chat-input" ></textarea>
En complément, permettez à la hauteur du textarea
de grandir en fonction du contenu:
field-sizing: content; max-block-size: 7lh; resize: none; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--grey-light) transparent;
La propriété field-sizing n’est pas encore disponible sur Firefox ni Safari. Vous devrez ajouter ce comportement via JavaScript en complément :
const autoresize = CSS.supports("field-sizing: content") ? null : function (e: Event) { const textarea = e.target as HTMLTextAreaElement; textarea.style.height = textarea.scrollHeight + "px"; }
Avec server-sent events, les messages reçus par le endpoint /chat
sont divisés en chunk. Au lieu d’attendre d’avoir reçu tous les chunks pour afficher le message final et faire attendre l’utilisateur pour rien, pensez à afficher ces chunks à l’utilisateur dés leur réception pour avoir une impression d’écriture progressive du message par le chatbot.
Aussi, scrollez la zone de chat vers le bas lorsque vous recevez des chunks de l’endpoint /chat
pour garder la dernière partie du message toujours visible à l’écran.
Une fois le message reçu ajoutez le focus
sur le textarea
pour permettre à l’utilisateur de directement écrire une réponse sans avoir à cliquer.
Il semblerait que la manière la plus efficace à l’heure actuelle est de rajouter un élément dans le bloc parent scrollable contenant nos messages
Avec Svelte, la rune $effect
et l’API scroll into view voilà le résultat :
let ui = $state({ isLoading: false, textarea: null as HTMLTextAreaElement | null, scrollAnchor: null as HTMLDivElement | null, }); function scrollToBottom() { if (ui.scrollAnchor) { requestAnimationFrame(() => { ui.scrollAnchor?.scrollIntoView({ behavior: "smooth", block: "end", }); }); } } $effect(() => { if (messages.length > 0) { // Handle scrolling scrollToBottom(); // Handle focus only when loading completes if (!ui.isLoading && ui.textarea) { requestAnimationFrame(() => { ui.textarea?.focus(); }); } } });
Dans mon cas le chatbot prend toute la hauteur de la page. Donc on doit permettre aux développeurs en charge du site de désactiver/activer le scroll sur la page lorsque le chatbot est ouvert/fermé.
On a certes pas la main sur la page accueillant l’instance de notre web component, mais on peut néanmoins fournir un custom event (CustomEvent API) lorsque notre composant s’ouvre et se ferme via la méthode dispatchEvent.
/** * Emit a custom event * (c) Chris Ferdinandi, MIT License, https://gomakethings.com * @param {String} type - The event type * @param {Object} detail - Any details to pass along with the event * @see https://gist.github.com/cferdinandi/58d272dd09ef4fe76609b7ae9be89c58#file-index-html-L462-L480 */ function emit(type: string, detail = {}) { let event = new CustomEvent(`chatbot:${type}`, { bubbles: true, cancelable: true, detail: detail, }); const rootElement = document.querySelector("chatbot"); rootElement?.dispatchEvent(event); } function openChatbot() { chatbotOpen = true; textarea?.focus(); emit("change-open-state", { open: chatbotOpen }); } function closeChatbot() { chatbotOpen = false; emit("change-open-state", { open: chatbotOpen }); }
Dans mon cas le chatbot ne fournissait pas de système d’authentification. Mais l’API me fournissait des endpoints pour créer une session, des conversations, et récupérer les messages d’une conversation.
const initChat = async () => { const sessionCookie = getCookie("session-id"); if (!sessionCookie) { sessionId = `session_${generateUUID()}_${Date.now()}`; createSession(sessionId); const duration = 7 * 24 * 60 * 60; const expires = new Date(Date.now() + duration * 1000).toUTCString(); document.cookie = `session-id=${sessionId}; path=/; expires=${expires}; Secure; SameSite=Strict;`; } else { sessionId = sessionCookie; const session = await getSession(sessionId); const conversations = await getConversations(session.id); // If we get conversations we retrieve // the most recent one and display // its messages in the chat } };
Le chatbot devait être disponible dans plusieurs langues. Son contenu est partagé entre le contenu statique et la conversation elle-même.
Dans mon cas, le contenu statique était composé :
aria-label
sur les icons en SVG<label>
du <textarea>
Le texte des empty states et de la politique de confidentialité change en fonction de la langue du navigateur de l’utilisateur.
J’ai préconisé un simple objet JavaScript regroupant toutes mes locales et leurs clés respectives pour gérer la traduction de ces contenus.
Voici un exemple simplifié :
export type SupportedLocale = "en" | "fr"; export const supportedLocales: SupportedLocale[] = ["en", "fr"]; const translations: Record<string, Record<string, string>> = { en: { welcome: "How may I help you?", }, fr: { welcome: "Comment puis-je vous aider ?", }, }; export function translate(locale: SupportedLocale) { const safeLocale = supportedLocales.includes(locale) ? (locale as SupportedLocale) : "en"; return translations[safeLocale] as Record<string, string>; }
Dans mon composant Svelte je pouvais ensuite utiliser la fonction translate
en passant la locale du navigateur et la clé souhaitée :
<p class="chatbot-fragrance__welcome-text"> {translate(userLanguage).welcome} </p>
Le contenu de la conversation étant généré via le backend, je n’avais uniquement qu’à fournir la langue du navigateur au backend dans le endpoint /chat
pour définir la langue de la conversation.
Le développement de ce web component via Svelte 5 et SvelteKit s’est déroulé sans encombres. J’aime la simplicité de Svelte et notamment leur documentation très fournie. Je vous conseille d’ailleurs leur MCP fraîchement sorti.
J’espère que ce retour d’expérience et ces quelques conseils vous seront utiles.
Et sur ce je vous dis à tantôt 🖖