0.0.1-rc.6 • Published 9 months ago

@nanotree/core v0.0.1-rc.6

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

NanoTree

NanoTree is a lightweight, type-safe, and reactive web UI library built with TypeScript.

The library uses Nanostores for state management and provides utilities for DOM manipulation, components creation, and event handling.

Installation

npm install @nanotree/core nanostores
yarn add @nanotree/core nanostores

Basic usage

At its core, Nanotree offer a wrapper around document.createElement which adds the ability to bind a nanostores atom to either an element property, or one of its nodes.

Creating a reactive element is done through the element helper exported by @nanotree/core:

const myDiv = element('div')

You can then customize your element through chainable modifier methods, allowing you to assign properties, bind event handlers, or manipulate children:

myDiv
  // Set a given prop
  .prop('id', 'root')
  // Set one or more props in one go
  .props({ className: 'section' })
  // Append a children
  .node(element('span').node('Text content'))
  // Append multiple children
  .nodes([
    'More content',
    element('strong').node('Strong content'),
    element('button')
      // Bind one event listener
      .event('click', someClickListener)
      // Bind multiple events in one go
      .events({
        mouseenter: someHoverStartListener,
        mouseleave: someHoverEndListener,
      }),
  ])

Binding atoms

You can use nanostores atoms as the value for any property you set, or for a text node:

import { atom } from 'nanostores'
import { element } from '@nanotree/core'

const $value = atom('')
const boundValue = element('section').nodes([
  element('input')
    .prop('value', $value)
    .event('input', (e) => $value.set(e.currentTarget.value)),
  element('p').node($value),
])

// The rendered p content will be synced with the input value

Creating reusable components

NanoTree is really permissive in terms of what can be passed as child nodes:

  • Any JS primitive type is valid (string, number and boolean), and will get stringified,
  • Any NanoTree Element is valid,
  • Any ReadableAtom wrapping a JS primitive or a NanoTree Element,
  • Any array containing valid children,
  • render/cleanup pairs (explained later), wrapped or not in a ReadableAtom.

Thanks to this, any function can be used as reusable component as long as it returns valid children. Since the reactivity is handled through nanostores atoms, the main body of a component function is not called multiple times, only once on mount. You have complete control on when the function is invoked, and you can take advantage of its closure to handle internal state:

import { atom } from 'nanostores'
import { element } from '@nanotree/core'

const SimpleCounter = (step: number = 1, defaultValue: number = 0) => {
  const $value = atom(defaultValue)
  const increase = () => $value.set($value.get() + step)
  const decrease = () => $value.set($value.get() - step)

  return element('div').nodes([
    element('button').event('click', decrease).node('Decrease'),
    element('span').node($value),
    element('button').event('click', increase).node('Increase'),
  ])
}

const myApp = element('main').nodes([
  element('p').node('First Counter:'),
  SimpleCounter(),
  element('p').node('2-by-2 Counter:'),
  SimpleCounter(2, 10),
])

Creating side effects with a clean-up step

Sometimes, our components might want to register side effects that should run while the component is mounted, and stop when unmounted.

To do so, instead of directly returning children, you can return a render/cleanup pair. The render function will be run when the component mounts, and should return the nodes to render; and the cleanup function will be called on component unmount, and can take care of cleaning up any side effects.

import { atom } from 'nanostores'
import { element } from '@nanotree/core'

const SimpleCounter = (step: number = 1, defaultValue: number = 0) => {
  const $value = atom(defaultValue)
  const increase = () => $value.set($value.get() + step)
  const decrease = () => $value.set($value.get() - step)

  let unsubscribe

  return {
    render() {
      unsubscribe = $value.listen((value) =>
        console.log('New counter value:', value),
      )

      return element('div').nodes([
        element('button').event('click', decrease).node('Decrease'),
        element('span').node($value),
        element('button').event('click', increase).node('Increase'),
      ])
    },
    cleanup() {
      unsubscribe()
    },
  }
}

Helpers

NanoTree exports some useful helpers to reduce boilerplate.

HTMLElement shortcuts

You can import the tree object from @nanotree/core, and use it to access shortcuts for creating HTML elements:

import { atom } from 'nanostores'
import { tree } from '@nanotree/core'

const $value = atom('')
const boundValue = tree.section.nodes([
  tree.input
    .prop('value', $value)
    .event('input', (e) => $value.set(e.currentTarget.value)),
  tree.p.node($value),
])

Component factory

