Skip to content

Commit 3f3bbde

Browse files
committed
[Update] ui: format supports a dict k:v mapping
1 parent 0a55a38 commit 3f3bbde

6 files changed

Lines changed: 160 additions & 7 deletions

File tree

docs/ref-ui.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,16 @@ If no handler is defined, `inout` uses the same default input behavior as `in`.
521521
522522
### Event Slots (`on:<event>`)
523523
524-
Bind event handlers with explicit event types using `on:<event>="handlerName"`.
524+
`on:<event>` supports two modes:
525+
526+
- handler mode: `on:<event>="handlerName"` (or `on:<event>` to default to the event type)
527+
- publish/effect mode: `on:<event>="effectExpr!EventName"`
528+
529+
Effect expression forms:
530+
531+
- `!EventName`: publish event only, payload defaults to current data
532+
- `path.to.value!EventName`: publish event with payload from data path
533+
- `path.to.value|processorA|processorB!EventName`: publish event with payload transformed by processors
525534
526535
```html
527536
<!-- Click event with "save" handler -->
@@ -530,6 +539,15 @@ Bind event handlers with explicit event types using `on:<event>="handlerName"`.
530539
<!-- Handler name defaults to event type if omitted -->
531540
<button on:click>Click me</button>
532541
542+
<!-- Publish-only effect -->
543+
<button on:click="!Clicked">Click me</button>
544+
545+
<!-- Publish payload from data path -->
546+
<button on:click="item.id!Select">Select</button>
547+
548+
<!-- Publish payload with processors -->
549+
<button on:click="item.total|asCurrency!CheckoutTotal">Checkout</button>
550+
533551
<!-- Form submit -->
534552
<form on:submit="handleSubmit">...</form>
535553
@@ -545,6 +563,10 @@ Bind event handlers with explicit event types using `on:<event>="handlerName"`.
545563
click: (self, data, event) => {
546564
console.log("Clicked!");
547565
},
566+
// Receives published events from effect mode
567+
onCheckoutTotal: (self, data, evt) => {
568+
console.log(evt.type, evt.data);
569+
},
548570
highlight: (self, data, event) => {
549571
event.target.classList.add("highlighted");
550572
},

docs/ui.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ Dynamic("Badge", { label: "Ready" })
212212
- `out` with processors: `out="slot|Formatter|Formatter"` pipes the slot value through processors.
213213
- `in`: Binds a slot's input (e.g., value of an `<input>`) to instance data.
214214
- `inout`: Two-way binding between slot and instance data.
215-
- `on:<event>`: Binds a DOM event to an instance method or behavior handler.
215+
- `on:<event>`: Binds a DOM event to a handler (`on:click="save"`) or an effect publish expression (`on:click="item.id!Selected"`).
216216
- `when`: Conditional rendering with shorthand predicates, safe comparisons, and processors (non-eval).
217217
- `ref`: Provides a reference to the DOM node in the instance's `self.ref`.
218218
- `out:<attr>`: Binds a specific DOM attribute to a data value.
@@ -258,8 +258,22 @@ Registering processors:
258258
```javascript
259259
ui.format("ClientItem", ClientItem)
260260
ui.format("asCurrency", (value) => `$${Number(value ?? 0).toFixed(2)}`)
261+
ui.format({
262+
asPercent: (value) => `${Math.round(Number(value ?? 0) * 100)}%`,
263+
asLabel: (value) => `${value ?? ""}`,
264+
})
261265
```
262266
267+
### `on:<event>` effects (`!Event`)
268+
269+
Event bindings support handler mode and publish/effect mode:
270+
271+
- `on:click="save"`: call behavior handler `save(self, data, event)`
272+
- `on:click`: same as `on:click="click"`
273+
- `on:click="!Clicked"`: publish `Clicked` with current component data as payload
274+
- `on:click="path.to.value!Selected"`: publish `Selected` with payload from data path
275+
- `on:click="path.to.value|processorA|processorB!Selected"`: same, after processors
276+
263277
Using nested component-local processors:
264278
265279
```html

