This commit is contained in:
vd
2026-10-12 20:38:04 +02:00
parent 18172b4004
commit 4a01f0d7a9
132 changed files with 3809 additions and 4830 deletions
+211 -51
View File
@@ -44,12 +44,21 @@ const age = new React.State(69); //will be State<number>
// Get value
console.log(name.value);
// John Doe
// Subscribe
age.subscribe(newAge => console.log(`Age changed to ${newAge}`));
// Age changed to 69
React.bulkSubscribe([name, age], () => console.log(`${name.value} is ${age.value} years old`))
// Set value
name.value = "Jeff";
age.value = 70;
// Age changed to 70
// John Doe is 70 years old
name.value = "Josh"
// Josh is 70 years old
```
## Proxy States
@@ -61,43 +70,85 @@ A Proxy State is a State based on multiple other States. This reduces code and c
import * as React from 'bloatless-react';
// Create States
const name = new React.State("John Doe"); // will be State<string>
const age = new React.State(69); //will be State<number>
const name = new React.State("John Doe");
const age = new React.State(69);
// Proxy State
const summary = React.createProxyState([name, age], () => `${name.value} is ${age.value} years old.`)
summary.subscribe(console.log);
//John Doe is 69 years old.
```
## ListStates
A `ListState<T>` is a State whose value is a `Set<T>`. A ListState allows specific subscriptions to detect when items get added and removed. This allows dynamic lists to run efficiently.
A `ListState<T>` is a State whose value is a `Set<T>`. A ListState allows specific subscriptions to detect when items get added and removed.
```TypeScript
// Import
import * as React from 'bloatless-react';
import * as React from "bloatless-react";
// Define Item type
class Item implements React.Identifiable {
id = React.UUID();
constructor(public text: string) {}
}
const listState = new React.ListState([1, 2]);
// Create ListState
const listState = new React.ListState<Item>();
console.log(listState.value);
// Set [ 1, 2 ]
// Handle addition and removal
listState.handleAddition((newItem) => {
console.log(`${newItem.text} was added`);
listState.handleRemoval(newItem, () => console.log(`${newItem.text} was removed`));
listState.handleRemovals((item) => console.log(item + " removed"));
listState.handleAddition((newItem: number) => {
console.log("+", newItem);
listState.handleRemoval(newItem, () => {
console.log("-", newItem);
});
});
// + 1
// + 2
// Add item
const newItem = new Item("hello, world!");
listState.add(newItem);
listState.add(3);
// + 3
// Remove item
listState.remove(newItem);
listState.remove(1);
// - 1
listState.clear();
// - 2
// - 3
```
## MapStates
A `MapState<T>` is a State whose value is a `Map<string, T>`. A ListState allows specific subscriptions to detect when items get added and removed.
```TypeScript
import * as React from "bloatless-react";
const mapState = new React.MapState<number>([
["a", 1],
["b", 2],
])
console.log(mapState.value);
// Map { a → 1, b → 2 }
mapState.handleRemovals((item) => console.log(item + " removed"));
mapState.handleAddition((newItem: number) => {
console.log("+", newItem)
mapState.handleRemoval(newItem, () => {
console.log("-", newItem)
})
});
// + 1
// + 2
mapState.set("c", 3);
// + 3
mapState.remove("a");
// - 1
mapState.clear();
// - 2
// - 3
```
## Persistence
@@ -105,11 +156,23 @@ listState.remove(newItem);
States can persist through reloads via LocalStorage. To implement this, modify your code like this:
```diff
normal State
-const myState = new React.State("hello");
+const myState = React.restoreState("my-state", "hello");
-const myListState = new React.ListState<Item>();
+const myListState = React.restoreListState<Item>("my-list-state");
MapState
-const myMapState = new React.MapState<T>();
+const myMapState = React.restoreMapState<T>("my-map-state");
OR
-const myMapState = new React.MapState<number>([["a", 1], ["b", 2]]);
+const myMapState = React.restoreMapState<number>("my-map-state", [["a", 1], ["b", 2]]);
ListState
-const myListState = new React.ListState<T>();
+const myListState = React.restoreListState<T>("my-list-state");
OR
-const myListState = new React.ListState<number>([1, 2]);
+const myListState = React.restoreListState<number>("my-list-state", [1, 2]);
```
# UI
@@ -125,6 +188,7 @@ The `on:<event>` directive adds an EventListener. This directive also supports t
```TypeScript
<input on:enter={someFunction}></input>
<button on:click={someFunction}>Click me</button>
<div metakey:s={someFunction}>...</div> // triggers when pressing Command/Ctrl+s
```
## Changing Properties
@@ -167,49 +231,70 @@ const name = new React.State("John Doe");
## Dynamically Creating and Removing Child Elements
The `subscribe:children` directive subscribes to a ListState and adds/removes child elements accordingly.
The **children:set** subscribes to a `State<HTMLElement>` or `State<HTMLElement[]>` and replaces the element's contents with the State's value.
```TypeScript
import * as React from 'bloatless-react';
let count = 0;
const childElement = new React.State(<div>0</div>);
// Define Item
class Item implements React.Identifiable {
id = React.UUID();
constructor(public text: string) {}
function change() {
count++;
childElement.value = <div>{count}</div>;
}
// This ListItemConverter creates an HTML element based on an Item
const convertItem: React.ListItemConverter<Item> = (item) => {
document.body.append(
<div>
<button on:click={change}>+</button>
<div children:set={childElement}></div>
</div>
);
```
The **children:append** and **children:prepend** directives subscribe to a `ListState` or `MapState` and sync the element's contents accordingly.
The StateItemConverter turns an item of the State into an HTMLElement.
```TypeScript
import * as React from "bloatless-react";
// Model
class MyDataModel {
items = new React.ListState<string>();
newItemName = new React.State("");
addItem = () => {
console.log(this);
this.items.add(this.newItemName.value);
};
removeItem = (item: string) => {
this.items.remove(item);
};
}
const model = new MyDataModel();
// Item -> Element
const convertItem: React.StateItemConverter<string> = (item: string) => {
function remove() {
// you should keep track of your data in a separate model
// keep your removeListItem() method there
myDataModel.removeListItem(item);
model.removeItem(item);
}
return (
<span>
{item.text}
{item}
<button on:click={remove}>Remove</button>
</span>
);
};
// Create ListState
const listState = new React.ListState<Item>();
// Prepare adding items
const newItemName = new React.State("");
function addItem() {
const newItem = new Item(newItemName.value);
listState.add(newItem);
}
// Build UI
document.body.append(
<div>
<input bind:value={newItemName}></input>
<button on:click={addItem}>Add</button>
<div subscribe:children={[listState, convertItem]}></div>
<input bind:value={model.newItemName}></input>
<button on:click={model.addItem}>Add</button>
<div
class="flex-column"
children:append={[model.items, convertItem]}
></div>
</div>
);
```
@@ -271,4 +356,79 @@ Other changes:
## 1.2.5
- Instead of using `scrollIntoView()`, `subscribe:children` now scrolls to the bottom
- Instead of using `scrollIntoView()`, `subscribe:children` now scrolls to the bottom
## 1.3.0
**BREAKING CHANGES**
- Replace `set:children` with `children:append`
Other changes
- Add `children:<action>` directive
- Improve documentation
## 1.3.1
- Allow arrays to be passed to `children:set`
## 1.3.2
- Allow initial value on `restoreListState()`
- Add `MapState` and `restoreMapState()`
## 1.3.3
- Rename `ListItemConverter` to `StateItemConverter`
- Improve documentation
## 1.3.4
- Remove old item from `MapState` when calling `MapState.set()` for an existing key
## 1.3.5
- Fix bug where `MapState.clear()` would not trigger removal handlers
## 1.3.6
- Add `State.subscribeSilent()`
- Add `bulkSubscribe()`
## 1.3.7
- `bulkSubscribe()` does not fire the callback when set up
## 1.3.8
- Allow multiple handlers for `MapState` and `ListState`
## 1.3.9
- Improve error handling
- Fix error where the `children:` directive would attempt to set attributes
## 1.3.10
- Improve error handling
## 1.3.11
- Add `metakey:` directive
- Pass event to `on:enter` call
## 1.3.12
- Replace `metakey:` with `keystroke:` directive to support control key additionally
## 1.3.13
- trigger state subscriptions even when value is unchanged
## 1.3.14
- fix bug where proxy states would trigger subscriptions upon creation$a
## 1.3.15
- do not re-add values to `ListState` if already present
## 1.4.0
- Add `handleRemovals` method
-1
View File
@@ -1 +0,0 @@
(()=>{var a=class{value;constructor(){this.value=""}toString(){return this.value}};function l(t){return t instanceof r?t.value:t}function o(t){return t instanceof r?t:new r(t)}var r=class{_value;_bindings=new Set;constructor(e){this._value=e}get value(){return this._value}set value(e){this._value!=e&&(this._value=e,this.callSubscriptions())}callSubscriptions(){this._bindings.forEach(e=>e(this._value))}subscribe(e){this._bindings.add(e)}},s=class extends r{additionHandlers=new Set;removalHandlers=new Map;constructor(){super(new Set)}add(...e){e.forEach(i=>{this.value.add(i),this.additionHandlers.forEach(n=>n(i))})}delete(...e){e.forEach(i=>{this.value.delete(i);let n=i.uuid;this.removalHandlers.has(n)&&(this.removalHandlers.get(n)(i),this.removalHandlers.delete(n))})}handleAddition(e){this.additionHandlers.add(e)}handleRemoval(e,i){this.removalHandlers.set(e.uuid,i)}};function d(t,e,i,n="input"){i.subscribe(u=>{t[e]=i.value}),t.addEventListener(n,()=>{i.value=t[e]})}})();
+219 -100
View File
@@ -24,7 +24,7 @@ export type RemovalSubscription<T> = (removedItem: T) => void;
export class State<T> {
private _value: T;
private _bindings = new Set<StateSubscription<T>>();
_bindings = new Set<StateSubscription<T>>();
// init
constructor(initialValue: T) {
@@ -37,7 +37,6 @@ export class State<T> {
}
set value(newValue: T) {
if (this._value == newValue) return;
this._value = newValue;
this.callSubscriptions();
}
@@ -52,6 +51,10 @@ export class State<T> {
fn(this._value);
}
subscribeSilent(fn: (newValue: T) => void): void {
this._bindings.add(fn);
}
// stringify
toString(): string {
return JSON.stringify(this._value);
@@ -60,7 +63,8 @@ export class State<T> {
export class ListState<T> extends State<Set<T>> {
private additionHandlers = new Set<AdditionSubscription<T>>();
private removalHandlers = new Map<T, RemovalSubscription<T>>();
private removalHandlers = new Map<T, Set<RemovalSubscription<T>>>();
private genericRemovalHandlers = new Set<RemovalSubscription<T>>();
// init
constructor(initialItems?: T[]) {
@@ -70,6 +74,7 @@ export class ListState<T> extends State<Set<T>> {
// list
add(...items: T[]): void {
items.forEach((item) => {
if (this.value.has(item)) return;
this.value.add(item);
this.additionHandlers.forEach((handler) => handler(item));
});
@@ -80,8 +85,9 @@ export class ListState<T> extends State<Set<T>> {
items.forEach((item) => {
this.value.delete(item);
this.genericRemovalHandlers.forEach(handler => handler(item));
if (!this.removalHandlers.has(item)) return;
this.removalHandlers.get(item)!(item);
this.removalHandlers.get(item)!.forEach((handler) => handler(item));
this.removalHandlers.delete(item);
});
this.callSubscriptions();
@@ -98,17 +104,82 @@ export class ListState<T> extends State<Set<T>> {
}
handleRemoval(item: T, handler: RemovalSubscription<T>): void {
this.removalHandlers.set(item, handler);
if (!this.removalHandlers.has(item))
this.removalHandlers.set(item, new Set());
this.removalHandlers.get(item)!.add(handler);
}
handleRemovals(handler: RemovalSubscription<T>): void {
this.genericRemovalHandlers.add(handler);
}
// stringification
toString(): string {
const array = [...this.value];
const array = [...this.value.values()];
const json = JSON.stringify(array);
return json;
}
}
export class MapState<T> extends State<Map<string, T>> {
private additionHandlers = new Set<AdditionSubscription<T>>();
private removalHandlers = new Map<T, Set<RemovalSubscription<T>>>();
private genericRemovalHandlers = new Set<RemovalSubscription<T>>();
// init
constructor(initialItems?: [string, T][]) {
super(new Map<string, T>(initialItems));
}
// list
set(key: string, item: T): void {
this.remove(key);
this.value.set(key, item);
this.additionHandlers.forEach((handler) => handler(item));
this.callSubscriptions();
}
remove(key: string): void {
const item = this.value.get(key);
if (!item) return;
this.value.delete(key);
this.callSubscriptions();
this.genericRemovalHandlers.forEach(handler => handler(item));
if (!this.removalHandlers.has(item)) return;
this.removalHandlers.get(item)!.forEach((handler) => handler(item));
this.removalHandlers.delete(item);
}
clear() {
[...this.value.keys()].forEach((key) => this.remove(key));
}
// handlers
handleAddition(handler: AdditionSubscription<T>): void {
this.additionHandlers.add(handler);
[...this.value.values()].forEach(handler);
}
handleRemoval(item: T, handler: RemovalSubscription<T>): void {
if (!this.removalHandlers.has(item))
this.removalHandlers.set(item, new Set());
this.removalHandlers.get(item)!.add(handler);
}
handleRemovals(handler: RemovalSubscription<T>): void {
this.genericRemovalHandlers.add(handler);
}
// stringification
toString(): string {
const array = [...this.value.entries()];
const json = JSON.stringify(array);
return json;
}
}
// UTILITY
export function createProxyState<T>(
statesToSubscibe: State<any>[],
@@ -116,15 +187,22 @@ export function createProxyState<T>(
): State<T> {
const proxyState = new State<T>(fn());
statesToSubscibe.forEach((state) =>
state.subscribe(() => (proxyState.value = fn()))
state.subscribeSilent(() => (proxyState.value = fn()))
);
return proxyState;
}
export function bulkSubscribe(
statesToSubscibe: State<any>[],
fn: () => void
): void {
statesToSubscibe.forEach((state) => state.subscribeSilent(fn));
}
function persistState(localStorageKey: string, state: State<any>) {
state.subscribe(() => {
const stringifiedValue = state.toString();
localStorage.setItem(localStorageKey, stringifiedValue);
const stringifiedValue = state.toString();
localStorage.setItem(localStorageKey, stringifiedValue);
});
}
@@ -133,8 +211,8 @@ export function restoreState<T>(
initialStateValue: T
): State<T> {
const storedString =
localStorage.getItem(localStorageKey) ??
JSON.stringify(initialStateValue);
localStorage.getItem(localStorageKey) ??
JSON.stringify(initialStateValue);
const convertedValue = JSON.parse(storedString);
const state = new State(convertedValue);
@@ -144,16 +222,15 @@ export function restoreState<T>(
}
export function restoreListState<T>(
localStorageKey: string
localStorageKey: string,
initialItems: any[] = []
): ListState<T> {
const storedString = localStorage.getItem(localStorageKey) ?? "";
let initialItems: any[] = [];
try {
const array = JSON.parse(storedString);
if (!Array.isArray(array)) throw "";
initialItems = array;
const array = JSON.parse(storedString);
if (!Array.isArray(array)) throw "";
initialItems = array;
} catch {}
const state = new ListState<T>(initialItems);
@@ -162,13 +239,29 @@ export function restoreListState<T>(
return state;
}
export type ListItemConverter<T> = (
item: T
) => HTMLElement;
export function restoreMapState<T>(
localStorageKey: string,
initialItems: [string, any][] = []
): MapState<T> {
const storedString = localStorage.getItem(localStorageKey) ?? "";
try {
const array = JSON.parse(storedString);
if (!Array.isArray(array)) throw "";
initialItems = array;
} catch {}
const state = new MapState<T>(initialItems);
persistState(localStorageKey, state);
return state;
}
export type StateItemConverter<T> = (item: T) => HTMLElement;
/*
JSX
*/
*/
export function createElement(
tagName: keyof HTMLElementTagNameMap,
@@ -178,87 +271,113 @@ export function createElement(
const element = document.createElement(tagName);
if (attributes != null)
Object.entries(attributes).forEach((entry) => {
const [attributename, value] = entry;
const [directiveKey, directiveValue] = attributename.split(":");
Object.entries(attributes).forEach((entry) => {
const [attributename, value] = entry;
const [directiveKey, directiveValue] = attributename.split(":");
switch (directiveKey) {
case "on": {
switch (directiveValue) {
case "enter": {
element.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key != "Enter") return;
value();
});
break;
}
default: {
element.addEventListener(directiveValue, value);
}
}
break;
}
case "subscribe": {
if (directiveValue == "children") {
element.style.scrollBehavior = "smooth";
try {
const [listState, toElement] = value as [
listState: ListState<any>,
ListItemConverter<any>
];
switch (directiveKey) {
case "on": {
switch (directiveValue) {
case "enter": {
element.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key != "Enter") return;
value(e);
});
break;
}
default: {
element.addEventListener(directiveValue, value);
}
}
break;
}
case "keystroke": {
element.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.metaKey == false && e.ctrlKey == false) return;
if (e.key != directiveValue) return;
value(e);
})
break;
}
case "subscribe": {
const state = value as State<any>;
state.subscribe(
(newValue) => (element[directiveValue] = newValue)
);
listState.handleAddition((newItem) => {
const child = toElement(newItem);
listState.handleRemoval(newItem, () =>
child.remove()
);
element.append(child);
element.scrollTop = element.scrollHeight;
});
} catch {
throw `error: cannot process subscribe:children directive because ListItemConverter is not defined. Usage: "subscribe:children={[list, converter]}"; you can find a more detailed example in the documentation`;
}
} else {
const state = value as State<any>;
state.subscribe(
(newValue) => (element[directiveValue] = newValue)
);
}
break;
}
case "bind": {
const state = value as State<any>;
state.subscribe(
(newValue) => (element[directiveValue] = newValue)
);
element.addEventListener(
"input",
() => (state.value = (element as any)[directiveValue])
);
break;
}
case "toggle": {
if (value.subscribe) {
const state = value as State<any>;
state.subscribe((newValue) =>
element.toggleAttribute(directiveValue, newValue)
);
} else {
element.toggleAttribute(directiveValue, value);
}
break;
}
case "set": {
const state = value as State<any>;
state.subscribe((newValue) =>
element.setAttribute(directiveValue, newValue)
);
break;
}
default:
element.setAttribute(attributename, value);
}
});
break;
}
case "bind": {
const state = value as State<any>;
state.subscribe(
(newValue) => (element[directiveValue] = newValue)
);
element.addEventListener(
"input",
() => (state.value = (element as any)[directiveValue])
);
break;
}
case "toggle": {
if (value.subscribe) {
const state = value as State<any>;
state.subscribe((newValue) =>
element.toggleAttribute(directiveValue, newValue)
);
} else {
element.toggleAttribute(directiveValue, value);
}
break;
}
case "set": {
const state = value as State<any>;
state.subscribe((newValue) =>
element.setAttribute(directiveValue, newValue)
);
break;
}
case "children": {
switch (directiveValue) {
case "set": {
const state = value as State<Node | Node[]>;
state.subscribe((newValue) => {
element.innerHTML = "";
element.append(...[newValue].flat());
});
break;
}
case "append":
case "prepend": {
try {
const [listState, toElement] = value as [
listState: ListState<any>,
StateItemConverter<any>
];
listState.handleAddition((newItem) => {
const child = toElement(newItem);
listState.handleRemoval(newItem, () =>
child.remove()
);
if (directiveValue == "append") {
element.append(child);
} else if (directiveValue == "prepend") {
element.prepend(child);
}
});
} catch (error) {
console.error(error);
throw `error: cannot process subscribe:children directive. \n Usage: "children:append={[list, converter]}"; you can find a more detailed example in the documentation.`;
}
}
}
break;
}
default:
element.setAttribute(attributename, value);
}
});
children.filter((x) => x).forEach((child) => element.append(child));
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "bloatless-react",
"version": "1.2.6",
"version": "1.4.0",
"description": "very minimal and flexible react alternative",
"repository": {
"type": "git",
@@ -9,6 +9,6 @@
"author": "marlon-erler",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"uuid": "^10.0.0"
"uuid": "^13.0.0"
}
}