speedster-js Guide

Actions et transitions

Deux portes de sortie du modèle déclaratif, pour ce qu'il ne sait pas exprimer.


use: — du comportement impératif

Une action est une fonction ordinaire qui reçoit un nœud : poser un IntersectionObserver, brancher une bibliothèque de glisser-déposer, piéger le focus dans une boîte de dialogue.

// actions/infobulle.ts
import type { Action } from '@ajustor/speedster-runtime'

export const infobulle: Action<HTMLElement, string> = (node, texte) => {
  let bulle: HTMLElement | null = null

  const montrer = () => {
    bulle = document.createElement('div')
    bulle.role = 'tooltip'
    bulle.textContent = texte
    node.after(bulle)
  }
  const cacher = () => { bulle?.remove(); bulle = null }

  node.addEventListener('mouseenter', montrer)
  node.addEventListener('mouseleave', cacher)

  return {
    update: (suivant) => { texte = suivant; if (bulle !== null) bulle.textContent = texte },
    destroy: () => {
      cacher()
      node.removeEventListener('mouseenter', montrer)
      node.removeEventListener('mouseleave', cacher)
    },
  }
}
<button use:infobulle={'Fermer la fenêtre'}>&times;</button>

Trois garanties, qui sont l'essentiel de ce que la primitive apporte par rapport à un appel direct :

  1. L'élément est dans le document. L'action est exécutée après l'insertion : mesurer ou observer fonctionne dès la première ligne, sans queueMicrotask défensif.
  2. Ce que l'action lit ne la relance pas. Son corps est exécuté hors suivi : une action qui consulte un signal au montage ne devient pas un effet.
  3. destroy n'est appelée qu'au démontage. Un changement de paramètre passe par update ; seule la disparition de l'élément détruit l'action.

Le type du paramètre est vérifié, et l'erreur tombe sur le nom de l'action dans le .spd. Une faute de frappe est un avertissement unknown-reference au build, avant même la vérification de types.

Sans paramètre

export const autofocus: Action = (node) => {
  node.focus()
}
<input use:autofocus aria-label="Recherche" />

transition: — entrée et sortie

Une transition est décrite, pas exécutée : elle retourne un style en fonction de l'avancement.

import type { Transition } from '@ajustor/speedster-runtime'

export const fondu: Transition = () => ({
  duration: 200,
  css: (t) => `opacity: ${t}`,
})
{#if visible}
  <p transition:fondu>Apparaît et disparaît</p>
{/if}
Forme Quand
transition:x À l'entrée et à la sortie
in:x À l'entrée seulement
out:x À la sortie seulement

Les trois acceptent un paramètre : in:vol={{ y: 20 }}.

Le contrat

interface TransitionConfig {
  delay?: number                          // attente, en ms
  duration?: number                       // durée, en ms — 0 rend instantané
  easing?: (t: number) => number          // courbe, de 0 à 1
  css?: (t: number, u: number) => string  // style ; `u` vaut 1 - t
  tick?: (t: number, u: number) => void   // alternative impérative
}

t va de 0 à 1 dans le sens de l'apparition, quel que soit le sens joué : une sortie parcourt la même courbe à l'envers. C'est ce qui fait qu'une entrée interrompue et sa sortie se ressemblent.

Préférez css. L'interpolation est alors échantillonnée une fois, à l'ouverture, et l'API d'animation du web la joue hors du fil principal. tick existe pour ce que le style ne peut pas exprimer — la position de lecture d'un média, le contenu d'un canevas — et s'exécute sur le fil principal.

La fonction reçoit aussi le sens déclaré, ce qui permet de proposer une sortie différente de l'entrée :

export const glisser: Transition = (node, _, { direction }) => ({
  duration: direction === 'out' ? 120 : 250,
  css: (t, u) => `transform: translateX(${u * -20}px); opacity: ${t}`,
})

Les transitions fournies

import { fade, fly, scale, slide, cubicOut } from '@ajustor/speedster-runtime/transitions'
<p transition:fade>…</p>
<p in:fly={{ y: 20, duration: 300 }}>…</p>
<p transition:scale={{ start: 0.8 }}>…</p>
<div transition:slide>…</div>
Options
fade delay, duration, easing
fly + x, y, opacity
scale + start, opacity
slide + axis: 'y' | 'x'

Elles vivent dans un sous-module : une application qui ne les importe pas ne les embarque pas. Aucune n'anime la disposition, sauf slide, qui le fait sciemment — replier un élément sans laisser sa place n'a pas d'autre solution, et le prix est un recalcul de mise en page à chaque image. À réserver aux éléments qui apparaissent isolément.

La sortie retarde le retrait

C'est la seule chose que le framework fasse vraiment ici : un bloc qui retire son contenu attend que les sorties aient joué. Sans cela, l'animation porterait sur des nœuds déjà partis — c'est-à-dire jamais.

Cela vaut pour {#if}, {#key}, le repli d'une liste, et chaque élément retiré d'une liste :

<ul>
  {#each taches as tache (tache.id)}
    <li transition:fade>{tache.titre}</li>
  {/each}
</ul>

Une application qui n'écrit aucune transition ne paie qu'une comparaison à null dans chaque retrait : tout le mécanisme vit dans un module que seul transition: fait entrer dans le bundle.

Ce qui n'est pas là

animate: — le déplacement animé d'un élément lorsqu'une liste est réordonnée. La réconciliation déplace déjà les nœuds ; il manque la mesure avant/après.


Accessibilité à l'exécution

Deux primitives, pour ce que le balisage ne peut pas dire seul.

import { announce, moveFocus } from '@ajustor/speedster-runtime'

announce('3 tâches supprimées')                 // région polie
announce('Connexion perdue', 'assertive')       // interrompt la lecture en cours

L'annonce passe par une région live gérée pour vous. Deux annonces identiques consécutives sont bien lues deux fois — la région est vidée puis remplie dans une tâche distincte, sinon la seconde passerait inaperçue.

moveFocus(element)                       // pose tabindex="-1" si nécessaire
moveFocus(element, { preventScroll: true })

Après une navigation ou l'ouverture d'un panneau, déplacer le focus est ce qui permet de continuer au clavier. moveFocus rend l'élément focalisable s'il ne l'était pas, plutôt que d'échouer en silence.