@madojs/mado 0.6.1 → 0.7.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.
@@ -1,10 +1,9 @@
1
1
  # Shadow DOM vs Light DOM
2
2
 
3
- Mado components use Shadow DOM by default. This is a good default for
4
- self-contained widgets, but it is not the right default for every component in
5
- an application.
3
+ Компоненты Mado по умолчанию используют Shadow DOM. Это хороший дефолт для
4
+ самодостаточных виджетов, но не для каждого компонента в приложении.
6
5
 
7
- ## Rule of Thumb
6
+ ## Правило большого пальца
8
7
 
9
8
  В Mado layout — это тоже component. Если файл описывает видимую переиспользуемую
10
9
  часть UI-дерева — app shell, sidebar, modal, table, page section — по умолчанию
@@ -17,25 +16,25 @@ const money = (value: number) => html`<span>${formatMoney(value)}</span>`;
17
16
  ```
18
17
 
19
18
  Не стоит делать app shell функцией в публичных примерах. Это работает, но
20
- прячет browser model вместо того, чтобы ее объяснять.
19
+ прячет browser model вместо того, чтобы её объяснять.
21
20
 
22
- Use **Shadow DOM** for leaf widgets:
21
+ Используйте **Shadow DOM** для leaf-виджетов:
23
22
 
24
- - buttons, badges, cards, metrics;
25
- - modals, toasts, small visual components;
26
- - embed widgets that should not inherit app CSS accidentally;
27
- - components whose styling should be owned by the component itself.
23
+ - кнопки, бейджи, карточки, метрики;
24
+ - модалы, тосты, маленькие визуальные компоненты;
25
+ - embed-виджеты, которые не должны наследовать CSS приложения случайно;
26
+ - компоненты, стили которых принадлежат самому компоненту.
28
27
 
29
- Use **Light DOM** (`{ shadow: false }`) for app structure that wants to share
30
- global CSS utilities:
28
+ Используйте **Light DOM** (`{ shadow: false }`) для структуры приложения,
29
+ которая хочет разделять глобальные CSS-утилиты:
31
30
 
32
- - route/page components;
33
- - admin screens with dense table/form layouts;
34
- - data-heavy screens with tables and forms;
35
- - components that intentionally share global layout, form and table utilities;
36
- - places where children should simply remain normal document DOM.
31
+ - route/page компоненты;
32
+ - admin-экраны с плотными таблицами/формами;
33
+ - data-heavy экраны с таблицами и формами;
34
+ - компоненты, которые намеренно используют глобальные layout/form/table утилиты;
35
+ - места, где children должны оставаться обычным document DOM.
37
36
 
38
- Use **Shadow DOM** для slot-based layouts:
37
+ Используйте **Shadow DOM** для slot-based layouts:
39
38
 
40
39
  - app shells с `<slot>`;
41
40
  - sidebar/content wrappers;
@@ -44,73 +43,89 @@ Use **Shadow DOM** для slot-based layouts:
44
43
  `<slot>` — это feature Shadow DOM. В компоненте с `shadow: false` тег `<slot>`
45
44
  становится обычным DOM-элементом и не переносит children в это место layout.
46
45
 
47
- ## The Footgun
46
+ ## Подвох
48
47
 
49
- Global CSS does not cross a Shadow DOM boundary.
48
+ Глобальный CSS не пересекает границу Shadow DOM.
50
49
 
51
50
  ```ts
52
51
  // global.ts
53
52
  export const globalStyles = css`
54
- .page-head { display: flex; justify-content: space-between; }
55
- .metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); }
53
+ .page-head {
54
+ display: flex;
55
+ justify-content: space-between;
56
+ }
57
+ .metric-grid {
58
+ display: grid;
59
+ grid-template-columns: repeat(4, 1fr);
60
+ }
56
61
  `;
57
62
 
