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'}>×</button>
Trois garanties, qui sont l'essentiel de ce que la primitive apporte par rapport à un appel direct :
- 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
queueMicrotaskdéfensif. - 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.
destroyn'est appelée qu'au démontage. Un changement de paramètre passe parupdate; 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.