You can import the component method from @nanotree/core, and use it to wrap your components to get some helpful helpers. The component factory hides away the complexity of maintaining your own render/cleanup pairs:

import { atom } from 'nanostores'
import { tree, component } from '@nanotree/core'

const SimpleCounter = component<{
  step?: number
  defaultValue?: number
}>(({ props: { step = 1, defaultValue = 0 }, effect }) => {
  const $value = atom(defaultValue)
  const increase = () => $value.set($value.get() + step)
  const decrease = () => $value.set($value.get() - step)

  effect(() => {
    const unsubscribe = $value.listen((value) =>
      console.log('New counter value:', value),
    )
    return () => unsubscribe()
  })

  return tree.div.nodes([
    tree.button.event('click', decrease).node('Decrease'),
    tree.span.node($value),
    tree.button.event('click', increase).node('Increase'),
  ])
})

Passing props to a component from the factory

The component factory returns a standard function interface, which receives options as a first argument, and children nodes as second argument.

However, the returned function also contains chainable methods to make passing props, children, or events easier:

const myApp = tree.main.node(SimpleCounter.props({ step: 2, defaultValue: 10 }))

The chainable methods are the same as for elements: props to set props, node/nodes to handle children, and event/events to bind events.

prop is not available as, contrarily to HTML elements, we can't know in advance if all props are optional, and therefore it might not make sense to pass partial props. To the same effect, the props function requires all required props to be set, it does not take a partial representation.

Emitting and subscribing to events

When calling a component it's possible to pass it a map of event name to event listeners. An additional helper function is accessible in the component function first argument: emit.

This can be used to emit an event that can be listened to by the parent:

import { component } from '@nanotree/core'

const myEmittingComponent = component<
  {},
  { click: CustomEvent<{ detail: any }> }
>(({ emit }) => {
  return tree.button.event('click', () =>
    emit(new CustomEvent('click', { detail: 'The button was clicked' })),
  )
})

const myReceivingComponent = component(() => {
  return myEmittingComponent.events({
    click: (event) => console.log(event.detail),
  })
})

JSX support

If you prefer using JSX rather than raw JS for building your UIs, NanoTree got you covered too!

The tree export from @nanotree/core double-duties as our JSX factory (and JSX namespace if you're using TypeScript).

You can either set your jsxFactory config entry to tree in your build tool, or use it on a per-file basis by appending the jsx-transform comments:

// @jsx tree
// @jsxFrag tree.Fragment

Passing props, children, and binding events

NanoTree uses JSX slightly differently from React and such. This is on purpose: it's a way to both simplify our internal logic, and clearly mark that the React ecosystem cannot be consumed in a NanoTree app. Indeed, the reactive approach used by NanoTree through atoms makes it impossible to create a compatibility layer to React.

As such, we opted not to support props on JSX tags directly. Instead, props need to be passed down as a JS object, through the special $props JSX prop. The same way, events are still passed as a map of event name to listener, through the special $events JSX prop. Children are passed as JSX children as usual:

import { tree } from '@nanotree/core'
import { SimpleCounter } from './simple-counter'

export const App = () => {
  return (
    <main>
      <SimpleCounter $props={{ step: 2, defaultValue: 10 }} />
    </main>
  )
}

Because of this, components usable through JSX must be created through the component factory. Direct function calls are still supported, but not as JSX tag:

import { tree } from '@nanotree/core'
import { SimpleCounter } from './simple-counter'

const Title = (message: string) => (
  <h1>This is a direct function, not a component: {message}</h1>
)

export const App = () => {
  return (
    <main>
      {/* This will fail: */}
      <Title $props="Hello world" />
      {/* This will work: */}
      {Title('Hello world')}
      <SimpleCounter $props={{ step: 2, defaultValue: 10 }} />
    </main>
  )
}

Configuring JSX with Typescript

In order for Typescript to understand that JSX is not the standard React JSX, but consumes NanoTree instead, you need to update some fields in your TS Config:

{
  compilerOptions: {
    jsx: 'react', // Use the old JSX transform
    jsxFactory: 'tree', // Use `tree` as the JSX factory
    jsxFragmentFactory: 'tree.Fragment', // Use `tree.Fragment` to handle JSX fragments
    reactNamespace: 'tree', // Use `tree` as JSX namespace
  },
}
0.0.1-rc.6

9 months ago

0.0.1-rc.5

9 months ago

0.0.1-rc.4

9 months ago

0.0.1-rc.3

9 months ago

0.0.1-rc.2

9 months ago

0.0.1-rc.1

9 months ago