1.3.2 • Published 8 months ago

tuff-core v1.3.2

Weekly downloads
-
License
MIT
Repository
github
Last release
8 months ago

Tuff Logo

Tuff is a Typescript UI Frontend Framework. It is designed to create dynamic, client-side web applications driven by type-safe, declarative code.

Usage

Tuff has no dependencies and can be integrated into any new or existing Typescript project. Just install tuff-core with your favorite package manager:

npm i tuff-core

or

yarn add tuff-core

Parts

The basic building blocks of Tuff applications are Parts. A Part is similar to a class-based React component. They're classes that inherit from the Part generic class and implement a render() method.

type CounterState = {
    count: number
}

// each part is parameterized by a state type
class Counter extends Part<CounterState> {
    
    // the render method accepts an HTML builder object that lets the 
    // part declaratively build the interface
    render(parent: PartTag) {
        // the CounterState object passed to this part is 
        // accessible as this.state
        parent.span({text: `Count: ${this.state.count}`})
    }

}

Declarative UI

The argument of a Part's render() method is an HTML builder element where the part can build its UI.

On each builder element, you can pass zero to many arguments that are one of:

  1. A string containing CSS selector-style classes and/or an id, e.g. ".foo.bar#foo1" will generate class="foo bar" id="foo1"
  2. An object literal containing attributes (like title, href, class, etc.) and/or a text value that will populate the literal body of the element
  3. A function that takes the element as an argument and allows you to specify children of the element in the function body

The assignment of attributes from #2 is also exposed as methods on the element.

For example, this element declaration:

render(parent: PartTag) {
    parent.div(".container", c => {
        c.span(".value")
         .text("Hello")
        c.a(".link", {href: "#", text: "Click Me"})
    })
}

will generate the following markup:

<div class="container">
    <span class="value">Hello</span>
    <a class="link" href="#">Click Me</a>
</div>

All attribute arguments are statically-typed and specific to the particular element (e.g. anchor tags can have an href attribute while input tags can have a type attribute, but not the other way around).

In addition to the proper HTML element attributes, you can assign arbitrary and nested data-attributes using the .data() method:

render(parent: PartTag) {
    parent.a(".link")
        .text("Click Me")
        .data({foo: 'bar', nested: {hello: 'world'}})
}

will generate:

<div>
    <a class="link" data-foo="bar" data-nested-hello="world">
        Click Me
    </a>
</div>

You can specify inline styles with the css() method:

render(parent: PartTag) {
    parent.text("This is centered")
        .css({textAlign: 'center'})
}

will generate:

<div style='text-align: center'>
    This is centered
</div>

Since the render() method is plain Typescript, it can incorporate arbitrary control flow and logic:

render(parent: PartTag) {
    for (let s in ['Foo', 'Bar', 'Baz']) {
        parent.a({href: `/page/${s.toLowerCase()}`, text: s})
    }
    if (this.state.count > 4) {
        parent.span({text: "Count is greater than 4"})
    }
}

Init and Load

Parts can define an init() method that will get called once before the first render() call. You can also define a load() method that will get called after init() but before render().

class Counter extends Part<CounterState> {

    // this is guaranteed to be called only once,
    // before the render() method is called for the first time
    async init() {
    }

    // this will get called after init() and before the first render()
    // it may be called more than once if the root part is reloaded
    load() {
    }
    
    // this will get called at least once, but possibly many
    // times as the UI is updated
    render(parent: PartTag) {
    }

}

The difference between init() and load() is that load() may be called multiple times (when the user navigates, see Routing and Navigation), whereas init() is guaranteed to only ever be called once.

init() is also async since it may need to perform some IO while initializing and the part's isInitialized will not get set until after it's complete.

Update

Each time a Part is actually rendered to the DOM, the update() method will get called and passed the corresponding DOM element. The update() method may also get called when a part is marked as stale.

