diff --git a/README.md b/README.md index 433c9306..5e059ca4 100644 --- a/README.md +++ b/README.md @@ -2,104 +2,42 @@ ⚠️ Expect API changes until v1.0.0 ⚠️ -Current version: 0.2.1. Follows [semver](https://semver.org/). +Current version: 0.3.0. Follows [semver](https://semver.org/). -Bundle Size: 12kb minified & gzipped. +Bundle Size: 14kb minified & gzipped. -A minimalist & flexible toolkit for interactive islands & state management in hypermedia-driven web applications. +A minimalist yet powerful toolkit for interactive islands in web applications. -## Table of Contents +## Features include: -- [Motivation](#motivation) -- [Key Features](#key-features) -- [Who is this for?](#who-is-this-for) -- [Philosophy](#philosophy) -- [Get Started](#get-started--view-examples) -- [Key Concepts / API](#key-concepts--api) - - [ReactiveElement Class, Observable Objects, and HTML Tagged Templates](#reactiveelement-class-observable-objects-and-html-tagged-templates) - - [Basics of Observables & Templates](#basics-of-observables--templates) - - [Basics of Computed Properties & Effects](#basics-of-computed-properties--effects) - - [cami.store(initialState)](#camistoreinitialstate) - - [html](#html) -- [Examples](#examples) -- [Dev Usage](#dev-usage) +* **Reactive Web Components**: Offers `ReactiveElement`, an extension of `HTMLElement` that automatically defines observables without any boilerplate. It also supports deeply nested updates, array manipulations, and observable attributes. +* **Async State Management**: It allows you to fetch, cache, and update data from APIs with ease. The `query` method enables fetching data and caching it with configurable options such as stale time and refetch intervals. The `mutation` method offers a way to perform data mutations and optimistically update the UI for a seamless user experience. +* **Streams & Functional Reactive Programming (FRP)**: Uses Observable Streams for managing asynchronous events, enabling complex event processing with methods like `map`, `filter`, `debounce`, and more. This allows for elegant composition of asynchronous logic. +* **Cross-component State Management with a Singleton Store**: Uses a singleton store to share state between components. Redux DevTools compatible. +* **Dependency Tracking**: Uses a dependency tracker to track dependencies between observables and automatically update them when their dependencies change. -## Motivation - -I wanted a minimalist javascript library that has no build steps, great debuggability, and didn't take over my front-end. - -My workflow is simple: I want to start any application with normal HTML/CSS, and if there were fragments or islands that needed to be interactive (such as dashboards & calculators), I needed a powerful enough library that I could easily drop in without rewriting my whole front-end. Unfortunately, the latter is the case for the majority of javascript libraries out there. - -That said, I like the idea of declarative templates, uni-directional data flow, time-travel debugging, and fine-grained reactivity. But I wanted no build steps (or at least make 'no build' the default). So I created Cami. - -## Key Features: - -- Reactive Web Components: We suggest to start any web application with normal HTML/CSS, then add interactive islands with Cami's reactive web components. Uses fine-grained reactivity with observables, computed properties, and effects. Also supports for deeply nested updates. Uses the Light DOM instead of Shadow DOM. -- Tagged Templates: Declarative templates with lit-html. Supports event handling, attribute binding, composability, caching, and expressions. -- Store / State Management: When you have multiple islands, you can use a singleton store to share state between them, and it acts as a single source of truth for your application state. Redux DevTools compatible. -- Easy Immutable Updates: Uses Immer under the hood, so you can update your state immutably without excessive boilerplate. -- Anti-Features: You can't be everything to everybody. So we made some hard choices: No Build Steps, No Client-Side Router, No JSX, No Shadow DOM. We want you to build an MPA, with mainly HTML/CSS, and return HTML responses instead of JSON. Then add interactivity as needed. - -## Who is this for? - -- **Lean Teams or Solo Devs**: If you're building a small to medium-sized application, I built Cami with that in mind. You can start with `ReactiveElement`, and once you need to share state between components, you can add our store. It's a great choice for rich data tables, dashboards, calculators, and other interactive islands. If you're working with large applications with large teams, you may want to consider other frameworks. -- **Developers of Multi-Page Applications**: For folks who have an existing server-rendered application, you can use Cami to add interactivity to your application, along with other MPA-oriented libraries like HTMX, Unpoly, Turbo, or TwinSpark. - -## Philosophy +Below covers three examples: a simple counter component to get you started, a shopping cart component to demonstrate server & client state management, and a registration form that demonstrates streams & functional reactive programming. -- **Less Code is Better**: In any organization, large or small, team shifts are inevitable. It's important that the codebase is easy to understand and maintain. This allows any enterprising developer to jump in, and expand the codebase that fits their specific problems. - -## Get Started & View Examples - -To see some examples, just do the following: - -```bash -git clone git@github.com:kennyfrc/cami.js.git -cd cami.js -bun install --global serve -bunx serve -``` - -Open http://localhost:3000 in your browser, then navigate to the examples folder. In the examples folder, you will find a series of examples that illustrate the key concepts of Cami.js. These examples are numbered & ordered by complexity. +## Counter Component -## Key Concepts / API - -### `ReactiveElement Class`, `Observable` Objects, and `HTML Tagged Templates` - -`ReactiveElement` is a class that extends `HTMLElement` to create reactive web components. These components can automatically update their view (the `template`) when their state changes. - -Automatic updates are done by observables. An observable is an object that can be observed for state changes, and when it changes, it triggers an effect (a function that runs in response to changes in observables). - -Cami's observables have the following characteristics: - -* They can be accessed and mutated like any other data structure. The difference is that observables, upon mutation, notify observers. -* The observer in the `ReactiveElement` case is the `html tagged template` or the `template()` method to be specific. - -When you mutate the value of an observable, it will automatically trigger a re-render of the component's `html tagged template`. This is what makes the component reactive. - -Let's illustrate these three concepts with an example. Here's a simple counter component: +Notice that you don't have to define observables or effects. You can just directly mutate the state, and the component will automatically update its view. Loading, error, and data states are also handled automatically. ```html +

Counter

- + - + ``` -In this example, `CounterElement` is a reactive web component that maintains an internal state (`count`) and updates its view whenever the state changes. +## Shopping Cart Component -### Basics of Observables & Templates +Notice that product data is fetched through a query (server state), and the cart data is managed through a shared store (client state). -**Creating an Observable:** - -In the context of a `ReactiveElement`, you can create an observable using the `this.setup()` method. For example, to create an observable `count` with an initial value of `0`, you would do: - -```javascript -// ... -count = 0 - -constructor() { - super(); - this.setup({ - observables: ['count'], - }) -} -// ... -``` +```html + +
+

Products

+

This fetches the products from an API, and uses a client-side store to manage the cart.

+ +
+
+

Cart

+ +
-**Getting the Value:** + + + + ``` -In the first example, only the `first` property of the `name` object is updated. The rest of the `user` object remains the same. In the second example, various array manipulations are performed on the `playlist` observable. +## Registration Form Component -**Template Rendering:** +Notice that the email input is debounced. This is to prevent unnecessary API calls. Loading states are also handled automatically. -Cami.js uses lit-html for its template rendering. This allows you to write HTML templates in JavaScript using tagged template literals. Think of it as fancy string interpolation. - -Here's the example from above: - -```javascript - template() { - return html` - -

Count: ${this.count}

- `; - } -``` +```html + +
+

Registration Form

+ +
+ +

Try entering an email that is already taken, such as geovanniheaney@block.info (mock email)

+
+ + + + ``` -In this case, `countSquared` will always hold the square of the current count value, and will automatically update whenever `count` changes. - -**Effects:** - -Effects in Cami.js are functions that run in response to changes in observable properties. They are a great way to handle side effects in your application, such as integrating with non-reactive components, emitting custom events, or logging/debugging. +## Motivation -Here is an example of an effect that logs the current count and its square whenever either of them changes: +I wanted a minimalist javascript library that has no build steps, great debuggability, and didn't take over my front-end. -```javascript -this.effect(() => console.log(`Count: ${this.count} & Count Squared: ${this.countSquared}`)); -``` +My workflow is simple: I want to start any application with normal HTML/CSS, and if there were fragments or islands that needed to be interactive (such as dashboards & calculators), I needed a powerful enough library that I could easily drop in without rewriting my whole front-end. Unfortunately, the latter is the case for the majority of javascript libraries out there. -This effect will run whenever `count` or `countSquared` changes, logging the new values to the console. +That said, I like the idea of declarative templates, uni-directional data flow, time-travel debugging, and fine-grained reactivity. But I wanted no build steps (or at least make 'no build' the default). So I created Cami. -**Observable Attributes:** +## Who is this for? -Observable attributes in Cami.js are attributes that are observed for changes. When an attribute changes, the component's state is updated and the component is re-rendered. +- **Lean Teams or Solo Devs**: If you're building a small to medium-sized application, I built Cami with that in mind. You can start with `ReactiveElement`, and once you need to share state between components, you can add our store. It's a great choice for rich data tables, dashboards, calculators, and other interactive islands. If you're working with large applications with large teams, you may want to consider other frameworks. +- **Developers of Multi-Page Applications**: For folks who have an existing server-rendered application, you can use Cami to add interactivity to your application. -Here is an example of an observable attribute `todos` which is parsed from a JSON string: +## Get Started & View Examples -```javascript -todos = [] +To see some examples, just do the following: -constructor() { - super(); - this.setup({ - observables: ['todos'], - attributes: { - todos: (v) => JSON.parse(v).data - } - }); -} +```bash +git clone git@github.com:kennyfrc/cami.js.git +cd cami.js +bun install --global serve +bunx serve ``` -In this case, `todos` will be updated whenever the `todos` attribute of the element changes. - -**Queries:** +Open http://localhost:3000 in your browser, then navigate to the examples folder. In the examples folder, you will find a series of examples that illustrate the key concepts of Cami.js. These examples are numbered & ordered by complexity. -Queries in Cami.js are a way to fetch data asynchronously and update the component's state when the data is available. The state is automatically updated with the loading status, the data, and any error that might occur. +## Learn Cami by Example -Here is an example of a query that fetches posts from a JSON placeholder API: +In this tutorial, we'll walk through creating a simple Task Manager component using Cami.js. By the end, you'll understand how to create interactive components with Cami's reactive system. -```javascript -posts = {} +### Step 1: Creating a Basic Component -constructor() { - super(); - this.setup({ - observables: ['posts'], - }); -} +Let's start by creating a basic structure for our Task Manager component. We'll define the HTML structure and include the necessary scripts to use Cami.js. -connectedCallback() { - super.connectedCallback(); - this.posts = this.query({ - queryKey: ["posts"], - queryFn: () => fetch("https://jsonplaceholder.typicode.com/posts").then(posts => posts.json()) - }); -} -``` +```html +
+

Task Manager

+ +
+ + + + +``` -The `cami.store` function is a core part of Cami.js. It creates a new store with the provided initial state. The store is a singleton, meaning that if it has already been created, the existing instance will be returned. This store is a central place where all the state of your application lives. It's like a data warehouse where different components of your application can communicate and share data. +### Step 2: Adding Observables -This concept is particularly useful in scenarios where multiple components need to share and manipulate the same state. A classic example of this is a shopping cart in an e-commerce application, where various components like product listing, cart summary, and checkout need access to the same cart state. +Now, let's add some reactivity to our component. We'll use observables to manage the tasks and the current filter state. -The store follows a flavor of the Flux architecture, which promotes unidirectional data flow. The cycle goes as follows: call a function -> update the store -> reflect changes in the view -> call another function. In addition, as we adhere to many of Redux's principles, our store is compatible with the Redux DevTools Chrome extension, which allows for time-travel debugging. +```javascript +class TaskManagerElement extends ReactiveElement { + tasks = []; // This observable array will hold our tasks + filter = 'all'; // This observable string will control the current filter + + // ... rest of the component + + getFilteredTasks() { + // This method will filter tasks based on the current filter state + switch (this.filter) { + case 'completed': + return this.tasks.filter(task => task.completed); + case 'active': + return this.tasks.filter(task => !task.completed); + default: + return this.tasks; + } + } -**Parameters:** + // ... rest of the component +} +``` -- `initialState` (Object): The initial state of the store. This is the starting point of your application state and can be any valid JavaScript object. +### Step 3: Adding Behavior -**Example:** +Finally, we'll implement the methods to add, remove, and toggle tasks, as well as to change the filter. We'll also update the template to bind these methods to the respective buttons and inputs. ```javascript -const CartStore = cami.store({ - cartItems: [], - products: [ - { id: 1, name: 'Product 1', price: 100, disabled: false, stock: 10 }, - { id: 2, name: 'Product 2', price: 200, disabled: false, stock: 5 }, - { id: 3, name: 'Product 3', price: 300, disabled: false, stock: 2 }, - ], - add: (store, product) => { - const cartItem = { ...product, cartItemId: Date.now() }; - store.cartItems.push(cartItem); - store.products = store.products.map(p => { - if (p.id === product.id) { - p.stock--; - } - return p; - }); - }, - remove: (store, product) => { - store.cartItems = store.cartItems.filter(item => item.cartItemId !== product.cartItemId); - store.products = store.products.map(p => { - if (p.id === product.id) { - p.stock++; - } - return p; - }); - } -}); +class TaskManagerElement extends ReactiveElement { + // ... observables from Step 2 -class CartElement extends ReactiveElement { - cartItems = this.connect(CartStore, 'cartItems'); + addTask(task) { + this.tasks.push({ name: task, completed: false }); + } - constructor() { - super(); - this.setup({ - observables: ['cartItems'], - computed: ['cartValue'], - }) + removeTask(index) { + this.tasks.splice(index, 1); } - get cartValue() { - return this.cartItems.reduce((acc, item) => acc + item.price, 0); + toggleTask(index) { + this.tasks[index].completed = !this.tasks[index].completed; } - removeFromCart(product) { - CartStore.remove(product); + setFilter(filter) { + this.filter = filter; } template() { return html` -

Cart value: ${this.cartValue}

+ + + + + `; } } - -customElements.define('cart-component', CartElement); -``` - -In this example, `CartStore` is a store that holds the state of a shopping cart. It has two methods, `add` and `remove`, which can be used to add and remove items from the cart. The `CartElement` is a custom element that connects to the `CartStore` and updates its view whenever the `cartItems` state changes. It also provides a method `removeFromCart` that removes an item from the cart when a button is clicked. - -### `html` - -The `html` function in Cami.js is a tagged template literal, based on lit-html, that allows for the creation of declarative templates. It provides several powerful features that make it effective in the context of Cami.js: - -1. **Event Handling**: It supports event handling with directives like `@click`, which can be used to bind DOM events to methods in your components. For example: -```javascript -html`` ``` -In this example, the `increment` method is called when the button is clicked. - -2. **Attribute Binding**: It allows for attribute binding, which means you can dynamically set the attributes of your HTML elements based on your component's state. For example: -```javascript -html`
` -``` -In this example, the class of the div is dynamically set based on the `isActive` property of the component. - -3. **Caching**: It caches templates, which means that if you render the same template multiple times, it will only update the parts of the DOM that have changed. This makes rendering more efficient. - -4. **Expressions**: It allows for the use of JavaScript expressions inside your templates, which means you can include complex logic in your templates. For example: -```javascript -html`
${this.items.length > 0 ? this.renderItems() : 'No items'}
` -``` -In this example, the template conditionally renders a list of items or a message saying 'No items' based on the length of the `items` array. - -For more detailed information, refer to the lit-html documentation: [docs](https://lit.dev/docs/templates/overview/) +For a complete example, including observables and behavior, refer to the `examples/010_taskmgmt.html` file in the Cami.js repository. ## Examples @@ -450,13 +480,6 @@ They are also listed below: class CounterElement extends ReactiveElement { count = 0 - constructor() { - super(); - this.setup({ - observables: ['count'] - }) - } - template() { return html` @@ -474,9 +497,12 @@ They are also listed below: ```html
-

Form Validation

+

Registration Form

+ +

Try entering an email that is already taken, such as geovanniheaney@block.info (mock email)

+
@@ -484,32 +510,91 @@ They are also listed below: const { html, ReactiveElement } = cami; class FormElement extends ReactiveElement { + emailError = '' + passwordError = '' email = ''; password = ''; - emailError = ''; - passwordError = ''; + emailIsValid = null; + isEmailAvailable = null; + + inputValidation$ = this.stream(); + passwordValidation$ = this.stream(); + + onConnect() { + this.inputValidation$ + .map(e => this.validateEmail(e.target.value)) + .debounce(300) + .subscribe(({ isEmailValid, emailError, email }) => { + this.emailError = emailError; + this.isEmailValid = isEmailValid; + this.email = email; + this.isEmailAvailable = this.queryEmail(this.email) + }); + + this.passwordValidation$ + .map(e => this.validatePassword(e.target.value)) + .debounce(300) + .subscribe(({ isValid, password }) => { + this.passwordError = isValid ? '' : 'Password must be at least 8 characters long.'; + this.password = password; + }); + } + + validateEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + let emailError = ''; + let isEmailValid = null; + if (email === '') { + emailError = ''; + isEmailValid = null; + } else if (!emailRegex.test(email)) { + emailError = 'Please enter a valid email address.'; + isEmailValid = false; + } else { + emailError = ''; + isEmailValid = true; + } + return { isEmailValid, emailError, email }; + } - constructor() { - super(); - this.setup({ - observables: ['email', 'password', 'emailError', 'passwordError'] + validatePassword(password) { + let isValid = false; + if (password === '') { + isValid = null; + } else if (password?.length >= 8) { + isValid = true; + } + + return { isValid, password } + } + + queryEmail(email) { + return this.query({ + queryKey: ['Email', email], + queryFn: () => { + return fetch(`https://mockend.com/api/kennyfrc/cami-mock-api/users?email_eq=${email}`).then(res => res.json()) + }, + staleTime: 1000 * 60 * 5 }) } - validateEmail() { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(this.email)) { - this.emailError = 'Please enter a valid email address.'; + getEmailInputState() { + if (this.email === '') { + return ''; + } else if (this.isEmailValid && this.isEmailAvailable?.status === 'success' && this.isEmailAvailable?.data?.length === 0) { + return false; } else { - this.emailError = ''; + return true; } } - validatePassword() { - if (this.password.length < 8) { - this.passwordError = 'Password must be at least 8 characters long.'; + getPasswordInputState() { + if (this.password === '') { + return ''; + } else if (this.passwordError === '') { + return false; } else { - this.passwordError = ''; + return true; } } @@ -518,15 +603,20 @@ They are also listed below:
- +
`; } @@ -538,65 +628,95 @@ They are also listed below: ```html -
+

Todo List

+