@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.
- package/AGENTS.md +82 -30
- package/CHANGELOG.md +150 -3
- package/dist/src/component.d.ts +17 -4
- package/dist/src/component.js +43 -4
- package/dist/src/component.js.map +1 -1
- package/dist/src/forms.js +4 -1
- package/dist/src/forms.js.map +1 -1
- package/dist/src/page.d.ts +12 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.js +7 -1
- package/dist/src/router/manifest.js.map +1 -1
- package/docs/en/07-llm-pitfalls.md +197 -60
- package/docs/en/08-llm-zero-history-test.md +1 -1
- package/docs/en/17-shadow-dom-forms.md +192 -0
- package/docs/en/README.md +20 -19
- package/docs/fr/07-llm-pitfalls.md +196 -60
- package/docs/fr/17-shadow-dom-forms.md +196 -0
- package/docs/fr/README.md +20 -19
- package/docs/ru/07-llm-pitfalls.md +198 -61
- package/docs/ru/08-llm-zero-history-test.md +39 -38
- package/docs/ru/09-shadow-vs-light-dom.md +97 -81
- package/docs/ru/17-shadow-dom-forms.md +193 -0
- package/docs/ru/README.md +20 -19
- package/docs/uk/07-llm-pitfalls.md +64 -3
- package/docs/uk/17-shadow-dom-forms.md +193 -0
- package/docs/uk/README.md +20 -19
- package/llms.txt +50 -1
- package/package.json +2 -2
- package/scripts/bake.mjs +25 -19
- package/scripts/cli.mjs +22 -33
- package/starters/admin/src/components/x-button.ts +40 -13
- package/starters/admin/src/components/x-input.ts +50 -19
- package/starters/admin/src/lib/api.ts +55 -4
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
|
8
|
-
|
|
|
9
|
-
|
|
|
10
|
-
|
|
|
11
|
-
| IDE
|
|
12
|
-
|
|
|
13
|
-
|
|
|
14
|
-
| LLM
|
|
15
|
-
| LLM
|
|
16
|
-
| Shadow DOM vs Light DOM
|
|
17
|
-
| Architecture d'application
|
|
18
|
-
|
|
|
19
|
-
| Auth et API
|
|
20
|
-
| Déploiement
|
|
21
|
-
| Tests
|
|
22
|
-
| Gestion des erreurs
|
|
23
|
-
|
|
|
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
|
|
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
|
|
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);
|
|
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:
|
|
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(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
`,
|
|
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
|
|
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(
|
|
361
|
-
|
|
362
|
-
`,
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
446
|
-
|
|
447
|
-
|
|
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(
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
| Если хочешь сделать…
|
|
468
|
-
|
|
469
|
-
| `useState(0)`
|
|
470
|
-
| `useEffect(() => {...}, [a, b])`
|
|
471
|
-
| `useEffect(() => return cleanup, [])` | `ctx.onDispose(cleanup)`
|
|
472
|
-
| `useMemo(() => x, [a])`
|
|
473
|
-
| `useCallback(fn, [])`
|
|
474
|
-
| `useContext(Ctx)`
|
|
475
|
-
| `useQuery(['key'], fn)`
|
|
476
|
-
| `useMutation(fn)`
|
|
477
|
-
| `useRouter().push('/')`
|
|
478
|
-
| `useRouter().query.q`
|
|
479
|
-
| `<input value={v} onChange={...}>`
|
|
480
|
-
| `{items.map(x => ...)}`
|
|
481
|
-
| `useForm({ resolver: zodResolver })`
|
|
482
|
-
| `class extends HTMLElement`
|
|
483
|
-
| `@customElement('x')`
|
|
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 специально маленький, чтобы быть читаемым.
|