@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,196 @@
1
+ # Shadow DOM + formulaires
2
+
3
+ L'utilisation de `useForm()` avec des composants input personnalisés en Shadow DOM
4
+ nécessite de connaître deux comportements au niveau du navigateur :
5
+
6
+ 1. **Retargeting des événements** — les événements qui remontent du Shadow DOM ont
7
+ leur `e.target` redirigé vers l'élément host. `useForm().onInput` lit
8
+ `e.target.name` et `e.target.value`, mais un élément host `<x-input>`
9
+ ne possède pas nativement ces propriétés.
10
+
11
+ 2. **Association de formulaire** — un `<button type="submit">` à l'intérieur d'un
12
+ Shadow Root ne fait PAS partie de l'algorithme form-owner pour `<form>` dans
13
+ le Light DOM. Cliquer dessus ne déclenche pas le submit du formulaire.
14
+
15
+ Ces deux limitations sont au niveau de la spécification, pas des bugs Mado. Mais
16
+ le framework fournit des patterns qui les rendent indolores.
17
+
18
+ ## Pattern : Propriétés proxy sur les composants input
19
+
20
+ En encapsulant un `<input>` dans un composant Shadow DOM, exposez `name` et
21
+ `value` comme propriétés DOM sur le host pour que `useForm().onInput` fonctionne
22
+ après le 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
+ // Propriétés proxy pour la compatibilité useForm().
33
+ // Après le retargeting Shadow DOM de e.target : <input> → <x-input>,
34
+ // useForm lit e.target.name / e.target.value — ces getters font le pont.
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
+ L'événement `input` du `<input>` interne a `composed: true` par défaut, donc
53
+ il remonte à travers la frontière shadow. Après le retargeting, `e.target` est
54
+ `<x-input>`, mais maintenant il a les getters `.name` et `.value` → `useForm`
55
+ fonctionne.
56
+
57
+ ## Pattern : Submit de formulaire depuis des boutons Shadow DOM
58
+
59
+ Un `<button type="submit">` dans le Shadow DOM ne peut pas déclencher le submit
60
+ d'un `<form>` dans le Light DOM. Pont via `requestSubmit()` :
61
+
62
+ ```ts
63
+ import { component, css, html } from "@madojs/mado";
64
+
65
+ component("x-button", ({ host, attr }) => {
66
+ const disabled = attr("disabled");
67
+
68
+ const handleClick = () => {
69
+ const typeAttr = host.getAttribute("type");
70
+ if (typeAttr === "button" || typeAttr === "reset") return;
71
+ const form = host.closest("form");
72
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
73
+ };
74
+
75
+ return () => html`
76
+ <button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
77
+ <slot></slot>
78
+ </button>
79
+ `;
80
+ });
81
+ ```
82
+
83
+ `host.closest("form")` fonctionne parce que l'élément host lui-même vit dans
84
+ le Light DOM (seuls ses éléments internes sont dans l'ombre). `requestSubmit()`
85
+ déclenche la validation et l'événement `submit` exactement comme si l'utilisateur
86
+ avait cliqué sur un bouton submit natif à l'intérieur du formulaire.
87
+
88
+ ## Pattern : Attributs réactifs avec ctx.attr()
89
+
90
+ Depuis la v0.7, `ctx.attr(name, defaultValue?)` retourne un `Signal<string>` qui
91
+ se met à jour automatiquement quand l'attribut change sur le host. Plus besoin de
92
+ `MutationObserver` :
93
+
94
+ ```ts
95
+ component("x-badge", ({ attr }) => {
96
+ const variant = attr("variant", "default"); // Signal<string>
97
+
98
+ return () =>
99
+ html`<span class=${() => `badge badge-${variant()}`}>
100
+ <slot></slot>
101
+ </span>`;
102
+ });
103
+ ```
104
+
105
+ Le parent peut utiliser `?disabled=${() => !form.isValid()}` (attribut booléen)
106
+ ou `.variant=${"danger"}` — le composant se re-rend de manière réactive dans
107
+ les deux cas.
108
+
109
+ ## Exemple complet de formulaire
110
+
111
+ ```ts
112
+ import { page, html, useForm, navigate } from "@madojs/mado";
113
+ import "../components/x-input.js";
114
+ import "../components/x-button.js";
115
+
116
+ export default page({
117
+ title: "Connexion",
118
+ view: () => {
119
+ const form = useForm({
120
+ email: { required: true, type: "email" },
121
+ password: { required: true, minLength: 6 },
122
+ });
123
+
124
+ const handleLogin = async (values) => {
125
+ await api("/auth/login", { method: "POST", json: values });
126
+ navigate("/admin");
127
+ };
128
+
129
+ return html`
130
+ <form @submit=${form.onSubmit(handleLogin)}>
131
+ <x-input
132
+ name="email"
133
+ type="email"
134
+ label="Email"
135
+ required
136
+ @input=${form.onInput}
137
+ @blur=${form.onBlur}
138
+ ></x-input>
139
+ ${() =>
140
+ form.errors().email
141
+ ? html`<small class="err">${form.errors().email}</small>`
142
+ : null}
143
+
144
+ <x-input
145
+ name="password"
146
+ type="password"
147
+ label="Mot de passe"
148
+ required
149
+ @input=${form.onInput}
150
+ @blur=${form.onBlur}
151
+ ></x-input>
152
+
153
+ <x-button type="submit" ?disabled=${() => !form.isValid()}>
154
+ Se connecter
155
+ </x-button>
156
+ </form>
157
+ `;
158
+ },
159
+ });
160
+ ```
161
+
162
+ ## Quand utiliser Light DOM à la place
163
+
164
+ Si votre composant input est juste un wrapper stylisé sans besoin
165
+ d'encapsulation, `shadow: false` évite les deux problèmes (retargeting et
166
+ association de formulaire) :
167
+
168
+ ```ts
169
+ component(
170
+ "x-field",
171
+ ({ attr }) => {
172
+ const label = attr("label", "");
173
+ return () => html`
174
+ <label>
175
+ <span>${label}</span>
176
+ <slot></slot>
177
+ </label>
178
+ `;
179
+ },
180
+ { shadow: false },
181
+ );
182
+ ```
183
+
184
+ Avec Light DOM, le `<input>` natif fait partie de l'arbre du document, les
185
+ événements ne sont pas retargetés, et le submit fonctionne nativement.
186
+ Le compromis : les styles ne sont pas encapsulés (vous devez les scoper
187
+ vous-même).
188
+
189
+ ## Résumé
190
+
191
+ | Problème | Solution Shadow DOM | Alternative Light DOM |
192
+ | ------------------------------ | -------------------------------------------- | ------------------------------- |
193
+ | `useForm` + input personnalisé | Proxy `name`/`value` sur le host | `<input>` natif dans un slot |
194
+ | Submit de formulaire | `form.requestSubmit()` dans le click handler | Le bouton natif fonctionne |
195
+ | Attributs réactifs | `ctx.attr()` → signal auto | `ctx.attr()` fonctionne partout |
196
+ | Encapsulation des styles | Oui (automatique) | `@scope` manuel ou BEM |
package/docs/fr/README.md CHANGED
@@ -2,22 +2,23 @@
2
2
 