58
- // ❌ .page-head and .metric-grid will not apply inside x-dashboard shadowRoot
59
- component("x-dashboard", () => () => html`
60
- <header class="page-head">...</header>
61
- <div class="metric-grid">...</div>
62
- `);
63
+ // ❌ .page-head и .metric-grid НЕ применятся внутри shadowRoot x-dashboard
64
+ component(
65
+ "x-dashboard",
66
+ () => () => html`
67
+ <header class="page-head">...</header>
68
+ <div class="metric-grid">...</div>
69
+ `,
70
+ );
63
71
  ```
64
72
 
65
- Fix it by making the route/page component Light DOM:
73
+ Решение сделать route/page компонент Light DOM:
66
74
 
67
75
  ```ts
68
- component("x-dashboard", () => () => html`
69
- <header class="page-head">...</header>
70
- <div class="metric-grid">...</div>
71
- `, {
72
- shadow: false,
73
- styles: css`
74
- x-dashboard { display: block; }
75
- x-dashboard .panel { padding: 1rem; }
76
+ component(
77
+ "x-dashboard",
78
+ () => () => html`
79
+ <header class="page-head">...</header>
80
+ <div class="metric-grid">...</div>
76
81
  `,
77
- });
82
+ {
83
+ shadow: false,
84
+ styles: css`
85
+ x-dashboard {
86
+ display: block;
87
+ }
88
+ x-dashboard .panel {
89
+ padding: 1rem;
90
+ }
91
+ `,
92
+ },
93
+ );
78
94
  ```
79
95
 
80
- Now global utilities and local scoped styles both work.
96
+ Теперь глобальные утилиты и локальные scoped-стили работают вместе.
81
97
 
82
- ## How Styles Behave
98
+ ## Как ведут себя стили
83
99
 
84
- - `styles: css\`\`` in Shadow DOM is adopted into the component shadowRoot.
85
- - `styles: css\`\`` with `shadow: false` is scoped to the tag name and adopted
86
- globally.
87
- - CSS custom properties (`--accent`, `--bg`, etc.) cross Shadow DOM boundaries.
88
- - Class selectors like `.btn`, `.form-grid`, `.page-head` do **not** cross
89
- Shadow DOM boundaries.
90
- - Slotted children keep their own document styles; the shadow component can only
91
- target them through `::slotted(...)`.
92
- - `<slot>` projects children only in Shadow DOM. In a `shadow: false` component
93
- it is just a normal `<slot>` element and will not move children into that
94
- place in your layout.
100
+ - `styles: css\`\`` в Shadow DOM адоптируется в shadowRoot компонента.
101
+ - `styles: css\`\``с`shadow: false` скоупится по имени тега и адоптируется
102
+ глобально.
103
+ - CSS custom properties (`--accent`, `--bg` и т.д.) пересекают границу Shadow DOM.
104
+ - Селекторы по классу (`.btn`, `.form-grid`, `.page-head`) **не** пересекают
105
+ границу Shadow DOM.
106
+ - Slotted-children сохраняют свои стили из документа; shadow-компонент может
107
+ таргетировать их только через `::slotted(...)`.
108
+ - `<slot>` проецирует children только в Shadow DOM. В компоненте с `shadow: false`
109
+ это обычный `<slot>` элемент, который не перемещает children в своё место.
95
110
 
96
- ## Recommended App Shape
111
+ ## Рекомендованная архитектура
97
112
 
98
113
  ```ts
99
- // root and pages: Light DOM
114
+ // root и pages: Light DOM
100
115
  component("x-app", setup, { shadow: false });
101
116
  component("x-users-page", setup, { shadow: false });
102
117
 
103
- // slot-based layout: Shadow DOM default, because it owns the shell grid
118
+ // slot-based layout: Shadow DOM по умолчанию, потому что владеет shell grid
104
119
  component("x-app-layout", setup);
105
120
 
106
- // leaf widgets: Shadow DOM default
121
+ // leaf widgets: Shadow DOM по умолчанию
107
122
  component("x-status-badge", setup);
108
123
  component("x-stat-card", setup);
109
124
  component("x-toast-stack", setup);
110
125
  ```
111
126
 
112
- This gives backend-admin screens predictable CSS while preserving encapsulation
113
- for reusable widgets and slot-based shells.
127
+ Это даёт backend-admin экранам предсказуемый CSS, сохраняя инкапсуляцию
128
+ для переиспользуемых виджетов и slot-based shells.
114
129
 
115
130
  Import model специально browser-native:
116
131
 
@@ -121,30 +136,31 @@ render(html`<x-app-layout>${router.view}</x-app-layout>`, app);
121
136
  ```
122
137
 
123
138
  Import регистрирует custom element через `customElements.define()`. Template
124
- создает `<x-app-layout>` element. Дальше браузер сам связывает тег с классом
125
- компонента. Тут нет React-style component value, который передается как функция.
139
+ создаёт `<x-app-layout>` элемент. Дальше браузер сам связывает тег с классом
140
+ компонента. Тут нет React-style component value, который передаётся как функция.
126
141
 
127
- If a layout does not need slot projection and should be styled entirely by
128
- global CSS, `shadow: false` can still be a good choice. If it contains
129
- `<slot>`, keep Shadow DOM and put the shell styles in that component.
142
+ Если layout не нуждается в slot projection и должен стилизоваться полностью
143
+ глобальным CSS, `shadow: false` хороший выбор. Если он содержит `<slot>`,
144
+ оставьте Shadow DOM и поместите стили shell в этот компонент.
130
145
 
131
- ## Routing and Links
146
+ ## Маршрутизация и ссылки
132
147
 
133
- `data-link` works inside Shadow DOM. The router uses `event.composedPath()`, so
134
- click interception and hover-prefetch can see links from open shadow roots.
148
+ `data-link` работает внутри Shadow DOM. Роутер использует `event.composedPath()`,
149
+ поэтому перехват кликов и hover-prefetch видят ссылки из open shadow roots.
135
150
 
136
151
  ```ts
137
- component("x-card-link", () => () => html`
138
- <a href="/app/accounts" data-link>Accounts</a>
139
- `);
152
+ component(
153
+ "x-card-link",
154
+ () => () => html` <a href="/app/accounts" data-link>Accounts</a> `,
155
+ );
140
156
  ```
141
157
 
142
- The link can be in Shadow DOM; navigation still stays SPA.
158
+ Ссылка может быть внутри Shadow DOM навигация всё равно остаётся SPA.
143
159
 
144
160
  ## Где импортировать компоненты
145
161
 
146
- Custom elements становятся глобальными после регистрации, но регистрация все
147
- равно остается явным JavaScript import.
162
+ Custom elements становятся глобальными после регистрации, но регистрация всё
163
+ равно остаётся явным JavaScript import.
148
164
 
149
165
  ```ts
150
166
  // main.ts: global app frame
@@ -158,7 +174,7 @@ import "../components/ticket-list.js";
158
174
  `<ticket-list>`. Файл должен быть где-то импортирован. После import он вызывает
159
175
  `customElements.define(...)`, и тег становится известен текущему document.
160
176
 
161
- Не стоит bulk-import всех компонентов в `main.ts` "just in case". Для маленьких
177
+ Не стоит bulk-import всех компонентов в `main.ts` «на всякий случай». Для маленьких
162
178
  demo это работает, но прячет ownership и ломает lazy route loading. Лучше:
163
179
 
164
180
  - global app shell/providers импортировать в `main.ts`;
@@ -167,16 +183,16 @@ demo это работает, но прячет ownership и ломает lazy r
167
183
  - truly global leaf components импортировать в `main.ts` только если они реально
168
184
  используются везде.
169
185
 
170
- ## Showcase Lesson
186
+ ## Урок из showcase
171
187
 
172
- `examples/showcase` uses this split deliberately:
188
+ `examples/showcase` использует это разделение намеренно:
173
189
 
174
- - `x-app` and CRM route pages are Light DOM;
175
- - `x-app-layout` keeps Shadow DOM because it owns a slot-based sidebar/content
176
- shell;
177
- - table/form/page utilities live in `styles/global.ts`;
178
- - leaf components such as `x-stat-card`, `x-status-badge`, `x-modal`, and
179
- `x-toast-stack` keep Shadow DOM.
190
+ - `x-app` и CRM route pages Light DOM;
191
+ - `x-app-layout` остаётся в Shadow DOM, потому что владеет slot-based
192
+ sidebar/content shell;
193
+ - table/form/page утилиты живут в `styles/global.ts`;
194
+ - leaf-компоненты типа `x-stat-card`, `x-status-badge`, `x-modal` и
195
+ `x-toast-stack` остаются в Shadow DOM.
180
196
 
181
- If a page suddenly looks unstyled, check whether it uses global classes inside a
182
- Shadow DOM component. That is usually the issue.
197
+ Если страница внезапно выглядит без стилей, проверьте не используете ли вы
198
+ глобальные классы внутри Shadow DOM компонента. Обычно проблема именно в этом.
@@ -0,0 +1,193 @@
1
+ # Shadow DOM + формы
2
+
3
+ Использование `useForm()` с кастомными input-компонентами в Shadow DOM требует
4
+ знания двух поведений на уровне браузера:
5
+
6
+ 1. **Ретаргетинг событий** — события, всплывающие из Shadow DOM, получают
7
+ `e.target`, перенаправленный на host-элемент. `useForm().onInput` читает
8
+ `e.target.name` и `e.target.value`, но host-элемент `<x-input>`
9
+ не имеет этих свойств нативно.
10
+
11
+ 2. **Ассоциация с формой** — `<button type="submit">` внутри Shadow Root
12
+ НЕ участвует в алгоритме form-owner для `<form>` в Light DOM. Клик по ней
13
+ не триггерит submit формы.
14
+
15
+ Оба ограничения — на уровне спецификации, не баги Mado. Но фреймворк предоставляет
16
+ паттерны, которые делают их безболезненными.
17
+
18
+ ## Паттерн: Proxy-свойства на input-компонентах
19
+
20
+ Оборачивая `<input>` в Shadow DOM компонент, экспонируйте `name` и `value`
21
+ как DOM-свойства на host, чтобы `useForm().onInput` работал после ретаргетинга:
22
+
23
+ ```ts
24
+ import { component, css, html } from "@madojs/mado";
25
+
26
+ component("x-input", ({ host, attr }) => {
27
+ const name = attr("name", "");
28
+ const type = attr("type", "text");
29
+ const value = attr("value", "");
30
+
31
+ // Proxy-свойства для совместимости с useForm().
32
+ // После ретаргетинга Shadow DOM e.target из <input> → <x-input>,
33
+ // useForm читает e.target.name / e.target.value — эти геттеры наводят мост.
34
+ Object.defineProperty(host, "name", {
35
+ get: () => host.getAttribute("name") ?? "",
36
+ configurable: true,
37
+ });
38
+ Object.defineProperty(host, "value", {
39
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
40
+ set: (v: string) => {
41
+ const input = host.shadowRoot?.querySelector("input");
42
+ if (input) input.value = v;
43
+ },
44
+ configurable: true,
45
+ });
46
+
47
+ return () => html`<input name=${name} type=${type} .value=${value} />`;
48
+ });
49
+ ```
50
+
51
+ Событие `input` от внутреннего `<input>` имеет `composed: true` по умолчанию,
52
+ поэтому оно всплывает через границу shadow. После ретаргетинга `e.target` —
53
+ это `<x-input>`, но теперь у него есть геттеры `.name` и `.value` → `useForm`
54
+ работает.
55
+
56
+ ## Паттерн: Submit формы из Shadow DOM кнопок
57
+
58
+ `<button type="submit">` внутри Shadow DOM не может триггерить submit `<form>`
59
+ в Light DOM. Мост через `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")` работает, потому что сам host-элемент живёт в Light DOM
83
+ (только его внутренности в тени). `requestSubmit()` триггерит валидацию и
84
+ событие `submit` точно так, как если бы пользователь кликнул нативную submit-кнопку
85
+ внутри формы.
86
+
87
+ ## Паттерн: Реактивные атрибуты через ctx.attr()
88
+
89
+ С версии 0.7 `ctx.attr(name, defaultValue?)` возвращает `Signal<string>`,
90
+ который автоматически обновляется при изменении атрибута на host. Никакого
91
+ `MutationObserver` бойлерплейта:
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
+ Родитель может использовать `?disabled=${() => !form.isValid()}` (boolean атрибут)
105
+ или `.variant=${"danger"}` — компонент перерендеривается реактивно в любом случае.
106
+
107
+ ## Полный пример формы
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: "Вход",
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="Пароль"
146
+ required
147
+ @input=${form.onInput}
148
+ @blur=${form.onBlur}
149
+ ></x-input>
150
+
151
+ <x-button type="submit" ?disabled=${() => !form.isValid()}>
152
+ Войти
153
+ </x-button>
154
+ </form>
155
+ `;
156
+ },
157
+ });
158
+ ```
159
+
160
+ ## Когда использовать Light DOM
161
+
162
+ Если ваш input-компонент — это просто стилизованная обёртка без нужды в
163
+ инкапсуляции, `shadow: false` избегает обеих проблем (ретаргетинг и
164
+ form-association):
165
+
166
+ ```ts
167
+ component(
168
+ "x-field",
169
+ ({ attr }) => {
170
+ const label = attr("label", "");
171
+ return () => html`
172
+ <label>
173
+ <span>${label}</span>
174
+ <slot></slot>
175
+ </label>
176
+ `;
177
+ },
178
+ { shadow: false },
179
+ );
180
+ ```
181
+
182
+ С Light DOM нативный `<input>` — часть дерева документа, события не
183
+ ретаргетируются, и submit формы работает нативно. Компромисс: стили не
184
+ инкапсулированы (нужно скоупить самостоятельно).
185
+
186
+ ## Итого
187
+
188
+ | Задача | Решение Shadow DOM | Альтернатива Light DOM |
189
+ | --------------------------- | -------------------------------------- | --------------------------- |
190
+ | `useForm` + кастомный input | Proxy `name`/`value` на host | Нативный `<input>` в slot |
191
+ | Submit формы | `form.requestSubmit()` в click handler | Нативная кнопка работает |
192
+ | Реактивные атрибуты | `ctx.attr()` → авто-сигнал | `ctx.attr()` работает везде |
193
+ | Инкапсуляция стилей | Да (автоматически) | Ручной `@scope` или BEM |
package/docs/ru/README.md CHANGED
@@ -1,21 +1,22 @@
1
1
  # Mado docs — Русский
2
2
 
3
- | Раздел | Файл |
4
- |---|---|
5
- | Принципы | [00-the-mado-way.md](./00-the-mado-way.md) |
6
- | Routing | [01-routing.md](./01-routing.md) |
7
- | Project layout | [02-project-layout.md](./02-project-layout.md) |
8
- | Static bake & SEO | [03-static-bake.md](./03-static-bake.md) |
9
- | IDE setup | [04-ide-setup.md](./04-ide-setup.md) |
10
- | Why Mado | [05-why-mado.md](./05-why-mado.md) |
11
- | For backenders | [06-for-backenders.md](./06-for-backenders.md) |
12
- | LLM pitfalls | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
13
- | LLM zero-history test | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
14
- | Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
15
- | Архитектура приложения | [10-app-architecture.md](./10-app-architecture.md) |
16
- | Layouts | [11-layouts.md](./11-layouts.md) |
17
- | Auth and API | [12-auth-and-api.md](./12-auth-and-api.md) |
18
- | Deployment | [13-deployment.md](./13-deployment.md) |
19
- | Тестирование | [14-testing.md](./14-testing.md) |
20
- | Обработка ошибок | [15-error-handling.md](./15-error-handling.md) |
21
- | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
3
+ | Раздел | Файл |
4
+ | --------------------------- | ------------------------------------------------------------ |
5
+ | Принципы | [00-the-mado-way.md](./00-the-mado-way.md) |
6
+ | Маршрутизация | [01-routing.md](./01-routing.md) |
7
+ | Структура проекта | [02-project-layout.md](./02-project-layout.md) |
8
+ | Статический bake и SEO | [03-static-bake.md](./03-static-bake.md) |
9
+ | Настройка IDE | [04-ide-setup.md](./04-ide-setup.md) |
10
+ | Почему Mado | [05-why-mado.md](./05-why-mado.md) |
11
+ | Для бекендеров | [06-for-backenders.md](./06-for-backenders.md) |
12
+ | Типичные ошибки LLM | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
13
+ | Тест LLM с нулевой историей | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
14
+ | Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
15
+ | Архитектура приложения | [10-app-architecture.md](./10-app-architecture.md) |
16
+ | Макеты (layouts) | [11-layouts.md](./11-layouts.md) |
17
+ | Авторизация и API | [12-auth-and-api.md](./12-auth-and-api.md) |
18
+ | Развёртывание | [13-deployment.md](./13-deployment.md) |
19
+ | Тестирование | [14-testing.md](./14-testing.md) |
20
+ | Обработка ошибок | [15-error-handling.md](./15-error-handling.md) |
21
+ | Рецепты bake | [16-bake-cookbook.md](./16-bake-cookbook.md) |
22
+ | Shadow DOM + формы | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
@@ -27,10 +27,10 @@ count.set(1);
27
27
 
28
28
  ```ts
29
29
  // Ні
30
- count.value
30
+ count.value;
31
31
 
32
32
  // Так
33
- count()
33
+ count();
34
34
  ```
35
35
 
36
36
  ## Нереактивний child binding
@@ -60,7 +60,11 @@ html`<button ?disabled=${loading}>Save</button>`;
60
60
  items().map((item) => html`<li>${item.name}</li>`);
61
61
 
62
62
  // Так
63
- each(items(), (item) => item.id, (item) => html`<li>${item.name}</li>`);
63
+ each(
64
+ items(),
65
+ (item) => item.id,
66
+ (item) => html`<li>${item.name}</li>`,
67
+ );
64
68
  ```
65
69
 
66
70
  ## Imports
@@ -80,3 +84,60 @@ import "./components/x-card.js";
80
84
 
81
85
  Custom element name має містити дефіс. `x-*` — демо-конвенція; production може
82
86
  мати `app-*`, `crm-*`, `ticket-*`, `admin-*`.
87
+
88
+ ## `host.getAttribute()` у render — не реактивно
89
+
90
+ ```ts
91
+ // Ні: читається один раз, не оновлюється
92
+ component("x-badge", ({ host }) => () => {
93
+ const variant = host.getAttribute("variant") ?? "default";
94
+ return html`<span class=${variant}>...</span>`;
95
+ });
96
+
97
+ // Так: ctx.attr() — реактивний Signal
98
+ component("x-badge", ({ attr }) => {
99
+ const variant = attr("variant", "default");
100
+ return () => html`<span class=${() => `badge-${variant()}`}>...</span>`;
101
+ });
102
+ ```
103
+
104
+ ## Shadow DOM кнопка і submit форми
105
+
106
+ `<button type="submit">` всередині Shadow DOM не тригерить submit `<form>` у Light DOM.
107
+ Використовуйте `form.requestSubmit()` у click handler.
108
+
109
+ ```ts
110
+ const handleClick = () => {
111
+ const form = host.closest("form");
112
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
113
+ };
114
+ ```
115
+
116
+ ## `useForm()` + Shadow DOM input
117
+
118
+ Після ретаргетингу `e.target` — це `<x-input>`, у якого немає `.name`/`.value`.
119
+ Додайте proxy-властивості на host:
120
+
121
+ ```ts
122
+ Object.defineProperty(host, "name", {
123
+ get: () => host.getAttribute("name") ?? "",
124
+ });
125
+ Object.defineProperty(host, "value", {
126
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
127
+ });
128
+ ```
129
+
130
+ Детальніше: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
131
+
132
+ ## Шпаргалка
133
+
134
+ | React/інше | Mado |
135
+ | --------------------------- | ---------------------------- |
136
+ | `useState(0)` | `signal(0)` |
137
+ | `useEffect(() => {...})` | `effect(() => {...})` |
138
+ | `useMemo(() => x)` | `computed(() => x)` |
139
+ | `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
140
+ | `useRouter().push('/')` | `navigate('/')` |
141
+ | `class extends HTMLElement` | `component('x-name', setup)` |
142
+ | `host.getAttribute('x')` | `ctx.attr('x', default)` |
143
+ | `jsonFetcher()` з auth | `apiFetcher()` |