ilha 0.4.4 → 0.5.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/README.md CHANGED
@@ -326,40 +326,6 @@ An error thrown inside an `.onError()` handler does not break other registered h
326
326
 
327
327
  ---
328
328
 
329
- ### `.bind(selector, stateKey | externalSignal)`
330
-
331
- Two-way binds a form element to a state key or an external signal. Handles `input`, `select`, `textarea`, `checkbox`, `radio`, and `number` inputs automatically.
332
-
333
- ```ts
334
- ilha
335
- .state("name", "")
336
- .state("agreed", false)
337
- .bind("input.name", "name")
338
- .bind("input[type=checkbox]", "agreed")
339
- .render(
340
- ({ state }) => html`
341
- <form>
342
- <input class="name" value="${state.name}" />
343
- <input type="checkbox" />
344
- <p>Hello, ${state.name}! Agreed: ${state.agreed}</p>
345
- </form>
346
- `,
347
- );
348
- ```
349
-
350
- You can also bind to an external signal — either a free-standing one created with `signal()` or a named global one created with `context()`:
351
-
352
- ```ts
353
- import { signal, context } from "ilha";
354
-
355
- const username = signal("");
356
- const theme = context("app.theme", "light");
357
-
358
- ilha.bind("input.name", username).bind("select.theme", theme).render(/* … */);
359
- ```
360
-
361
- ---
362
-
363
329
  ### `.css(strings, ...values)`
364
330
 
365
331
  Attaches scoped styles to the island. Accepts a tagged template literal or a plain string. The CSS is automatically wrapped in a `@scope` rule bounded to the island host, so styles are contained within the island and do not leak into child islands.
