@madojs/mado 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,192 @@
1
+ # Shadow DOM + Forms
2
+
3
+ Using `useForm()` with custom input components that have Shadow DOM requires
4
+ awareness of two browser-level behaviours:
5
+
6
+ 1. **Event retargeting** — events that bubble from Shadow DOM have their
7
+ `e.target` retargeted to the host element. `useForm().onInput` reads
8
+ `e.target.name` and `e.target.value`, but an `<x-input>` host element
9
+ doesn't natively have these properties.
10
+
11
+ 2. **Form association** — a `<button type="submit">` inside a Shadow Root is
12
+ NOT part of the form-owner algorithm for `<form>` in Light DOM. Clicking it
13
+ does not trigger form submit.
14
+
15
+ Both are spec-level limitations, not Mado bugs. But the framework provides
16
+ patterns that make them painless.
17
+
18
+ ## Pattern: Proxy Properties on Input Components
19
+
20
+ When wrapping `<input>` in a Shadow DOM component, expose `name` and `value`
21
+ as DOM properties on the host so that `useForm().onInput` works after event
22
+ retargeting:
23
+
24
+ ```ts
25
+ import { component, css, html } from "@madojs/mado";
26
+
27
+ component("x-input", ({ host, attr }) => {
28
+ const name = attr("name", "");
29
+ const type = attr("type", "text");
30
+ const value = attr("value", "");
31
+
32
+ // Proxy properties for useForm() compatibility.
33
+ // After Shadow DOM retargets e.target from <input> to <x-input>,
34
+ // useForm reads e.target.name / e.target.value — these getters bridge the gap.
35
+ Object.defineProperty(host, "name", {
36
+ get: () => host.getAttribute("name") ?? "",
37
+ configurable: true,
38
+ });
39
+ Object.defineProperty(host, "value", {
40
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
41
+ set: (v: string) => {
42
+ const input = host.shadowRoot?.querySelector("input");
43
+ if (input) input.value = v;
44
+ },
45
+ configurable: true,
46
+ });
47
+
48
+ return () => html` <input name=${name} type=${type} .value=${value} /> `;
49
+ });
50
+ ```
51
+
52
+ The `input` event from the inner `<input>` has `composed: true` by default, so
53
+ it will bubble through the shadow boundary. After retargeting, `e.target` is
54
+ `<x-input>`, but now it has `.name` and `.value` getters → `useForm` works.
55
+
56
+ ## Pattern: Form Submit from Shadow DOM Buttons
57
+
58
+ A `<button type="submit">` inside Shadow DOM cannot trigger `<form>` submit in
59
+ Light DOM. Bridge it with `requestSubmit()`:
60
+
61
+ ```ts
62
+ import { component, css, html } from "@madojs/mado";
63
+
64
+ component("x-button", ({ host, attr }) => {
65
+ const disabled = attr("disabled");
66
+
67
+ const handleClick = () => {
68
+ const typeAttr = host.getAttribute("type");
69
+ if (typeAttr === "button" || typeAttr === "reset") return;
70
+ const form = host.closest("form");
71
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
72
+ };
73
+
74
+ return () => html`
75
+ <button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
76
+ <slot></slot>
77
+ </button>
78
+ `;
79
+ });
80
+ ```
81
+
82
+ `host.closest("form")` works because the host element itself lives in Light DOM
83
+ (only its internals are shadowed). `requestSubmit()` triggers validation and the
84
+ `submit` event exactly as if the user had clicked a native submit button inside
85
+ the form.
86
+
87
+ ## Pattern: Reactive Attributes with ctx.attr()
88
+
89
+ Since v0.7, `ctx.attr(name, defaultValue?)` returns a `Signal<string>` that
90
+ updates automatically when the attribute changes on the host. No more
91
+ `MutationObserver` boilerplate:
92
+
93
+ ```ts
94
+ component("x-badge", ({ attr }) => {
95
+ const variant = attr("variant", "default"); // Signal<string>
96
+
97
+ return () =>
98
+ html`<span class=${() => `badge badge-${variant()}`}>
99
+ <slot></slot>
100
+ </span>`;
101
+ });
102
+ ```
103
+
104
+ The parent can use `?disabled=${() => !form.isValid()}` (boolean attribute) or
105
+ `.variant=${"danger"}` — the component re-renders reactively either way.
106
+
107
+ ## Complete Form Example
108
+
109
+ ```ts
110
+ import { page, html, useForm, navigate } from "@madojs/mado";
111
+ import "../components/x-input.js";
112
+ import "../components/x-button.js";
113
+
114
+ export default page({
115
+ title: "Login",
116
+ view: () => {
117
+ const form = useForm({
118
+ email: { required: true, type: "email" },
119
+ password: { required: true, minLength: 6 },
120
+ });
121
+
122
+ const handleLogin = async (values) => {
123
+ await api("/auth/login", { method: "POST", json: values });
124
+ navigate("/admin");
125
+ };
126
+
127
+ return html`
128
+ <form @submit=${form.onSubmit(handleLogin)}>
129
+ <x-input
130
+ name="email"
131
+ type="email"
132
+ label="Email"
133
+ required
134
+ @input=${form.onInput}
135
+ @blur=${form.onBlur}
136
+ ></x-input>
137
+ ${() =>
138
+ form.errors().email
139
+ ? html`<small class="err">${form.errors().email}</small>`
140
+ : null}
141
+
142
+ <x-input
143
+ name="password"
144
+ type="password"
145
+ label="Password"
146
+ required
147
+ @input=${form.onInput}
148
+ @blur=${form.onBlur}
149
+ ></x-input>
150
+
151
+ <x-button type="submit" ?disabled=${() => !form.isValid()}>
152
+ Sign in
153
+ </x-button>
154
+ </form>
155
+ `;
156
+ },
157
+ });
158
+ ```
159
+
160
+ ## When to Use Light DOM Instead
161
+
162
+ If your input component is just a styled wrapper without encapsulation needs,
163
+ `shadow: false` avoids both retargeting and form-association issues entirely:
164
+
165
+ ```ts
166
+ component(
167
+ "x-field",
168
+ ({ attr }) => {
169
+ const label = attr("label", "");
170
+ return () => html`
171
+ <label>
172
+ <span>${label}</span>
173
+ <slot></slot>
174
+ </label>
175
+ `;
176
+ },
177
+ { shadow: false },
178
+ );
179
+ ```
180
+
181
+ With Light DOM, the native `<input>` is part of the document tree, events
182
+ are not retargeted, and form submission works natively. The tradeoff: styles
183
+ are not encapsulated (you must scope them yourself).
184
+
185
+ ## Summary
186
+
187
+ | Concern | Shadow DOM Solution | Light DOM Alternative |
188
+ | ------------------------ | --------------------------------------- | ----------------------------- |
189
+ | `useForm` + custom input | Proxy `name`/`value` on host | Use native `<input>` in slot |
190
+ | Form submit | `form.requestSubmit()` in click handler | Native button works |
191
+ | Reactive attributes | `ctx.attr()` → auto-signal | `ctx.attr()` works everywhere |
192
+ | Style encapsulation | Yes (automatic) | Manual `@scope` or BEM |
package/docs/en/README.md CHANGED
@@ -2,22 +2,23 @@
2
2
 
