@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.
@@ -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/uk/README.md CHANGED
@@ -2,22 +2,23 @@
2
2
 
3
3
  Український комплект документації.
4
4
 
5
- | Розділ | Файл |
6
- |---|---|
7
- | Шлях Mado | [00-the-mado-way.md](./00-the-mado-way.md) |
8
- | Маршрутизація | [01-routing.md](./01-routing.md) |
9
- | Структура проєкту | [02-project-layout.md](./02-project-layout.md) |
10
- | Static bake & SEO | [03-static-bake.md](./03-static-bake.md) |
11
- | Налаштування IDE | [04-ide-setup.md](./04-ide-setup.md) |
12
- | Чому Mado | [05-why-mado.md](./05-why-mado.md) |
13
- | Для бекендерів | [06-for-backenders.md](./06-for-backenders.md) |
14
- | Типові помилки LLM | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
15
- | LLM zero-history тест | [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
- | Архітектура застосунку | [10-app-architecture.md](./10-app-architecture.md) |
18
- | Layouts | [11-layouts.md](./11-layouts.md) |
19
- | Auth та API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
- | Deployment | [13-deployment.md](./13-deployment.md) |
21
- | Тестування | [14-testing.md](./14-testing.md) |
22
- | Обробка помилок | [15-error-handling.md](./15-error-handling.md) |
23
- | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
5
+ | Розділ | Файл |
6
+ | ----------------------- | ------------------------------------------------------------ |
7
+ | Шлях Mado | [00-the-mado-way.md](./00-the-mado-way.md) |
8
+ | Маршрутизація | [01-routing.md](./01-routing.md) |
9
+ | Структура проєкту | [02-project-layout.md](./02-project-layout.md) |
10
+ | Статичний bake & SEO | [03-static-bake.md](./03-static-bake.md) |
11
+ | Налаштування IDE | [04-ide-setup.md](./04-ide-setup.md) |
12
+ | Чому Mado | [05-why-mado.md](./05-why-mado.md) |
13
+ | Для бекендерів | [06-for-backenders.md](./06-for-backenders.md) |
14
+ | Типові помилки LLM | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
15
+ | LLM zero-history тест | [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
+ | Архітектура застосунку | [10-app-architecture.md](./10-app-architecture.md) |
18
+ | Макети (layouts) | [11-layouts.md](./11-layouts.md) |
19
+ | Auth та API | [12-auth-and-api.md](./12-auth-and-api.md) |
20
+ | Розгортання | [13-deployment.md](./13-deployment.md) |
21
+ | Тестування | [14-testing.md](./14-testing.md) |
22
+ | Обробка помилок | [15-error-handling.md](./15-error-handling.md) |
23
+ | Рецепти bake | [16-bake-cookbook.md](./16-bake-cookbook.md) |
24
+ | Shadow DOM + форми | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
package/llms.txt CHANGED
@@ -13,6 +13,7 @@ Mado is a narrowly-focused frontend framework that deliberately avoids React pat
13
13
  - **Components are Web Components.** Registered via `component('x-name', setupFn, options)`. Names must include a hyphen (`x-foo`, `my-button`).
14
14
  - **Component files register tags as side effects.** The browser does not auto-import files by tag name. If `<x-card>` works, some imported module already ran `customElements.define("x-card", ...)`.
15
15
  - **Cleanup via `ctx.onDispose(fn)`** in setup, not via return from effect.
16
+ - **Reactive attributes via `ctx.attr(name, default?)`** — returns a Signal<string> that auto-updates when the attribute changes. No MutationObserver needed.
16
17
 
17
18
  ## Critical template rules
18
19
 
@@ -64,6 +65,53 @@ import {
64
65
  } from "@madojs/mado";
65
66
  ```
66
67
 
68
+ ## Component ctx.attr() — reactive attributes
69
+
70
+ ```ts
71
+ component("x-badge", ({ attr }) => {
72
+ const variant = attr("variant", "default"); // Signal<string>, auto-updates
73
+ const size = attr("size", "md");
74
+
75
+ return () => html`<span class=${() => `badge-${variant()} size-${size()}`}>
76
+ <slot></slot>
77
+ </span>`;
78
+ });
79
+ ```
80
+
81
+ No MutationObserver boilerplate. The parent can bind with `?disabled=${...}` or
82
+ plain attribute changes and the component re-renders reactively.
83
+
84
+ ## Shadow DOM + Forms
85
+
86
+ Custom inputs in Shadow DOM need two things for `useForm()` compatibility:
87
+
88
+ 1. **Proxy properties** — expose `name` and `value` on the host:
89
+ ```ts
90
+ Object.defineProperty(host, "name", { get: () => host.getAttribute("name") ?? "" });
91
+ Object.defineProperty(host, "value", { get: () => host.shadowRoot?.querySelector("input")?.value ?? "" });
92
+ ```
93
+
94
+ 2. **Form submit bridge** — buttons inside Shadow DOM can't submit Light DOM forms:
95
+ ```ts
96
+ @click=${() => { const form = host.closest("form"); if (form) form.requestSubmit(); }}
97
+ ```
98
+
99
+ See `docs/en/17-shadow-dom-forms.md` for the full recipe.
100
+
101
+ ## Auth fetcher for resource()
102
+
103
+ The admin starter provides `apiFetcher<T>()` in `lib/api.ts` for protected endpoints:
104
+
105
+ ```ts
106
+ import { resource } from "@madojs/mado";
107
+ import { apiFetcher } from "../lib/api.js";
108
+
109
+ const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
110
+ ```
111
+
112
+ Unlike `jsonFetcher()`, `apiFetcher()` attaches the Bearer token from memory.
113
+ Use `jsonFetcher()` for public endpoints, `apiFetcher()` for anything behind auth.
114
+
67
115
  ## Canonical "Hello world"
68
116
 
69
117
  ```ts
@@ -150,6 +198,7 @@ export default page({
150
198
  - docs/en/14-testing.md — testing strategy and commands
151
199
  - docs/en/15-error-handling.md — route/data/action error boundaries
152
200
  - docs/en/16-bake-cookbook.md — static bake recipes and failure modes
201
+ - docs/en/17-shadow-dom-forms.md — Shadow DOM + useForm() patterns (proxy properties, form submit bridge)
153
202
  - examples/basic/ — minimal API tour
154
203
  - examples/tickets/ — LLM zero-history CRUD validation
155
204
  - examples/showcase/ — flagship CRM pressure app (auth, nested routes, forms, mutations)
@@ -170,7 +219,7 @@ export default page({
170
219
 
171
220
  ## Version
172
221
 
173
- `0.6.0` — pre-1.0 product-surface release. API may still change before 1.0.
222
+ `0.7.0` — pre-1.0 product-surface release. API may still change before 1.0.
174
223
  Semver is not guaranteed on minor versions before 1.0.
175
224
 
176
225
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@madojs/mado",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Mado — a small native-web SPA framework with Web Components, signals, tagged-template html, router, resources, and forms. TypeScript-only build, zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,4 +80,4 @@
80
80
  "engines": {
81
81
  "node": ">=20"
82
82
  }
83
- }
83
+ }
package/scripts/bake.mjs CHANGED
@@ -41,19 +41,19 @@ const { flags } = parseFlags(process.argv.slice(2));
41
41
  const cfg = loadConfig({
42
42
  overrides: {
43
43
  bake: {
44
- entry: typeof flags.entry === "string" ? flags.entry : undefined,
44
+ entry: typeof flags.entry === "string" ? flags.entry : undefined,
45
45
  template: typeof flags.template === "string" ? flags.template : undefined,
46
- baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
47
- outDir: typeof flags.out === "string" ? flags.out : undefined,
46
+ baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
47
+ outDir: typeof flags.out === "string" ? flags.out : undefined,
48
48
  },
49
49
  },
50
50
  });
51
51
 
52
52
  // Env vars are legacy escape hatches (kept so old CI keeps working).
53
- const ENTRY = process.env.ENTRY ?? resolveProjectPath(cfg, cfg.bake.entry);
54
- const TEMPLATE = process.env.TEMPLATE ?? resolveProjectPath(cfg, cfg.bake.template);
55
- const BASE_URL = process.env.BASE_URL ?? cfg.bake.baseUrl;
56
- const OUT_DIR = process.env.OUT_DIR
53
+ const ENTRY = process.env.ENTRY ?? resolveProjectPath(cfg, cfg.bake.entry);
54
+ const TEMPLATE = process.env.TEMPLATE ?? resolveProjectPath(cfg, cfg.bake.template);
55
+ const BASE_URL = process.env.BASE_URL ?? cfg.bake.baseUrl;
56
+ const OUT_DIR = process.env.OUT_DIR
57
57
  ?? resolveProjectPath(cfg, cfg.bake.outDir ?? join(cfg.build.out, "baked"));
58
58
 
59
59
  /** Write message to stderr and exit. Sync write keeps CI/execFile output reliable. */
@@ -119,21 +119,21 @@ const { window: linkedomWindow } = parseHTML(baseHtml);
119
119
  globalThis.window = linkedomWindow;
120
120
  globalThis.document = linkedomWindow.document;
121
121
  globalThis.location = new URL("http://localhost/");
122
- globalThis.history = { pushState: () => {}, replaceState: () => {} };
122
+ globalThis.history = { pushState: () => { }, replaceState: () => { } };
123
123
  globalThis.customElements = {
124
- define: () => {},
124
+ define: () => { },
125
125
  get: () => undefined,
126
126
  whenDefined: () => Promise.resolve(),
127
127
  };
128
- globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class {};
128
+ globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class { };
129
129
  globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? class {
130
130
  cssRules = [];
131
- replaceSync() {}
131
+ replaceSync() { }
132
132
  };
133
133
  globalThis.matchMedia = () => ({
134
134
  matches: false,
135
- addEventListener: () => {},
136
- removeEventListener: () => {},
135
+ addEventListener: () => { },
136
+ removeEventListener: () => { },
137
137
  });
138
138
  if (!globalThis.queueMicrotask) {
139
139
  globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
@@ -167,7 +167,7 @@ await esbuild.build({
167
167
 
168
168
  const routesUrl = pathToFileURL(tmpFile).href;
169
169
  const routesModule = await import(routesUrl);
170
- await rm(tmpFile).catch(() => {});
170
+ await rm(tmpFile).catch(() => { });
171
171
  const routeApi = routesModule.default;
172
172
 
173
173
  if (!routeApi) {
@@ -232,6 +232,12 @@ for (const [pattern, entry] of Object.entries(manifest)) {
232
232
  }
233
233
  const headMeta = pg.head ? pg.head(params, data) : {};
234
234
 
235
+ // Ensure <title> is always set in baked HTML. page.title is the primary
236
+ // source (string or function of params); head().title overrides it.
237
+ if (!headMeta.title && pg.title) {
238
+ headMeta.title = typeof pg.title === "function" ? pg.title(params) : pg.title;
239
+ }
240
+
235
241
  const tpl = pg.view({
236
242
  params,
237
243
  data,
@@ -273,11 +279,11 @@ for (const [pattern, entry] of Object.entries(manifest)) {
273
279
  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
274
280
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
275
281
  ${sitemapEntries
276
- .map(
277
- (e) =>
278
- ` <url><loc>${escapeXml(e.loc)}</loc><changefreq>${e.changefreq}</changefreq></url>`,
279
- )
280
- .join("\n")}
282
+ .map(
283
+ (e) =>
284
+ ` <url><loc>${escapeXml(e.loc)}</loc><changefreq>${e.changefreq}</changefreq></url>`,
285
+ )
286
+ .join("\n")}
281
287
  </urlset>
282
288
  `;
283
289
  await writeFile(join(OUT_DIR, "sitemap.xml"), sitemap);
package/scripts/cli.mjs CHANGED
@@ -92,11 +92,12 @@ async function runServe(rawArgs) {
92
92
  // example name; everything else (including `--host`, `--port`, etc.) is
93
93
  // forwarded verbatim to server/serve.mjs.
94
94
  const { example, forwarded } = splitDevArgs(rawArgs);
95
- if (!example && PROJECT_ROOT !== PACKAGE_ROOT) {
96
- await serveStaticProject(PROJECT_ROOT);
97
- return;
98
- }
99
95
  if (example) assertExample(example, { serveable: true });
96
+
97
+ // In app-mode (generated project, no example argument) we also go through
98
+ // server/serve.mjs to get config support (--host, --port, mado.config.json
99
+ // dev.proxy, HMR, etc.) — previously this fell back to serveStaticProject()
100
+ // which only read PORT from env and had no proxy/config/HMR.
100
101
  await run(
101
102
  process.execPath,
102
103
  [join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
@@ -235,7 +236,8 @@ async function runRelease(rawArgs) {
235
236
  // have to remember the order, and so the deploy artifact (out/) is always
236
237
  // assembled the same way.
237
238
  //
238
- // mado release
239
+ // mado release [--no-clean]
240
+ // → rm -rf out/ (unless --no-clean)
239
241
  // → mado typecheck
240
242
  // → mado build (tsc → dist/)
241
243
  // → mado bundle (esbuild → out/assets/, also copies index.html)
@@ -243,6 +245,7 @@ async function runRelease(rawArgs) {
243
245
  // → copy public/* → out/
244
246
  //
245
247
  // Flags are forwarded to bake/bundle.
248
+ const { flags: releaseFlags } = parseFlags(rawArgs);
246
249
  const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
247
250
  const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
248
251
  const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
@@ -251,6 +254,18 @@ async function runRelease(rawArgs) {
251
254
  console.log(`[release] artifact: ${outDir}`);
252
255
  console.log("");
253
256
 
257
+ // Deterministic builds: remove the entire output directory so stale assets,
258
+ // removed bake routes, and deleted public files don't linger in the deploy
259
+ // artifact. Use --no-clean to opt out (e.g. incremental CI workflows).
260
+ if (!releaseFlags["no-clean"]) {
261
+ if (existsSync(outDir)) {
262
+ await rm(outDir, { recursive: true, force: true });
263
+ console.log(`[release] cleaned ${outDir}`);
264
+ }
265
+ } else {
266
+ console.log("[release] --no-clean: keeping existing out/");
267
+ }
268
+
254
269
  console.log("[release] step 1/5 typecheck");
255
270
  await runNodeBin("typescript/bin/tsc", ["--noEmit"]);
256
271
 
@@ -527,31 +542,5 @@ function contentType(file) {
527
542
  }[ext] ?? "application/octet-stream";
528
543
  }
529
544
 
530
- async function serveStaticProject(rootDir) {
531
- const port = Number(process.env.PORT || 5173);
532
- const server = http.createServer((req, res) => {
533
- const url = new URL(req.url || "/", `http://localhost:${port}`);
534
- const pathname = decodeURIComponent(url.pathname);
535
- const normalized = pathname.replace(/^\/+/, "");
536
- let file = resolve(rootDir, normalized);
537
- if (pathname === "/" || !normalized.includes(".")) file = join(rootDir, "index.html");
538
- if (!file.startsWith(rootDir) || !existsSync(file) || statSync(file).isDirectory()) {
539
- file = join(rootDir, "index.html");
540
- }
541
-
542
- if (!existsSync(file)) {
543
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
544
- res.end("Not found");
545
- return;
546
- }
547
-
548
- res.writeHead(200, { "content-type": contentType(file) });
549
- res.end(readFileSync(file));
550
- });
551
-
552
- await new Promise((resolveListen) => {
553
- server.listen(port, resolveListen);
554
- });
555
- console.log(`[mado] serving ${rootDir}`);
556
- console.log(`[mado] http://localhost:${port}`);
557
- }
545
+ // serveStaticProject removed in v0.7 — mado serve now always goes through
546
+ // server/serve.mjs to get --host, --port, dev.proxy, and HMR support.
@@ -1,25 +1,45 @@
1
1
  // <x-button variant="primary|ghost|danger" ?disabled>
2
2
  //
3
3
  // Wraps a native <button> so it can be slotted with text/icon and styled
4
- // consistently across the app. Click events bubble naturally because Shadow
5
- // DOM is `mode: open` and composed: true is the default for `click`.
4
+ // consistently across the app.
5
+ //
6
+ // Handles two Shadow DOM gotchas out of the box:
7
+ // 1. Reactive attributes via ctx.attr() — external ?disabled changes
8
+ // re-render the inner button automatically.
9
+ // 2. Form submit — a <button type="submit"> inside Shadow DOM cannot
10
+ // trigger <form> submit in Light DOM (spec limitation). We call
11
+ // form.requestSubmit() from a click handler to bridge this gap.
6
12
 
7
13
  import { component, css, html } from "@madojs/mado";
8
14
 
9
15
  component(
10
16
  "x-button",
11
- ({ host }) => () => {
12
- const variant = host.getAttribute("variant") ?? "primary";
13
- const disabled = host.hasAttribute("disabled");
14
- return html`
15
- <button data-variant=${variant} ?disabled=${disabled}>
17
+ ({ host, attr }) => {
18
+ const variant = attr("variant", "primary");
19
+ const disabled = attr("disabled");
20
+
21
+ const handleClick = () => {
22
+ const typeAttr = host.getAttribute("type");
23
+ if (typeAttr === "button" || typeAttr === "reset") return;
24
+ const form = host.closest("form");
25
+ if (form && !host.hasAttribute("disabled")) form.requestSubmit();
26
+ };
27
+
28
+ return () => html`
29
+ <button
30
+ data-variant=${variant()}
31
+ ?disabled=${() => disabled() !== ""}
32
+ @click=${handleClick}
33
+ >
16
34
  <slot></slot>
17
35
  </button>
18
36
  `;
19
37
  },
20
38
  {
21
39
  styles: css`
22
- :host { display: inline-flex; }
40
+ :host {
41
+ display: inline-flex;
42
+ }
23
43
  button {
24
44
  display: inline-flex;
25
45
  align-items: center;
@@ -31,11 +51,18 @@ component(
31
51
  cursor: pointer;
32
52
  background: var(--accent);
33
53
  color: var(--accent-fg);
34
- transition: filter .12s ease;
54
+ transition: filter 0.12s ease;
55
+ }
56
+ button:hover:not(:disabled) {
57
+ filter: brightness(1.07);
58
+ }
59
+ button:active:not(:disabled) {
60
+ filter: brightness(0.95);
61
+ }
62
+ button:disabled {
63
+ opacity: 0.55;
64
+ cursor: not-allowed;
35
65
  }
36
- button:hover:not(:disabled) { filter: brightness(1.07); }
37
- button:active:not(:disabled) { filter: brightness(.95); }
38
- button:disabled { opacity: .55; cursor: not-allowed; }
39
66
 
40
67
  button[data-variant="ghost"] {
41
68
  background: transparent;
@@ -52,4 +79,4 @@ component(
52
79
  }
53
80
  `,
54
81
  },
55
- );
82
+ );