ilha 0.4.3 → 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
@@ -34,6 +34,10 @@ declare const RAW: unique symbol;
34
34
  declare const SIGNAL_ACCESSOR: unique symbol;
35
35
  declare const ISLAND: unique symbol;
36
36
  declare const ISLAND_CALL: unique symbol;
37
+ /** @internal Internal hook used by a parent's mountSlots to mount a child island and
38
+ * retain a handle to push updated props into it on subsequent parent
39
+ * re-renders. Not part of the public surface. */
40
+ declare const ISLAND_MOUNT_INTERNAL: unique symbol;
37
41
  interface RawHtml {
38
42
  [RAW]: true;
39
43
  value: string;
@@ -60,7 +64,8 @@ declare function ilhaContext<T>(key: string, initial: T): ContextSignal<T>;
60
64
  /**
61
65
  * Create a free-standing reactive signal that lives outside any island.
62
66
  * Useful for sharing state across islands without prop drilling, or for
63
- * 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.
64
69
  *
65
70
  * The returned accessor is a getter when called with no arguments and a
66
71
  * setter when called with one. Reading it inside a `.derived()`, `.effect()`,
@@ -102,16 +107,12 @@ interface DerivedEntry<TInput, TStateMap extends Record<string, unknown>> {
102
107
  fn: DerivedFn<TInput, TStateMap, unknown>;
103
108
  }
104
109
  type IslandDerived<TDerivedMap extends Record<string, unknown>> = { readonly [K in keyof TDerivedMap]: DerivedValue<TDerivedMap[K]> };
105
- type ExternalSignal<T = unknown> = {
110
+ interface ExternalSignal<T = unknown> {
106
111
  (): T;
107
112
  (value: T): void;
108
- };
109
- interface BindEntry<TStateMap extends Record<string, unknown>> {
110
- selector: string;
111
- target: (keyof TStateMap & string) | ExternalSignal;
112
113
  }
113
114
  type SignalAccessor<T> = MarkedSignalAccessor<T>;
114
- 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>;
115
116
  type IslandState<TStateMap extends Record<string, unknown>> = { readonly [K in keyof TStateMap]-?: SignalAccessor<TStateMap[K]> };
116
117
  interface HydratableOptions {
117
118
  name: string;
@@ -152,14 +153,14 @@ type EffectContext<TInput, TStateMap extends Record<string, unknown>> = {
152
153
  */
153
154
  signal: AbortSignal;
154
155
  };
155
- 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>> = {
156
157
  state: IslandState<TStateMap>;
157
158
  derived: IslandDerived<TDerivedMap>;
158
159
  input: TInput;
159
160
  host: Element;
160
161
  hydrated: boolean;
161
162
  };
162
- 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>> = {
163
164
  state: IslandState<TStateMap>;
164
165
  derived: IslandDerived<TDerivedMap>;
165
166
  input: TInput;
@@ -176,17 +177,15 @@ type HandlerContext<TInput, TStateMap extends Record<string, unknown>, TDerivedM
176
177
  */
177
178
  signal: AbortSignal;
178
179
  };
179
- type HTMLEventName = keyof HTMLElementEventMap & string;
180
- type Modifier = "once" | "capture" | "passive" | "abortable";
181
- type WithModifiers<E extends string> = E | `${E}:${Modifier}` | `${E}:${Modifier}:${Modifier}` | `${E}:${Modifier}:${Modifier}:${Modifier}`;
182
- type OnSelectorString = `@${WithModifiers<HTMLEventName>}` | `${string}@${WithModifiers<HTMLEventName>}`;
183
- 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>> = {
184
183
  state: IslandState<TStateMap>;
185
184
  derived: IslandDerived<TDerivedMap>;
186
185
  input: TInput;
187
186
  host: Element;
188
- target: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName]["target"] extends Element | null ? NonNullable<HTMLElementEventMap[TEventName]["target"]> : Element : Element;
189
- event: TEventName extends keyof HTMLElementEventMap ? HTMLElementEventMap[TEventName] : Event;
187
+ target: HTMLTargetFor<TEventName>;
188
+ event: HTMLEventFor<TEventName>;
190
189
  /**
191
190
  * AbortSignal that fires when the island unmounts. If the handler's selector
192
191
  * was registered with the `:abortable` modifier, the signal is also aborted
@@ -199,7 +198,7 @@ interface StateEntry<TInput> {
199
198
  key: string;
200
199
  init: StateInit<TInput, unknown>;
201
200
  }
202
- 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>> {
203
202
  selector: string;
204
203
  event: string;
205
204
  options: AddEventListenerOptions;
@@ -213,7 +212,7 @@ interface EffectEntry<TInput, TStateMap extends Record<string, unknown>> {
213
212
  * from `.on()` handlers; `"effect"` covers sync throws from `.effect()` runs
214
213
  * (async work spawned inside an effect is not awaited by the runtime). */