@@ -616,7 +582,7 @@ const Footer = ilha.render(() => html`<footer>Logged in as ${username()}</footer
616
582
  username("alice");
617
583
  ```
618
584
 
619
- Pairs naturally with `.bind()` for two-way form bindings against module-level state.
585
+ Works naturally with `bind:` template syntax for two-way form bindings against module-level state.
620
586
 
621
587
  ---
622
588
 
@@ -635,7 +601,7 @@ theme("dark"); // → sets to "dark"
635
601
 
636
602
  Safe to call in both SSR and browser environments.
637
603
 
638
- > **`signal()` vs `context()`** — both return the same accessor shape and can be passed to `.bind()`. Use `signal()` for one-off shared state where you'd hold the reference yourself; use `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key.
604
+ > **`signal()` vs `context()`** — both return the same accessor shape and can be used with `bind:` template syntax. Use `signal()` for one-off shared state where you'd hold the reference yourself; use `context()` when you want a name-keyed registry so the same signal can be looked up from anywhere by string key.
639
605
 
640
606
  ---
641
607
 
@@ -713,6 +679,32 @@ Interpolation rules:
713
679
  | Island / Island call | Emitted as `data-ilha-slot` host element |
714
680
  | Array | Each item processed recursively (no commas) |
715
681
 
682
+ **Template bindings** — use `bind:property=${signal}` inside `html\`\`` to create two-way bindings between form elements and signals:
683
+
684
+ ```ts
685
+ ilha.state("name", "").render(
686
+ ({ state }) => html`
687
+ <input bind:value=${state.name} />
688
+ <p>Hello, ${state.name()}!</p>
689
+ `,
690
+ );
691
+ ```
692
+
693
+ Supported bindings:
694
+
695
+ | Binding | Element | Bound property | Trigger event |
696
+ | -------------------- | ------------------------------------------------- | ------------------- | ------------- |
697
+ | `bind:value` | `<input>`, `<textarea>`, `<select>` | `value` | `input` |
698
+ | `bind:valueAsNumber` | `<input type="number">` | `valueAsNumber` | `input` |
699
+ | `bind:valueAsDate` | `<input type="date">` | `valueAsDate` | `input` |
700
+ | `bind:checked` | `<input type="checkbox">` | `checked` | `change` |
701
+ | `bind:group` | `<input type="radio">`, `<input type="checkbox">` | `checked` / `value` | `change` |
702
+ | `bind:open` | `<details>` | `open` | `toggle` |
703
+ | `bind:files` | `<input type="file">` | `files` | `change` |
704
+ | `bind:this` | Any element | element reference | — |
705
+
706
+ `bind:group` connects multiple inputs to a single signal — radio buttons hold the selected `value`, checkboxes hold an array of checked values. `bind:this` writes the DOM element into a signal on mount and `null` on unmount. External signals from `signal()` or `context()` work as binding targets too, enabling shared state across islands.
707
+
716
708
  **List rendering pattern:**
717
709
 
718
710
  ```ts
package/dist/index.d.ts CHANGED
@@ -64,7 +64,8 @@ declare function ilhaContext<T>(key: string, initial: T): ContextSignal<T>;
64
64
  /**
65
65
  * Create a free-standing reactive signal that lives outside any island.
66
66
  * Useful for sharing state across islands without prop drilling, or for
67
- * binding form inputs to module-level state via `.bind(selector, signal)`.
67
+ * binding form inputs to module-level state via the `bind:value=${signal}`
68
+ * template syntax.
68
69
  *
69
70
  * The returned accessor is a getter when called with no arguments and a
70
71
  * setter when called with one. Reading it inside a `.derived()`, `.effect()`,
@@ -106,16 +107,12 @@ interface DerivedEntry<TInput, TStateMap extends Record<string, unknown>> {
106
107
  fn: DerivedFn<TInput, TStateMap, unknown>;
107
108
  }
108
109
  type IslandDerived<TDerivedMap extends Record<string, unknown>> = { readonly [K in keyof TDerivedMap]: DerivedValue<TDerivedMap[K]> };
109
- type ExternalSignal<T = unknown> = {
110
+ interface ExternalSignal<T = unknown> {
110
111
  (): T;
111
112
  (value: T): void;
112
- };
113
- interface BindEntry<TStateMap extends Record<string, unknown>> {
114
- selector: string;
115
- target: (keyof TStateMap & string) | ExternalSignal;
116
113
  }
117
114
  type SignalAccessor<T> = MarkedSignalAccessor<T>;
118
- type MergeState<TStateMap extends Record<string, unknown>, K extends string, V> = { [P in keyof TStateMap as P extends K ? never : P]-?: TStateMap[P] } & Record<K, V>;
115
+ type MergeState<TStateMap extends Record<string, unknown>, K extends string, V> = Omit<TStateMap, K> & Record<K, V>;
119
116
  type IslandState<TStateMap extends Record<string, unknown>> = { readonly [K in keyof TStateMap]-?: SignalAccessor<TStateMap[K]> };
120
117
  interface HydratableOptions {
121
118
  name: string;
@@ -156,14 +153,14 @@ type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
156
153
  */
157
154
  signal: AbortSignal;
158
155
  };
159
- type OnMountContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
156
+ type OnMountContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> = {
160
157
  state: IslandState<TStateMap>;
161
158
  derived: IslandDerived<TDerivedMap>;
162
159
  input: TInput;
163
160
  host: Element;
164
161
  hydrated: boolean;
165
162
  };
166
- type HandlerContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
163
+ type HandlerContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> = {
167
164
  state: IslandState<TStateMap>;
168
165
  derived: IslandDerived<TDerivedMap>;
169
166
  input: TInput;
@@ -180,17 +177,15 @@ type HandlerContext<TInput, TStateMap extends Record<string, unknown>, TDerivedM
180
177
  */
181
178
  signal: AbortSignal;
182
179
  };
183
- type HTMLEventName = keyof HTMLElementEventMap & string;
184
- type Modifier = "once" | "capture" | "passive" | "abortable";
185
- type WithModifiers<E extends string> = E | `${E}:${Modifier}` | `${E}:${Modifier}:${Modifier}` | `${E}:${Modifier}:${Modifier}:${Modifier}`;
186
- type OnSelectorString = `@${WithModifiers<HTMLEventName>}` | `${string}@${WithModifiers<HTMLEventName>}`;
187
- type HandlerContextFor<TInput, TStateMap extends Record<string, unknown>, TEventName extends string, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
180
+ type HTMLEventFor<E extends string> = E extends keyof HTMLElementEventMap ? HTMLElementEventMap[E] : Event;
181
+ type HTMLTargetFor<E extends string> = E extends keyof HTMLElementEventMap ? NonNullable<HTMLElementEventMap[E]["target"]> extends Element ? NonNullable<HTMLElementEventMap[E]["target"]> : Element : Element;
182
+ type HandlerContextFor<TInput, TStateMap extends Record<string, unknown>, TEventName extends string, TDerivedMap extends Record<string, unknown> = Record<never, never>> = {
188
183
  state: IslandState<TStateMap>;
189
184
  derived: IslandDerived<TDerivedMap>;
190
185
  input: TInput;
191
186
  host: Element;
192
- target: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName]["target"] extends Element | null ? NonNullable<HTMLElementEventMap[TEventName]["target"]> : Element : Element;
193
- event: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName] : Event;
187
+ target: HTMLTargetFor<TEventName>;
188
+ event: HTMLEventFor<TEventName>;
194
189
  /**
195
190
  * AbortSignal that fires when the island unmounts. If the handler's selector
196
191
  * was registered with the `:abortable` modifier, the signal is also aborted
@@ -203,7 +198,7 @@ interface StateEntry<TInput> {
203
198
  key: string;
204
199
  init: StateInit<TInput, unknown>;
205
200
  }
206
- interface OnEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> {
201
+ interface OnEntry<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> {
207
202
  selector: string;
208
203
  event: string;
209
204
  options: AddEventListenerOptions;
@@ -217,7 +212,7 @@ interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
217
212
  * from `.on()` handlers; `"effect"` covers sync throws from `.effect()` runs
218
213
  * (async work spawned inside an effect is not awaited by the runtime). */
219
214
  type ErrorSource = "on" | "effect";
220
- type ErrorContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> = {
215
+ type ErrorContext<TInput, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> = {
221
216
  error: Error;
222
217
  source: ErrorSource;
223
218
  state: IslandState<TStateMap>;
@@ -251,20 +246,16 @@ interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDeri
251
246
  onMounts: OnMountEntry<TInput, TStateMap, TDerivedMap>[];
252
247
  onErrors: OnErrorEntry<TInput, TStateMap, TDerivedMap>[];
253
248
  transition: TransitionOptions | null;
254
- binds: BindEntry<TStateMap>[];
255
249
  css: string | null;
256
250
  }
257
- declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<string, never>> {
251
+ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap extends Record<string, unknown>, TDerivedMap extends Record<string, unknown> = Record<never, never>> {
258
252
  readonly _cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>;
259
253
  constructor(cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>);
260
- input<T extends Record<string, unknown>>(): IlhaBuilder<T, Record<string, never>, Record<string, never>>;
261
- input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<string, never>, Record<string, never>>;
254
+ input<T extends Record<string, unknown>>(): IlhaBuilder<T, Record<never, never>, Record<never, never>>;
255
+ input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<never, never>, Record<never, never>>;
262
256
  state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap>;
263
257
  derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>>;
264
- bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
265
- bind<T>(selector: string, externalSignal: ExternalSignal<T>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
266
- on<S extends OnSelectorString>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
267
- on(selectorOrCombined: string, handler: (ctx: HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
258
+ on<S extends string>(selectorOrCombined: S, handler: (ctx: S extends `${string}@${infer E}:${string}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : S extends `${string}@${infer E}` ? HandlerContextFor<TInput, TStateMap, E, TDerivedMap> : HandlerContext<TInput, TStateMap, TDerivedMap>) => void | Promise<void>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
268
259
  effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
