@brandup/ui 1.0.44 → 2.0.2
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 +482 -33
- package/dist/cjs/constants.js +14 -0
- package/dist/cjs/constants.js.map +1 -0
- package/dist/cjs/dom/bind-each.js +90 -0
- package/dist/cjs/dom/bind-each.js.map +1 -0
- package/dist/cjs/dom/bind.js +29 -0
- package/dist/cjs/dom/bind.js.map +1 -0
- package/dist/cjs/dom/binding-cleanup.js +162 -0
- package/dist/cjs/dom/binding-cleanup.js.map +1 -0
- package/dist/cjs/dom/dom.js +184 -0
- package/dist/cjs/dom/dom.js.map +1 -0
- package/dist/cjs/dom/helpers.js +33 -0
- package/dist/cjs/dom/helpers.js.map +1 -0
- package/dist/cjs/dom/index.js +14 -0
- package/dist/cjs/dom/index.js.map +1 -0
- package/dist/cjs/dom/tag.js +207 -0
- package/dist/cjs/dom/tag.js.map +1 -0
- package/dist/cjs/element.js +265 -0
- package/dist/cjs/element.js.map +1 -0
- package/dist/cjs/events.js +204 -0
- package/dist/cjs/events.js.map +1 -0
- package/dist/cjs/ext.js +20 -0
- package/dist/cjs/ext.js.map +1 -0
- package/dist/cjs/index.js +37 -313
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/reactive/computed.js +36 -0
- package/dist/cjs/reactive/computed.js.map +1 -0
- package/dist/cjs/reactive/effect.js +197 -0
- package/dist/cjs/reactive/effect.js.map +1 -0
- package/dist/cjs/reactive/reactive.js +106 -0
- package/dist/cjs/reactive/reactive.js.map +1 -0
- package/dist/mjs/constants.js +10 -0
- package/dist/mjs/constants.js.map +1 -0
- package/dist/mjs/dom/bind-each.js +86 -0
- package/dist/mjs/dom/bind-each.js.map +1 -0
- package/dist/mjs/dom/bind.js +26 -0
- package/dist/mjs/dom/bind.js.map +1 -0
- package/dist/mjs/dom/binding-cleanup.js +156 -0
- package/dist/mjs/dom/binding-cleanup.js.map +1 -0
- package/dist/mjs/dom/dom.js +169 -0
- package/dist/mjs/dom/dom.js.map +1 -0
- package/dist/mjs/dom/helpers.js +29 -0
- package/dist/mjs/dom/helpers.js.map +1 -0
- package/dist/mjs/dom/index.js +12 -0
- package/dist/mjs/dom/index.js.map +1 -0
- package/dist/mjs/dom/tag.js +203 -0
- package/dist/mjs/dom/tag.js.map +1 -0
- package/dist/mjs/element.js +260 -0
- package/dist/mjs/element.js.map +1 -0
- package/dist/mjs/events.js +202 -0
- package/dist/mjs/events.js.map +1 -0
- package/dist/mjs/ext.js +18 -0
- package/dist/mjs/ext.js.map +1 -0
- package/dist/mjs/index.js +11 -314
- package/dist/mjs/index.js.map +1 -1
- package/dist/mjs/reactive/computed.js +33 -0
- package/dist/mjs/reactive/computed.js.map +1 -0
- package/dist/mjs/reactive/effect.js +187 -0
- package/dist/mjs/reactive/effect.js.map +1 -0
- package/dist/mjs/reactive/reactive.js +102 -0
- package/dist/mjs/reactive/reactive.js.map +1 -0
- package/dist/types.d.ts +489 -14
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -12,65 +12,514 @@ npm i @brandup/ui@latest
|
|
|
12
12
|
|
|
13
13
|
## UIElement
|
|
14
14
|
|
|
15
|
-
`UIElement`
|
|
15
|
+
`UIElement` is a wrapper for `HTMLElement` that lets you attach your own business logic to it.
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
17
|
+
Features:
|
|
18
|
+
- Handling of commands declared in the markup of the `HTMLElement` that is bound to the `UIElement`.
|
|
19
|
+
- Subscribing to events through `EventEmitter`, which `UIElement` extends.
|
|
20
20
|
|
|
21
|
-
```
|
|
22
|
-
abstract class UIElement {
|
|
21
|
+
```ts
|
|
22
|
+
abstract class UIElement extends EventEmitter {
|
|
23
23
|
abstract typeName: string;
|
|
24
24
|
readonly element: HTMLElement | undefined;
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
protected defineEvent(eventName: string, eventOptions?: EventInit): void;
|
|
29
|
-
protected raiseEvent(eventName: string, eventArgs?: any): boolean;
|
|
26
|
+
static hasElement(elem: HTMLElement): boolean;
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
removeEventListener(type: string, listener?: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
|
|
33
|
-
dispatchEvent(event: Event): boolean;
|
|
28
|
+
protected setElement(elem: HTMLElement): void;
|
|
34
29
|
|
|
35
|
-
registerCommand(name: string, execute:
|
|
30
|
+
registerCommand(name: string, execute: CommandExecuteFunction, canExecute?: CommandCanExecuteFunction): this;
|
|
36
31
|
hasCommand(name: string): boolean;
|
|
37
|
-
|
|
38
|
-
protected _onRenderElement(_elem: HTMLElement);
|
|
39
|
-
protected _onCanExecCommand(_name: string, _elem: HTMLElement): boolean;
|
|
40
32
|
|
|
41
|
-
|
|
33
|
+
protected _onRenderElement(elem: HTMLElement): void;
|
|
34
|
+
protected _onCanExecCommand(name: string, elem: HTMLElement): boolean;
|
|
35
|
+
|
|
36
|
+
effectScope(): EffectScope;
|
|
42
37
|
destroy(): void;
|
|
38
|
+
|
|
39
|
+
toString(): string;
|
|
43
40
|
}
|
|
44
41
|
```
|
|
45
42
|
|
|
46
|
-
|
|
43
|
+
`UIElement` is an abstract class. A subclass sets `typeName` and binds a DOM element via `setElement`. An element can be bound only once; binding again (or binding an element that already belongs to another instance) throws an exception.
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { UIElement } from "@brandup/ui";
|
|
47
|
+
|
|
48
|
+
class MyWidget extends UIElement {
|
|
49
|
+
typeName = "MyWidget";
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
constructor(elem: HTMLElement) {
|
|
52
|
+
super();
|
|
53
|
+
this.setElement(elem);
|
|
54
|
+
}
|
|
49
55
|
|
|
56
|
+
protected _onRenderElement(elem: HTMLElement) {
|
|
57
|
+
// initialize the markup
|
|
58
|
+
}
|
|
59
|
+
}
|
|
50
60
|
```
|
|
51
|
-
<button data-command="send">Send</button>
|
|
52
61
|
|
|
53
|
-
|
|
62
|
+
The `HTMLElement.prototype.ui` extension lets you bind a `UIElement` through a factory and returns the element itself. It is opt-in (not installed on import) — call `enableElementExtensions()` once before using it:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { enableElementExtensions } from "@brandup/ui";
|
|
66
|
+
|
|
67
|
+
enableElementExtensions();
|
|
68
|
+
document.getElementById("widget")!.ui(elem => new MyWidget(elem));
|
|
54
69
|
```
|
|
55
70
|
|
|
56
|
-
|
|
71
|
+
The bound `UIElement` is available on the node through the `node.uielement` property.
|
|
72
|
+
|
|
73
|
+
### Element bound at construction
|
|
57
74
|
|
|
75
|
+
On the base `UIElement`, `element` is `HTMLElement | undefined` because the element may be bound later (an `Application`, for example, binds its element on run). When a component always receives its element in the constructor, extend `UIElementBound` instead — it binds the element immediately, so `element` is typed `HTMLElement` (never `undefined`):
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { UIElementBound } from "@brandup/ui";
|
|
79
|
+
|
|
80
|
+
class MyWidget extends UIElementBound {
|
|
81
|
+
constructor(elem: HTMLElement) {
|
|
82
|
+
super("MyWidget", elem); // typeName + element
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const w = new MyWidget(document.createElement("div"));
|
|
87
|
+
w.element.focus(); // element: HTMLElement — no ?./!
|
|
58
88
|
```
|
|
89
|
+
|
|
90
|
+
## UI commands
|
|
91
|
+
|
|
92
|
+
`UIElement` lets you register command handlers, which are declared in the markup through the `data-command` attribute.
|
|
93
|
+
|
|
94
|
+
```html
|
|
95
|
+
<button data-command="send">Send</button>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
this.registerCommand("send", (context: CommandContext) => {
|
|
100
|
+
context.target.innerHTML = "ok";
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
You can register asynchronous command handlers — just return a `Promise`:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
59
107
|
this.registerCommand("command1-async", (context: CommandContext) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
108
|
+
return new Promise<void>(resolve => {
|
|
109
|
+
context.target.innerHTML = "Loading...";
|
|
110
|
+
window.setTimeout(() => {
|
|
111
|
+
context.target.innerHTML = "Ok";
|
|
112
|
+
resolve();
|
|
113
|
+
}, 2000);
|
|
114
|
+
});
|
|
67
115
|
});
|
|
68
116
|
```
|
|
69
117
|
|
|
70
|
-
|
|
118
|
+
The third argument, `canExecute`, lets you restrict execution of the command:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
this.registerCommand(
|
|
122
|
+
"submit",
|
|
123
|
+
(context) => { /* ... */ },
|
|
124
|
+
(context) => context.target.dataset.enabled === "true"
|
|
125
|
+
);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Commands are triggered by the `click` event. The handler is looked up by walking up the DOM from the element with the `data-command` attribute to the nearest `UIElement` in which that command is registered. The global click listener must be enabled once via `initUICommands()` — `Application` does this automatically (see [Command click handler](#command-click-handler)).
|
|
129
|
+
|
|
130
|
+
While an asynchronous command is running, the **executing** CSS class is added to the target element (and removed once the `Promise` settles).
|
|
71
131
|
|
|
72
|
-
|
|
132
|
+
Command type signatures:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
type CommandExecuteFunction = (context: CommandContext) => void | Promise<void | any>;
|
|
136
|
+
type CommandCanExecuteFunction = (context: CommandContext) => boolean;
|
|
137
|
+
|
|
138
|
+
interface CommandContext {
|
|
139
|
+
/** HTMLElement on which the command is executed. */
|
|
140
|
+
target: HTMLElement;
|
|
141
|
+
/** UIElement in which the command handler is registered. */
|
|
142
|
+
uiElem: UIElement;
|
|
143
|
+
/** Don't stop the click event chain of target. */
|
|
144
|
+
transparent?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface CommandResult {
|
|
148
|
+
status: CommandExecStatus; // "disallow" | "already" | "success"
|
|
149
|
+
context: CommandContext;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Before executing a command, `UIElement` triggers the `command` event with `CommandEventArgs` arguments (`{ element, name }`).
|
|
73
154
|
|
|
74
155
|
## UI Events
|
|
75
156
|
|
|
76
|
-
`UIElement` extends
|
|
157
|
+
`UIElement` extends the `EventEmitter` class.
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
class EventEmitter<TEvents = EventMap> {
|
|
161
|
+
on<K extends keyof TEvents & string>(eventName: K, callback: TEvents[K], context?: any): this;
|
|
162
|
+
once<K extends keyof TEvents & string>(eventName: K, callback: TEvents[K], context?: any): this;
|
|
163
|
+
off<K extends keyof TEvents & string>(eventName?: K | "all" | null, callback?: TEvents[K] | EventCallbackFunc | null, context?: any | null): this;
|
|
164
|
+
|
|
165
|
+
protected listenTo(source: EventEmitter<any>, eventName: string, callback: EventCallbackFunc): this;
|
|
166
|
+
protected listenToOnce(source: EventEmitter<any>, eventName: string, callback: EventCallbackFunc): this;
|
|
167
|
+
protected stopListening(source?: EventEmitter<any>, eventName?: string, callback?: EventCallbackFunc): this;
|
|
168
|
+
|
|
169
|
+
protected trigger<K extends keyof TEvents & string>(eventName: K, ...args: Parameters<TEvents[K]>): this;
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Typed events
|
|
174
|
+
|
|
175
|
+
The optional `TEvents` type parameter is an **event map** (`{ eventName: (args) => void }`) that gives a subclass strongly-typed event names, callback signatures and `trigger` arguments. It defaults to a loose map, so untyped usage keeps working.
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
interface CounterEvents {
|
|
179
|
+
increment: (by: number) => void;
|
|
180
|
+
reset: () => void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class Counter extends EventEmitter<CounterEvents> {
|
|
184
|
+
add(n: number) {
|
|
185
|
+
this.trigger("increment", n); // ✅ ok
|
|
186
|
+
// this.trigger("increment", "x"); // ❌ string is not number
|
|
187
|
+
// this.trigger("nope"); // ❌ unknown event
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const c = new Counter();
|
|
192
|
+
c.on("increment", by => console.log(by.toFixed(0))); // by: number
|
|
193
|
+
// c.on("unknown", () => {}); // ❌ unknown event
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`UIElement` is itself generic — `UIElement<TEvents>` merges `TEvents` with the built-in `command`/`rendered`/`destroy` events, so subclasses can add their own typed events:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
class MyWidget extends UIElement<{ ready: () => void }> {
|
|
200
|
+
// on/trigger accept "command", "destroy" AND "ready"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Subscribing to and unsubscribing from events:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
widget.on("command", (args: CommandEventArgs) => {
|
|
208
|
+
console.log("executing", args.name);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// one-time handler
|
|
212
|
+
widget.once("destroy", () => console.log("destroyed"));
|
|
213
|
+
|
|
214
|
+
// unsubscribe (filters can be omitted — an omitted filter matches anything)
|
|
215
|
+
widget.off("command");
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
The special event name `"all"` receives every triggered event.
|
|
219
|
+
|
|
220
|
+
The protected `listenTo` / `listenToOnce` methods subscribe one emitter to another's events and track the subscription so it can be released via `stopListening`. On `destroy()`, all of a `UIElement`'s subscriptions are removed automatically.
|
|
221
|
+
|
|
222
|
+
## UIElement lifecycle
|
|
223
|
+
|
|
224
|
+
### destroy()
|
|
225
|
+
|
|
226
|
+
`destroy()` cleans up the element completely:
|
|
227
|
+
|
|
228
|
+
- Fires the `"destroy"` event.
|
|
229
|
+
- Stops all event subscriptions.
|
|
230
|
+
- **Cascades** to every nested `UIElement` found in the subtree (deepest descendants first), so you never need to destroy children manually.
|
|
231
|
+
- Stops all reactive `bind`/`bindEach` effects rendered inside the element.
|
|
232
|
+
- Detaches the `UIElement` from its DOM node (clears the `data-uiElement` attribute and the `uielement` property).
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const parent = new ParentWidget(parentElem); // contains child UIElements
|
|
236
|
+
parent.destroy(); // automatically destroys all nested UIElements too
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Auto-destroy on DOM removal
|
|
240
|
+
|
|
241
|
+
When a bound element is removed from the document **after having been connected to it**, `destroy()` is called automatically. This works for all nested UIElements too — removing a parent node triggers the full destroy cascade.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const w = new MyWidget(elem);
|
|
245
|
+
document.body.appendChild(elem);
|
|
246
|
+
|
|
247
|
+
elem.remove(); // destroy() fires automatically on next microtask
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
If the element was never connected to the document (e.g. built in memory and then discarded), auto-destroy does **not** fire — only mounted-then-removed elements are watched.
|
|
251
|
+
|
|
252
|
+
### Command click handler
|
|
253
|
+
|
|
254
|
+
Commands are dispatched by a single global `click` listener on `window`. It is **not** registered automatically on import (so the command system can be tree-shaken away when unused) — call `initUICommands()` once during startup. It is idempotent and a no-op without a DOM. `Application.run()` (from `@brandup/ui-app`) calls it for you, so you only need it when using `UIElement` commands **without** an `Application`:
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { initUICommands, destroyUI } from "@brandup/ui";
|
|
258
|
+
|
|
259
|
+
initUICommands(); // enable command handling
|
|
260
|
+
// ...
|
|
261
|
+
destroyUI(); // remove the listener on app teardown or HMR disposal
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## DOM helpers
|
|
265
|
+
|
|
266
|
+
> Previously published as the separate `@brandup/ui-dom` package, now merged into `@brandup/ui`.
|
|
267
|
+
|
|
268
|
+
All DOM helpers are available through the `DOM` object.
|
|
269
|
+
|
|
270
|
+
```ts
|
|
271
|
+
import { DOM } from "@brandup/ui";
|
|
272
|
+
|
|
273
|
+
const DOM = {
|
|
274
|
+
// Finding elements
|
|
275
|
+
getById<T extends HTMLElement = HTMLElement>(id: string): T | null;
|
|
276
|
+
getByClass<T extends HTMLElement = HTMLElement>(container: Element, className: string): T | null;
|
|
277
|
+
getByName<T extends HTMLElement = HTMLElement>(name: string): T | null;
|
|
278
|
+
getElementByTagName<T extends HTMLElement = HTMLElement>(container: Element, tagName: string): T | null;
|
|
279
|
+
getElementsByTagName(container: Element, tagName: string): HTMLCollectionOf<Element>;
|
|
280
|
+
queryElement<T extends HTMLElement = HTMLElement>(container: Element, query: string): T | null;
|
|
281
|
+
queryElements<T extends HTMLElement = HTMLElement>(container: Element, query: string): NodeListOf<T>;
|
|
282
|
+
|
|
283
|
+
// Navigating sibling elements
|
|
284
|
+
nextElement<T extends HTMLElement = HTMLElement>(current: Element): T | null;
|
|
285
|
+
prevElement<T extends HTMLElement = HTMLElement>(current: Element): T | null;
|
|
286
|
+
nextElementByClass<T extends HTMLElement = HTMLElement>(current: Element, className: string): T | null;
|
|
287
|
+
prevElementByClass<T extends HTMLElement = HTMLElement>(current: Element, className: string): T | null;
|
|
288
|
+
|
|
289
|
+
// CSS classes
|
|
290
|
+
addClass(container: Element | null | undefined, selectors: string, cssClass: CssClass): void;
|
|
291
|
+
removeClass(container: Element | null | undefined, selectors: string, cssClass: CssClass): void;
|
|
292
|
+
|
|
293
|
+
// Clearing
|
|
294
|
+
empty(container: Element | null | undefined): void;
|
|
295
|
+
|
|
296
|
+
// Creating elements
|
|
297
|
+
tag<T extends keyof HTMLElementTagNameMap>(tagName: T, options?: ElementOptions | null, ...children: TagChildrenLike[]): HTMLElementTagNameMap[T];
|
|
298
|
+
tag<T extends keyof HTMLElementTagNameMap>(tagName: T, firstChild: TagFirstChild, ...children: TagChildrenLike[]): HTMLElementTagNameMap[T];
|
|
299
|
+
};
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### Creating HTML elements
|
|
303
|
+
|
|
304
|
+
`DOM.tag` creates an element from a tag name. The remaining arguments are children or options:
|
|
305
|
+
|
|
306
|
+
- **Options** — pass `null` (no options) or an `ElementOptions` plain object as the second argument.
|
|
307
|
+
- **Children** — any other value in the second position (string, number, `Element`, `Binding`, `BindingEach`, `Promise`, function, array) is treated as the **first child**, so the options argument can be omitted entirely.
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
// No options, no children
|
|
311
|
+
DOM.tag("div");
|
|
312
|
+
|
|
313
|
+
// Options object (id, class, dataset, styles, events, arbitrary attributes)
|
|
314
|
+
DOM.tag("div", { class: "box", id: "main" });
|
|
315
|
+
|
|
316
|
+
// null → no options, children follow
|
|
317
|
+
DOM.tag("div", null, "<p>test</p>");
|
|
318
|
+
|
|
319
|
+
// String as second arg → inserted as HTML child (NOT a CSS class)
|
|
320
|
+
DOM.tag("div", "<b>hello</b>");
|
|
321
|
+
DOM.tag("p", "plain text");
|
|
322
|
+
|
|
323
|
+
// Number or boolean as second arg → text child
|
|
324
|
+
DOM.tag("span", 42);
|
|
325
|
+
|
|
326
|
+
// Element/UIElement child — no null needed
|
|
327
|
+
DOM.tag("div", DOM.tag("span", "child"));
|
|
328
|
+
DOM.tag("div", new MyWidget(DOM.tag("span")));
|
|
329
|
+
|
|
330
|
+
// Multiple children
|
|
331
|
+
DOM.tag("ul", null, DOM.tag("li", "1"), DOM.tag("li", "2"));
|
|
332
|
+
|
|
333
|
+
// Children in an array
|
|
334
|
+
DOM.tag("ul", [DOM.tag("li", "1"), DOM.tag("li", "2")]);
|
|
335
|
+
|
|
336
|
+
// Factory function: receives the container element
|
|
337
|
+
DOM.tag("div", (elem) => { elem.id = "x"; });
|
|
338
|
+
DOM.tag("div", () => DOM.tag("span", "child"));
|
|
339
|
+
|
|
340
|
+
// Promise child — appended once it resolves
|
|
341
|
+
DOM.tag("div", fetch("/fragment").then(r => r.text()));
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The full `ElementOptions` object:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
interface ElementOptions {
|
|
348
|
+
id?: string; // id attribute
|
|
349
|
+
class?: CssClass; // CSS class(es): a string or an array of strings
|
|
350
|
+
command?: string; // data-command
|
|
351
|
+
dataset?: ElementData; // arbitrary data-* attributes
|
|
352
|
+
events?: ElementEvents; // event handlers keyed by lowercase name
|
|
353
|
+
styles?: ElementStyles; // inline styles (Partial<CSSStyleDeclaration>)
|
|
354
|
+
[name: string]: // any other key is a plain attribute:
|
|
355
|
+
| string | number | boolean | object | null | undefined;
|
|
356
|
+
// null → empty attribute, object → JSON.stringify, undefined → ignored
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Reactivity
|
|
361
|
+
|
|
362
|
+
A small fine-grained reactivity layer (Vue/MobX-style) with auto-tracking, plus `DOM.tag` bindings that update the DOM in place.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { reactive, effect, computed, nextTick, untrack, bind, bindEach, DOM } from "@brandup/ui";
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### reactive / effect / computed
|
|
369
|
+
|
|
370
|
+
`reactive(obj)` returns a deep reactive proxy: reads are tracked and writes notify the effects that read them.
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const state = reactive({ first: "Ada", last: "Lovelace", tags: ["math"] });
|
|
374
|
+
|
|
375
|
+
// effect re-runs when any property it reads changes
|
|
376
|
+
effect(() => console.log(state.first));
|
|
377
|
+
|
|
378
|
+
// computed: lazily cached, recomputes only when its dependencies change
|
|
379
|
+
const full = computed(() => `${state.first} ${state.last}`);
|
|
380
|
+
|
|
381
|
+
state.first = "Augusta"; // schedules the effect and invalidates `full`
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
- **Deep**: nested objects and arrays are reactive (`state.tags.push(...)` is tracked).
|
|
385
|
+
- **Dynamic dependencies**: an effect only depends on the properties it actually reads on its last run (`useA ? a : b` re-subscribes).
|
|
386
|
+
- **Batched**: effect re-runs are coalesced on the microtask queue, so multiple synchronous writes trigger a single run. Await `nextTick()` to observe the result:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
state.first = "A";
|
|
390
|
+
state.last = "B";
|
|
391
|
+
await nextTick(); // effects have now re-run once
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### untrack
|
|
395
|
+
|
|
396
|
+
`untrack(fn)` runs a function **without** recording any reactive reads as dependencies. Use it when you need to read reactive state inside an effect without creating a dependency on that read:
|
|
397
|
+
|
|
398
|
+
```ts
|
|
399
|
+
import { untrack } from "@brandup/ui";
|
|
400
|
+
|
|
401
|
+
effect(() => {
|
|
402
|
+
const items = state.list; // tracked — effect re-runs when list changes
|
|
403
|
+
const config = untrack(() => state.config); // not tracked — config changes won't re-run this effect
|
|
404
|
+
render(items, config);
|
|
405
|
+
});
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Binding DOM with `bind`
|
|
409
|
+
|
|
410
|
+
`bind(() => expr)` is a reactive `tag` child. The expression is tracked and re-rendered in place when its reactive state changes — text values reuse a text node (rendered via `textContent`, so safe from HTML injection), element/`UIElement` values swap the node.
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
const state = reactive({ name: "Alice", online: true });
|
|
414
|
+
|
|
415
|
+
const el = DOM.tag("div", null,
|
|
416
|
+
"Hi, ", bind(() => state.name), "! ",
|
|
417
|
+
bind(() => state.online ? DOM.tag("b", null, "online") : "offline")
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
state.name = "Bob"; // the text updates on the next tick
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Binding an array property with `bindEach`
|
|
424
|
+
|
|
425
|
+
`bindEach` is a reactive tag child — used exactly like `bind` — that renders a keyed list with minimal DOM updates. Each item is identified by a stable key; when the array changes, only new, removed, or reordered nodes are touched.
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
import { reactive, bindEach, bind, nextTick, DOM } from "@brandup/ui";
|
|
429
|
+
|
|
430
|
+
const state = reactive({
|
|
431
|
+
users: [
|
|
432
|
+
{ id: 1, name: "Alice" },
|
|
433
|
+
{ id: 2, name: "Bob" },
|
|
434
|
+
{ id: 3, name: "Charlie" },
|
|
435
|
+
]
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
const list = DOM.tag("ul", "user-list",
|
|
439
|
+
bindEach(
|
|
440
|
+
() => state.users, // reactive item source (tracked)
|
|
441
|
+
user => user.id, // stable key — identifies each node across re-renders
|
|
442
|
+
user => DOM.tag("li", null, // render one item → Element (called once per key)
|
|
443
|
+
bind(() => user.name) // bind() inside render for fine-grained per-item updates
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
);
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Array mutations are tracked — the list reconciles on the next tick:
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
state.users.push({ id: 4, name: "Diana" }); // inserts one new <li>
|
|
453
|
+
state.users.splice(1, 1); // removes the <li> for id=2
|
|
454
|
+
state.users.unshift(state.users.pop()!); // moves last node to front, no re-render
|
|
455
|
+
state.users = [{ id: 1, name: "Alice" }]; // full reassignment — removes all but id=1
|
|
456
|
+
|
|
457
|
+
await nextTick(); // DOM is up to date
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Because `render` is called **once per key** and runs **untracked**, reads inside it do not create dependencies on the list reconciler. Use `bind()` inside `render` so individual property changes update only the affected node — not the whole list:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// ✅ only the text node re-renders when user.name changes
|
|
464
|
+
user => DOM.tag("li", null, bind(() => user.name))
|
|
465
|
+
|
|
466
|
+
// ⚠️ name changes have no effect — render is untracked and never called again for existing keys
|
|
467
|
+
user => DOM.tag("li", null, user.name)
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
The binding stops automatically once its rendered nodes leave the document — whether the container is removed or just cleared/replaced (same lifecycle as `bind`).
|
|
471
|
+
|
|
472
|
+
### Disposal
|
|
473
|
+
|
|
474
|
+
Bindings hold a reactive effect that must be stopped to avoid leaks. This is handled automatically in common cases:
|
|
475
|
+
|
|
476
|
+
- **`UIElement.destroy()`** stops every `bind`/`bindEach` effect in the subtree and cascades to nested UIElements — no manual wiring needed.
|
|
477
|
+
- A binding **stops itself** once its node has been mounted into the document and then removed from it.
|
|
478
|
+
- Removing a bound element from the document also calls `destroy()` automatically (see [UIElement lifecycle](#uielement-lifecycle)).
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
class Widget extends UIElementBound {
|
|
482
|
+
constructor(elem: HTMLElement) {
|
|
483
|
+
super("widget", elem);
|
|
484
|
+
elem.append(DOM.tag("span", null, bind(() => state.name)));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const w = new Widget(document.createElement("div"));
|
|
488
|
+
w.destroy(); // stops bind() effects, cascades to nested UIElements
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
For effects and bindings outside a `UIElement`, or to group them explicitly, use `EffectScope`:
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { effectScope } from "@brandup/ui";
|
|
495
|
+
|
|
496
|
+
const scope = effectScope();
|
|
497
|
+
const view = scope.run(() => DOM.tag("div", null, bind(() => state.name)));
|
|
498
|
+
scope.stop(); // stops every effect/binding created inside the scope
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
`UIElement.effectScope()` returns a scope that is stopped automatically when the element is destroyed:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
class Widget extends UIElementBound {
|
|
505
|
+
constructor(elem: HTMLElement) {
|
|
506
|
+
super("widget", elem);
|
|
507
|
+
this.effectScope().run(() => {
|
|
508
|
+
elem.append(DOM.tag("span", null, bind(() => state.name)));
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
## Constants
|
|
515
|
+
|
|
516
|
+
The names of DOM attributes, properties, and CSS classes are exported as the `UICONSTANTS` namespace:
|
|
517
|
+
|
|
518
|
+
```ts
|
|
519
|
+
import { UICONSTANTS } from "@brandup/ui";
|
|
520
|
+
|
|
521
|
+
UICONSTANTS.ElemAttributeName; // "uiElement" — data attribute holding the typeName
|
|
522
|
+
UICONSTANTS.ElemPropertyName; // "uielement" — property on the DOM element referencing the UIElement
|
|
523
|
+
UICONSTANTS.CommandAttributeName; // "command" — data attribute of the command
|
|
524
|
+
UICONSTANTS.CommandExecutingCssClassName; // "executing" — class applied while an async command is running
|
|
525
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
/** Default constant values used across the library. */
|
|
6
|
+
const constants = {
|
|
7
|
+
ElemAttributeName: "uiElement",
|
|
8
|
+
ElemPropertyName: "uielement",
|
|
9
|
+
CommandAttributeName: "command",
|
|
10
|
+
CommandExecutingCssClassName: "executing"
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
exports.default = constants;
|
|
14
|
+
//# sourceMappingURL=constants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constants.js","sources":["../../../source/constants.ts"],"sourcesContent":[null],"names":[],"mappings":";;;;AAYA;AACA,MAAM,SAAS,GAAgB;AAC9B,IAAA,iBAAiB,EAAE,WAAW;AAC9B,IAAA,gBAAgB,EAAE,WAAW;AAC7B,IAAA,oBAAoB,EAAE,SAAS;AAC/B,IAAA,4BAA4B,EAAE;;;;;"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var effect = require('../reactive/effect.js');
|
|
4
|
+
var bindingCleanup = require('./binding-cleanup.js');
|
|
5
|
+
|
|
6
|
+
/** A keyed-list binding produced by {@link bindEach}; handled by `appendChild` in `tag.ts`. */
|
|
7
|
+
class BindingEach {
|
|
8
|
+
getItems;
|
|
9
|
+
getKey;
|
|
10
|
+
render;
|
|
11
|
+
constructor(getItems, getKey, render) {
|
|
12
|
+
this.getItems = getItems;
|
|
13
|
+
this.getKey = getKey;
|
|
14
|
+
this.render = render;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create a reactive keyed-list binding for use as a {@link tag} child.
|
|
19
|
+
*
|
|
20
|
+
* The `getItems` function is tracked; the list is reconciled whenever the array
|
|
21
|
+
* changes (push, splice, reassignment, etc.). Items are matched by `getKey` so
|
|
22
|
+
* unchanged nodes stay in place — only new, removed, or reordered nodes are touched.
|
|
23
|
+
*
|
|
24
|
+
* `render` is called **once per key** and runs **untracked**. Use `bind()` inside
|
|
25
|
+
* the render function for item properties that should update independently:
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ The item object passed to `render` is captured at first render for that key.
|
|
28
|
+
* Mutate items in place (`item.name = "..."`) so `bind()` reactions fire. Replacing
|
|
29
|
+
* the array with **new objects that reuse the same keys** keeps the cached node bound
|
|
30
|
+
* to the *old* object, so per-item `bind()`s won't update — change the key, or mutate
|
|
31
|
+
* the existing item, when its identity should change.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* DOM.tag("ul", null,
|
|
35
|
+
* bindEach(() => state.users, u => u.id, u =>
|
|
36
|
+
* DOM.tag("li", null, bind(() => u.name))
|
|
37
|
+
* )
|
|
38
|
+
* );
|
|
39
|
+
*/
|
|
40
|
+
function bindEach(getItems, getKey, render) {
|
|
41
|
+
return new BindingEach(getItems, getKey, render);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Mount a {@link BindingEach} into `container` and start tracking.
|
|
45
|
+
* Called by `appendChild` in `tag.ts`; not intended for direct use.
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
function appendBindingEach(container, binding) {
|
|
49
|
+
const nodes = new Map();
|
|
50
|
+
// Anchor comment marks the start of the managed region inside the container.
|
|
51
|
+
// Using an anchor (rather than container.firstChild) lets other children coexist.
|
|
52
|
+
const anchor = document.createComment("");
|
|
53
|
+
container.append(anchor);
|
|
54
|
+
const eff = effect.effect(() => {
|
|
55
|
+
const items = binding.getItems();
|
|
56
|
+
const nextKeys = new Set();
|
|
57
|
+
for (let i = 0; i < items.length; i++)
|
|
58
|
+
nextKeys.add(binding.getKey(items[i], i));
|
|
59
|
+
// Remove nodes whose keys are no longer present
|
|
60
|
+
for (const [key, node] of nodes) {
|
|
61
|
+
if (!nextKeys.has(key)) {
|
|
62
|
+
node.remove();
|
|
63
|
+
nodes.delete(key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Insert new nodes and restore order in a single pass starting after the anchor.
|
|
67
|
+
// If the node is already at the expected position we advance; otherwise insertBefore moves it.
|
|
68
|
+
let cursor = anchor.nextSibling;
|
|
69
|
+
for (let i = 0; i < items.length; i++) {
|
|
70
|
+
const key = binding.getKey(items[i], i);
|
|
71
|
+
let node = nodes.get(key);
|
|
72
|
+
if (!node) {
|
|
73
|
+
// Render untracked so item-property reads don't create dependencies on
|
|
74
|
+
// this list effect — use bind() inside render for fine-grained updates.
|
|
75
|
+
node = effect.untrack(() => binding.render(items[i]));
|
|
76
|
+
nodes.set(key, node);
|
|
77
|
+
}
|
|
78
|
+
if (cursor !== node)
|
|
79
|
+
container.insertBefore(node, cursor);
|
|
80
|
+
else
|
|
81
|
+
cursor = cursor.nextSibling;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
bindingCleanup.autoDisposeBinding(container, () => anchor, eff);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
exports.BindingEach = BindingEach;
|
|
88
|
+
exports.appendBindingEach = appendBindingEach;
|
|
89
|
+
exports.bindEach = bindEach;
|
|
90
|
+
//# sourceMappingURL=bind-each.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bind-each.js","sources":["../../../../source/dom/bind-each.ts"],"sourcesContent":[null],"names":["effect","untrack","autoDisposeBinding"],"mappings":";;;;;AAGA;MACa,WAAW,CAAA;AAEb,IAAA,QAAA;AACA,IAAA,MAAA;AACA,IAAA,MAAA;AAHV,IAAA,WAAA,CACU,QAAmB,EACnB,MAAmD,EACnD,MAA4B,EAAA;QAF5B,IAAA,CAAA,QAAQ,GAAR,QAAQ;QACR,IAAA,CAAA,MAAM,GAAN,MAAM;QACN,IAAA,CAAA,MAAM,GAAN,MAAM;IACZ;AACJ;AAED;;;;;;;;;;;;;;;;;;;;;;AAsBG;SACa,QAAQ,CACvB,QAAmB,EACnB,MAAmD,EACnD,MAA4B,EAAA;IAE5B,OAAO,IAAI,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;AACjD;AAEA;;;;AAIG;AACG,SAAU,iBAAiB,CAAI,SAAsB,EAAE,OAAuB,EAAA;AACnF,IAAA,MAAM,KAAK,GAAG,IAAI,GAAG,EAA4B;;;IAIjD,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC;AACzC,IAAA,SAAS,CAAC,MAAM,CAAC,MAAM,CAAC;AAExB,IAAA,MAAM,GAAG,GAAGA,aAAM,CAAC,MAAK;AACvB,QAAA,MAAM,KAAK,GAAG,OAAO,CAAC,QAAQ,EAAE;AAEhC,QAAA,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAmB;AAC3C,QAAA,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE;AACpC,YAAA,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;;QAG1C,KAAK,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,KAAK,EAAE;YAChC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE;gBACvB,IAAI,CAAC,MAAM,EAAE;AACb,gBAAA,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC;YAClB;QACD;;;AAIA,QAAA,IAAI,MAAM,GAAqB,MAAM,CAAC,WAAW;AACjD,QAAA,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;AACtC,YAAA,MAAM,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YACvC,IAAI,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC;YAEzB,IAAI,CAAC,IAAI,EAAE;;;AAGV,gBAAA,IAAI,GAAGC,cAAO,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;AAC9C,gBAAA,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC;YACrB;YAEA,IAAI,MAAM,KAAK,IAAI;AAClB,gBAAA,SAAS,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC;;AAEpC,gBAAA,MAAM,GAAG,MAAM,CAAC,WAAW;QAC7B;AACD,IAAA,CAAC,CAAC;IAEFC,iCAAkB,CAAC,SAAS,EAAE,MAAM,MAAM,EAAE,GAAG,CAAC;AACjD;;;;;;"}
|