@manyducks.co/dolla 2.0.0-alpha.42 → 2.0.0-alpha.43

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/notes/atomic.md CHANGED
@@ -1,209 +1,384 @@
1
- # Atomic API
1
+ # ATOMIC
2
+
3
+ - New library; core is just signals, templates and views.
4
+ - Router released as companion library.
5
+ - Localize released as companion library.
6
+ - CSS components as new companion library.
7
+
8
+ Goals:
9
+
10
+ - Easy drop-in script tag to get started. Feasible to start with a CDN for prototyping and introduce build step later.
11
+ - Server side rendering support. `html` templates should create intermediate data structure that can be turned into DOM nodes or a string.
12
+
13
+ ## Signals
2
14
 
3
15
  ```js
4
- function SomeView(props, ctx) {
5
- // Atoms are the basic building block of state.
6
- const count = new Atom(5);
16
+ import { atom, memo, createScope, $effect } from "@manyducks.co/atomic";
7
17
 
8
- count.value; // returns the value
9
- count.value = 12; // replaces the value
10
- count.update((value) => value + 1); // updates the value. You can use Immer here for complex objects.
18
+ // Atoms are hybrid getter/setter functions. Call without a value to get, call with a value to set.
19
+ const count = atom(0);
11
20
 
12
- // or you could just implement an update function yourself with Immer. Probably gonna cut it.
13
- function update(atom, callback) {
14
- atom.value = produce(atom.value, callback);
15
- }
16
- update(count, (value) => value + 1);
21
+ // Basic computed properties are just functions. Taking advantage of the fact that functions called in functions will still be tracked.
22
+ const doubled = () => count() * 2;
17
23
 
18
- // Listen for changes. Callback will be run the next time the value changes and each time again afterwards.
19
- const unsubscribe = count.subscribe((value) => {
20
- console.log(value);
21
- });
24
+ // Use memo to make a memoized value for more expensive calculations.
25
+ const quadrupled = memo(() => doubled() * 2);
26
+ ```
22
27
 
23
- // Composed is a state that depends on one or more other states.
24
- // The callback takes a getter function that will track that state as a dependency and return its current value.
25
- // We recompute if any tracked dependency receives a new value.
26
- const doubled = new Composed((get) => get(count) * 2);
28
+ ### Scopes
27
29
 
