speedster-js Guide

Formulaires et liaisons

bind: relie un état à une propriété du DOM, dans les deux sens.

<script lang="ts">
  let filtre = ''
</script>

<label for="f">Filtrer</label>
<input id="f" bind:value={filtre} />
<p>{filtre.length} caractères</p>

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


Champs

<input bind:value={texte} />
<input type="number" bind:value={nombre} />
<textarea bind:value={contenu}></textarea>
<select bind:value={choix}>
  <option value="a">A</option>
</select>

<input type="checkbox" bind:checked={accepte} />
<input type="file" bind:files={fichiers} />

bind:value sur un <select> écoute change, sur les autres champs input : la valeur suit la frappe.


Groupes

Plusieurs champs qui se partagent une valeur.

<script lang="ts">
  let couleur = 'vert'
</script>

<label><input type="radio" bind:group={couleur} value="rouge" /> Rouge</label>
<label><input type="radio" bind:group={couleur} value="vert" /> Vert</label>

Pour des cases à cocher, l'état est le tableau des valeurs cochées :

<script lang="ts">
  export let taches: Tache[]
  let choisies: number[] = []
</script>

{#each taches as tache (tache.id)}
  <label>
    <input type="checkbox" bind:group={choisies} value={tache.id} />
    {tache.titre}
  </label>
{/each}

Un attribut HTML est toujours du texte. Quand la valeur écrite n'en est pas — value={tache.id} est un nombre — elle est transmise à part, et le tableau contient bien des nombres. Sans cela, il se remplirait de "1" au lieu de 1.

Le tableau est remplacé, jamais muté : la réactivité se fonde sur l'affectation.


Le nœud lui-même

<script lang="ts">
  import { moveFocus } from '@ajustor/speedster-runtime'
  let champ: HTMLInputElement | null = null

  const cibler = () => { if (champ !== null) moveFocus(champ) }
</script>

<input bind:this={champ} aria-label="Recherche" />
<button on:click={cibler}>Aller au champ</button>

La référence est rendue à null quand le nœud disparaît — un bloc qui se ferme, un composant qui se démonte. Sans cela, le composant garderait un nœud détaché, invisible mais bien vivant.

Déclarez donc le type comme … | null.


Dimensions

<script lang="ts">
  let largeur = 0
</script>

<div bind:clientWidth={largeur}>
  {#if largeur < 600}<Compact />{:else}<Etendu />{/if}
</div>

clientWidth, clientHeight, offsetWidth, offsetHeight. La liaison va dans un seul sens : une dimension se lit, elle ne s'écrit pas.

Un ResizeObserver unique est partagé par tous les éléments mesurés de l'application — pas un par élément.


Média

<script lang="ts">
  let position = 0
  let enPause = true
  let duree = 0
  let volume = 1
</script>

<video
  src="/film.mp4"
  bind:currentTime={position}
  bind:paused={enPause}
  bind:duration={duree}
  bind:volume={volume}
>
  <track kind="captions" src="/film.vtt" />
</video>

<p>{Math.round(position)} s sur {Math.round(duree)} s</p>
<button on:click={() => { enPause = !enPause }}>
  {enPause ? 'Lire' : 'Pause'}
</button>
Liaison Sens Événement écouté
currentTime ↔ timeupdate
paused ↔ play, pause
volume, muted ↔ volumechange
playbackRate ↔ ratechange
duration → lecture seule durationchange
ended → lecture seule ended, play, emptied
seeking → lecture seule seeking, seeked
videoWidth, videoHeight → lecture seule loadedmetadata

Une propriété que seul le navigateur décide n'est jamais réécrite : lui écrire une valeur ferait taire le navigateur.

Sur une image : bind:naturalWidth, bind:naturalHeight (lecture seule, load).


Autres

<details bind:open={ouvert}>
  <summary>Détails</summary>
  <p>…</p>
</details>

<div contenteditable="true" role="textbox" aria-label="Notes" bind:innerHTML={notes}></div>

open écoute toggle — un événement qui ne remonte pas l'arbre, et qui passe donc par un écouteur direct. innerHTML, innerText et textContent écoutent input.

bind:indeterminate écrit la propriété sans jamais la relire : c'est un état que le navigateur ne modifie pas de lui-même.


Ce qui n'est pas là

bind:scrollY, bind:innerWidth et les autres liaisons portant sur la fenêtre : elles supposeraient un équivalent de <svelte:window>, qui n'existe pas. En attendant, un effet suffit :

<script lang="ts">
  import { effect } from '@ajustor/speedster-runtime'
  let defilement = 0

  effect(() => {
    const suivre = () => { defilement = window.scrollY }
    window.addEventListener('scroll', suivre, { passive: true })
    return () => window.removeEventListener('scroll', suivre)
  })
</script>

Un formulaire complet

<script lang="ts">
  interface Brouillon { titre: string; urgent: boolean; categorie: string }

  export let enregistrer: (valeur: Brouillon) => void

  let titre = ''
  let urgent = false
  let categorie = 'general'

  const valide = titre.trim().length > 0

  const envoyer = () => {
    if (!valide) return
    enregistrer({ titre, urgent, categorie })
    titre = ''
    urgent = false
  }
</script>

<form on:submit|preventDefault={envoyer}>
  <label for="titre">Titre</label>
  <input id="titre" bind:value={titre} required />

  <label>
    <input type="checkbox" bind:checked={urgent} />
    Urgent
  </label>

  <label for="categorie">Catégorie</label>
  <select id="categorie" bind:value={categorie}>
    <option value="general">Général</option>
    <option value="travail">Travail</option>
  </select>

  <button type="submit" disabled={!valide}>Ajouter</button>
</form>

valide est une dérivée : elle se recalcule quand titre change, et le bouton suit. Rien à déclarer.

Les trois <label> sont associés à leur champ — le compilateur le vérifie, et signale a11y-missing-name si un champ n'a rien qui le nomme. Voir Accessibilité.