This is useful for executing code that depends on the DOM itself, needs to be called whenever the part's DOM element changes, and possibly more often (e.g. rendering a canvas element based on user input).

class Counter extends Part<CounterState> {

    fooKey = messages.untypedKey()

    async init() {
        this.onClick(m => {
            this.stale() // don't force a re-render, only update
        })
    }
    
    render(parent: PartTag) {
        parent.class('foo').a().emitClick(this.fooKey)
    }

    // elem will be the .foo element created by the render() method
    update(elem: HTMLElement) {
        // this gets called once for every call of render()
        // as well as any time the anchor is clicked
    }

}

Mounting

The Part.mount() method is used to attach parts to the DOM. It accepts either a DOM element or an id string as the mount point and an instance of the part's state:

// mounts to the element with id 'container':
Part.mount(Counter, 'container', {count: 0})

// which is the same as:
Part.mount(Counter, document.getElementById('container')!, {count: 0})

Optionally, you can choose to capture a base path when mounting a part. This means Tuff will prevent any navigation to a part starting with that base path by the browser and instead push the path to the browser history and reload the mounted Part.

// mounts the part and captures any navigation to /path or any of its subpaths
Part.mount(RootPart, 'container', {}, {capturePath: '/path'})

Path capture is useful for client-side routing. See the Routing and Navigation section.

Child Parts

Each part can have nested child parts such that the UI is composed of an arbitrary tree of parts. Child parts are created by the parent calling makePart (usually in the init() method) and are rendered to the UI with the part method on a DOM element in the render() method:

type ButtonState = {text: string}

class Button extends Part<ButtonState> {
    render(parent: PartTag) {
        parent.a(".button", {text: this.state.text})
    }
}

type ToolbarState = {count: number}

class Toolbar extends Part<ToolbarState> {
    buttons = Array<Button>()

    async init() {
        // populate the this.buttons array of child parts
        for (let i=0; i<this.state.count; i++) {
            this.buttons.push(
                this.makePart(Button, {text: `Button ${i}`})
            )
        }
    }

    render(parent: PartTag) {
        // render the button parts to the parent div
        for (let button of this.buttons) {
            parent.part(button)
        }
    }
}

Dirty Tracking

Unlike React or other reactive UI libraries, the update of Tuff UIs does not happen automatically when state changes. Instead, parts must explicitly mark themselves as dirty using the dirty() method when they're state has changed.

This was a conscious design decision to make the re-paint logic easier to reason about and more predictable. So, instead of knowing if/when a call to e.g. React's forceUpdate is needed, the updating is always precisely controlled.

When a part calls dirty(), the UI is not rendered immediately. Instead, an update is scheduled for the next animation frame and only dirty parts are re-rendered. This has several effects:

  1. Multiple dirty() calls at the same time will only result in a single render
  2. Rendering happens only during the browser-specified animation frames, so even rapid calls to dirty() will result in smooth UI updates
  3. As longs as the UI is composed of relatively fine-grained parts, updating small parts of the interface will result in only small re-renders, not a global virtual DOM diff

Collections

When rendering an array of parts associated with an array of states, Tuff provides the collections API to ease the bookkeeping.

The collections API lets you specified named collections of states that can be assigned using the assignCollection(name, partType, states) method. Then the collection can be rendered with the renderCollection(parent, name) method:

class ContactsList extends Part<{}> {

    // store a collection of states you'd like to render
    contacts: ContactState[] = []

    appendContact() {
        this.contacts.push({...}) // append an object to the states collection
        
        // call assignCollection any time the collection changes
        this.assignCollection('contacts', ContactFormPart, this.contacts)
    }

    async init() {
        this.appendContact()

        this.onClick(newContactKey, _ => {
            this.appendContact()
        })

        this.onClick(deleteContactKey, m => {
            const id = m.data.id
            const contact = arrays.find(this.contacts, c => c.id == id)
            if (contact) {
                // remove an object from the array
                this.contacts = arrays.without(this.contacts, contact)
                
                // this call will update the collection parts' state,
                // removing the deleted contact
                this.assignCollection('contacts', ContactFormPart, this.contacts)
            }
        })
    }