src/js/select/icons.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ function icon(
146146
style = Object.assign({}, IconDefaults.style, source?.style, style);
147147
const node = Object.entries({ width: size, height: size }).reduce(
148148
(r, [k, v], i) => {
149-
r.setAttribute(k, v instanceof Array ? v[i] : v);
149+
r.setAttribute(k, Array.isArray(v) ? v[i] : v);
150150
return r;
151151
},
152152
document.createElementNS(SVG, "svg"),
@@ -185,7 +185,9 @@ function icon(
185185
// We support an inline mode, which is necessary for web components.
186186
case "inline":
187187
Object.assign(node.style, style);
188-
classes.forEach((_) => node.classList.add(_));
188+
classes.forEach((_) => {
189+
node.classList.add(_);
190+
});
189191
icon.then((symbol) => {
190192
if (!symbol) {
191193
node.classList.add("missing");
@@ -209,7 +211,9 @@ function icon(
209211
return node;
210212
default: {
211213
const use = document.createElementNS(SVG, "use");
212-
use.classList.forEach((_) => node.classList.add(_));
214+
use.classList.forEach((_) => {
215+
node.classList.add(_);
216+
});
213217
Object.assign(node.style, style);
214218
use.setAttribute("href", `#icon-${name}-${source}`);
215219
node.appendChild(use);

src/js/select/ui/components.js

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,19 @@ class UITemplateSlot {
432432
for (const attr of node.attributes) {
433433
if (attr.name.startsWith(prefix)) {
434434
const eventType = attr.name.slice(prefix.length);
435-
const handlerName = attr.value || eventType;
435+
const parsed = TemplateParser.parseEventEffect(attr.value, eventType);
436+
if (!parsed) {
437+
log.warn("UITemplateSlot.FindEvent: invalid event effect, details", {
438+
eventType,
439+
effect: attr.value,
440+
});
441+
toRemove.push(attr.name);
442+
continue;
443+
}
444+
const handlerName =
445+
parsed.mode === "handler"
446+
? parsed.handlerName || eventType
447+
: `!${parsed.publishEvent}:${parsed.binding?.sourceKey || "data"}`;
436448
toRemove.push(attr.name);
437449

438450
const slot = new UIEventTemplateSlot(
@@ -441,6 +453,9 @@ class UITemplateSlot {
441453
UITemplateSlot.Path(node, parent, [i]),
442454
eventType,
443455
handlerName,
456+
parsed.mode,
457+
parsed.publishEvent,
458+
parsed.binding,
444459
);
445460

446461
if (!res[handlerName]) res[handlerName] = [];
@@ -715,14 +730,26 @@ class UIAttributeSlot {
715730
// - `eventType`: string - DOM event type (e.g., "click")
716731
// - `handlerName`: string - behavior method name to call
717732
class UIEventTemplateSlot {
718-
constructor(node, parent, path, eventType, handlerName) {
733+
constructor(
734+
node,
735+
parent,
736+
path,
737+
eventType,
738+
handlerName,
739+
mode = "handler",
740+
publishEvent = null,
741+
binding = null,
742+
) {
719743
this.node = node;
720744
this.parent = parent;
721745
this.path = path;
722746
this.rootIndex = path[0];
723747
this.tailPath = path.length > 1 ? path.slice(1) : null;
724748
this.eventType = eventType;
725749
this.handlerName = handlerName;
750+
this.mode = mode;
751+
this.publishEvent = publishEvent;
752+
this.binding = binding;
726753
}
727754

728755
resolve(nodes) {
@@ -747,6 +774,9 @@ class UIEventSlot {
747774
this.parent = parent;
748775
this.eventType = template.eventType;
749776
this.handlerName = template.handlerName;
777+
this.mode = template.mode;
778+
this.publishEvent = template.publishEvent;
779+
this.binding = template.binding;
750780
}
751781
}
752782
// Class: UITemplate
@@ -1844,6 +1874,32 @@ class UIInstance {
18441874

18451875
// Binds event slot with explicit event type.
18461876
_bindEvent(name, target, handler = this.template.behavior?.[name]) {
1877+
if (target.mode === "publish" && target.publishEvent) {
1878+
const listener = (_event) => {
1879+
const data = this.data || {};
1880+
let payload = data;
1881+
if (target.binding?.sourceKey) {
1882+
payload = expand(resolveSourceValue(data, target.binding.sourceKey));
1883+
if (target.binding.processors?.length) {
1884+
payload = applyNamedProcessors(
1885+
this,
1886+
data,
1887+
payload,
1888+
target.binding.processors,
1889+
target.binding.sourceKey,
1890+
);
1891+
}
1892+
}
1893+
this.pub(target.publishEvent, payload);
1894+
};
1895+
target.node.addEventListener(target.eventType, listener);
1896+
this._domListeners.push({
1897+
node: target.node,
1898+
type: target.eventType,
1899+
handler: listener,
1900+
});
1901+
return;
1902+
}
18471903
if (handler) {
18481904
const listener = (event) => {
18491905
const result = handler(this, this.data || {}, event);

src/js/select/ui/html.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,57 @@ class TemplateParser {
252252
return { path, processors };
253253
}
254254

255+
static parseEventEffect(expr, eventType = "") {
256+
const source = typeof expr === "string" ? expr.trim() : "";
257+
if (!source) {
258+
return { mode: "handler", handlerName: eventType };
259+
}
260+
const separator = source.lastIndexOf("!");
261+
if (separator === -1) {
262+
return { mode: "handler", handlerName: source };
263+
}
264+
const publishEvent = source.slice(separator + 1).trim();
265+
if (!publishEvent || /\s/.test(publishEvent)) {
266+
return null;
267+
}
268+
const payloadExpr = source.slice(0, separator).trim();
269+
if (!payloadExpr) {
270+
return {
271+
mode: "publish",
272+
publishEvent,
273+
binding: null,
274+
};
275+
}
276+
const parts = payloadExpr.split("|");
277+
for (let i = 0; i < parts.length; i++) {
278+
parts[i] = parts[i].trim();
279+
if (!parts[i]) {
280+
return null;
281+
}
282+
}
283+
const path = TemplateParser.parseTemplatePath(parts[0]);
284+
if (!path) {
285+
return null;
286+
}
287+
const processors = parts.length > 1 ? parts.slice(1) : [];
288+
for (let i = 0; i < processors.length; i++) {
289+
if (/\s/.test(processors[i])) {
290+
return null;
291+
}
292+
}
293+
const sourceKey =
294+
path[0] === "."
295+
? path.length === 1
296+
? "."
297+
: `.${path.slice(1).join(".")}`
298+
: path.join(".");
299+
return {
300+
mode: "publish",
301+
publishEvent,
302+
binding: { sourceKey, processors },
303+
};
304+
}
305+
255306
static parseOutAttributeBinding(expr) {
256307
const source = typeof expr === "string" ? expr : "";
257308
if (!source) {

src/js/select/ui/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,12 @@ function remap(value, f) {
343343
};
344344

345345
function format(name, formatter) {
346+
if (name && typeof name === "object" && !Array.isArray(name)) {
347+
for (const key in name) {
348+
format(key, name[key]);
349+
}
350+
return ui;
351+
}
346352
if (typeof name !== "string" || !name.trim()) {
347353
log.error("ui.formats: invalid formatter name, details", {
348354
name,

0 commit comments

Comments
 (0)