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 +28 -36
- package/dist/index.d.ts +23 -28
- package/dist/index.js +271 -77
- 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
|
@@ -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
|
|
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
|
-
|
|
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> =
|
|
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<
|
|
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<
|
|
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
|
|
180
|
-
type
|
|
181
|
-
type
|
|
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:
|
|
189
|
-
event: TEventName
|
|
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<
|
|
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<
|
|
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<
|
|
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<
|
|
257
|
-
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>>;
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
207
|
-
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);
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
596
|
+
function applyTemplateBindings(host, binds) {
|
|
597
|
+
if (binds.length === 0) return () => {};
|
|
445
598
|
const cleanups = [];
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const
|
|
453
|
-
|
|
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(
|
|
456
|
-
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
|
+
}
|
|
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
|
-
|
|
467
|
-
cleanups.push(() =>
|
|
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,
|
|
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 =
|
|
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 =
|
|
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(() =>
|
|
1290
|
+
result.then(() => {
|
|
1291
|
+
for (const c of cleanups) c();
|
|
1292
|
+
}).catch(console.error);
|
|
1098
1293
|
return;
|
|
1099
1294
|
}
|
|
1100
1295
|
}
|
|
1101
|
-
|
|
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.
|
|
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"
|