    render(parent: PartTag) {
        // render the entire collection to the given container
        // make sure to use the same name argument that was passed to assignCollection()
        this.renderCollection(parent, 'contacts')
        
        parent.a({text: "+ Add"})
            .emitClick(newContactKey)
    }


}

Successive calls to assignCollection() will automatically add/update/remove parts as necessary and only re-render those that changed. This means that parts can be added and removed without having to re-render the entire parent part, providing a considerable performance improvement over managing the collection manually.

Routing and Navigation

Tuff supports typesafe client client-side using the Typesafe Routes library.

Routes are declared as a constant composed of calls to partRoute() and redirectRoute():

const routes = {
    root: partRoute(RootPart, '/', {}),
    fooList: partRoute(FooListPart, '/foos', {}).
    fooShow: partRoute(FooShowPart, '/foos/:id', {
        id: stringParser
    }),
    bar: redirectRoute('/bar', '/foos')
}

partRoute routes a particular path to the given part. If the part's state type is non-empty, the route must match each property with a parser (i.e. stringParser). These properties are strongly-typed and matched to the part's state type at compile time. See the Typesafe Routes documentation for more details about parsers.

redirectRoute simply redirects one path to another.

Routers

To actually use a set of routes, you must create a subclass of RouterPart:

export MyRouter extends RouterPart {
    get routes() {
        return routes
    }

    get defaultPart() {
        return UnknownPathPart
    }

    render(parent: PartTag) {
        parent.div('.child', child => {
            super.render(child)
        })
    }
}

A router needs to implement routes and defaultPart to specify the routes structure and default Part class if the current route is not found, respecitely. In the render() method, it must call super.render() and pass the tag in which the matched part will be rendered.

Tuff routers have some special properties that aren't present many other frameworks: 1. Routers do not need to be the root (mounted) part 2. You can have more than one router as children (or grandchildren, etc.) of the root part 3. The router's render() method can call super.render() at an arbitrary point in its render tree, acting like a layout

Combined, these properties allow the Tuff routing system to break free from the traditional one-router/one-layout paradigm and enable composable, dynamic UIs that still leverage traditional URL-based routing.

Navigation

When a part is mounted with path capture (see Mounting), all anchor tag clicks are intercepted and the mounted part is reloaded instead of the native browser navigation occurring.

You can also programmatically navigate to a different URL using Nav.visit():

Part.mount(RootPart, 'container', {}, {capturePath: '/root'})

// will update the URL bar to /root/foos/123 and reload the mounted part without actually reloading the page
Nav.visit("/root/foos/123")

// will perform the native navigation to /other since that's outside of the mounted path
Nav.visit("/other")

Messages

Event handling in Tuff is done with its messages system.

Logging

Tuff comes with a very simple logging system that you can optionally use for your application. Simply create a Logger instance with an arbitrary prefix anywhere you'd like (Tuff libraries tend to do it at the top of the file) and call the usual logging methods (debug, info, warn, error):

const log = new Logger('MyThing')

// regular log statements
log.info('hello')
// [MyThing] hello

// console-style extra args
log.warn('an object', {foo: 'bar'})
// [MyThing] an object
// {foo: 'bar'}

// log the time it takes to execute a function
log.time('count things', () => {
    // something that takes time to do
})
// [MyThing] count things: 0.312 ms

// set the global minimum log level to filter output
// (default is 'info')
Logger.level = 'warn'
log.info('too much info')
// (won't print anything)

Forms

Tuff provides a special Part class, FormPart, which provides special methods to generate HTML form elements that are bound directly the properties of its data type.

Simply extend FormPart with your form-specific data type and then declare the form fields in the render() method with helpers like textInput(), dateInput, radio, and checkbox:

type MyFormData = {
    text: string
    date: string
    either: "a" | "b"
    isChecked: boolean
}