269
260
  onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
270
261
  onError(fn: (ctx: ErrorContext<TInput, TStateMap, TDerivedMap>) => void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
@@ -275,7 +266,7 @@ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap exte
275
266
  declare function ilhaFrom<TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>): (() => void) | null;
276
267
  type IslandRegistry = Record<string, AnyIsland>;
277
268
  declare function mountAll(registry: IslandRegistry, options?: MountOptions): MountResult;
278
- declare const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
269
+ declare const ilha: IlhaBuilder<Record<string, unknown>, Record<never, never>, Record<never, never>> & {
279
270
  html: typeof ilhaHtml;
280
271
  raw: typeof ilhaRaw;
281
272
  mount: typeof mountAll;
package/dist/index.js CHANGED
@@ -109,7 +109,8 @@ function pushRenderCtx(liveHost, asyncChildren) {
109
109
  slots: /* @__PURE__ */ new Map(),
110
110
  positional: 0,
111
111
  liveHost,
112
- pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0
112
+ pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0,
113
+ binds: []
113
114
  };
114
115
  renderCtxStack.push(ctx);
115
116
  return ctx;
@@ -203,12 +204,113 @@ function interpolateValue(v) {
203
204
  if (typeof v === "function") return escapeHtml(v());
204
205
  return escapeHtml(v);
205
206
  }
207
+ const BIND_VALID_KINDS = new Set([
208
+ "value",
209
+ "checked",
210
+ "valueAsNumber",
211
+ "valueAsDate",
212
+ "files",
213
+ "open",
214
+ "group",
215
+ "this"
216
+ ]);
217
+ const BIND_PREFIX_RE = /\bbind:([a-zA-Z]+)\s*=\s*("|')?$/;
218
+ function formatDateForInput(d) {
219
+ if (d instanceof Date && !isNaN(d.getTime())) {
220
+ const pad = (n) => String(n).padStart(2, "0");
221
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
222
+ }
223
+ return "";
224
+ }
225
+ function extractElementStaticValue(prefix) {
226
+ const lastOpen = prefix.lastIndexOf("<");
227
+ if (lastOpen === -1) return null;
228
+ const tagPrefix = prefix.slice(lastOpen);
229
+ if (tagPrefix.includes(">")) return null;
230
+ const m = tagPrefix.match(/\bvalue\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/);
231
+ if (!m) return null;
232
+ return m[2] ?? m[3] ?? m[4] ?? null;
233
+ }
234
+ function emitBindSSR(kind, index, accessor, prefixForStaticPeek) {
235
+ const spec = `${kind}:${index}`;
236
+ let v;
237
+ try {
238
+ v = accessor();
239
+ } catch {
240
+ v = void 0;
241
+ }
242
+ switch (kind) {
243
+ case "value": return [` value="${escapeHtml(v ?? "")}"`, spec];
244
+ case "valueAsNumber": return [` value="${escapeHtml(v == null ? "" : String(v))}"`, spec];
245
+ case "valueAsDate": return [` value="${escapeHtml(formatDateForInput(v))}"`, spec];
246
+ case "checked": return [v ? ` checked` : ``, spec];
247
+ case "open": return [v ? ` open` : ``, spec];
248
+ case "files": return [``, spec];
249
+ case "this": return [``, spec];
250
+ case "group": {
251
+ const optionValue = extractElementStaticValue(prefixForStaticPeek);
252
+ if (optionValue == null) return [``, spec];
253
+ return [(Array.isArray(v) ? v.map(String).includes(optionValue) : v != null && String(v) === optionValue) ? ` checked` : ``, spec];
254
+ }
255
+ }
256
+ }
206
257
  function ilhaHtml(strings, ...values) {
207
258
  let result = "";
259
+ let stripLeadingQuote = null;
260
+ let pendingBindSpecs = "";
261
+ const ctx = currentRenderCtx();
208
262
  for (let i = 0; i < strings.length; i++) {
209
- result += strings[i];
210
- if (i < values.length) result += interpolateValue(values[i]);
263
+ let chunk = strings[i];
264
+ if (stripLeadingQuote !== null) {
265
+ if (chunk.startsWith(stripLeadingQuote)) chunk = chunk.slice(1);
266
+ stripLeadingQuote = null;
267
+ }
268
+ if (pendingBindSpecs !== "") {
269
+ const gtIdx = chunk.indexOf(">");
270
+ if (gtIdx !== -1) {
271
+ chunk = chunk.slice(0, gtIdx) + ` data-ilha-bind="${pendingBindSpecs}">` + chunk.slice(gtIdx + 1);
272
+ pendingBindSpecs = "";
273
+ }
274
+ }
275
+ if (i >= values.length) {
276
+ result += chunk;
277
+ continue;
278
+ }
279
+ const value = values[i];
280
+ const m = chunk.match(BIND_PREFIX_RE);
281
+ if (m && isSignalAccessor(value)) {
282
+ const name = m[1];
283
+ const openQuote = m[2] ?? null;
284
+ if (!BIND_VALID_KINDS.has(name)) {
285
+ if (__DEV__) warn(`Unknown bind:${name} — supported bindings are ${[...BIND_VALID_KINDS].map((k) => `bind:${k}`).join(", ")}.`);
286
+ result += chunk + interpolateValue(value);
287
+ continue;
288
+ }
289
+ if (!ctx) {
290
+ if (__DEV__) warn(`bind:${name} used outside an island render — bindings only work in .render(). The value is reflected once but not wired.`);
291
+ const stripped = chunk.slice(0, chunk.length - m[0].length);
292
+ const quote = openQuote ?? "\"";
293
+ result += stripped + name + "=" + quote + interpolateValue(value) + quote;
294
+ if (openQuote) stripLeadingQuote = openQuote;
295
+ continue;
296
+ }
297
+ const matchStart = chunk.length - m[0].length;
298
+ const cleanPrefix = chunk.slice(0, matchStart).replace(/\s+$/, "");
299
+ const index = ctx.binds.length;
300
+ ctx.binds.push({
301
+ kind: name,
302
+ accessor: value
303
+ });
304
+ const [valueAttrs, spec] = emitBindSSR(name, index, value, cleanPrefix);
305
+ result += cleanPrefix + valueAttrs;
306
+ pendingBindSpecs += (pendingBindSpecs ? "," : "") + spec;
307
+ if (openQuote) stripLeadingQuote = openQuote;
308
+ continue;
309
+ }
310
+ if (m && __DEV__) warn(`bind:${m[1]} requires a signal accessor — got ${typeof value}. Use ilha.signal() or a .state() accessor.`);
311
+ result += chunk + interpolateValue(value);
211
312
  }
313
+ if (pendingBindSpecs !== "") result += ` data-ilha-bind="${pendingBindSpecs}"`;
212
314
  return {
213
315
  [RAW]: true,
214
316
  value: dedentString(result)
@@ -231,7 +333,8 @@ function ilhaContext(key, initial) {
231
333
  /**
232
334
  * Create a free-standing reactive signal that lives outside any island.
233
335
  * Useful for sharing state across islands without prop drilling, or for
234
- * binding form inputs to module-level state via `.bind(selector, signal)`.
336
+ * binding form inputs to module-level state via the `bind:value=${signal}`
337
+ * template syntax.
235
338
  *
236
339
  * The returned accessor is a getter when called with no arguments and a
237
340
  * setter when called with one. Reading it inside a `.derived()`, `.effect()`,
@@ -240,11 +343,10 @@ function ilhaContext(key, initial) {
240
343
  */
241
344
  function ilhaSignal(initial) {
242
345
  const s = signal(initial);
243
- const accessor = (...args) => {
346
+ return markSignalAccessor((...args) => {
244
347
  if (args.length === 0) return s();
245
348
  s(args[0]);
246
- };
247
- return accessor;
349
+ });
248
350
  }
249
351
  /**
250
352
  * Run `fn` with reactive tracking suspended. Reading signals inside `fn`
@@ -411,63 +513,159 @@ function createDerivedProxy(entries, state, input, derivedSnapshot) {
411
513
  setup
412
514
  };
413
515
  }
414
- function resolveBindConfig(el) {
415
- const tag = el.tagName.toLowerCase();
416
- const type = el.type?.toLowerCase() ?? "";
417
- if (tag === "input" && type === "checkbox") return {
418
- prop: "checked",
419
- event: "change",
420
- read: (el) => el.checked,
421
- write: (el, v) => el.checked = Boolean(v)
422
- };
423
- if (tag === "input" && type === "radio") return {
424
- prop: "checked",
425
- event: "change",
426
- read: (el) => {
427
- const input = el;
428
- return input.checked ? input.value : void 0;
429
- },
430
- write: (el, v) => {
431
- el.checked = String(v ?? "") === el.value;
516
+ const BIND_SENTINEL_ATTR = "data-ilha-bind";
517
+ function parseBindSentinel(value) {
518
+ const out = [];
519
+ for (const part of value.split(",")) {
520
+ const [kind, idx] = part.split(":");
521
+ if (!kind || !idx) continue;
522
+ const i = Number(idx);
523
+ if (!Number.isInteger(i) || i < 0) continue;
524
+ out.push({
525
+ kind,
526
+ index: i
527
+ });
528
+ }
529
+ return out;
530
+ }
531
+ function resolveBindOps(el, kind) {
532
+ const input = el;
533
+ switch (kind) {
534
+ case "value": return {
535
+ event: el.tagName === "SELECT" ? "change" : "input",
536
+ read: (el) => el.value,
537
+ write: (el, v) => el.value = v == null ? "" : String(v)
538
+ };
539
+ case "valueAsNumber": return {
540
+ event: "input",
541
+ read: (el) => {
542
+ const n = el.valueAsNumber;
543
+ return Number.isNaN(n) ? null : n;
544
+ },
545
+ write: (el, v) => el.value = v == null || Number.isNaN(v) ? "" : String(v)
546
+ };
547
+ case "valueAsDate": return {
548
+ event: "input",
549
+ read: (el) => el.valueAsDate,
550
+ write: (el, v) => {
551
+ el.value = formatDateForInput(v);
552
+ }
553
+ };
554
+ case "checked": return {
555
+ event: "change",
556
+ read: (el) => el.checked,
557
+ write: (el, v) => el.checked = Boolean(v)
558
+ };
559
+ case "files": return {
560
+ event: "change",
561
+ read: (el) => el.files,
562
+ write: () => {}
563
+ };
564
+ case "open": return {
565
+ event: "toggle",
566
+ read: (el) => el.open,
567
+ write: (el, v) => el.open = Boolean(v)
568
+ };
569
+ case "this": return {
570
+ event: null,
571
+ read: () => void 0,
572
+ write: () => {}
573
+ };
574
+ case "group": {
575
+ const isCheckbox = input.type === "checkbox";
576
+ return {
577
+ event: "change",
578
+ read: (el) => {
579
+ const i = el;
580
+ if (isCheckbox) return {
581
+ __ilhaGroup: true,
582
+ value: i.value,
583
+ checked: i.checked
584
+ };
585
+ return i.checked ? i.value : void 0;
586
+ },
587
+ write: (el, v) => {
588
+ const i = el;
589
+ if (isCheckbox) i.checked = (Array.isArray(v) ? v.map(String) : []).includes(i.value);
590
+ else i.checked = v != null && String(v) === i.value;
591
+ }
592
+ };
432
593
  }
433
- };
434
- if (tag === "input" && type === "number") return {
435
- prop: "valueAsNumber",
436
- event: "input",
437
- read: (el) => el.valueAsNumber,
438
- write: (el, v) => el.value = String(v ?? "")
439
- };
440
- return {
441
- prop: "value",
442
- event: tag === "select" ? "change" : "input",
443
- read: (el) => el.value,
444
- write: (el, v) => el.value = String(v ?? "")
445
- };
594
+ }
446
595
  }
447
- function applyBindings(host, bindings, state) {
596
+ function applyTemplateBindings(host, binds) {
597
+ if (binds.length === 0) return () => {};
448
598
  const cleanups = [];
449
- for (const binding of bindings) {
450
- const accessor = typeof binding.target === "function" ? binding.target : state[binding.target];
451
- const targets = binding.selector === "" ? [host] : Array.from(host.querySelectorAll(binding.selector));
452
- if (__DEV__ && binding.selector !== "" && targets.length === 0) warn(`bind(): selector "${binding.selector}" matched no elements inside the island host. Check that the element exists in your render output.`);
453
- for (const target of targets) {
454
- const { event, read, write } = resolveBindConfig(target);
455
- const isRadio = target.tagName.toLowerCase() === "input" && target.type?.toLowerCase() === "radio";
456
- write(target, accessor());
599
+ const elements = [];
600
+ if (host.hasAttribute(BIND_SENTINEL_ATTR)) elements.push(host);
601
+ for (const el of host.querySelectorAll(`[${BIND_SENTINEL_ATTR}]`)) elements.push(el);
602
+ for (const el of elements) {
603
+ const specs = parseBindSentinel(el.getAttribute(BIND_SENTINEL_ATTR));
604
+ for (const spec of specs) {
605
+ const record = binds[spec.index];
606
+ if (!record) {
607
+ if (__DEV__) warn(`bind:${spec.kind} index ${spec.index} not found in render — the data-ilha-bind sentinel may have been hand-edited or survived a stale render.`);
608
+ continue;
609
+ }
610
+ if (record.kind !== spec.kind) {
611
+ if (__DEV__) warn(`bind:${spec.kind} sentinel points at a binding registered as bind:${record.kind}. Sentinel may be stale.`);
612
+ continue;
613
+ }
614
+ const { event, read, write } = resolveBindOps(el, spec.kind);
615
+ const accessor = record.accessor;
616
+ if (spec.kind === "this") {
617
+ accessor(el);
618
+ cleanups.push(() => accessor(null));
619
+ continue;
620
+ }
621
+ try {
622
+ write(el, accessor());
623
+ } catch (err) {
624
+ if (__DEV__) console.error(`[ilha] bind:${spec.kind} write failed:`, err);
625
+ }
626
+ if (event === null) continue;
457
627
  const listener = () => {
458
- const raw = read(target);
459
- if (isRadio && raw === void 0) return;
628
+ const raw = read(el);
629
+ if (spec.kind === "group") {
630
+ const groupRead = raw;
631
+ if (groupRead === void 0) return;
632
+ if (typeof groupRead === "object" && groupRead.__ilhaGroup) {
633
+ const currentArr = accessor();
634
+ const arr = Array.isArray(currentArr) ? [...currentArr] : [];
635
+ const idx = arr.findIndex((x) => String(x) === groupRead.value);
636
+ if (groupRead.checked && idx === -1) {
637
+ let coercedVal = groupRead.value;
638
+ const templateVal = Array.isArray(currentArr) && currentArr.length > 0 ? currentArr[0] : void 0;
639
+ if (templateVal !== void 0) {
640
+ if (typeof templateVal === "number") {
641
+ const n = Number(coercedVal);
642
+ coercedVal = Number.isNaN(n) ? coercedVal : n;
643
+ } else if (typeof templateVal === "boolean") coercedVal = Boolean(coercedVal);
644
+ }
645
+ arr.push(coercedVal);
646
+ } else if (!groupRead.checked && idx !== -1) arr.splice(idx, 1);
647
+ accessor(arr);
648
+ return;
649
+ }
650
+ const currentVal = accessor();
651
+ let coerced = groupRead;
652
+ if (typeof currentVal === "number" && typeof groupRead === "string") {
653
+ const n = Number(groupRead);
654
+ coerced = Number.isNaN(n) ? 0 : n;
655
+ } else if (typeof currentVal === "boolean") coerced = Boolean(groupRead);
656
+ accessor(coerced);
657
+ return;
658
+ }
460
659
  const currentVal = accessor();
461
- let value;
462
- if (typeof currentVal === "number") {
660
+ let value = raw;
661
+ if (typeof currentVal === "number" && typeof raw === "string") {
463
662
  const n = Number(raw);
464
- value = isNaN(n) ? 0 : n;
663
+ value = Number.isNaN(n) ? 0 : n;
465
664
  } else if (typeof currentVal === "boolean") value = Boolean(raw);
466
- else value = raw;
467
665
  accessor(value);
468
666
  };
469
- target.addEventListener(event, listener);
470
- cleanups.push(() => target.removeEventListener(event, listener));
667
+ el.addEventListener(event, listener);
668
+ cleanups.push(() => el.removeEventListener(event, listener));
471
669
  }
472
670
  }
473
671
  return () => cleanups.forEach((c) => c());
@@ -529,7 +727,6 @@ var IlhaBuilder = class IlhaBuilder {
529
727
  onMounts: [],
530
728
  onErrors: [],
531
729
  transition: null,
532
- binds: [],
533
730
  css: null
534
731
  });
535
732
  }
@@ -553,15 +750,6 @@ var IlhaBuilder = class IlhaBuilder {
553
750
  }]
554
751
  });
555
752
  }
556
- bind(selector, target) {
557
- return new IlhaBuilder({
558
- ...this._cfg,
559
- binds: [...this._cfg.binds, {
560
- selector,
561
- target
562
- }]
563
- });
564
- }
565
753
  on(selectorOrCombined, handler) {
566
754
  const parsed = parseOnArgs(selectorOrCombined);
567
755
  return new IlhaBuilder({
@@ -617,7 +805,7 @@ var IlhaBuilder = class IlhaBuilder {
617
805
  });
618
806
  }
619
807
  render(fn) {
620
- const { schema, states, deriveds, ons, effects, onMounts, onErrors, transition, binds, css: cssSource } = this._cfg;
808
+ const { schema, states, deriveds, ons, effects, onMounts, onErrors, transition, css: cssSource } = this._cfg;
621
809
  const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
622
810
  function resolveInput(props) {
623
811
  const value = props ?? {};
@@ -635,7 +823,8 @@ var IlhaBuilder = class IlhaBuilder {
635
823
  try {
636
824
  return {
637
825
  html: await resolveAsyncChildren(html, ctx),
638
- slots: ctx.slots
826
+ slots: ctx.slots,
827
+ binds: ctx.binds
639
828
  };
640
829
  } finally {
641
830
  popRenderCtx();
@@ -644,7 +833,8 @@ var IlhaBuilder = class IlhaBuilder {
644
833
  }
645
834
  return {
646
835
  html,
647
- slots: ctx.slots
836
+ slots: ctx.slots,
837
+ binds: ctx.binds
648
838
  };
649
839
  } finally {
650
840
  if (!isAsync) popRenderCtx();
@@ -986,7 +1176,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
986
1176
  }), hasExistingContent ? host : void 0);
987
1177
  if (!hasExistingContent) host.innerHTML = stylePrefix + initial.html;
988
1178
  attachListeners();
989
- let stopBindings = applyBindings(host, binds, state);
1179
+ let stopBindings = applyTemplateBindings(host, initial.binds);
990
1180
  cleanups.push(() => stopBindings());
991
1181
  mountSlots(initial.slots);
992
1182
  cleanups.push(() => mountedSlots.forEach((entry) => entry.unmount()));
@@ -1050,7 +1240,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1050
1240
  let initialized = false;
1051
1241
  const initialRenderedHtml = initial.html;
1052
1242
  const stopRender = effect(() => {
1053
- const { html: rendered, slots: newSlotMap } = renderWithCtx(() => fn({
1243
+ const { html: rendered, slots: newSlotMap, binds: newBinds } = renderWithCtx(() => fn({
1054
1244
  state,
1055
1245
  derived,
1056
1246
  input
@@ -1084,24 +1274,26 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1084
1274
  stub.replaceWith(liveEl);
1085
1275
  }
1086
1276
  attachListeners();
1087
- stopBindings = applyBindings(host, binds, state);
1277
+ stopBindings = applyTemplateBindings(host, newBinds);
1088
1278
  mountSlots(newSlotMap);
1089
1279
  });
1090
- cleanups.push(stopRender);
1091
- cleanups.push(detachListeners);
1092
1280
  let tornDown = false;
1093
1281
  const unmount = () => {
1094
1282
  if (tornDown) return;
1095
1283
  tornDown = true;
1096
1284
  if (__DEV__ && _mountedHosts) _mountedHosts.delete(host);
1285
+ stopRender();
1286
+ detachListeners();
1097
1287
  if (transition?.leave) {
1098
1288
  const result = transition.leave(host);
1099
1289
  if (result instanceof Promise) {
1100
- result.then(() => cleanups.forEach((c) => c())).catch(console.error);
1290
+ result.then(() => {
1291
+ for (const c of cleanups) c();
1292
+ }).catch(console.error);
1101
1293
  return;
1102
1294
  }
1103
1295
  }
1104
- cleanups.forEach((c) => c());
1296
+ for (const c of cleanups) c();
1105
1297
  };
1106
1298
  const updateProps = (nextProps) => {
1107
1299
  if (tornDown) return;
@@ -1272,7 +1464,6 @@ const rootBuilder = new IlhaBuilder({
1272
1464
  onMounts: [],
1273
1465
  onErrors: [],
1274
1466
  transition: null,
1275
- binds: [],
1276
1467
  css: null
1277
1468
  });
1278
1469
  const ilha = Object.assign(rootBuilder, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "A tiny, framework-free island architecture library",
5
5
  "license": "MIT",
6
6
  "author": "Ryuz <ryuzer@proton.me>",
@@ -12,6 +12,9 @@
12
12
  "dist"
13
13
  ],
14
14
  "type": "module",
15
+ "browser": {
16
+ "process": false
17
+ },
15
18
  "exports": {
16
19
  ".": {
17
20
  "types": "./dist/index.d.ts",
@@ -23,7 +26,7 @@
23
26
  "test": "bun test"
24
27
  },
25
28
  "dependencies": {
26
- "alien-signals": "3.2.0"
29
+ "alien-signals": "3.2.1"
27
30
  },
28
31
  "devDependencies": {
29
32
  "zod": "^4.4.3"