3
3
  English documentation set.
4
4
 
5
- | Section | Source |
6
- |---|---|
7
- | The Mado way | [00-the-mado-way.md](./00-the-mado-way.md) |
8
- | Routing | [01-routing.md](./01-routing.md) |
9
- | Project layout | [02-project-layout.md](./02-project-layout.md) |
10
- | Static bake & SEO | [03-static-bake.md](./03-static-bake.md) |
11
- | IDE setup | [04-ide-setup.md](./04-ide-setup.md) |
12
- | Why Mado | [05-why-mado.md](./05-why-mado.md) |
13
- | For backenders | [06-for-backenders.md](./06-for-backenders.md) |
14
- | LLM pitfalls | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
15
- | LLM zero-history test | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
16
- | Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
17
- | App architecture | [10-app-architecture.md](./10-app-architecture.md) |
18
- | Layouts | [11-layouts.md](./11-layouts.md) |
19
- | Auth and API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
- | Deployment | [13-deployment.md](./13-deployment.md) |
21
- | Testing | [14-testing.md](./14-testing.md) |
22
- | Error handling | [15-error-handling.md](./15-error-handling.md) |
23
- | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
5
+ | Section | Source |
6
+ | ----------------------- | ------------------------------------------------------------ |
7
+ | The Mado way | [00-the-mado-way.md](./00-the-mado-way.md) |
8
+ | Routing | [01-routing.md](./01-routing.md) |
9
+ | Project layout | [02-project-layout.md](./02-project-layout.md) |
10
+ | Static bake & SEO | [03-static-bake.md](./03-static-bake.md) |
11
+ | IDE setup | [04-ide-setup.md](./04-ide-setup.md) |
12
+ | Why Mado | [05-why-mado.md](./05-why-mado.md) |
13
+ | For backenders | [06-for-backenders.md](./06-for-backenders.md) |
14
+ | LLM pitfalls | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
15
+ | LLM zero-history test | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
16
+ | Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
17
+ | App architecture | [10-app-architecture.md](./10-app-architecture.md) |
18
+ | Layouts | [11-layouts.md](./11-layouts.md) |
19
+ | Auth and API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
+ | Deployment | [13-deployment.md](./13-deployment.md) |
21
+ | Testing | [14-testing.md](./14-testing.md) |
22
+ | Error handling | [15-error-handling.md](./15-error-handling.md) |
23
+ | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
24
+ | Shadow DOM + Forms | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
@@ -4,6 +4,7 @@
4
4
  > commettent lors de la génération de code Mado. Et comment les corriger.