class MyFormPart extends FormPart<MyFormData> {

    render(parent: PartTag) {
        this.textInput(parent, "text", {placeholder: "Enter Text Here"})
        this.dateInput(parent, "date")
        parent.label(label => {
            this.radio(label, "either", "a")
            label.span({text: "A"})
        })
        parent.label(label => {
            this.radio(label, "either", "b")
            label.span({text: "B"})
        })
        parent.label(label => {
            this.checkbox(label, "isChecked")
            label.span({text: "Is Checked?"})
        })
    }

}

This part will render the given form elements and automatically assign the values from its state to them.

Whenever an input on the form is changed, the shouldUpdateState() method will be called, allowing the part to decide whether or not the new data should override the existing state (default is true if not implemented):

class MyFormPart extends FormPart<MyFormData> {

    // render() {...}

    shouldUpdateState(newData: DataType): boolean {
        if (newData.text.length) { // some validation
            return true
        }
        else {
            this.dirty()
            return false
        }
    }

}

This is a good place to perform validation and possibly return false and mark the part as dirty - forcing it to re-render.

If shouldUpdateState() returns true, the new data is then emitted as a "datachange" event that other parts in the tree can handle. The event is keyed with a TypedKey called dataChangeKey specific to the part:

class ParentPart extends Part<{}> {

    myForm!: MyFormPart

    async init() {
        this.myForm = this.makePart(MyFormPart, {
            text: "New Form",
            date: "2022-01-01",
            either: "a",
            isChecked: false
        })

        // this will get called whenever the form part's data changes
        this.onDataChanged(this.myForm.dataChangeKey, m => {
            // m.data has type MyFormData
            log.info(`My form data changed with text=${m.data.text}`, m)
        })
    }

}

Development

To run the demo application, clone this repository and run:

npm install

then:

npm run dev

to start the development server, which will serve the demo application at http://localhost:3000/.

Source Generation

Tuff parses the Typescript DOM type definitions to programmatically generate the tag and event handling code. In the event that such code needs to be regenerated, run:

npm run gen

Publishing

Due to reasons that I cannot fathom, npm publish doesn't seem to support publishing just the dist directory in a reasonable way. So, we use a custom script instead:

npm run pub

License (MIT)

© 2022 Terrier Technologies LLC

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

1.2.0

9 months ago

1.1.0

9 months ago

1.0.2

10 months ago

1.3.2

8 months ago

1.3.1

8 months ago

1.3.0

8 months ago

0.32.1

10 months ago

0.32.0

10 months ago

0.33.0

10 months ago

1.0.1

10 months ago

1.0.0

10 months ago

0.31.6

1 year ago

0.31.5

1 year ago

0.31.4

1 year ago

0.31.3

1 year ago

0.31.2

1 year ago

0.31.1

1 year ago

0.31.0

1 year ago

0.30.17

1 year ago

0.30.16

1 year ago

0.30.15

1 year ago

0.30.14

1 year ago

0.30.13

2 years ago

0.29.0

2 years ago

0.30.10

2 years ago

0.30.11

2 years ago

0.30.12

2 years ago

0.30.9

2 years ago

0.30.8

2 years ago

0.30.7

2 years ago

0.30.6

2 years ago

0.30.5

2 years ago

0.30.4

2 years ago

0.30.3

2 years ago

0.30.2

2 years ago

0.30.1

2 years ago

0.30.0

2 years ago

0.27.2

2 years ago

0.27.1

2 years ago

0.27.5

2 years ago

0.27.4

2 years ago

0.27.3

2 years ago

0.28.1

2 years ago

0.28.0

2 years ago

0.28.8

2 years ago

0.28.7

2 years ago

0.28.6

2 years ago

0.28.5

2 years ago

0.28.4

2 years ago

0.28.3

2 years ago

0.28.2

2 years ago

0.25.0

2 years ago

0.21.9

2 years ago

