betal-fe 3.0.0 → 4.0.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.
Files changed (3) hide show
  1. package/README.md +317 -0
  2. package/dist/betal-fe.js +425 -43
  3. package/package.json +16 -5
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # Betal-FE
2
+
3
+ A lightweight, modern frontend framework implementing Virtual DOM, reactive components, routing, and slots from the ground up.
4
+
5
+ ## 📦 Installation
6
+
7
+ ```bash
8
+ npm install betal-fe
9
+ ```
10
+
11
+ ## ✨ Features
12
+
13
+ - **Virtual DOM** - Efficient DOM updates through reconciliation
14
+ - **Component-Based** - Reusable UI components with props and state
15
+ - **Reactive State** - Automatic re-rendering on state changes
16
+ - **Lifecycle Hooks** - `onMounted`, `onUnmounted`, `onPropsChange`, `onStateChange`
17
+ - **Slots** - Content projection for flexible component composition (Vue-style)
18
+ - **Hash Router** - Built-in SPA routing with route guards and params
19
+ - **Event System** - Parent-child communication via emit/subscribe
20
+ - **Scheduler** - Async lifecycle execution with microtask batching
21
+
22
+ ## 🚀 Quick Start
23
+
24
+ ### Basic Counter Example
25
+
26
+ ```javascript
27
+ import { createBetalApp, defineComponent, h } from 'betal-fe';
28
+
29
+ const Counter = defineComponent({
30
+ state() {
31
+ return { count: 0 };
32
+ },
33
+
34
+ render() {
35
+ return h("div", {}, [
36
+ h("p", {}, [`Count: ${this.state.count}`]),
37
+ h("button", {
38
+ on: { click: () => this.updateState({ count: this.state.count + 1 }) }
39
+ }, ["Increment"]),
40
+ ]);
41
+ },
42
+ });
43
+
44
+ createBetalApp(Counter).mount(document.getElementById('app'));
45
+ ```
46
+
47
+ ### With Routing
48
+
49
+ ```javascript
50
+ import { createBetalApp, defineComponent, HashRouter, RouterLink, RouterOutlet, hFragment, h } from 'betal-fe';
51
+
52
+ const HomePage = defineComponent({
53
+ render() {
54
+ return h("h1", {}, ["Home Page"]);
55
+ }
56
+ });
57
+
58
+ const AboutPage = defineComponent({
59
+ render() {
60
+ return h("h1", {}, ["About Page"]);
61
+ }
62
+ });
63
+
64
+ const router = new HashRouter([
65
+ { path: '/', component: HomePage },
66
+ { path: '/about', component: AboutPage },
67
+ { path: '/user/:id', component: UserPage },
68
+ ]);
69
+
70
+ const App = defineComponent({
71
+ render() {
72
+ return hFragment([
73
+ h("nav", {}, [
74
+ h(RouterLink, { to: '/' }, ['Home']),
75
+ h(RouterLink, { to: '/about' }, ['About']),
76
+ ]),
77
+ h(RouterOutlet),
78
+ ]);
79
+ }
80
+ });
81
+
82
+ createBetalApp(App, {}, { router }).mount(document.getElementById('app'));
83
+ ```
84
+
85
+ ## 📚 API Reference
86
+
87
+ ### Core Functions
88
+
89
+ #### `createBetalApp(RootComponent, props, context)`
90
+
91
+ Creates and returns an application instance.
92
+
93
+ ```javascript
94
+ const app = createBetalApp(MyApp, { title: 'Hello' }, { router });
95
+ app.mount(document.getElementById('app'));
96
+ ```
97
+
98
+ #### `defineComponent(config)`
99
+
100
+ Defines a component with state, lifecycle hooks, and render method.
101
+
102
+ ```javascript
103
+ const MyComponent = defineComponent({
104
+ state(props) {
105
+ return { count: 0 };
106
+ },
107
+
108
+ onMounted() {
109
+ // Called after component is mounted
110
+ },
111
+
112
+ onUnmounted() {
113
+ // Called before component is unmounted
114
+ },
115
+
116
+ onPropsChange(newProps, oldProps) {
117
+ // Called when props change
118
+ },
119
+
120
+ onStateChange(newState, oldState) {
121
+ // Called after state changes
122
+ },
123
+
124
+ render() {
125
+ return h("div", {}, [/* children */]);
126
+ }
127
+ });
128
+ ```
129
+
130
+ #### `h(tag, props, children)`
131
+
132
+ Creates a virtual DOM element.
133
+
134
+ ```javascript
135
+ h("div", { class: "container", id: "app" }, [
136
+ h("h1", {}, ["Hello World"]),
137
+ h("button", { on: { click: handleClick } }, ["Click Me"])
138
+ ]);
139
+ ```
140
+
141
+ #### `hFragment(children)`
142
+
143
+ Creates a fragment node (renders children without wrapper element).
144
+
145
+ ```javascript
146
+ hFragment([
147
+ h("h1", {}, ["Title"]),
148
+ h("p", {}, ["Content"])
149
+ ]);
150
+ ```
151
+
152
+ #### `hSlot(defaultContent)`
153
+
154
+ Creates a slot for content projection.
155
+
156
+ ```javascript
157
+ const Card = defineComponent({
158
+ render() {
159
+ return h("div", { class: "card" }, [
160
+ h("h3", {}, [this.props.title]),
161
+ hSlot([h("p", {}, ["Default content"])])
162
+ ]);
163
+ }
164
+ });
165
+
166
+ // Usage
167
+ h(Card, { title: "My Card" }, [
168
+ h("p", {}, ["Custom content!"])
169
+ ]);
170
+ ```
171
+
172
+ ### Component Instance Methods
173
+
174
+ #### `this.updateState(newState)`
175
+
176
+ Updates component state and triggers re-render.
177
+
178
+ ```javascript
179
+ this.updateState({ count: this.state.count + 1 });
180
+ ```
181
+
182
+ #### `this.emit(eventName, payload)`
183
+
184
+ Emits an event to parent component.
185
+
186
+ ```javascript
187
+ this.emit('itemAdded', { id: 123, name: 'New Item' });
188
+ ```
189
+
190
+ ### Router
191
+
192
+ #### `new HashRouter(routes, options)`
193
+
194
+ Creates a hash-based router.
195
+
196
+ ```javascript
197
+ const router = new HashRouter([
198
+ { path: '/', component: HomePage },
199
+ { path: '/user/:id', component: UserPage },
200
+ ], {
201
+ beforeEnter: (to, from) => {
202
+ // Route guard - return false to cancel navigation
203
+ return true;
204
+ }
205
+ });
206
+ ```
207
+
208
+ #### Router Context (in components)
209
+
210
+ ```javascript
211
+ this.appContext.router.params // { id: '123' }
212
+ this.appContext.router.query // { tab: 'profile' }
213
+ this.appContext.router.navigateTo('/path')
214
+ ```
215
+
216
+ #### `RouterLink` Component
217
+
218
+ ```javascript
219
+ h(RouterLink, { to: '/about', activeClass: 'active' }, ['About'])
220
+ ```
221
+
222
+ #### `RouterOutlet` Component
223
+
224
+ Renders the current route component.
225
+
226
+ ```javascript
227
+ h(RouterOutlet)
228
+ ```
229
+
230
+ ### Event Dispatcher
231
+
232
+ #### Subscribe to Events
233
+
234
+ ```javascript
235
+ this.appContext.dispatcher.subscribe('eventName', (payload) => {
236
+ console.log('Event received:', payload);
237
+ });
238
+ ```
239
+
240
+ #### Dispatch Events
241
+
242
+ ```javascript
243
+ this.emit('eventName', { data: 'value' });
244
+ ```
245
+
246
+ ## 🎯 Component Lifecycle
247
+
248
+ 1. **Constructor** → Component instance created
249
+ 2. **state()** → Initial state computed
250
+ 3. **render()** → Virtual DOM created
251
+ 4. **Mount** → DOM elements created and inserted
252
+ 5. **onMounted()** → Component is now in the DOM
253
+ 6. **Props/State Change** → Re-render triggered
254
+ 7. **onPropsChange() / onStateChange()** → After update
255
+ 8. **onUnmounted()** → Before removal from DOM
256
+ 9. **Unmount** → DOM cleanup
257
+
258
+ ## 🔧 Props & Attributes
259
+
260
+ ### Event Handlers
261
+
262
+ ```javascript
263
+ h("button", {
264
+ on: {
265
+ click: (event) => console.log('clicked'),
266
+ input: (event) => this.updateState({ value: event.target.value })
267
+ }
268
+ }, ["Click Me"]);
269
+ ```
270
+
271
+ ### Class Binding
272
+
273
+ ```javascript
274
+ h("div", {
275
+ class: "container active",
276
+ // or
277
+ className: "container active"
278
+ }, [/* children */]);
279
+ ```
280
+
281
+ ### Style Binding
282
+
283
+ ```javascript
284
+ h("div", {
285
+ style: "color: red; font-size: 16px;"
286
+ }, [/* children */]);
287
+ ```
288
+
289
+ ### Other Attributes
290
+
291
+ ```javascript
292
+ h("input", {
293
+ type: "text",
294
+ value: this.state.inputValue,
295
+ placeholder: "Enter text",
296
+ disabled: true
297
+ });
298
+ ```
299
+
300
+ ## 📖 Examples
301
+
302
+ Check out the [GitHub repository](https://github.com/yourusername/betal-fe) for complete examples including:
303
+ - Todo app
304
+
305
+ ## 🤝 Contributing
306
+
307
+ Contributions are welcome! Visit the [GitHub repository](https://github.com/yourusername/betal-fe) for contribution guidelines.
308
+
309
+ ## 📄 License
310
+
311
+ MIT - Feel free to fork, modify, and build upon this project!
312
+
313
+ ## 🔗 Links
314
+
315
+ - [GitHub Repository](https://github.com/SherlockHolmes07/betal-fe)
316
+ - [npm Package](https://www.npmjs.com/package/betal-fe)
317
+ - [Report Issues](https://github.com/SherlockHolmes07/betal-fe/issues)
package/dist/betal-fe.js CHANGED
@@ -141,9 +141,12 @@ const DOM_TYPES = {
141
141
  ELEMENT: "element",
142
142
  FRAGMENT: "fragment",
143
143
  COMPONENT: "component",
144
+ SLOT: "slot",
144
145
  };
146
+ let hSlotCalled = false;
145
147
  function h(tag, props = {}, children = []) {
146
- const type = typeof tag === "string" ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT;
148
+ const type =
149
+ typeof tag === "string" ? DOM_TYPES.ELEMENT : DOM_TYPES.COMPONENT;
147
150
  return {
148
151
  type,
149
152
  tag,
@@ -152,9 +155,7 @@ function h(tag, props = {}, children = []) {
152
155
  };
153
156
  }
154
157
  function mapTextNodes(nodes) {
155
- return nodes.map((node) =>
156
- typeof node === "string" ? hString(node) : node
157
- );
158
+ return nodes.map((node) => (typeof node === "string" ? hString(node) : node));
158
159
  }
159
160
  function hString(str) {
160
161
  return { type: DOM_TYPES.TEXT, value: str };
@@ -165,6 +166,16 @@ function hFragment(vNodes) {
165
166
  children: mapTextNodes(withoutNulls(vNodes)),
166
167
  };
167
168
  }
169
+ function didCreateSlot() {
170
+ return hSlotCalled;
171
+ }
172
+ function resetDidCreateSlot() {
173
+ hSlotCalled = false;
174
+ }
175
+ function hSlot(children = []) {
176
+ hSlotCalled = true;
177
+ return { type: DOM_TYPES.SLOT, children };
178
+ }
168
179
  function extractChildren(vdom) {
169
180
  if (vdom.children == null) {
170
181
  return [];
@@ -249,6 +260,39 @@ function removeEventListeners(listeners = {}, el) {
249
260
  });
250
261
  }
251
262
 
263
+ let isScheduled = false;
264
+ const jobs = [];
265
+ function enqueueJob(job) {
266
+ jobs.push(job);
267
+ scheduleUpdate();
268
+ }
269
+ function scheduleUpdate() {
270
+ if (isScheduled) return;
271
+ isScheduled = true;
272
+ queueMicrotask(processJobs);
273
+ }
274
+ function processJobs() {
275
+ while (jobs.length > 0) {
276
+ const job = jobs.shift();
277
+ const result = job();
278
+ Promise.resolve(result).then(
279
+ () => {
280
+ },
281
+ (error) => {
282
+ console.error(`[scheduler]: ${error}`);
283
+ }
284
+ );
285
+ }
286
+ isScheduled = false;
287
+ }
288
+ function nextTick() {
289
+ scheduleUpdate();
290
+ return flushPromises();
291
+ }
292
+ function flushPromises() {
293
+ return new Promise((resolve) => setTimeout(resolve));
294
+ }
295
+
252
296
  function extractPropsAndEvents(vdom) {
253
297
  const { on: events = {}, ...props } = vdom.props;
254
298
  delete props.key;
@@ -267,6 +311,7 @@ function mountDOM(vDom, parentElement, index, hostComponent = null) {
267
311
  }
268
312
  case DOM_TYPES.COMPONENT: {
269
313
  createComponentNode(vDom, parentElement, index, hostComponent);
314
+ enqueueJob(() => vDom.component.onMounted());
270
315
  break;
271
316
  }
272
317
  case DOM_TYPES.FRAGMENT: {
@@ -320,9 +365,11 @@ function insert(el, parentEl, index) {
320
365
  }
321
366
  }
322
367
  function createComponentNode(vdom, parentEl, index, hostComponent) {
323
- const Component = vdom.tag;
368
+ const { tag: Component, children } = vdom;
324
369
  const { props, events } = extractPropsAndEvents(vdom);
325
370
  const component = new Component(props, events, hostComponent);
371
+ component.setExternalContent(children);
372
+ component.setAppContext(hostComponent?.appContext ?? {});
326
373
  component.mount(parentEl, index);
327
374
  vdom.component = component;
328
375
  vdom.el = component.firstElement;
@@ -345,6 +392,7 @@ function destroyDOM(vDom) {
345
392
  }
346
393
  case DOM_TYPES.COMPONENT: {
347
394
  vDom.component.unmount();
395
+ enqueueJob(() => vDom.component.onUnmounted());
348
396
  break;
349
397
  }
350
398
  default: {
@@ -371,10 +419,259 @@ function removeFragmentNodes(vDom) {
371
419
  children.forEach(destroyDOM);
372
420
  }
373
421
 
374
- function createApp(RootComponent, props = {}) {
422
+ class Dispatcher {
423
+ #subs = new Map();
424
+ #afterHandlers = [];
425
+ subscribe(commandName, handler) {
426
+ if (!this.#subs.has(commandName)) {
427
+ this.#subs.set(commandName, []);
428
+ }
429
+ const handlers = this.#subs.get(commandName);
430
+ if (handlers.includes(handler)) {
431
+ return () => {};
432
+ }
433
+ handlers.push(handler);
434
+ return () => {
435
+ const idx = handlers.indexOf(handler);
436
+ handlers.splice(idx, 1);
437
+ };
438
+ }
439
+ afterEveryCommand(handler) {
440
+ this.#afterHandlers.push(handler);
441
+ return () => {
442
+ const idx = this.#afterHandlers.indexOf(handler);
443
+ this.#afterHandlers.splice(idx, 1);
444
+ };
445
+ }
446
+ dispatch(commandName, payload) {
447
+ if (this.#subs.has(commandName)) {
448
+ this.#subs.get(commandName).forEach((handler) => handler(payload));
449
+ }
450
+ else {
451
+ console.warn(`No handlers for command: ${commandName}`);
452
+ }
453
+ this.#afterHandlers.forEach((handler) => handler());
454
+ }
455
+ }
456
+
457
+ const CATCH_ALL_ROUTE = "*";
458
+ function makeRouteMatcher(route) {
459
+ return routeHasParams(route)
460
+ ? makeMatcherWithParams(route)
461
+ : makeMatcherWithoutParams(route);
462
+ }
463
+ function routeHasParams({ path }) {
464
+ return path.includes(":");
465
+ }
466
+ function makeMatcherWithParams(route) {
467
+ const regex = makeRouteWithParamsRegex(route);
468
+ const isRedirect = typeof route.redirect === "string";
469
+ return {
470
+ route,
471
+ isRedirect,
472
+ checkMatch(path) {
473
+ return regex.test(path);
474
+ },
475
+ extractParams(path) {
476
+ const { groups } = regex.exec(path);
477
+ return groups;
478
+ },
479
+ extractQuery,
480
+ };
481
+ }
482
+ function makeRouteWithParamsRegex({ path }) {
483
+ const regex = path.replace(
484
+ /:([^/]+)/g,
485
+ (_, paramName) => `(?<${paramName}>[^/]+)`
486
+ );
487
+ return new RegExp(`^${regex}$`);
488
+ }
489
+ function makeMatcherWithoutParams(route) {
490
+ const regex = makeRouteWithoutParamsRegex(route);
491
+ const isRedirect = typeof route.redirect === "string";
492
+ return {
493
+ route,
494
+ isRedirect,
495
+ checkMatch(path) {
496
+ return regex.test(path);
497
+ },
498
+ extractParams() {
499
+ return {};
500
+ },
501
+ extractQuery,
502
+ };
503
+ }
504
+ function makeRouteWithoutParamsRegex({ path }) {
505
+ if (path === CATCH_ALL_ROUTE) {
506
+ return new RegExp("^.*$");
507
+ }
508
+ return new RegExp(`^${path}$`);
509
+ }
510
+ function extractQuery(path) {
511
+ const queryIndex = path.indexOf("?");
512
+ if (queryIndex === -1) {
513
+ return {};
514
+ }
515
+ const search = new URLSearchParams(path.slice(queryIndex + 1));
516
+ return Object.fromEntries(search.entries());
517
+ }
518
+
519
+ function assert(condition, message = 'Assertion failed') {
520
+ if (!condition) {
521
+ throw new Error(message)
522
+ }
523
+ }
524
+
525
+ const ROUTER_EVENT = "router-event";
526
+ class HashRouter {
527
+ #isInitialized = false;
528
+ #matchers = [];
529
+ #matchedRoute = null;
530
+ #dispatcher = new Dispatcher();
531
+ #subscriptions = new WeakMap();
532
+ #subscriberFns = new Set();
533
+ get matchedRoute() {
534
+ return this.#matchedRoute;
535
+ }
536
+ #params = {};
537
+ get params() {
538
+ return this.#params;
539
+ }
540
+ #query = {};
541
+ get query() {
542
+ return this.#query;
543
+ }
544
+ get #currentRouteHash() {
545
+ const hash = document.location.hash;
546
+ if (hash === "") {
547
+ return "/";
548
+ }
549
+ return hash.slice(1);
550
+ }
551
+ #onPopState = () => this.#matchCurrentRoute();
552
+ constructor(routes = []) {
553
+ assert(Array.isArray(routes), "Routes must be an array");
554
+ this.#matchers = routes.map(makeRouteMatcher);
555
+ }
556
+ async init() {
557
+ if (this.#isInitialized) {
558
+ return;
559
+ }
560
+ if (document.location.hash === "") {
561
+ window.history.replaceState({}, "", "#/");
562
+ }
563
+ window.addEventListener("popstate", this.#onPopState);
564
+ await this.#matchCurrentRoute();
565
+ this.#isInitialized = true;
566
+ }
567
+ destroy() {
568
+ if (!this.#isInitialized) {
569
+ return;
570
+ }
571
+ window.removeEventListener("popstate", this.#onPopState);
572
+ Array.from(this.#subscriberFns).forEach(this.unsubscribe, this);
573
+ this.#isInitialized = false;
574
+ }
575
+ async navigateTo(path) {
576
+ const matcher = this.#matchers.find((matcher) => matcher.checkMatch(path));
577
+ if (matcher == null) {
578
+ console.warn(`[Router] No route matches path "${path}"`);
579
+ this.#matchedRoute = null;
580
+ this.#params = {};
581
+ this.#query = {};
582
+ return;
583
+ }
584
+ if (matcher.isRedirect) {
585
+ return this.navigateTo(matcher.route.redirect);
586
+ }
587
+ const from = this.#matchedRoute;
588
+ const to = matcher.route;
589
+ const { shouldNavigate, shouldRedirect, redirectPath } =
590
+ await this.#canChangeRoute(from, to);
591
+ if (shouldRedirect) {
592
+ return this.navigateTo(redirectPath);
593
+ }
594
+ if (shouldNavigate) {
595
+ this.#matchedRoute = matcher.route;
596
+ this.#params = matcher.extractParams(path);
597
+ this.#query = matcher.extractQuery(path);
598
+ this.#pushState(path);
599
+ this.#dispatcher.dispatch(ROUTER_EVENT, { from, to, router: this });
600
+ }
601
+ }
602
+ back() {
603
+ window.history.back();
604
+ }
605
+ forward() {
606
+ window.history.forward();
607
+ }
608
+ subscribe(handler) {
609
+ const unsubscribe = this.#dispatcher.subscribe(ROUTER_EVENT, handler);
610
+ this.#subscriptions.set(handler, unsubscribe);
611
+ this.#subscriberFns.add(handler);
612
+ }
613
+ unsubscribe(handler) {
614
+ const unsubscribe = this.#subscriptions.get(handler);
615
+ if (unsubscribe) {
616
+ unsubscribe();
617
+ this.#subscriptions.delete(handler);
618
+ this.#subscriberFns.delete(handler);
619
+ }
620
+ }
621
+ #pushState(path) {
622
+ window.history.pushState({}, "", `#${path}`);
623
+ }
624
+ #matchCurrentRoute() {
625
+ return this.navigateTo(this.#currentRouteHash);
626
+ }
627
+ async #canChangeRoute(from, to) {
628
+ const guard = to.beforeEnter;
629
+ if (typeof guard !== "function") {
630
+ return {
631
+ shouldRedirect: false,
632
+ shouldNavigate: true,
633
+ redirectPath: null,
634
+ };
635
+ }
636
+ const result = await guard(from?.path, to?.path);
637
+ if (result === false) {
638
+ return {
639
+ shouldRedirect: false,
640
+ shouldNavigate: false,
641
+ redirectPath: null,
642
+ };
643
+ }
644
+ if (typeof result === "string") {
645
+ return {
646
+ shouldRedirect: true,
647
+ shouldNavigate: false,
648
+ redirectPath: result,
649
+ };
650
+ }
651
+ return {
652
+ shouldRedirect: false,
653
+ shouldNavigate: true,
654
+ redirectPath: null,
655
+ };
656
+ }
657
+ }
658
+ class NoopRouter {
659
+ init() {}
660
+ destroy() {}
661
+ navigateTo() {}
662
+ back() {}
663
+ forward() {}
664
+ subscribe() {}
665
+ unsubscribe() {}
666
+ }
667
+
668
+ function createBetalApp(RootComponent, props = {}, options = {}) {
375
669
  let parentEl = null;
376
670
  let isMounted = false;
377
671
  let vdom = null;
672
+ const context = {
673
+ router: options.router || new NoopRouter(),
674
+ };
378
675
  function reset() {
379
676
  parentEl = null;
380
677
  isMounted = false;
@@ -387,7 +684,8 @@ function createApp(RootComponent, props = {}) {
387
684
  }
388
685
  parentEl = _parentEl;
389
686
  vdom = h(RootComponent, props);
390
- mountDOM(vdom, parentEl);
687
+ mountDOM(vdom, parentEl, null, { appContext: context });
688
+ context.router.init();
391
689
  isMounted = true;
392
690
  },
393
691
  unmount() {
@@ -395,6 +693,7 @@ function createApp(RootComponent, props = {}) {
395
693
  throw new Error("The application is not mounted");
396
694
  }
397
695
  destroyDOM(vdom);
696
+ context.router.destroy();
398
697
  reset();
399
698
  },
400
699
  };
@@ -585,15 +884,16 @@ function patchStyles(el, oldStyle = {}, newStyle = {}) {
585
884
  }
586
885
  function patchEvents(el, oldListeners = {}, oldEvents = {}, newEvents = {}, hostComponent) {
587
886
  const { removed, added, updated } = objectsDiff(oldEvents, newEvents);
887
+ const listeners = { ...oldListeners };
588
888
  for (const eventName of removed.concat(updated)) {
589
889
  el.removeEventListener(eventName, oldListeners[eventName]);
890
+ delete listeners[eventName];
590
891
  }
591
- const addedListeners = {};
592
892
  for (const eventName of added.concat(updated)) {
593
893
  const listener = addEventListener(eventName, newEvents[eventName], el, hostComponent);
594
- addedListeners[eventName] = listener;
894
+ listeners[eventName] = listener;
595
895
  }
596
- return addedListeners;
896
+ return listeners;
597
897
  }
598
898
  function patchChildren(oldVdom, newVdom, hostComponent) {
599
899
  const oldChildren = extractChildren(oldVdom);
@@ -630,48 +930,53 @@ function patchChildren(oldVdom, newVdom, hostComponent) {
630
930
  }
631
931
  function patchComponent(oldVdom, newVdom) {
632
932
  const { component } = oldVdom;
933
+ const { children } = newVdom;
633
934
  const { props } = extractPropsAndEvents(newVdom);
935
+ component.setExternalContent(children);
634
936
  component.updateProps(props);
635
937
  newVdom.component = component;
636
938
  newVdom.el = component.firstElement;
637
939
  }
638
940
 
639
- class Dispatcher {
640
- #subs = new Map();
641
- #afterHandlers = [];
642
- subscribe(commandName, handler) {
643
- if (!this.#subs.has(commandName)) {
644
- this.#subs.set(commandName, []);
645
- }
646
- const handlers = this.#subs.get(commandName);
647
- if (handlers.includes(handler)) {
648
- return () => {};
649
- }
650
- handlers.push(handler);
651
- return () => {
652
- const idx = handlers.indexOf(handler);
653
- handlers.splice(idx, 1);
654
- };
941
+ function traverseDFS(
942
+ vdom,
943
+ processNode,
944
+ shouldSkipBranch = () => false,
945
+ parentNode = null,
946
+ index = null
947
+ ) {
948
+ if (shouldSkipBranch(vdom)) return;
949
+ processNode(vdom, parentNode, index);
950
+ if (vdom.children) {
951
+ vdom.children.forEach((child, i) =>
952
+ traverseDFS(child, processNode, shouldSkipBranch, vdom, i)
953
+ );
655
954
  }
656
- afterEveryCommand(handler) {
657
- this.#afterHandlers.push(handler);
658
- return () => {
659
- const idx = this.#afterHandlers.indexOf(handler);
660
- this.#afterHandlers.splice(idx, 1);
661
- };
955
+ }
956
+
957
+ function fillSlots(vdom, externalContent = []) {
958
+ function processNode(node, parent, index) {
959
+ insertViewInSlot(node, parent, index, externalContent);
662
960
  }
663
- dispatch(commandName, payload) {
664
- if (this.#subs.has(commandName)) {
665
- this.#subs.get(commandName).forEach((handler) => handler(payload));
666
- }
667
- else {
668
- console.warn(`No handlers for command: ${commandName}`);
669
- }
670
- this.#afterHandlers.forEach((handler) => handler());
961
+ traverseDFS(vdom, processNode, shouldSkipBranch);
962
+ }
963
+ function insertViewInSlot(node, parent, index, externalContent) {
964
+ if (node.type !== DOM_TYPES.SLOT) return;
965
+ const defaultContent = node.children;
966
+ const views = externalContent.length > 0 ? externalContent : defaultContent;
967
+ const hasContent = views.length > 0;
968
+ if (hasContent) {
969
+ parent.children.splice(index, 1, hFragment(views));
970
+ } else {
971
+ parent.children.splice(index, 1);
671
972
  }
672
973
  }
974
+ function shouldSkipBranch(node) {
975
+ return node.type === DOM_TYPES.COMPONENT;
976
+ }
673
977
 
674
- function defineComponent({ render, state, ...methods }) {
978
+ const emptyFunction = () => {};
979
+ function defineComponent({ render, state, onMounted = emptyFunction, onUnmounted = emptyFunction, onPropsChange = emptyFunction, onStateChange = emptyFunction, ...methods }) {
675
980
  class Component {
676
981
  #vdom = null;
677
982
  #isMounted = false;
@@ -680,18 +985,29 @@ function defineComponent({ render, state, ...methods }) {
680
985
  #parentComponent = null;
681
986
  #dispatcher = new Dispatcher();
682
987
  #subscriptions = [];
988
+ #children = [];
989
+ #appContext = null;
683
990
  constructor(props = {}, eventHandlers = {}, parentComponent = null) {
684
991
  this.props = props;
685
992
  this.state = state ? state(props) : {};
686
993
  this.#eventHandlers = eventHandlers;
687
994
  this.#parentComponent = parentComponent;
688
995
  }
996
+ setExternalContent(children) {
997
+ this.#children = children;
998
+ }
689
999
  updateState(newState) {
690
1000
  this.state = { ...this.state, ...newState };
691
1001
  this.#patch();
1002
+ enqueueJob(() => this.onStateChange());
692
1003
  }
693
1004
  render() {
694
- return render.call(this);
1005
+ const vdom = render.call(this);
1006
+ if (didCreateSlot()) {
1007
+ fillSlots(vdom, this.#children);
1008
+ resetDidCreateSlot();
1009
+ }
1010
+ return vdom;
695
1011
  }
696
1012
  mount(hostEl, index = null) {
697
1013
  if (this.#isMounted) {
@@ -714,17 +1030,37 @@ function defineComponent({ render, state, ...methods }) {
714
1030
  this.#hostEl = null;
715
1031
  this.#isMounted = false;
716
1032
  }
1033
+ onMounted() {
1034
+ return Promise.resolve(onMounted.call(this));
1035
+ }
1036
+ onUnmounted() {
1037
+ return Promise.resolve(onUnmounted.call(this));
1038
+ }
1039
+ onPropsChange(newProps, oldProps) {
1040
+ return Promise.resolve(onPropsChange.call(this, newProps, oldProps));
1041
+ }
1042
+ onStateChange() {
1043
+ return Promise.resolve(onStateChange.call(this));
1044
+ }
717
1045
  updateProps(props) {
718
1046
  const newProps = { ...this.props, ...props };
719
1047
  if (equal(this.props, newProps)) {
720
1048
  return;
721
1049
  }
1050
+ const oldProps = this.props;
722
1051
  this.props = newProps;
723
1052
  this.#patch();
1053
+ enqueueJob(() => this.onPropsChange(this.props, oldProps));
724
1054
  }
725
1055
  emit(eventName, payload) {
726
1056
  this.#dispatcher.dispatch(eventName, payload);
727
1057
  }
1058
+ setAppContext(appContext) {
1059
+ this.#appContext = appContext;
1060
+ }
1061
+ get appContext() {
1062
+ return this.#appContext;
1063
+ }
728
1064
  #patch() {
729
1065
  if (!this.#isMounted) {
730
1066
  throw new Error("Component is not mounted");
@@ -779,4 +1115,50 @@ function defineComponent({ render, state, ...methods }) {
779
1115
  return Component;
780
1116
  }
781
1117
 
782
- export { createApp, defineComponent, h, hFragment, hString };
1118
+ const RouterLink = defineComponent({
1119
+ render() {
1120
+ const { to } = this.props;
1121
+ return h(
1122
+ "a",
1123
+ {
1124
+ href: to,
1125
+ on: {
1126
+ click: (e) => {
1127
+ e.preventDefault();
1128
+ this.appContext.router.navigateTo(to);
1129
+ },
1130
+ },
1131
+ },
1132
+ [hSlot()]
1133
+ );
1134
+ },
1135
+ });
1136
+ const RouterOutlet = defineComponent({
1137
+ state() {
1138
+ return {
1139
+ matchedRoute: null,
1140
+ subscription: null,
1141
+ };
1142
+ },
1143
+ onMounted() {
1144
+ const subscription = this.appContext.router.subscribe(({ to }) => {
1145
+ this.handleRouteChange(to);
1146
+ });
1147
+ this.updateState({ subscription });
1148
+ },
1149
+ onUnmounted() {
1150
+ const { subscription } = this.state;
1151
+ this.appContext.router.unsubscribe(subscription);
1152
+ },
1153
+ handleRouteChange(matchedRoute) {
1154
+ this.updateState({ matchedRoute });
1155
+ },
1156
+ render() {
1157
+ const { matchedRoute } = this.state;
1158
+ return h("div", { id: "router-outlet" }, [
1159
+ matchedRoute ? h(matchedRoute.component) : null,
1160
+ ]);
1161
+ },
1162
+ });
1163
+
1164
+ export { HashRouter, RouterLink, RouterOutlet, createBetalApp, defineComponent, h, hFragment, hSlot, hString, nextTick };
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "betal-fe",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "type": "module",
5
5
  "main": "dist/betal-fe.js",
6
6
  "files": [
7
- "dist/betal-fe.js"
7
+ "dist/betal-fe.js",
8
+ "README.md"
8
9
  ],
9
10
  "scripts": {
10
11
  "build": "rollup -c",
@@ -14,10 +15,20 @@
14
15
  "test": "vitest",
15
16
  "test:run": "vitest --run"
16
17
  },
17
- "keywords": [],
18
+ "keywords": [
19
+ "framework",
20
+ "virtual-dom",
21
+ "vdom",
22
+ "components",
23
+ "reactive",
24
+ "router",
25
+ "slots",
26
+ "spa",
27
+ "frontend"
28
+ ],
18
29
  "author": "",
19
- "license": "ISC",
20
- "description": "",
30
+ "license": "MIT",
31
+ "description": "A lightweight frontend framework with Virtual DOM, reactive components, routing, and slots",
21
32
  "devDependencies": {
22
33
  "@eslint/js": "^9.38.0",
23
34
  "@rollup/plugin-commonjs": "^29.0.0",