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 +28 -36
- package/dist/index.d.ts +18 -27
- package/dist/index.js +267 -76
- package/package.json +5 -2
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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> =
|
|
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<
|
|
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<
|
|
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
|
|
184
|
-
type
|
|
185
|
-
type
|
|
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:
|
|
193
|
-
event: TEventName
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
261
|
-
input<S extends StandardSchemaV1>(schema: S): IlhaBuilder<StandardSchemaV1.InferOutput<S> & Record<string, unknown>, Record<
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
210
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
596
|
+
function applyTemplateBindings(host, binds) {
|
|
597
|
+
if (binds.length === 0) return () => {};
|
|
448
598
|
const cleanups = [];
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
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(
|
|
459
|
-
if (
|
|
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
|
-
|
|
470
|
-
cleanups.push(() =>
|
|
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,
|
|
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 =
|
|
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 =
|
|
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(() =>
|
|
1290
|
+
result.then(() => {
|
|
1291
|
+
for (const c of cleanups) c();
|
|
1292
|
+
}).catch(console.error);
|
|
1101
1293
|
return;
|
|
1102
1294
|
}
|
|
1103
1295
|
}
|
|
1104
|
-
|
|
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.
|
|
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.
|
|
29
|
+
"alien-signals": "3.2.1"
|
|
27
30
|
},
|
|
28
31
|
"devDependencies": {
|
|
29
32
|
"zod": "^4.4.3"
|