speedster-js Guide

Composition

Un composant reçoit du contenu comme il reçoit une propriété.


Propriétés

<!-- Carte.spd -->
<script lang="ts">
  export let titre: string
  export let sousTitre: string = ''      // facultative : elle a une valeur par défaut
  export let compte: number
</script>

<article>
  <h2>{titre}</h2>
  {#if sousTitre !== ''}<p>{sousTitre}</p>{/if}
  <span>{compte}</span>
</article>
<Carte titre="Aujourd'hui" compte={taches.length} />

Annotez le type. Il devient l'interface Props que le compilateur exporte, et c'est elle qui rend <Carte titre={42} /> refusé par speedster-check — sur la ligne du parent, pas dans du code généré. Sans annotation ni valeur littérale, un avertissement untyped-prop le rappelle.

Une propriété se transmet par accesseur : l'enfant lit à travers, donc jusqu'à l'état du parent. Elle reste réactive sans que l'enfant soit reconstruit quand la valeur change.

Étalement

<Carte {...donnees} />

Copié une fois : les valeurs ne resteront pas réactives. Le compilateur le signale (static-spread). Préférez nommer les propriétés qui doivent bouger.


Contenu

Ce qu'on écrit entre les balises d'un composant devient une propriété nommée children :

<!-- Boite.spd -->
<script lang="ts">
  import type { Snippet } from '@ajustor/speedster-runtime'
  export let titre: string
  export let children: Snippet
</script>

<section>
  <h2>{titre}</h2>
  <div class="corps">{@render children()}</div>
</section>
<Boite titre="Composition">
  <p>Ce paragraphe est écrit dans le parent…</p>
  <button on:click={() => n++}>…et il en garde l'état : {n}</button>
</Boite>

Le contenu s'exécute dans la portée du parent : il voit ses états, ses fonctions, son contexte. L'enfant décide seulement où l'afficher.

Un composant qui ne déclare pas children mais en reçoit est une erreur de type, signalée sur la ligne d'appel.


Fragments nommés

Un fragment nommé est du contenu paramétré, transmissible comme une valeur.

<!-- Liste.spd -->
<script lang="ts">
  import type { Snippet } from '@ajustor/speedster-runtime'

  export let items: Tache[]
  export let ligne: Snippet<[Tache, number]>
  export let vide: Snippet
</script>

<ul>
  {#each items as item, i (item.id)}
    {@render ligne(item, i)}
  {:else}
    {@render vide()}
  {/each}
</ul>
<!-- Page.spd -->
{#snippet ligne(tache: Tache, index: number)}
  <li class:faite={tache.faite}>
    {index + 1}. {tache.titre}
    <button on:click={() => basculer(tache.id)}>Basculer</button>
  </li>
{/snippet}

{#snippet vide()}
  <li>Rien à faire</li>
{/snippet}

<Liste items={taches} {ligne} {vide} />

Snippet<[Tache, number]> déclare les arguments : passer un string là où un Tache est attendu est une erreur de compilation.

Déclarés là où ils servent

Un fragment peut aussi s'écrire dans les enfants du composant qui le reçoit. La forme au-dessus reste valable ; celle-ci se lit sans aller chercher le nom ailleurs dans le fichier, et dit à qui le fragment appartient.

<!-- Page.spd -->
<Liste items={taches}>
  {#snippet ligne(tache: Tache, index: number)}
    <li class:faite={tache.faite}>
      {index + 1}. {tache.titre}
    </li>
  {/snippet}

  {#snippet vide()}
    <li>Rien à faire</li>
  {/snippet}
</Liste>

C'est la même chose pour l'enfant : chaque fragment arrive sous son nom, comme une propriété, et le typage est vérifié de la même façon. Ce qui reste entre les balises en dehors d'un {#snippet} devient children, comme avant.

Un fragment déclaré ainsi n'est pas visible de ses frères, et ne peut pas être affiché par un {@render} du parent : il appartient au composant dans la balise duquel il est écrit. Pour le partager entre plusieurs endroits, déclarez-le au niveau du composant et transmettez-le par son nom.

Les arguments restent vivants

Les arguments d'un fragment sont transmis comme des fonctions, et déballés à la lecture. Vous écrivez {tache.titre} sans y penser, et la valeur reste réactive : si la tâche change, la ligne se met à jour sans être reconstruite.

C'est aussi ce qui permet au même contrat de valoir côté serveur, où un fragment empile du texte au lieu d'insérer du DOM.


Liaison entre composants

bind: traverse la frontière d'un composant. Le parent transmet un accesseur en lecture et en écriture :

<!-- Champ.spd -->
<script lang="ts">
  export let valeur: string
  export let etiquette: string
  const vider = () => { valeur = '' }     // réaffectée : c'est une liaison
</script>

<label>{etiquette}
  <input bind:value={valeur} />
</label>
<button type="button" on:click={vider}>Vider</button>
<!-- Page.spd -->
<Champ bind:valeur={nom} etiquette="Nom" />
<p>Bonjour {nom}</p>

L'enfant ne déclare rien de particulier : il réaffecte sa propriété, et c'est tout. Ce qui se passe ensuite dépend du parent, et couvre les deux usages du même composant :

Lecture Écriture de l'enfant
bind:valeur={nom} va chercher chez le parent y retourne
valeur={nom} idem, tant que l'enfant n'écrit pas reste locale ; une nouvelle valeur du parent reprend la main

Autrement dit : un composant lié et le même composant non lié se comportent tous deux correctement, sans être modifiés. La forme raccourcie bind:valeur fonctionne quand le nom est le même des deux côtés.

La cible doit être un let du composant : bind:valeur={objet.champ} est refusé, parce qu'écrire dans une propriété ne préviendrait pas le graphe réactif.


Ce qui ne s'applique pas à un composant

<Carte on:click={agir} />       <!-- refusé -->
<Carte use:infobulle />         <!-- refusé -->
<Carte transition:fondu />      <!-- refusé -->

Un composant n'est pas un élément du document : il peut n'avoir aucune racine, ou plusieurs. Pour un gestionnaire, transmettez-le en propriété :

<Carte auClic={agir} />
<script lang="ts">
  export let auClic: () => void
</script>

<article on:click={auClic}>…</article>

C'est plus explicite, et cela laisse l'enfant décider quel élément porte le gestionnaire — ce qu'un transfert automatique ne saurait pas faire.


Organiser une application

Rien n'est imposé, mais deux habitudes se marient bien avec le modèle :

Les états partagés vivent dans des modules, pas dans un composant racine passé de propriété en propriété :

// magasin/taches.ts
import { signal, computed } from '@ajustor/speedster-runtime'

export const taches = signal<Tache[]>([])
export const restantes = computed(() => taches().filter((t) => !t.faite).length)

export const ajouter = (titre: string) => {
  taches.set([...taches(), { id: Date.now(), titre, faite: false }])
}
<script lang="ts">
  import { taches, restantes, ajouter } from './magasin/taches.js'
</script>

<p>{restantes()} restantes sur {taches().length}</p>

Le compilateur voit des appels de fonction : il pose un effet là où ils sont lus, et rien d'autre. C'est exactement ce qu'il ferait pour un état local.

Le contexte sert à ce qui dépend de l'endroit — un thème, une locale, une connexion — plutôt qu'à ce qui est global. Voir Réactivité.