5
5
 
6
6
  Ce document s'adresse à **deux publics** :
7
+
7
8
  1. **Les agents IA dans l'IDE** qui lisent `AGENTS.md` / `.cursorrules` / `.github/copilot-instructions.md`. Plus de détails sur les pièges typiques sont fournis ici.
8
9
  2. **Les humains** qui ont reçu du code d'une IA avec ces erreurs et ne comprennent pas ce qui ne va pas.
9
10
 
@@ -17,19 +18,20 @@ Ce document s'adresse à **deux publics** :
17
18
  const count = signal(0);
18
19
 
19
20
  // ❌ L'IA génère souvent ceci
20
- html`<div>Compte : ${count() * 2}</div>`
21
+ html`<div>Compte : ${count() * 2}</div>`;
21
22
  // → Affichera "Compte : 0" et ne se mettra plus jamais à jour.
22
23
  // count() est lu une seule fois quand le TemplateResult est créé.
23
24
 
24
25
  // ✅ Correct — fonction getter
25
- html`<div>Compte : ${() => count() * 2}</div>`
26
+ html`<div>Compte : ${() => count() * 2}</div>`;
26
27
  // → Mado créera un effect() pour cette fonction et re-rendra quand count change.
27
28
 
28
29
  // ✅ Aussi correct — le signal lui-même est une fonction
29
- html`<div>Compte : ${count}</div>`
30
+ html`<div>Compte : ${count}</div>`;
30
31
  ```
31
32
 
32
33
  **Règle :**
34
+
33
35
  - Si `${...}` contient une **expression** (quelque chose est fait avec le signal) — enveloppez dans `() => ...`.
34
36
  - Si `${...}` contient **le signal lui-même** — il peut être utilisé tel quel.
35
37
 
@@ -45,10 +47,10 @@ Ceci s'applique aux **bindings enfants** (texte à l'intérieur des tags) et aux
45
47
  const loading = signal(false);
46
48
 
47
49
  // ❌ C'est setAttribute("disabled", "false") — le DOM traite ça comme disabled
48
- html`<button disabled=${loading()}>Enregistrer</button>`
50
+ html`<button disabled=${loading()}>Enregistrer</button>`;
49
51
 
50
52
  // ✅ Correct — binding booléen (basculer l'attribut)
51
- html`<button ?disabled=${loading}>Enregistrer</button>`
53
+ html`<button ?disabled=${loading}>Enregistrer</button>`;
52
54
  ```
53
55
 
54
56
  **Règles pour les attributs :**