28
- // Effects follow the same pattern as a Composed callback.
29
- ctx.effect(() => {
30
- console.log(doubled.value);
30
+ > NOTE: Views and directives are called within a scope.
31
+
32
+ ```js
33
+ // Functions starting with $ can be called within a scope.
34
+ // Scopes will clean up all effects created within them when they are disconnected.
35
+ const scope = createScope(() => {
36
+ $effect(() => {
37
+ // Atoms and memos called within an $effect are automatically tracked.
38
+ console.log(`count is ${count()} (doubled: ${doubled()})`);
31
39
  });
32
40
 
33
- const print = new Atom(false);
41
+ // Changing tracked values will trigger effects to run again.
42
+ count(1);
34
43
 
35
- // Dependency lists are rebuilt every time the callback is run.
36
- // Below, `value` will not be tracked as a dependency until `print` has changed to true.
37
- ctx.effect(() => {
38
- if (get(print)) {
39
- console.log(get(value));
40
- }
41
- });
44
+ // $effects are called immediately once, then again each time one or more dependencies change.
45
+ // Effects are settled in queueMicrotask(), effectively batching them.
42
46
 
43
- // get() is also the ONLY way to track dependencies.
44
- // You're free to use the state's own getter if you want the value without actually tracking it.
45
- ctx.effect((get) => {
46
- if (get(value) > 5) {
47
- console.log(doubled.get()); // will not be tracked
48
- }
49
- });
47
+ count(2);
48
+ count(3);
49
+ count(4);
50
+ // Multiple synchronous calls in a row like this will only trigger the effect once.
51
+ });
50
52
 
51
- // ALSO: Need to track sets and updates so we can throw an error if a set was committed in the same scope that value is tracked. Otherwise this will cause an infinite loop.
52
- }
53
+ // ----- Scope Functions ----- //
54
+
55
+ $effect(() => {
56
+ // Tracks dependencies. This function will run again when any of them change.
57
+ });
58
+
59
+ $connected(() => {
60
+ // Runs when scope is connected.
61
+ // In views and directives this happens in the next microtask after DOM nodes are attached.
62
+ });
63
+
64
+ $disconnected(() => {
65
+ // Runs when scope is disconnected.
66
+ // In views and directives this happens in the next microtask after DOM nodes are disconnected.
67
+ });
68
+
69
+ // ----- Scope API ----- //
70
+
71
+ const scope = createScope(() => {
72
+ /* ... */
73
+ });
74
+
75
+ // Connect starts all $effects and runs $connected callbacks.
76
+ scope.connect();
77
+
78
+ // Disconnect disposes all $effects and runs $disconnected callbacks.
79
+ scope.disconnect();
53
80
  ```
54
81
 
55
- Refined API:
82
+ ## Templates
56
83
 
57
84
  ```js
58
- const $count = atom(5);
59
- $count.value++;
60
- $count.value; // 6
85
+ // Provide directives and views in a config object.
86
+ const template = html({
87
+ directives: { custom: customDirective },
88
+ views: { SomeView },
89
+ })`
90
+ <div>
91
+ <SomeView *custom=${x} prop=${value} />
92
+ </div>
93
+ `;
94
+
95
+ // Or put the views and directives at the end?
96
+ const template = html`
97
+ <div>
98
+ <p>Counter: ${count}</p>
99
+
100
+ <!-- bind events with @name -->
101
+ <div>
102
+ <button @click=${increment}>+1</button>
103
+ <button @click=${decrement}>-1</button>
104
+ </div>
61
105
 
62
- const $doubled = compose(() => get($count) * 2);
63
- const $quadrupled = compose(() => get($doubled) * 2);
106
+ <!-- apply directives with *name -->
107
+ <div *ref=${refAtom} *if=${x} *unless=${x} *show=${x} *hide=${x} *classes=${x} *styles=${x} *custom=${whatever} />
64
108
 
65
- ctx.effect(() => {
66
- if (get($count) > 25) {
67
- console.log($doubled.value);
68
- get($quadrupled);
69
- }
70
- });
109
+ <!-- bind properties with .name -->
110
+ <span .textContent=${x} />
111
+
112
+ <!-- two-way bind atoms with :value -->
113
+ <input :value=${valueAtom} />
114
+
115
+ <ul *if=${hasValues}>
116
+ <!-- render iterables from signals with list() -->
117
+ ${list(values, (value, index) => {
118
+ // Render views into HTML templates with view()
119
+ return html`<li><SomeView item=${value} /></li>`.withViews({ SomeView });
120
+ })}
121
+ </ul>
122
+ </div>
123
+ `
124
+ .withDirectives({ custom: customDirective })
125
+ .withViews({ SomeView });
126
+
127
+ // Key
128
+ // @ for event listeners
129
+ // * for directives
130
+ // . for properties
131
+ // :value for two-way value binding
132
+ // no prefix for attributes
71
133
  ```
72
134
 
73
- vs old API:
135
+ ### Event modifiers
74
136
 
75
137
  ```js
76
- // ----- Basic State ----- //
138
+ html`<button
139
+ @click.stop.prevent.throttle[250]=${() => {
140
+ // stopPropagation & preventDefault already called
141
+ // Listener will be triggered a maximum of once every 250 milliseconds.
142
+ }}
143
+ >
144
+ Click Me
145
+ </button>`;
146
+ ```
77
147
 
78
- // Old
79
- const [$count, setCount] = createState(5);
80
- setCount((count) => count + 1);
81
- $count.get(); // 6
148
+ You can chain modifiers on event handlers. Inspired by [`Mizu.js`](https://mizu.sh/#event).
82
149
 
83
- // New
84
- const count = atom(5);
85
- count.value++;
86
- count.value; // 6
150
+ #### `.prevent`
87
151
 
88
- // ----- Derived State ----- //
152
+ Calls event.preventDefault() when triggered.
89
153
 
90
- // Old
91
- const $doubled = derive([$count], (count) => count * 2);
92
- const $quadrupled = derive([$doubled], (doubled) => doubled * 2);
154
+ #### `.stop`
93
155
 
94
- // New
95
- const doubled = compose((get) => get(count) * 2);
96
- const quadrupled = compose((get) => get(doubled) * 2);
156
+ Calls event.stopPropagation() when triggered.
97
157
 
98
- // ----- Side Effects ----- //
158
+ #### `.once`
99
159
 
100
- // Old
101
- ctx.watch([$count, $quadrupled], (count, quadrupled) => {
102
- if (count > 25) {
103
- console.log($doubled.get()); // not tracked
160
+ Register listener with { once: true }. If present the listener is removed after being triggered once.
104
161
 
105
- console.log(quadrupled);
106
- // changes to $quadrupled will trigger this callback to re-run, even if count is still <= 25
107
- }
108
- });
162
+ #### `.passive`
109
163
 
110
- // New
111
- ctx.effect((get) => {
112
- // count is tracked by reading it with 'get'
113
- if (get(count) > 25) {
114
- console.log(doubled.value); // not tracked
164
+ Register listener with { passive: true }.
115
165
 
116
- get(quadrupled); // only tracked while count > 25 (this 'get' doesn't run otherwise)
117
- // changes to 'quadrupled' will NOT trigger this callback to re-run unless 'count' is already >= 25
118
- }
119
- });
120
- ```
166
+ #### `.capture`
121
167
 
122
- Atoms and composed values implement the `Reactive<T>` interface for TypeScript purposes. There is also `Atom<T>` and `Composed<T>` if you want to be specific.
168
+ Register listener with { capture: true }.
123
169
 
124
- The API above is a remix of Preact signals, Jotai and the TC39 Signals proposal. I strongly dislike automatic dependency tracking. I think the developer should explicitly describe what they want instead of having the language assume what they want and making them opt out with `untrack` and such. Madness.
170
+ #### `.self`
125
171
 
126
- It's also unintuitive what's a tracked scope and what isn't. There's nothing to tell you that at a glance. It's left up to the framework conventions. With this API you know; if you're in a function scope and you have a getter, you're in a tracking-capable scope. Doing that tracking is then left up to you. You can explicitly see that things are being tracked by reading the code. There is no background knowledge needed and no side effects required.
172
+ Trigger listener only if event.target is the element itself.
127
173
 
128
- Further API:
174
+ #### `.attach[element | window | document]`
129
175
 
130
- ```js
131
- // If count is reactive we get its current value. Otherwise we get it as is.
132
- const value = unwrap(count);
176
+ > `@click.attach[document]=${...}`
133
177
 
134
- // If count is reactive we get it as is (typed as Reactive<T>). Otherwise we get it wrapped as a Reactive<T>.
135
- const value = reactive(count);
136
- ```
178
+ Attach listener to a different target.
179
+
180
+ #### `.throttle[duration≈250ms]`
181
+
182
+ Prevent listener from being called more than once during the specified time frame. Duration value is in milliseconds.
183
+
184
+ #### `.debounce[duration≈250ms]`
185
+
186
+ Delay listener execution until the specified time frame has passed without any activity. Duration value is in milliseconds.
187
+
188
+ ### Lists
137
189
 
138
190
  ```js
139
- const me = compose((get) => {
140
- const id = get(userId);
141
- return get(users)_?.find((u) => u.id === id);
191
+ list(values, (value, index) => {
192
+ return html`<li>${view(SomeComponent, value)}</li>`;
142
193
  });
194
+ ```
143
195
 
144
- const $me = derive([$userId, $users], (id, users) => users?.find((u) => u.id === id));
196
+ ### Custom Directives
197
+
198
+ ```js
199
+ function myDirective(element, value, modifiers) {
200
+ // Directives are called inside a scope.
201
+ $disconnected(() => {
202
+ // Cleanup
203
+ });
204
+ }
145
205
  ```
146
206
 
207
+ ## Full Example
208
+
147
209
  ```js
148
- const Counter = view("Counter", function () {
210
+ import { atom, memo, html, connect, $effect } from "@manyducks.co/atomic";
211
+
212
+ // Functions starting with $ can only be called in the body of a component function.
213
+
214
+ // IDEA: CSS components. Ref counted and added to head while used at least once on the page.
215
+ const button = css`
216
+ color: "red";
217
+
218
+ &:hover {
219
+ color: "blue";
220
+ }
221
+ `;
222
+
223
+ function Counter() {
224
+ const debug = logger("Component");
225
+
149
226
  const count = atom(0);
150
227
 
151
- return html`
152
- <div>
153
- <span>${count}</span>
228
+ // Simple computed value; computation runs each time function is called
229
+ const doubled = () => count() * 2;
154
230
 
155
- <button onclick=${() => count.value++}>Increment</button>
156
- <button onclick=${() => count.value--}>Decrement</button>
157
- </div>
158
- `;
159
- });
231
+ // Memoized; computation only runs when one of its dependencies changes
232
+ const quadrupled = memo((previousValue) => doubled() * 2, { equals: deepEqual });
233
+ // memos pass their previous to their callback
234
+ // memos can have an equality function specified (as can atoms)
235
+
236
+ $effect(() => {
237
+ // Dependencies are tracked when getters are called in a tracked scope.
238
+ // Tracked scopes are the body of a `memo` or `effect` callback.
239
+ debug.log(`Count is: ${count()}`);
240
+
241
+ // untrack
242
+ const value = peek(count);
243
+ const doubled = peek(() => {
244
+ return count() * 2;
245
+ });
246
+ });
247
+
248
+ $connected(() => {
249
+ // Runs when component is connected.
250
+ });
160
251
 
161
- const Routes = view("Routes", function () {
162
- this.onMount(function () {
163
- // this still refers to view context
164
- this.log("hello!");
252
+ $disconnected(() => {
253
+ // Runs when component is disconnected.
165
254
  });
166
255
 
256
+ function increment() {
257
+ // Set new value
258
+ count(count() + 1);
259
+ }
260
+
261
+ function decrement() {
262
+ count(count() - 1);
263
+ }
264
+
265
+ const hasValues = () => values().length > 0;
266
+
167
267
  return html`
168
268
  <div>
169
- ${this.router(function () {
170
- this.route("/path", View);
269
+ <p>Counter: ${count}</p>
270
+ <div>
271
+ <button @click=${increment}>+1</button>
272
+ <button @click=${decrement}>-1</button>
273
+ </div>
171
274
 
172
- this.route("/nested", Layout, function () {
173
- this.route("/test", Nested); // RouterOutlet passed as children
174
- });
175
- })}
275
+ <div *ref=${refAtom} *if=${x} *unless=${x} *show=${x} *hide=${x} *classes=${x} *styles=${x} *custom=${whatever} />
276
+
277
+ <!-- Property binding -->
278
+ <span .textContent=${x} />
279
+
280
+ <!-- Two way binding of atoms -->
281
+ <input :value=${valueAtom} />
282
+
283
+ <ul *if=${hasValues}>
284
+ ${list(values, (value, index) => {
285
+ return html`<li>${view(SomeComponent, value)}</li>`;
286
+ })}
287
+ </ul>
176
288
  </div>
177
- `;
178
- });
289
+ `.withDirectives({ custom: customDirective });
290
+ }
179
291
 
180
- const CounterStore = store("Counter", function () {
181
- const count = atom(0);
292
+ // In another file...
182
293
 
183
- return {
184
- count: compose(() => get(count)),
294
+ function refDirective(element, fn) {
295
+ fn(element);
296
+ $disconnected(() => {
297
+ fn(undefined);
298
+ });
299
+ }
185
300
 
186
- increment() {
187
- count.value++;
188
- },
189
- decrement() {
190
- count.value--;
191
- },
192
- };
193
- });
301
+ function ifDirective(element, condition) {
302
+ // directives run in microtask immediately after element is attached to parent, before next paint
303
+
304
+ const placeholder = document.createComment("");
305
+
306
+ // $functions work in directives; they hook into the lifecycle of the element
307
+ $effect(() => {
308
+ if (condition()) {
309
+ // show element
310
+ if (!element.parentNode && placeholder.parentNode) {
311
+ element.insertBefore(placeholder.parentNode);
312
+ placeholder.parentNode.removeChild(placeholder);
313
+ }
314
+ } else {
315
+ // hide element
316
+ if (element.parentNode && !placeholder.parentNode) {
317
+ placeholder.insertBefore(element.parentNode);
318
+ element.parentNode.removeChild(element);
319
+ }
320
+ }
321
+ });
322
+ }
194
323
 
195
- const Counter = view("Counter", function () {
196
- const counter = this.attach(CounterStore);
324
+ function unlessDirective(element, condition) {
325
+ return ifDirective(element, () => !condition());
326
+ }
197
327
 
198
- // const { count, increment, decrement } = this.get(CounterStore);
328
+ function showDirective(element, condition) {
329
+ // Store the element's current value.
330
+ let value = element.style.display;
331
+
332
+ $effect(() => {
333
+ if (condition()) {
334
+ // Apply the stored value when truthy.
335
+ element.style.display = value;
336
+ } else {
337
+ // Store value and hide when falsy.
338
+ value = element.style.display;
339
+ element.style.display = "none !important";
340
+ }
341
+ });
342
+ }
199
343
 
200
- return html`
201
- <div>
202
- <span>${counter.count}</span>
344
+ function hideDirective(element, condition) {
345
+ return showDirective(element, () => !condition());
346
+ }
203
347
 
204
- <button onclick=${counter.increment}>Increment</button>
205
- <button onclick=${counter.decrement}>Decrement</button>
206
- </div>
207
- `;
348
+ function classesDirective(element, classes) {
349
+ // TODO: Applies an object of class names and values, where the values may be signals or plain values.
350
+ // Truthy means "apply this class" while falsy means don't.
351
+ }
352
+
353
+ function stylesDirective(element, styles) {
354
+ // TODO: Same idea as *classes but for styles.
355
+ }
356
+
357
+ connect(Component, document.body);
358
+
359
+ // Easy custom elements? Could be another library.
360
+ element("my-counter", function () {
361
+ // Runs just after connectedCallback. `this` is bound to the custom HTMLElement class.
362
+ const shadow = this.attachShadow({ mode: "closed" });
363
+
364
+ return html`<div></div>`;
208
365
  });
366
+
367
+ // function css(strings, values) {
368
+ // return {
369
+ // type: "css",
370
+
371
+ // }
372
+ // }
373
+ ```
374
+
375
+ ```ts
376
+ interface TemplateNode {
377
+ mount(parent: Node, after?: Node): void;
378
+ unmount(skipDOM?: boolean): void;
379
+ }
380
+
381
+ interface TemplateDirective {
382
+ (element: Element, value: unknown): Node | TemplateNode;
383
+ }
209
384
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manyducks.co/dolla",
3
- "version": "2.0.0-alpha.42",
3
+ "version": "2.0.0-alpha.43",
4
4
  "description": "Front-end components, routing and state management.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",