0.22.7

2 years ago

0.22.6

2 years ago

0.22.5

2 years ago

0.26.0

2 years ago

0.22.4

2 years ago

0.22.3

2 years ago

0.22.2

2 years ago

0.22.1

2 years ago

0.22.0

2 years ago

0.22.8

2 years ago

0.21.10

2 years ago

0.27.0

2 years ago

0.23.0

2 years ago

0.24.3

2 years ago

0.24.2

2 years ago

0.24.1

2 years ago

0.24.0

2 years ago

0.20.0

2 years ago

0.17.0

3 years ago

0.21.8

2 years ago

0.21.7

2 years ago

0.21.6

2 years ago

0.21.5

2 years ago

0.21.4

2 years ago

0.21.3

2 years ago

0.21.2

2 years ago

0.21.1

2 years ago

0.21.0

2 years ago

0.18.1

3 years ago

0.18.2

3 years ago

0.18.3

3 years ago

0.18.4

3 years ago

0.18.5

3 years ago

0.18.6

3 years ago

0.18.7

3 years ago

0.18.0

3 years ago

0.19.8

2 years ago

0.19.9

2 years ago

0.19.1

3 years ago

0.19.2

3 years ago

0.19.3

3 years ago

0.19.4

3 years ago

0.19.5

3 years ago

0.19.6

2 years ago

0.19.7

2 years ago

0.19.11

2 years ago

0.19.10

2 years ago

0.11.0

3 years ago

0.11.1

3 years ago

0.13.0

3 years ago

0.15.0

3 years ago

0.15.1

3 years ago

0.16.3

3 years ago

0.12.0

3 years ago

0.12.1

3 years ago

0.14.0

3 years ago

0.14.1

3 years ago

0.16.0

3 years ago

0.14.2

3 years ago

0.16.1

3 years ago

0.14.3

3 years ago

0.16.2

3 years ago

0.10.0

3 years ago

0.9.10

3 years ago

0.9.0

3 years ago

0.9.2

3 years ago

0.9.1

3 years ago

0.9.8

3 years ago

0.9.7

3 years ago

0.7.10

3 years ago

0.7.9

3 years ago

0.9.9

3 years ago

0.9.4

3 years ago

0.9.3

3 years ago

0.9.6

3 years ago

0.7.8

3 years ago

0.9.5

3 years ago

0.7.7

3 years ago

0.8.0

3 years ago

0.7.2

3 years ago

0.7.1

3 years ago

0.7.4

3 years ago

0.7.3

3 years ago

0.5.0

3 years ago

0.7.0

3 years ago

0.5.2

3 years ago

0.7.6

3 years ago

0.7.5

3 years ago

0.6.3

3 years ago

0.6.2

3 years ago

0.6.4

3 years ago

0.6.1

3 years ago

0.6.0

3 years ago

0.2.22

3 years ago

0.2.21

3 years ago

0.2.20

3 years ago

0.3.6

3 years ago

0.3.5

3 years ago

0.3.2

3 years ago

0.3.1

3 years ago

0.3.4

3 years ago

0.3.3

3 years ago

0.2.19

3 years ago

0.2.18

3 years ago

0.2.17

3 years ago

0.2.16

3 years ago

0.2.15

3 years ago

0.2.14

3 years ago

0.2.13

3 years ago

0.2.12

3 years ago

0.2.11

3 years ago

0.2.10

3 years ago

0.2.9

3 years ago

0.2.8

3 years ago

0.2.7

3 years ago

0.2.6

3 years ago

0.2.5

3 years ago

0.2.4

3 years ago

0.2.1

3 years ago

0.2.0

3 years ago

0.1.10

3 years ago

0.1.9

3 years ago

0.1.8

3 years ago

0.1.6

3 years ago

0.1.5

3 years ago

0.1.4

3 years ago

0.1.3

3 years ago

0.1.2

3 years ago

0.1.1

3 years ago

0.1.0

3 years ago