@@ -86,6 +88,7 @@ component("x-counter", () => {
86
88
  ```
87
89
 
88
90
  **Différences clés :**
91
+
89
92
  - Pas de hooks, pas de règles de hooks.
90
93
  - `signal()` peut être créé n'importe où — dans le setup, dans un effect, dans un handler.
91
94
  - `effect()` voit ce qu'il a lu de lui-même — pas besoin de tableau de dépendances.
@@ -172,11 +175,20 @@ import { Home } from "./pages/home.js";
172
175
 
173
176
  ```ts
174
177
  // ❌ Fonctionne, mais sans clé : recrée le DOM à chaque changement
175
- html`<ul>${() => items().map(t => html`<li>${t.name}</li>`)}</ul>`
178
+ html`<ul>
179
+ ${() => items().map((t) => html`<li>${t.name}</li>`)}
180
+ </ul>`;
176
181
 
177
182
  // ✅ Correct : each() avec une fonction de clé
178
183
  import { each } from "@madojs/mado";
179
- html`<ul>${() => each(items(), t => t.id, t => html`<li>${t.name}</li>`)}</ul>`
184
+ html`<ul>
185
+ ${() =>
186
+ each(
187
+ items(),
188
+ (t) => t.id,
189
+ (t) => html`<li>${t.name}</li>`,
190
+ )}
191
+ </ul>`;
180
192
  ```
181
193
 
182
194
  **Règle :** utilisez toujours `each()` pour les listes de tableaux avec des IDs stables. Réservez `.map()` uniquement pour les listes statiques.
@@ -191,15 +203,15 @@ html`<ul>${() => each(items(), t => t.id, t => html`<li>${t.name}</li>`)}</ul>`
191
203
  const count = signal(0);
192
204
 
193
205
  // ❌ Pas une telle API
194
- count.value
195
- count.value = 5
196
- count.get()
206
+ count.value;
207
+ count.value = 5;
208
+ count.get();
197
209
 
198
210
  // ✅ Correct
199
- count() // lecture
200
- count.set(5) // écriture
201
- count.update(n => n + 1)
202
- count.peek() // lecture sans abonnement
211
+ count(); // lecture
212
+ count.set(5); // écriture
213
+ count.update((n) => n + 1);
214
+ count.peek(); // lecture sans abonnement
203
215
  ```
204
216
 
205
217
  ---
@@ -220,7 +232,7 @@ component("x-app", ({ host }) => {
220
232
  });
221
233
 
222
234
  component("x-child", ({ host }) => {
223
- const api = inject(host, ApiCtx); // signal<valeur>
235
+ const api = inject(host, ApiCtx); // signal<valeur>
224
236
  return () => html`...`;
225
237
  });
226
238
  ```
@@ -242,6 +254,7 @@ if (typeof window !== "undefined") { ... } // dans Mado, window est TOUJOURS di
242
254
  Mado **ne fait pas de SSR avec hydratation**. Le code ne s'exécute pas sur le serveur — il y a uniquement `bake` (prérendu statique au moment du build) et edge-prerender. Les deux remplacent le code utilisateur par un environnement linkedom, mais c'est **uniquement** pour générer du HTML avec des meta tags, pas pour exécuter la logique de page.
243
255
 
244
256
  Cela signifie :
257
+
245
258
  - ✅ `window`, `document`, `location`, `fetch` — disponibles sans vérifications.
246
259
  - ❌ N'écrivez pas de code qui essaie de "fonctionner universellement sur serveur et client".
247
260
  - ❌ N'utilisez pas les patterns Next.js (`getServerSideProps`, `headers()`).
@@ -259,7 +272,7 @@ const f = useForm({ resolver: zodResolver(schema) });
259
272
  // ✅ Correct : validation proche du HTML via le schéma useForm
260
273
  const f = useForm({
261
274
  email: { required: true, type: "email" },
262
- age: { required: true, type: "number", min: 18 },
275
+ age: { required: true, type: "number", min: 18 },
263
276
  });
264
277
 
265
278
  // ✅ Ou une fonction personnalisée si HTML5 ne suffit pas
@@ -288,11 +301,13 @@ Bootstrap-via-classes) **ne fonctionnent qu'en light DOM** (`shadow: false`) :
288
301
 
289
302
  ```ts
290
303
  // Composant page/écran Light-DOM, les classes Tailwind fonctionnent
291
- component("x-admin-page", () => () => html`
292
- <section class="bg-white shadow-lg rounded-lg p-4">
293
- ...
294
- </section>
295
- `, { shadow: false });
304
+ component(
305
+ "x-admin-page",
306
+ () => () => html`
307
+ <section class="bg-white shadow-lg rounded-lg p-4">...</section>
308
+ `,
309
+ { shadow: false },
310
+ );
296
311
 
297
312
  // Composant Shadow-DOM (par défaut) — Tailwind ne fonctionne PAS.
298
313
  // Utilisez css`` ou ::part() pour le stylage externe.
@@ -301,7 +316,7 @@ component("x-button", () => () => html`<button><slot></slot></button>`, {
301
316
  button {
302
317
  background: var(--button-bg, #2563eb);
303
318
  color: white;
304
- padding: .5rem 1rem;
319
+ padding: 0.5rem 1rem;
305
320
  border-radius: 6px;
306
321
  }
307
322
  `,
@@ -334,6 +349,7 @@ Mado.signal(0);
334
349
  **Symptôme :** l'IA suggère `npm install lodash` / `npm install date-fns` / etc.
335
350
 
336
351
  Mado est **zéro dépendances runtime** par conception. Si l'IA veut ajouter :
352
+
337
353
  - **lodash** → utilisez du JS natif (`Object.entries`, `Array.prototype`, `structuredClone`) ;
338
354
  - **date-fns** → utilisez `Intl.DateTimeFormat` et `Intl.RelativeTimeFormat` ;
339
355
  - **uuid** → `crypto.randomUUID()` ;
@@ -352,19 +368,27 @@ Toute dépendance runtime est une **violation des principes du framework**. Si v
352
368
  // ❌ Fonctionne, mais se met à l'échelle difficilement et complique le nettoyage
353
369
  page({
354
370
  view: () => html`
355
- <style>.panel { padding: 1rem; }</style>
371
+ <style>
372
+ .panel {
373
+ padding: 1rem;
374
+ }
375
+ </style>
356
376
  <section class="panel">...</section>
357
377
  `,
358
378
  });
359
379
 
360
380
  // ✅ Correct : styles de composant via css``
361
- component("x-admin-panel", () => () => html`
362
- <section class="panel">...</section>
363
- `, {
364
- styles: css`
365
- .panel { padding: 1rem; }
366
- `,
367
- });
381
+ component(
382
+ "x-admin-panel",
383
+ () => () => html` <section class="panel">...</section> `,
384
+ {
385
+ styles: css`
386
+ .panel {
387
+ padding: 1rem;
388
+ }
389
+ `,
390
+ },
391
+ );
368
392
  ```
369
393
 
370
394
  Pour les écrans route/page d'admin backend, il est souvent approprié d'utiliser `shadow: false`,
@@ -381,10 +405,10 @@ la page ou n'est pas préchargé.
381
405
 
382
406
  ```ts
383
407
  // ❌ Lien ordinaire : le navigateur effectuera un rechargement complet
384
- html`<a href="/tickets/42">Ouvrir</a>`
408
+ html`<a href="/tickets/42">Ouvrir</a>`;
385
409
 
386
410
  // ✅ Navigation SPA : router() interceptera le clic même à travers Shadow DOM
387
- html`<a href="/tickets/42" data-link>Ouvrir</a>`
411
+ html`<a href="/tickets/42" data-link>Ouvrir</a>`;
388
412
  ```
389
413
 
390
414
  Mado trouve le lien via `event.composedPath()`, donc `data-link` fonctionne aussi à l'intérieur
@@ -400,7 +424,10 @@ entre les pages.
400
424
 
401
425
  ```ts
402
426
  // ❌ Pas de nettoyage du lifecycle, générera un avertissement dev
403
- const tickets = resource(() => "tickets", () => api.listTickets());
427
+ const tickets = resource(
428
+ () => "tickets",
429
+ () => api.listTickets(),
430
+ );
404
431
 
405
432
  component("x-tickets", () => {
406
433
  return () => html`${() => tickets.data()?.length ?? 0}`;
@@ -408,7 +435,10 @@ component("x-tickets", () => {
408
435
 
409
436
  // ✅ Créer la resource à l'intérieur du setup du composant
410
437
  component("x-tickets", () => {
411
- const tickets = resource(() => "tickets", () => api.listTickets());
438
+ const tickets = resource(
439
+ () => "tickets",
440
+ () => api.listTickets(),
441
+ );
412
442
  return () => html`${() => tickets.data()?.length ?? 0}`;
413
443
  });
414
444
  ```
@@ -427,7 +457,7 @@ nettoyés quand le composant se déconnecte.
427
457
  const view = signal(html`<x-home></x-home>`);
428
458
 
429
459
  // ✅ Pattern normal : un TemplateResult imbriqué peut être retourné depuis un binding enfant
430
- html`${view}`
460
+ html`${view}`;
431
461
  ```
432
462
 
433
463
  À partir de v0.3, ceci est garanti par des tests de régression : quand un binding enfant est
@@ -444,16 +474,23 @@ nettoyer manuellement.
444
474
 
445
475
  ```ts
446
476
  // ❌ .page-head est déclaré globalement, mais x-dashboard utilise Shadow DOM par défaut
447
- component("x-dashboard", () => () => html`
448
- <header class="page-head">...</header>
449
- <div class="metric-grid">...</div>
450
- `);
477
+ component(
478
+ "x-dashboard",
479
+ () => () => html`
480
+ <header class="page-head">...</header>
481
+ <div class="metric-grid">...</div>
482
+ `,
483
+ );
451
484
 
452
485
  // ✅ Les composants page/layout/admin-shell doivent souvent être Light DOM
453
- component("x-dashboard", () => () => html`
454
- <header class="page-head">...</header>
455
- <div class="metric-grid">...</div>
456
- `, { shadow: false });
486
+ component(
487
+ "x-dashboard",
488
+ () => () => html`
489
+ <header class="page-head">...</header>
490
+ <div class="metric-grid">...</div>
491
+ `,
492
+ { shadow: false },
493
+ );
457
494
  ```
458
495
 
459
496
  Règle : Shadow DOM — pour les widgets feuilles et les layouts basés sur slot, Light DOM — pour
@@ -464,24 +501,123 @@ Plus de détails : [`09-shadow-vs-light-dom.md`](./09-shadow-vs-light-dom.md).
464
501
 
465
502
  ---
466
503
 
504
+ ## Piège #20 : `host.getAttribute()` dans render = pas réactif
505
+
506
+ **Symptôme :** l'apparence du composant ne se met pas à jour quand le parent change un attribut.
507
+
508
+ ```ts
509
+ // ❌ host.getAttribute() dans la fonction render est lu une seule fois.
510
+ // Le render ne se relance que quand ses propres signaux changent.
511
+ component("x-badge", ({ host }) => () => {
512
+ const variant = host.getAttribute("variant") ?? "default";
513
+ return html`<span class=${variant}>...</span>`;
514
+ });
515
+
516
+ // ✅ Correct : ctx.attr() — retourne un Signal<string> réactif
517
+ component("x-badge", ({ attr }) => {
518
+ const variant = attr("variant", "default");
519
+ return () => html`<span class=${() => `badge-${variant()}`}>...</span>`;
520
+ });
521
+ ```
522
+
523
+ **Règle :** n'utilisez jamais `host.getAttribute()` ou `host.hasAttribute()` dans la
524
+ fonction render pour des valeurs qui peuvent changer de l'extérieur. Utilisez `ctx.attr()` —
525
+ il retourne un Signal qui se met à jour via `attributeChangedCallback`.
526
+
527
+ ---
528
+
529
+ ## Piège #21 : `<button>` Shadow DOM ne soumet pas les formulaires
530
+
531
+ **Symptôme :** cliquer sur `<x-button type="submit">` dans un `<form>` ne fait rien.
532
+
533
+ Un `<button>` dans le Shadow DOM ne participe pas à l'algorithme form-owner pour
534
+ `<form>` dans le Light DOM — c'est une limitation de la spécification.
535
+
536
+ ```ts
537
+ // ❌ Le <button type="submit"> interne ne peut pas déclencher le <form> parent
538
+ component("x-button", ({ host }) => {
539
+ return () => html`<button type="submit"><slot></slot></button>`;
540
+ });
541
+
542
+ // ✅ Pont via requestSubmit()
543
+ component("x-button", ({ host, attr }) => {
544
+ const disabled = attr("disabled");
545
+
546
+ const handleClick = () => {
547
+ const typeAttr = host.getAttribute("type");
548
+ if (typeAttr === "button" || typeAttr === "reset") return;
549
+ const form = host.closest("form");
550
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
551
+ };
552
+
553
+ return () => html`
554
+ <button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
555
+ <slot></slot>
556
+ </button>
557
+ `;
558
+ });
559
+ ```
560
+
561
+ Plus de détails : [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
562
+
563
+ ---
564
+
565
+ ## Piège #22 : `useForm()` avec des inputs Shadow DOM personnalisés
566
+
567
+ **Symptôme :** `form.onInput` reçoit `undefined` pour name/value de `<x-input>`.
568
+
569
+ Quand un input Shadow DOM dispatche un événement `input`, le navigateur retarget
570
+ `e.target` du `<input>` interne vers le host `<x-input>`. Mais `<x-input>`
571
+ (HTMLElement) n'a pas `.name` ni `.value` — donc `useForm` ne reçoit rien.
572
+
573
+ ```ts
574
+ // ❌ Pas de propriétés proxy — useForm ignore silencieusement les événements
575
+ component("x-input", ({ host, attr }) => {
576
+ const name = attr("name", "");
577
+ return () => html`<input name=${name} />`;
578
+ });
579
+
580
+ // ✅ Ajouter des propriétés proxy pour la compatibilité useForm
581
+ component("x-input", ({ host, attr }) => {
582
+ const name = attr("name", "");
583
+
584
+ Object.defineProperty(host, "name", {
585
+ get: () => host.getAttribute("name") ?? "",
586
+ configurable: true,
587
+ });
588
+ Object.defineProperty(host, "value", {
589
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
590
+ configurable: true,
591
+ });
592
+
593
+ return () => html`<input name=${name} />`;
594
+ });
595
+ ```
596
+
597
+ Plus de détails : [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
598
+
599
+ ---
600
+
467
601
  ## Aide-mémoire pour l'IA
468
602
 
469
- | Si vous voulez faire… | Correct dans Mado |
470
- |---|---|
471
- | `useState(0)` | `signal(0)` |
472
- | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-dépendances) |
473
- | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
474
- | `useMemo(() => x, [a])` | `computed(() => x)` |
475
- | `useCallback(fn, [])` | fonction ordinaire |
476
- | `useContext(Ctx)` | `inject(host, Ctx)` |
477
- | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
478
- | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
479
- | `useRouter().push('/')` | `navigate('/')` |
480
- | `useRouter().query.q` | `queryParam('q')` |
481
- | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
482
- | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
483
- | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
484
- | `class extends HTMLElement` | `component('x-name', setup)` |
485
- | `@customElement('x')` | `component('x-name', setup)` |
603
+ | Si vous voulez faire… | Correct dans Mado |
604
+ | ------------------------------------- | ------------------------------------------- |
605
+ | `useState(0)` | `signal(0)` |
606
+ | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-dépendances) |
607
+ | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
608
+ | `useMemo(() => x, [a])` | `computed(() => x)` |
609
+ | `useCallback(fn, [])` | fonction ordinaire |
610
+ | `useContext(Ctx)` | `inject(host, Ctx)` |
611
+ | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
612
+ | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
613
+ | `useRouter().push('/')` | `navigate('/')` |
614
+ | `useRouter().query.q` | `queryParam('q')` |
615
+ | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
616
+ | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
617
+ | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
618
+ | `class extends HTMLElement` | `component('x-name', setup)` |
619
+ | `@customElement('x')` | `component('x-name', setup)` |
620
+ | `host.getAttribute('x')` dans render | `ctx.attr('x', default)` (réactif) |
621
+ | `jsonFetcher()` avec auth | `apiFetcher()` (attache le Bearer token) |
486
622
 
487
623
  Si quelque chose ne rentre pas dans cette liste — ouvrez `src/` et **lisez 500 lignes**. Sérieusement. Mado est intentionnellement petit pour être lisible.