215
214
  type ErrorSource = "on" | "effect";
216
- 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>> = {
217
216
  error: Error;
218
217
  source: ErrorSource;
219
218
  state: IslandState<TStateMap>;
@@ -247,20 +246,16 @@ interface BuilderConfig<TInput, TStateMap extends Record<string, unknown>, TDeri
247
246
  onMounts: OnMountEntry<TInput, TStateMap, TDerivedMap>[];
248
247
  onErrors: OnErrorEntry<TInput, TStateMap, TDerivedMap>[];
249
248
  transition: TransitionOptions | null;
250
- binds: BindEntry<TStateMap>[];
251
249
  css: string | null;
252
250
  }
253
- 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>> {
254
252
  readonly _cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>;
255
253
  constructor(cfg: BuilderConfig<TInput, TStateMap, TDerivedMap>);
256
- input<T extends Record<string, unknown>>(): IlhaBuilder<T, Record<string, never>, Record<string, never>>;
257
- 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>>;
258
256
  state<V = undefined, K extends string = string>(key: K, init?: StateInit<TInput, V> | undefined): IlhaBuilder<TInput, MergeState<TStateMap, K, V>, TDerivedMap>;
259
257
  derived<K extends string, V>(key: K, fn: DerivedFn<TInput, TStateMap, V>): IlhaBuilder<TInput, TStateMap, TDerivedMap & Record<K, V>>;
260
- bind(selector: string, stateKey: keyof TStateMap & string): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
261
- bind<T>(selector: string, externalSignal: ExternalSignal<T>): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
262
- 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>;
263
- 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>;
264
259
  effect(fn: (ctx: EffectContext<TInput, TStateMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
265
260
  onMount(fn: (ctx: OnMountContext<TInput, TStateMap, TDerivedMap>) => (() => void) | void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
266
261
  onError(fn: (ctx: ErrorContext<TInput, TStateMap, TDerivedMap>) => void): IlhaBuilder<TInput, TStateMap, TDerivedMap>;
@@ -271,7 +266,7 @@ declare class IlhaBuilder<TInput extends Record<string, unknown>, TStateMap exte
271
266
  declare function ilhaFrom<TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>): (() => void) | null;
272
267
  type IslandRegistry = Record<string, AnyIsland>;
273
268
  declare function mountAll(registry: IslandRegistry, options?: MountOptions): MountResult;
274
- 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>> & {
275
270
  html: typeof ilhaHtml;
276
271
  raw: typeof ilhaRaw;
277
272
  mount: typeof mountAll;
@@ -288,4 +283,4 @@ declare const mount: typeof mountAll;
288
283
  declare const from: typeof ilhaFrom;
289
284
  declare const context: typeof ilhaContext;
290
285
  //#endregion
291
- export { DerivedValue, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
286
+ export { DerivedValue, ErrorContext, ErrorSource, ExternalSignal, HandlerContext, HandlerContextFor, HydratableOptions, ISLAND_MOUNT_INTERNAL, Island, IslandDerived, IslandState, KeyedIsland, MountOptions, MountResult, OnMountContext, RawHtml, SignalAccessor, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
package/dist/index.js CHANGED
@@ -92,6 +92,9 @@ const RAW = Symbol("ilha.raw");
92
92
  const SIGNAL_ACCESSOR = Symbol("ilha.signalAccessor");
93
93
  const ISLAND = Symbol("ilha.island");
94
94
  const ISLAND_CALL = Symbol("ilha.islandCall");
95
+ /** @internal Internal hook used by a parent's mountSlots to mount a child island and
96
+ * retain a handle to push updated props into it on subsequent parent
97
+ * re-renders. Not part of the public surface. */
95
98
  const ISLAND_MOUNT_INTERNAL = Symbol("ilha.islandMountInternal");
96
99
  const SLOT_ATTR = "data-ilha-slot";
97
100
  const PROPS_ATTR = "data-ilha-props";
@@ -106,7 +109,8 @@ function pushRenderCtx(liveHost, asyncChildren) {
106
109
  slots: /* @__PURE__ */ new Map(),
107
110
  positional: 0,
108
111
  liveHost,
109
- pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0
112
+ pending: asyncChildren ? /* @__PURE__ */ new Map() : void 0,
113
+ binds: []
110
114
  };
111
115
  renderCtxStack.push(ctx);
112
116
  return ctx;
@@ -200,12 +204,113 @@ function interpolateValue(v) {
200
204
  if (typeof v === "function") return escapeHtml(v());
201
205
  return escapeHtml(v);
202
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
+ }
203
257
  function ilhaHtml(strings, ...values) {
204
258
  let result = "";
259
+ let stripLeadingQuote = null;
260
+ let pendingBindSpecs = "";
261
+ const ctx = currentRenderCtx();
205
262
  for (let i = 0; i < strings.length; i++) {
206
- result += strings[i];
207
- 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);
208
312
  }
313
+ if (pendingBindSpecs !== "") result += ` data-ilha-bind="${pendingBindSpecs}"`;
209
314
  return {
210
315
  [RAW]: true,
211
316
  value: dedentString(result)
@@ -228,7 +333,8 @@ function ilhaContext(key, initial) {
228
333
  /**
229
334
  * Create a free-standing reactive signal that lives outside any island.
230
335
  * Useful for sharing state across islands without prop drilling, or for
231
- * 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.
232
338
  *
233
339
  * The returned accessor is a getter when called with no arguments and a
234
340
  * setter when called with one. Reading it inside a `.derived()`, `.effect()`,
@@ -237,11 +343,10 @@ function ilhaContext(key, initial) {
237
343
  */
238
344
  function ilhaSignal(initial) {
239
345
  const s = signal(initial);
240
- const accessor = (...args) => {
346
+ return markSignalAccessor((...args) => {
241
347
  if (args.length === 0) return s();
242
348
  s(args[0]);
243
- };
244
- return accessor;
349
+ });
245
350
  }
246
351
  /**
247
352
  * Run `fn` with reactive tracking suspended. Reading signals inside `fn`
@@ -408,63 +513,159 @@ function createDerivedProxy(entries, state, input, derivedSnapshot) {
408
513
  setup
409
514
  };
410
515
  }
411
- function resolveBindConfig(el) {
412
- const tag = el.tagName.toLowerCase();
413
- const type = el.type?.toLowerCase() ?? "";
414
- if (tag === "input" && type === "checkbox") return {
415
- prop: "checked",
416
- event: "change",
417
- read: (el) => el.checked,
418
- write: (el, v) => el.checked = Boolean(v)
419
- };
420
- if (tag === "input" && type === "radio") return {
421
- prop: "checked",
422
- event: "change",
423
- read: (el) => {
424
- const input = el;
425
- return input.checked ? input.value : void 0;
426
- },
427
- write: (el, v) => {
428
- 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
+ };
429
593
  }
430
- };
431
- if (tag === "input" && type === "number") return {
432
- prop: "valueAsNumber",
433
- event: "input",
434
- read: (el) => el.valueAsNumber,
435
- write: (el, v) => el.value = String(v ?? "")
436
- };
437
- return {
438
- prop: "value",
439
- event: tag === "select" ? "change" : "input",
440
- read: (el) => el.value,
441
- write: (el, v) => el.value = String(v ?? "")
442
- };
594
+ }
443
595
  }
444
- function applyBindings(host, bindings, state) {
596
+ function applyTemplateBindings(host, binds) {
597
+ if (binds.length === 0) return () => {};
445
598
  const cleanups = [];
446
- for (const binding of bindings) {
447
- const accessor = typeof binding.target === "function" ? binding.target : state[binding.target];
448
- const targets = binding.selector === "" ? [host] : Array.from(host.querySelectorAll(binding.selector));
449
- 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.`);
450
- for (const target of targets) {
451
- const { event, read, write } = resolveBindConfig(target);
452
- const isRadio = target.tagName.toLowerCase() === "input" && target.type?.toLowerCase() === "radio";
453
- 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;
454
627
  const listener = () => {
455
- const raw = read(target);
456
- 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
+ }
457
659
  const currentVal = accessor();
458
- let value;
459
- if (typeof currentVal === "number") {
660
+ let value = raw;
661
+ if (typeof currentVal === "number" && typeof raw === "string") {
460
662
  const n = Number(raw);
461
- value = isNaN(n) ? 0 : n;
663
+ value = Number.isNaN(n) ? 0 : n;
462
664
  } else if (typeof currentVal === "boolean") value = Boolean(raw);
463
- else value = raw;
464
665
  accessor(value);
465
666
  };
466
- target.addEventListener(event, listener);
467
- cleanups.push(() => target.removeEventListener(event, listener));
667
+ el.addEventListener(event, listener);
668
+ cleanups.push(() => el.removeEventListener(event, listener));
468
669
  }
469
670
  }
470
671
  return () => cleanups.forEach((c) => c());
@@ -526,7 +727,6 @@ var IlhaBuilder = class IlhaBuilder {
526
727
  onMounts: [],
527
728
  onErrors: [],
528
729
  transition: null,
529
- binds: [],
530
730
  css: null
531
731
  });
532
732
  }
@@ -550,15 +750,6 @@ var IlhaBuilder = class IlhaBuilder {
550
750
  }]
551
751
  });
552
752
  }
553
- bind(selector, target) {
554
- return new IlhaBuilder({
555
- ...this._cfg,
556
- binds: [...this._cfg.binds, {
557
- selector,
558
- target
559
- }]
560
- });
561
- }
562
753
  on(selectorOrCombined, handler) {
563
754
  const parsed = parseOnArgs(selectorOrCombined);
564
755
  return new IlhaBuilder({
@@ -614,7 +805,7 @@ var IlhaBuilder = class IlhaBuilder {
614
805
  });
615
806
  }
616
807
  render(fn) {
617
- 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;
618
809
  const stylePrefix = cssSource != null ? buildScopedStyle(cssSource) : "";
619
810
  function resolveInput(props) {
620
811
  const value = props ?? {};
@@ -632,7 +823,8 @@ var IlhaBuilder = class IlhaBuilder {
632
823
  try {
633
824
  return {
634
825
  html: await resolveAsyncChildren(html, ctx),
635
- slots: ctx.slots
826
+ slots: ctx.slots,
827
+ binds: ctx.binds
636
828
  };
637
829
  } finally {
638
830
  popRenderCtx();
@@ -641,7 +833,8 @@ var IlhaBuilder = class IlhaBuilder {
641
833
  }
642
834
  return {
643
835
  html,
644
- slots: ctx.slots
836
+ slots: ctx.slots,
837
+ binds: ctx.binds
645
838
  };
646
839
  } finally {
647
840
  if (!isAsync) popRenderCtx();
@@ -983,7 +1176,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
983
1176
  }), hasExistingContent ? host : void 0);
984
1177
  if (!hasExistingContent) host.innerHTML = stylePrefix + initial.html;
985
1178
  attachListeners();
986
- let stopBindings = applyBindings(host, binds, state);
1179
+ let stopBindings = applyTemplateBindings(host, initial.binds);
987
1180
  cleanups.push(() => stopBindings());
988
1181
  mountSlots(initial.slots);
989
1182
  cleanups.push(() => mountedSlots.forEach((entry) => entry.unmount()));
@@ -1047,7 +1240,7 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1047
1240
  let initialized = false;
1048
1241
  const initialRenderedHtml = initial.html;
1049
1242
  const stopRender = effect(() => {
1050
- const { html: rendered, slots: newSlotMap } = renderWithCtx(() => fn({
1243
+ const { html: rendered, slots: newSlotMap, binds: newBinds } = renderWithCtx(() => fn({
1051
1244
  state,
1052
1245
  derived,
1053
1246
  input
@@ -1081,24 +1274,26 @@ Element: ${host.outerHTML.slice(0, 120)}`);
1081
1274
  stub.replaceWith(liveEl);
1082
1275
  }
1083
1276
  attachListeners();
1084
- stopBindings = applyBindings(host, binds, state);
1277
+ stopBindings = applyTemplateBindings(host, newBinds);
1085
1278
  mountSlots(newSlotMap);
1086
1279
  });
1087
- cleanups.push(stopRender);
1088
- cleanups.push(detachListeners);
1089
1280
  let tornDown = false;
1090
1281
  const unmount = () => {
1091
1282
  if (tornDown) return;
1092
1283
  tornDown = true;
1093
1284
  if (__DEV__ && _mountedHosts) _mountedHosts.delete(host);
1285
+ stopRender();
1286
+ detachListeners();
1094
1287
  if (transition?.leave) {
1095
1288
  const result = transition.leave(host);
1096
1289
  if (result instanceof Promise) {
1097
- result.then(() => cleanups.forEach((c) => c())).catch(console.error);
1290
+ result.then(() => {
1291
+ for (const c of cleanups) c();
1292
+ }).catch(console.error);
1098
1293
  return;
1099
1294
  }
1100
1295
  }
1101
- cleanups.forEach((c) => c());
1296
+ for (const c of cleanups) c();
1102
1297
  };
1103
1298
  const updateProps = (nextProps) => {
1104
1299
  if (tornDown) return;
@@ -1269,7 +1464,6 @@ const rootBuilder = new IlhaBuilder({
1269
1464
  onMounts: [],
1270
1465
  onErrors: [],
1271
1466
  transition: null,
1272
- binds: [],
1273
1467
  css: null
1274
1468
  });
1275
1469
  const ilha = Object.assign(rootBuilder, {
@@ -1289,4 +1483,4 @@ const mount = mountAll;
1289
1483
  const from = ilhaFrom;
1290
1484
  const context = ilhaContext;
1291
1485
  //#endregion
1292
- export { batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
1486
+ export { ISLAND_MOUNT_INTERNAL, batch, context, css, ilha as default, from, html, ilhaSignal, ilhaSignal as signal, mount, raw, untrack };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ilha",
3
- "version": "0.4.3",
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"