Skip to content

Commit

Permalink
Add basic Stamp tools (#81)
Browse files Browse the repository at this point in the history
The road to the key building blocks of #10 via #60:

- [x] `buildStamp` to build stamps
- [x] `collectBindings` to collect bindings for applying to a stamp
- [x] Basic test for stamp building/bindings
- [x] Advanced tests for stamp building/bindings
- [x] `StampCollection` for registering stamps
- [x] Tests for `StampCollection`
- [x] Support `StampCollection` as wiring helper
- [x] Support `StampCollection` as only wiring option (no static DOM
builder)
- [x] Support wiring base container as "stamped"
- [x] Document Stamps
- [x] Figure out solution for "trampoline components" (fragments created
for children bindings, etc) with respect to Stamps or remove
`runOnlyStamps` (marked as experimental)
- [x] Document jsdom `innerHTML` workaround for `<template>` rendering;
see jsdom/jsdom#3783 (using `template.content` better now)

Resolves #60
  • Loading branch information
WorldMaker authored Nov 11, 2024
2 parents 682d394 + af399e5 commit 3ca0fea
Show file tree
Hide file tree
Showing 57 changed files with 2,129 additions and 203 deletions.
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,74 @@ and operators.
[Getting Started][started] can lead you through a gentle tour of
Butterfloat features.

## A Usage Example

A complex component with embedded state may look something like this:

```tsx
import { ComponentContext, ObservableEvent, butterfly, jsx } from 'butterfloat'
import { map } from 'rxjs'

interface GardenProps {}

interface GardenEvents {
rake: ObservableEvent<MouseEvent>
}

function Garden(
props: GardenProps,
{ bindEffect, events }: ComponentContext<GardenEvents>,
) {
const [money, setMoney] = butterfly(1)
const [labor, setLabor] = butterfly(0)

const moneyPercent = money.pipe(
map((money) => money.toLocaleString(undefined, { style: 'percent ' })),
)

const laborPercent = labor.pipe(
map((labor) => labor.toLocaleString(undefined, { style: 'percent' })),
)

bindEffect(events.rake, () => {
setMoney((money) => money - 0.15)
setLabor((labor) => labor + 0.3)
})

return (
<div class="garden">
<div class="stat-label">Money</div>
<progress
title="Money"
bind={{ value: money, innerText: moneyPercent }}
/>
<div class="stat-label">Labor</div>
<progress
title="Labor"
bind={{ value: labor, innerText: laborPercent }}
/>
<div class="section-label">Activities</div>
<button type="button" events={{ click: events.rake }}>
Rake
</button>
</div>
)
}
```

This may look like React at first glance, especially the intentional
surface level resemblance of `butterfly` to `useState` and `bindEffect`
to `useEffect`. This exact example is refactored in ways that a React
component can't be (moving the `butterfly` state to its own "view model")
in the [State Management][state] documentation, but it is suggested you
take the scenic route and start with [Getting Started][started].

## Other Examples

Example projects migrated from Knockout:

- [compradprog](https://github.com/WorldMaker/compradprog)
- [macrotx](https://github.com/WorldMaker/macrotx)

[started]: ./docs/getting-started.md
[state]: ./docs/state.md
3 changes: 3 additions & 0 deletions binding.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { ElementDescription } from './component.js'
import { jsx } from './jsx.js'
import { ObservableEvent, makeEventProxy } from './events.js'
import buildDomStrategy from './wiring-dom-build.js'

describe('binding', () => {
it("doesn't schedule immediates", () => {
Expand Down Expand Up @@ -104,6 +105,7 @@ describe('binding', () => {
const complete = () => {}
const subscription = new Subscription()
bindElement(element, (<div />) as ElementDescription, {
domStrategy: buildDomStrategy,
error,
complete,
componentRunner(_container, description, _context, _placeholder) {
Expand Down Expand Up @@ -152,6 +154,7 @@ describe('binding', () => {
element,
(<div events={{ bfDomAttach }} />) as ElementDescription,
{
domStrategy: buildDomStrategy,
error,
complete,
componentRunner(_container, description, _context, _placeholder) {
Expand Down
8 changes: 6 additions & 2 deletions binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ function bindElementChildren(
const placeholder = document.createComment(`replaceable child component`)
element.append(placeholder)
const activeChild = description.childrenBind.pipe(
switchMap((child) => componentWirer(child, context, document)),
switchMap((child) =>
componentWirer(child, context, undefined, document),
),
)
const childComponent = activeChild as ObservableComponent
childComponent.name = `${element.nodeName} replaceable child`
Expand Down Expand Up @@ -377,7 +379,9 @@ export function bindFragmentChildren(

if (nodeDescription.childrenBindMode === 'replace') {
const activeChild = nodeDescription.childrenBind.pipe(
switchMap((child) => componentWirer(child, context, document)),
switchMap((child) =>
componentWirer(child, context, undefined, document),
),
)
const childComponent = activeChild as ObservableComponent
childComponent.name = `${node.nodeName} replaceable child`
Expand Down
69 changes: 69 additions & 0 deletions component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,54 @@ export interface ComponentContext<Events = DefaultEvents> {
bindImmediateEffect: EffectHandler
}

/**
* A Butterfloat Component provided properties and additional context-sensitive tools
*/
// Want to be forgiving in what we accept as a "component"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ContextComponent<Props = any, Events = any> = (
props: Props,
context: ComponentContext<Events>,
) => NodeDescription

/**
* The simplest form of Butterfloat Component
*/
export type SimpleComponent = () => NodeDescription

/**
* A Butterfloat Component
*/
export type Component = ContextComponent | SimpleComponent

/**
* Possible children to a JSX node
*/
export type JsxChildren = Array<NodeDescription | string>

/**
* Attributes of a Node Description
*/
export type Attributes = Record<string, unknown>

/**
* HTML Attributes
*/
export type HtmlAttributes = Record<string, unknown>

/**
* An Observable that produces child Components
*/
export type ChildrenBind = Observable<Component>

/**
* The mode to bind new children to a container
*/
export type ChildrenBindMode = 'append' | 'prepend' | 'replace'

/**
* A JSX node that may produce child Components
*/
export interface ChildrenBindable {
/**
* Bind children as they are observed.
Expand All @@ -51,10 +78,19 @@ export interface ChildrenBindable {
childrenBindMode?: ChildrenBindMode
}

/**
* Butterfloat Attributes
*/
export type ButterfloatAttributes = HtmlAttributes & ChildrenBindable

/**
* Default bind attribute accepted binds
*/
export type DefaultBind = Record<string, Observable<unknown>>

/**
* Support for delay binding special properties
*/
export interface DelayBind {
/**
* Delay scheduled binding for the "value" property.
Expand All @@ -67,10 +103,19 @@ export interface DelayBind {
bfDelayValue?: Observable<unknown>
}

/**
* Default styleBind attribute accepted binds
*/
export type DefaultStyleBind = Record<string, Observable<unknown>>

/**
* Bind for classBind
*/
export type ClassBind = Record<string, Observable<boolean>>

/**
* JSX attributes for "intrinics" (elements) supported by Butterfloat
*/
export interface ButterfloatIntrinsicAttributes<
Bind = DefaultBind,
Events = DefaultEvents & ButterfloatEvents,
Expand Down Expand Up @@ -120,12 +165,18 @@ export interface ButterfloatIntrinsicAttributes<
So it makes sense to use full words. Users may work with these in their tests.
*/

/**
* A Description that supports binding Children
*/
export interface ChildrenBindDescription {
children: JsxChildren
childrenBind?: ChildrenBind
childrenBindMode?: ChildrenBindMode
}

/**
* Description of a DOM element and its bindings
*/
export interface ElementDescription<Bind = DefaultBind>
extends ChildrenBindDescription {
type: 'element'
Expand All @@ -140,34 +191,52 @@ export interface ElementDescription<Bind = DefaultBind>
immediateClassBind: ClassBind
}

/**
* Description of a Component binding
*/
export interface ComponentDescription extends ChildrenBindDescription {
type: 'component'
component: Component
properties: Attributes
}

/**
* Description of a Fragment (the `<Fragment>` pseudo-component which powers `<></>` fragment notation)
*/
export interface FragmentDescription extends ChildrenBindDescription {
type: 'fragment'
attributes: Attributes
}

/**
* Description of the `<Children>` pseudo-component
*/
export interface ChildrenDescription {
type: 'children'
context?: ComponentContext<unknown>
}

/**
* Description of the `<Static>` pseudo-component
*/
export interface StaticDescription {
type: 'static'
element: Element
}

/**
* A description of a node in a Butterfloat DOM tree
*/
export type NodeDescription =
| ElementDescription
| ComponentDescription
| FragmentDescription
| ChildrenDescription
| StaticDescription

/**
* A Component Context for Testing purposes
*/
export interface TestComponentContext<Events = DefaultEvents> {
context: ComponentContext<Events>
// Types here are just for examing test results
Expand Down
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
- [Class and Style Binding](/style.md)
- [Children Binding and Dynamic Children](/children.md)
- [Suspense and Advanced Binding](/suspense.md)
- [Stamps](/stamps.md)
- [Modules](/types/modules.md)
6 changes: 3 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ component and has no way to change it in the future.
In Butterfloat, static HTML looks _static_ and the only things that
can dynamically change things are RxJS Observables and other
Butterfloat components (which, if you are curious, are wired into
Observables). To add some dynamic changes to our we'll need to
Observables). To add some dynamic changes to our example we'll need to
_bind_ an Observable.

```tsx
Expand All @@ -241,7 +241,7 @@ function Main() {
const helloTo = concat(
of('World'),
interval(15_000 /* ms */).pipe(
map(() => greetable[Math.round(Math.random() * greetable.length)]),
map(() => greetable[Math.floor(Math.random() * greetable.length)]),
),
)

Expand Down Expand Up @@ -323,7 +323,7 @@ function Main() {
const helloTo = concat(
of('World'),
interval(15_000 /* ms */).pipe(
map(() => greetable[Math.round(Math.random() * greetable.length)]),
map(() => greetable[Math.floor(Math.random() * greetable.length)]),
),
)

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('getting started documentation', () => {
const helloTo = concat(
of('World'),
interval(15_000 /* ms */).pipe(
map(() => greetable[Math.round(Math.random() * greetable.length)]),
map(() => greetable[Math.floor(Math.random() * greetable.length)]),
),
)

Expand Down Expand Up @@ -115,7 +115,7 @@ describe('getting started documentation', () => {
const helloTo = concat(
of('World'),
interval(15_000 /* ms */).pipe(
map(() => greetable[Math.round(Math.random() * greetable.length)]),
map(() => greetable[Math.floor(Math.random() * greetable.length)]),
),
)

Expand Down
Loading

0 comments on commit 3ca0fea

Please sign in to comment.