3
3
  Documentation française.
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
- | Architecture d'application | [10-app-architecture.md](./10-app-architecture.md) |
18
- | Layouts | [11-layouts.md](./11-layouts.md) |
19
- | Auth et API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
- | Déploiement | [13-deployment.md](./13-deployment.md) |
21
- | Tests | [14-testing.md](./14-testing.md) |
22
- | Gestion des erreurs | [15-error-handling.md](./15-error-handling.md) |
23
- | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
5
+ | Section | Fichier |
6
+ | ----------------------------- | ------------------------------------------------------------ |
7
+ | La voie Mado | [00-the-mado-way.md](./00-the-mado-way.md) |
8
+ | Routage | [01-routing.md](./01-routing.md) |
9
+ | Structure du projet | [02-project-layout.md](./02-project-layout.md) |
10
+ | Prérendu statique & SEO | [03-static-bake.md](./03-static-bake.md) |
11
+ | Configuration IDE | [04-ide-setup.md](./04-ide-setup.md) |
12
+ | Pourquoi Mado | [05-why-mado.md](./05-why-mado.md) |
13
+ | Pour les développeurs backend | [06-for-backenders.md](./06-for-backenders.md) |
14
+ | Pièges LLM | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
15
+ | Test LLM sans historique | [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
+ | Architecture d'application | [10-app-architecture.md](./10-app-architecture.md) |
18
+ | Mises en page (layouts) | [11-layouts.md](./11-layouts.md) |
19
+ | Auth et API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
+ | Déploiement | [13-deployment.md](./13-deployment.md) |
21
+ | Tests | [14-testing.md](./14-testing.md) |
22
+ | Gestion des erreurs | [15-error-handling.md](./15-error-handling.md) |
23
+ | Guide de recettes bake | [16-bake-cookbook.md](./16-bake-cookbook.md) |
24
+ | Shadow DOM + formulaires | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
@@ -4,6 +4,7 @@
4
4
  > делают при генерации Mado-кода. И как их исправлять.
5
5
 
6
6
  Этот документ — для **двух аудиторий**:
7
+
7
8
  1. **AI-агентов в IDE**, которые читают `AGENTS.md` / `.cursorrules` / `.github/copilot-instructions.md`. Здесь больше деталей по типичным граблям.
8
9
  2. **Людей**, которые получили от AI код с этими ошибками и не понимают, что не так.
9
10
 
@@ -17,19 +18,20 @@
17
18
  const count = signal(0);
18
19
 
19
20
  // ❌ AI генерирует это часто
20
- html`<div>Count: ${count() * 2}</div>`
21
+ html`<div>Count: ${count() * 2}</div>`;
21
22
  // → Отрисует "Count: 0", и больше никогда не обновится.
22
23
  // count() прочитан один раз в момент создания TemplateResult.
23
24
 
24
25
  // ✅ Правильно — функция-геттер
25
- html`<div>Count: ${() => count() * 2}</div>`
26
+ html`<div>Count: ${() => count() * 2}</div>`;
26
27
  // → Mado создаст effect() на эту функцию, при изменении count перерисует.
27
28
 
28
29
  // ✅ Тоже правильно — сам сигнал является функцией
29
- html`<div>Count: ${count}</div>`
30
+ html`<div>Count: ${count}</div>`;
30
31
  ```
31
32
 
32
33
  **Правило:**
34
+
33
35
  - Если в `${...}` есть **выражение** (что-то делает с сигналом) — оборачивай в `() => ...`.
34
36
  - Если в `${...}` **сам сигнал** — можно как есть.
35
37
 
@@ -45,10 +47,10 @@ html`<div>Count: ${count}</div>`
45
47
  const loading = signal(false);
46
48
 
47
49
  // ❌ Это setAttribute("disabled", "false") — DOM воспринимает это как disabled
48
- html`<button disabled=${loading()}>Save</button>`
50
+ html`<button disabled=${loading()}>Save</button>`;
49
51
 
50
52
  // ✅ Правильно — boolean-биндинг (toggle attribute)
51
- html`<button ?disabled=${loading}>Save</button>`
53
+ html`<button ?disabled=${loading}>Save</button>`;
52
54
  ```
53
55
 
54
56
  **Правило для атрибутов:**
@@ -86,6 +88,7 @@ component("x-counter", () => {
86
88
  ```
87
89
 
88
90
  **Ключевые отличия:**
91
+
89
92
  - Нет хуков, нет правил хуков.
90
93
  - `signal()` можно создавать где угодно — в setup, в effect, в обработчике.
91
94
  - `effect()` сам видит, что прочитал — не нужен dependency array.
@@ -93,7 +96,7 @@ component("x-counter", () => {
93
96
 
94
97
  ---
95
98
 
96
- ## Pitfall #4: `useEffect(() => { ... return cleanup })`
99
+ ## Pitfall #4: `useEffect(() => { ... return cleanup })`
97
100
 
98
101
  **Симптом:** AI пишет `return cleanup` в effect, ожидая что это сработает как в React.
99
102
 
@@ -172,11 +175,20 @@ import { Home } from "./pages/home.js";
172
175
 
173
176
  ```ts
174
177
  // ❌ Работает, но не keyed: пересоздаёт DOM на каждое изменение
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
  // ✅ Правильно: each() с key-функцией
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
  **Правило:** всегда используй `each()` для списков из массивов с устойчивыми ID. `.map()` оставь только для статичных списков.
@@ -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
  // ❌ Нет такого 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
  // ✅ Правильно
199
- count() // прочитать
200
- count.set(5) // записать
201
- count.update(n => n + 1)
202
- count.peek() // прочитать без подписки
211
+ count(); // прочитать
212
+ count.set(5); // записать
213
+ count.update((n) => n + 1);
214
+ count.peek(); // прочитать без подписки
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<value>
235
+ const api = inject(host, ApiCtx); // signal<value>
224
236
  return () => html`...`;
225
237
  });
226
238
  ```
@@ -242,6 +254,7 @@ if (typeof window !== "undefined") { ... } // в Mado window есть ВСЕГ
242
254
  Mado **не делает SSR с гидрацией**. На сервере код не выполняется — есть только `bake` (статический prerender на build) и edge-prerender. Оба заменяют user code на linkedom-окружение, но это **только** для генерации HTML с meta-тегами, не для выполнения логики страницы.
243
255
 
244
256
  Это значит:
257
+
245
258
  - ✅ `window`, `document`, `location`, `fetch` — доступны без проверок.
246
259
  - ❌ Не пиши код, который пытается «универсально работать на сервере и клиенте».
247
260
  - ❌ Не используй паттерны Next.js (`getServerSideProps`, `headers()`).
@@ -259,7 +272,7 @@ const f = useForm({ resolver: zodResolver(schema) });
259
272
  // ✅ Правильно: HTML5-валидация атрибутами
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
  // ✅ Или кастомная функция, если HTML5 не хватает
@@ -287,11 +300,13 @@ Mado использует **Shadow DOM + `css\`\`` + CSS variables**. Глоба
287
300
 
288
301
  ```ts
289
302
  // Light-DOM page/screen компонент, Tailwind-классы работают
290
- component("x-admin-page", () => () => html`
291
- <section class="bg-white shadow-lg rounded-lg p-4">
292
- ...
293
- </section>
294
- `, { shadow: false });
303
+ component(
304
+ "x-admin-page",
305
+ () => () => html`
306
+ <section class="bg-white shadow-lg rounded-lg p-4">...</section>
307
+ `,
308
+ { shadow: false },
309
+ );
295
310
 
296
311
  // Shadow-DOM компонент (default) — Tailwind НЕ работает.
297
312
  // Используй css`` или ::part() для внешней стилизации.
@@ -300,7 +315,7 @@ component("x-button", () => () => html`<button><slot></slot></button>`, {
300
315
  button {
301
316
  background: var(--button-bg, #2563eb);
302
317
  color: white;
303
- padding: .5rem 1rem;
318
+ padding: 0.5rem 1rem;
304
319
  border-radius: 6px;
305
320
  }
306
321
  `,
@@ -333,6 +348,7 @@ Mado.signal(0);
333
348
  **Симптом:** AI предлагает `npm install lodash` / `npm install date-fns` / etc.
334
349
 
335
350
  Mado — **zero runtime deps** by design. Если AI хочет добавить:
351
+
336
352
  - **lodash** → используй нативный JS (`Object.entries`, `Array.prototype`, `structuredClone`);
337
353
  - **date-fns** → используй `Intl.DateTimeFormat` и `Intl.RelativeTimeFormat`;
338
354
  - **uuid** → `crypto.randomUUID()`;
@@ -351,19 +367,27 @@ Mado — **zero runtime deps** by design. Если AI хочет добавит
351
367
  // ❌ Работает, но плохо масштабируется и усложняет cleanup
352
368
  page({
353
369
  view: () => html`
354
- <style>.panel { padding: 1rem; }</style>
370
+ <style>
371
+ .panel {
372
+ padding: 1rem;
373
+ }
374
+ </style>
355
375
  <section class="panel">...</section>
356
376
  `,
357
377
  });
358
378
 
359
379
  // ✅ Правильно: стили компонента через css``
360
- component("x-admin-panel", () => () => html`
361
- <section class="panel">...</section>
362
- `, {
363
- styles: css`
364
- .panel { padding: 1rem; }
365
- `,
366
- });
380
+ component(
381
+ "x-admin-panel",
382
+ () => () => html` <section class="panel">...</section> `,
383
+ {
384
+ styles: css`
385
+ .panel {
386
+ padding: 1rem;
387
+ }
388
+ `,
389
+ },
390
+ );
367
391
  ```
368
392
 
369
393
  Для backend-admin route/page экранов часто уместен `shadow: false`, чтобы
@@ -380,10 +404,10 @@ prefetch'ится.
380
404
 
381
405
  ```ts
382
406
  // ❌ Обычная ссылка: браузер сделает full reload
383
- html`<a href="/tickets/42">Open</a>`
407
+ html`<a href="/tickets/42">Open</a>`;
384
408
 
385
409
  // ✅ SPA-навигация: router() перехватит click даже через Shadow DOM
386
- html`<a href="/tickets/42" data-link>Open</a>`
410
+ html`<a href="/tickets/42" data-link>Open</a>`;
387
411
  ```
388
412
 
389
413
  Mado ищет ссылку через `event.composedPath()`, поэтому `data-link` работает
@@ -399,7 +423,10 @@ Mado ищет ссылку через `event.composedPath()`, поэтому `da
399
423
 
400
424
  ```ts
401
425
  // ❌ Нет lifecycle cleanup, будет dev-warning
402
- const tickets = resource(() => "tickets", () => api.listTickets());
426
+ const tickets = resource(
427
+ () => "tickets",
428
+ () => api.listTickets(),
429
+ );
403
430
 
404
431
  component("x-tickets", () => {
405
432
  return () => html`${() => tickets.data()?.length ?? 0}`;
@@ -407,7 +434,10 @@ component("x-tickets", () => {
407
434
 
408
435
  // ✅ Создавай resource внутри setup компонента
409
436
  component("x-tickets", () => {
410
- const tickets = resource(() => "tickets", () => api.listTickets());
437
+ const tickets = resource(
438
+ () => "tickets",
439
+ () => api.listTickets(),
440
+ );
411
441
  return () => html`${() => tickets.data()?.length ?? 0}`;
412
442
  });
413
443
  ```
@@ -426,7 +456,7 @@ disconnect компонента.
426
456
  const view = signal(html`<x-home></x-home>`);
427
457
 
428
458
  // ✅ Нормальный паттерн: вложенный TemplateResult можно возвращать из child-binding
429
- html`${view}`
459
+ html`${view}`;
430
460
  ```
431
461
 
432
462
  Начиная с v0.3 это закреплено регрессиями: при замене child-binding Mado
@@ -442,16 +472,23 @@ dispose'ит вложенные template instances/effects рекурсивно.
442
472
 
443
473
  ```ts
444
474
  // ❌ .page-head объявлен глобально, но x-dashboard по умолчанию Shadow DOM
445
- component("x-dashboard", () => () => html`
446
- <header class="page-head">...</header>
447
- <div class="metric-grid">...</div>
448
- `);
475
+ component(
476
+ "x-dashboard",
477
+ () => () => html`
478
+ <header class="page-head">...</header>
479
+ <div class="metric-grid">...</div>
480
+ `,
481
+ );
449
482
 
450
483
  // ✅ Page/layout/admin-shell компоненты часто должны быть Light DOM
451
- component("x-dashboard", () => () => html`
452
- <header class="page-head">...</header>
453
- <div class="metric-grid">...</div>
454
- `, { shadow: false });
484
+ component(
485
+ "x-dashboard",
486
+ () => () => html`
487
+ <header class="page-head">...</header>
488
+ <div class="metric-grid">...</div>
489
+ `,
490
+ { shadow: false },
491
+ );
455
492
  ```
456
493
 
457
494
  Правило: Shadow DOM — для leaf widgets и slot-based layouts, Light DOM — для
@@ -462,24 +499,124 @@ Shadow DOM; при `shadow: false` это обычный элемент.
462
499
 
463
500
  ---
464
501
 
502
+ ## Pitfall #20: `host.getAttribute()` в render = не реактивно
503
+
504
+ **Симптом:** внешний вид компонента не обновляется при изменении атрибута родителем.
505
+
506
+ ```ts
507
+ // ❌ host.getAttribute() в render-функции читается один раз, но
508
+ // render перезапускается только при изменении его собственных сигналов.
509
+ // Внешние изменения атрибута не триггерят перерисовку.
510
+ component("x-badge", ({ host }) => () => {
511
+ const variant = host.getAttribute("variant") ?? "default";
512
+ return html`<span class=${variant}>...</span>`;
513
+ });
514
+
515
+ // ✅ Правильно: ctx.attr() — возвращает реактивный Signal<string>
516
+ component("x-badge", ({ attr }) => {
517
+ const variant = attr("variant", "default");
518
+ return () => html`<span class=${() => `badge-${variant()}`}>...</span>`;
519
+ });
520
+ ```
521
+
522
+ **Правило:** никогда не читайте `host.getAttribute()` или `host.hasAttribute()` внутри
523
+ render-функции для значений, которые могут измениться снаружи. Используйте `ctx.attr()` —
524
+ он возвращает Signal, который автоматически обновляется через `attributeChangedCallback`.
525
+
526
+ ---
527
+
528
+ ## Pitfall #21: Shadow DOM `<button>` не сабмитит формы
529
+
530
+ **Симптом:** клик по `<x-button type="submit">` внутри `<form>` ничего не делает.
531
+
532
+ `<button>` внутри Shadow DOM не участвует в алгоритме form-owner для
533
+ `<form>` в Light DOM — это ограничение спецификации, не баг Mado.
534
+
535
+ ```ts
536
+ // ❌ Внутренняя <button type="submit"> не может триггерить родительскую <form>
537
+ component("x-button", ({ host }) => {
538
+ return () => html`<button type="submit"><slot></slot></button>`;
539
+ });
540
+
541
+ // ✅ Мост через requestSubmit()
542
+ component("x-button", ({ host, attr }) => {
543
+ const disabled = attr("disabled");
544
+
545
+ const handleClick = () => {
546
+ const typeAttr = host.getAttribute("type");
547
+ if (typeAttr === "button" || typeAttr === "reset") return;
548
+ const form = host.closest("form");
549
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
550
+ };
551
+
552
+ return () => html`
553
+ <button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
554
+ <slot></slot>
555
+ </button>
556
+ `;
557
+ });
558
+ ```
559
+
560
+ Подробнее: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
561
+
562
+ ---
563
+
564
+ ## Pitfall #22: `useForm()` с Shadow DOM кастомными input
565
+
566
+ **Симптом:** `form.onInput` получает `undefined` для name/value от `<x-input>`.
567
+
568
+ Когда Shadow DOM input диспатчит `input` событие, браузер ретаргетирует
569
+ `e.target` с внутреннего `<input>` на host `<x-input>`. Но `<x-input>`
570
+ (HTMLElement) не имеет `.name` или `.value` — поэтому `useForm` ничего не получает.
571
+
572
+ ```ts
573
+ // ❌ Нет proxy-свойств — useForm тихо игнорирует события
574
+ component("x-input", ({ host, attr }) => {
575
+ const name = attr("name", "");
576
+ return () => html`<input name=${name} />`;
577
+ });
578
+
579
+ // ✅ Добавить proxy-свойства для совместимости с useForm
580
+ component("x-input", ({ host, attr }) => {
581
+ const name = attr("name", "");
582
+
583
+ Object.defineProperty(host, "name", {
584
+ get: () => host.getAttribute("name") ?? "",
585
+ configurable: true,
586
+ });
587
+ Object.defineProperty(host, "value", {
588
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
589
+ configurable: true,
590
+ });
591
+
592
+ return () => html`<input name=${name} />`;
593
+ });
594
+ ```
595
+
596
+ Подробнее: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
597
+
598
+ ---
599
+
465
600
  ## Cheat-sheet для AI
466
601
 
467
- | Если хочешь сделать… | Правильно в Mado |
468
- |---|---|
469
- | `useState(0)` | `signal(0)` |
470
- | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-deps) |
471
- | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
472
- | `useMemo(() => x, [a])` | `computed(() => x)` |
473
- | `useCallback(fn, [])` | обычная функция |
474
- | `useContext(Ctx)` | `inject(host, Ctx)` |
475
- | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
476
- | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
477
- | `useRouter().push('/')` | `navigate('/')` |
478
- | `useRouter().query.q` | `queryParam('q')` |
479
- | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
480
- | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
481
- | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
482
- | `class extends HTMLElement` | `component('x-name', setup)` |
483
- | `@customElement('x')` | `component('x-name', setup)` |
602
+ | Если хочешь сделать… | Правильно в Mado |
603
+ | ------------------------------------- | ------------------------------------------- |
604
+ | `useState(0)` | `signal(0)` |
605
+ | `useEffect(() => {...}, [a, b])` | `effect(() => {...})` (auto-deps) |
606
+ | `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)` |
607
+ | `useMemo(() => x, [a])` | `computed(() => x)` |
608
+ | `useCallback(fn, [])` | обычная функция |
609
+ | `useContext(Ctx)` | `inject(host, Ctx)` |
610
+ | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
611
+ | `useMutation(fn)` | `mutation(fn, { invalidates: [...] })` |
612
+ | `useRouter().push('/')` | `navigate('/')` |
613
+ | `useRouter().query.q` | `queryParam('q')` |
614
+ | `<input value={v} onChange={...}>` | `<input .value=${v} @input=${...}>` |
615
+ | `{items.map(x => ...)}` | `${() => each(items, x => x.id, x => ...)}` |
616
+ | `useForm({ resolver: zodResolver })` | `useForm({...}, { validate: (v) => ... })` |
617
+ | `class extends HTMLElement` | `component('x-name', setup)` |
618
+ | `@customElement('x')` | `component('x-name', setup)` |
619
+ | `host.getAttribute('x')` в render | `ctx.attr('x', default)` (реактивно) |
620
+ | `jsonFetcher()` с авторизацией | `apiFetcher()` (прикрепляет Bearer токен) |
484
621
 
485
622
  Если что-то не подходит из этого списка — открой `src/` и **прочитай 500 строк**. Это серьёзно. Mado специально маленький, чтобы быть читаемым.