@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.
@@ -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
+ );
@@ -1,43 +1,74 @@
1
- // <x-input label name type placeholder required value @input @blur>
1
+ // <x-input label name type placeholder required value error @input @blur>
2
2
  //
3
3
  // Labeled input that proxies its events. Use inside `useForm()`:
4
4
  //
5
5
  // <x-input name="email" type="email" required
6
6
  // @input=${form.onInput} @blur=${form.onBlur}></x-input>
7
+ //
8
+ // Shadow DOM integration notes:
9
+ // - `name` and `value` are exposed as DOM properties on the host so that
10
+ // event retargeting (e.target → <x-input>) still works with useForm().
11
+ // useForm.onInput reads e.target.name and e.target.value — without these
12
+ // getters the form silently receives undefined.
13
+ // - The inner <input> dispatches its events with `composed: true` (native
14
+ // behaviour), so @input/@blur bubble through the shadow boundary.
7
15
 
8
16
  import { component, css, html } from "@madojs/mado";
9
17
 
10
18
  component(
11
19
  "x-input",
12
- ({ host }) => () => {
13
- const label = host.getAttribute("label") ?? "";
14
- const name = host.getAttribute("name") ?? "";
15
- const type = host.getAttribute("type") ?? "text";
16
- const placeholder = host.getAttribute("placeholder") ?? "";
17
- const required = host.hasAttribute("required");
18
- const value = host.getAttribute("value") ?? "";
19
- const error = host.getAttribute("error");
20
+ ({ host, attr }) => {
21
+ const label = attr("label", "");
22
+ const name = attr("name", "");
23
+ const type = attr("type", "text");
24
+ const placeholder = attr("placeholder", "");
25
+ const required = attr("required");
26
+ const value = attr("value", "");
27
+ const error = attr("error");
28
+
29
+ // Proxy properties so useForm().onInput can read e.target.name / .value
30
+ // even after Shadow DOM retargets e.target from <input> to <x-input>.
31
+ Object.defineProperty(host, "name", {
32
+ get: () => host.getAttribute("name") ?? "",
33
+ configurable: true,
34
+ });
35
+ Object.defineProperty(host, "value", {
36
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
37
+ set: (v: string) => {
38
+ const input = host.shadowRoot?.querySelector("input");
39
+ if (input) input.value = v;
40
+ },
41
+ configurable: true,
42
+ });
20
43
 
21
- return html`
44
+ return () => html`
22
45
  <label>
23
- ${label
24
- ? html`<span class="lbl">${label}${required ? html`<em>*</em>` : null}</span>`
25
- : null}
46
+ ${() =>
47
+ label()
48
+ ? html`<span class="lbl"
49
+ >${label}${() =>
50
+ required() !== "" ? html`<em>*</em>` : null}</span
51
+ >`
52
+ : null}
26
53
  <input
27
54
  name=${name}
28
55
  type=${type}
29
56
  placeholder=${placeholder}
30
- ?required=${required}
57
+ ?required=${() => required() !== ""}
31
58
  .value=${value}
32
- >
33
- ${error ? html`<small class="err">${error}</small>` : null}
59
+ />
60
+ ${() => (error() ? html`<small class="err">${error}</small>` : null)}
34
61
  </label>
35
62
  `;
36
63
  },
37
64
  {
38
65
  styles: css`
39
- :host { display: block; }
40
- label { display: block; }
66
+ :host {
67
+ display: block;
68
+ }
69
+ label {
70
+ display: block;
71
+ }
41
72
  .lbl {
42
73
  display: block;
43
74
  font-size: 12px;
@@ -71,4 +102,4 @@ component(
71
102
  }
72
103
  `,
73
104
  },
74
- );
105
+ );
@@ -71,9 +71,9 @@ export function createApiClient(baseUrl: string) {
71
71
  credentials: "include",
72
72
  });
73
73
  if (!res.ok) return false;
74
- const data = (await res.json().catch(() => null)) as
75
- | { accessToken?: string }
76
- | null;
74
+ const data = (await res.json().catch(() => null)) as {
75
+ accessToken?: string;
76
+ } | null;
77
77
  if (!data?.accessToken) return false;
78
78
  accessToken.set(data.accessToken);
79
79
  return true;
@@ -118,7 +118,11 @@ export function createApiClient(baseUrl: string) {
118
118
  }
119
119
  if (!res.ok) {
120
120
  const body = await res.json().catch(() => null);
121
- throw new ApiError(res.status, body, `HTTP ${res.status} ${res.statusText}`);
121
+ throw new ApiError(
122
+ res.status,
123
+ body,
124
+ `HTTP ${res.status} ${res.statusText}`,
125
+ );
122
126
  }
123
127
  if (res.status === 204) return null as unknown as T;
124
128
  return (await res.json()) as T;
@@ -131,3 +135,50 @@ export function createApiClient(baseUrl: string) {
131
135
 
132
136
  /** Default app-wide client. Change the base URL via mado.config.json dev.proxy. */
133
137
  export const api = createApiClient("/api");
138
+
139
+ /**
140
+ * Fetcher for resource() that attaches the Bearer token automatically.
141
+ * Use this instead of jsonFetcher() for protected endpoints:
142
+ *
143
+ * const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
144
+ *
145
+ * Unlike jsonFetcher(), this:
146
+ * - Sends the access token from memory (no cookie-based auth needed).
147
+ * - Does NOT prepend a base URL — the key is the full URL (matches
148
+ * resource key semantics).
149
+ * - Throws ApiError on non-2xx (consistent with api()).
150
+ */
151
+ export function apiFetcher<T>(): (
152
+ url: string,
153
+ signal: AbortSignal,
154
+ ) => Promise<T> {
155
+ return async (url, abortSignal) => {
156
+ const token = accessToken();
157
+ const headers = new Headers();
158
+ headers.set("accept", "application/json");
159
+ if (token) headers.set("authorization", `Bearer ${token}`);
160
+
161
+ const res = await fetch(url, {
162
+ signal: abortSignal,
163
+ headers,
164
+ credentials: "include",
165
+ });
166
+
167
+ if (!res.ok) {
168
+ let body: unknown = null;
169
+ try {
170
+ const ct = res.headers.get("content-type") ?? "";
171
+ body = ct.includes("json") ? await res.json() : await res.text();
172
+ } catch {
173
+ body = null;
174
+ }
175
+ throw new ApiError(
176
+ res.status,
177
+ body,
178
+ `HTTP ${res.status} ${res.statusText}`,
179
+ );
180
+ }
181
+ if (res.status === 204) return null as unknown as T;
182
+ return (await res.json()) as T;
183
+ };
184
+ }