{ } Lips v0.2.0

Examples

Every example here is a complete component. Register it, render it, and it runs.

Counter

const counter = {
  state: { count: 0, step: 1 },

  handler: {
    increment(){ this.state.count += this.state.step },
    decrement(){ this.state.count -= this.state.step },
    reset(){ this.state.count = 0 }
  },

  default: `
    <div class="counter">
      <h2>{state.count}</h2>

      <button on-click(decrement)>−{state.step}</button>
      <button on-click(increment)>+{state.step}</button>
      <button on-click(reset) disabled=!state.count>Reset</button>

      <label>
        Step
        <input type="number" value=state.step
               on-input( e => state.step = Number( e.target.value ) || 1 )/>
      </label>
    </div>`,

  stylesheet: `
    .counter { display: grid; gap: .75rem; justify-items: center; padding: 1.5rem }
    button { padding: .5rem 1rem; cursor: pointer }
    button[disabled] { opacity: .4; cursor: default }`
}

Todo list

const todoList = {
  state: {
    todos: [
      { id: 1, text: 'Learn Lips', done: true },
      { id: 2, text: 'Build something', done: false }
    ],
    draft: '',
    filter: 'all'
  },

  handler: {
    add(){
      const text = this.state.draft.trim()
      if( !text ) return

      this.state.todos.push({ id: Date.now(), text, done: false })
      this.state.draft = ''
    },
    toggle( id ){
      const todo = this.state.todos.find( t => t.id === id )
      todo && ( todo.done = !todo.done )
    },
    remove( id ){
      this.state.todos = this.state.todos.filter( t => t.id !== id )
    },
    visible(){
      switch( this.state.filter ){
        case 'active': return this.state.todos.filter( t => !t.done )
        case 'done':   return this.state.todos.filter( t => t.done )
        default:       return this.state.todos
      }
    },
    remaining(){ return this.state.todos.filter( t => !t.done ).length }
  },

  default: `
    <div class="todos">
      <form on-submit( e => { e.preventDefault(); self.add() } )>
        <input placeholder="What needs doing?"
               value=state.draft
               on-input( e => state.draft = e.target.value )/>
        <button type="submit">Add</button>
      </form>

      <nav class="filters">
        <for [name] in=[ 'all', 'active', 'done' ]>
          <button class=(state.filter === name && 'on')
                  on-click( () => state.filter = name )>{name}</button>
        </for>
      </nav>

      <ul>
        <for [todo] in=self.visible() by="id">
          <li class=(todo.done && 'done')>
            <input type="checkbox" checked=todo.done on-change(toggle, todo.id)/>
            <span>{todo.text}</span>
            <button on-click(remove, todo.id)>×</button>
          </li>
        </for>
      </ul>

      <if( !self.visible().length )>
        <p class="empty">Nothing here.</p>
      </if>

      <footer>{self.remaining()} remaining</footer>
    </div>`,

  stylesheet: `
    .todos { max-width: 480px; margin: 0 auto }
    form { display: flex; gap: .5rem; margin-bottom: 1rem }
    form input { flex: 1; padding: .5rem }
    .filters { display: flex; gap: .5rem; margin-bottom: 1rem }
    .filters .on { font-weight: 600; text-decoration: underline }
    ul { list-style: none; padding: 0 }
    li { display: flex; align-items: center; gap: .5rem; padding: .4rem 0 }
    li.done span { text-decoration: line-through; opacity: .55 }
    li button { margin-left: auto }
    .empty { opacity: .6; font-style: italic }`
}
const search = {
  state: { query: '', results: [], loading: false, error: null },

  _static: { endpoint: 'https://api.example.com/search' },

  handler: {
    onCreate(){ this.timer = null },
    onDestroy(){ clearTimeout( this.timer ) },

    onType( e ){
      this.state.query = e.target.value
      clearTimeout( this.timer )
      this.timer = setTimeout( () => this.run(), 300 )
    },

    async run(){
      const q = this.state.query.trim()
      if( !q ){
        this.state.results = []
        return
      }

      this.state.loading = true
      this.state.error = null

      try {
        const res = await fetch(`${this.static.endpoint}?q=${encodeURIComponent( q )}`)
        if( !res.ok ) throw new Error(`Search failed (${res.status})`)

        this.state.results = await res.json()
      }
      catch( err ){ this.state.error = err.message }
      finally { this.state.loading = false }
    }
  },

  default: `
    <div class="search">
      <input type="search" placeholder="Search…"
             value=state.query on-input(onType)/>

      <if( state.loading )>
        <p>Searching…</p>
      </if>
      <else-if( state.error )>
        <p class="error">{state.error}</p>
      </else-if>
      <else-if( state.results.length )>
        <ul>
          <for [hit] in=state.results by="id">
            <li><a href=hit.url>{hit.title}</a></li>
          </for>
        </ul>
      </else-if>
      <else-if( state.query )>
        <p>No matches for "{state.query}".</p>
      </else-if>
    </div>`
}

Tabs with slots

const tabs = {
  state: { active: 0 },

  handler: {
    select( i ){
      this.state.active = i
      this.emit('change', i )
    }
  },

  default: `
    <div class="tabs">
      <nav role="tablist">
        <for [label, i] in=input.labels>
          <button role="tab"
                  aria-selected=(state.active === i)
                  class=(state.active === i && 'on')
                  on-click(select, i)>{label}</button>
        </for>
      </nav>

      <div class="panel"><{input.renderer} index=state.active/></div>
    </div>`,

  stylesheet: `
    nav { display: flex; gap: .25rem; border-bottom: 1px solid #ddd }
    button { padding: .5rem 1rem; border: 0; background: none; cursor: pointer }
    button.on { font-weight: 600; box-shadow: inset 0 -2px 0 currentColor }
    .panel { padding: 1rem }`
}
<tabs [index] labels=[ 'Overview', 'Settings' ] on-change(tabChanged)>
  <switch( index )>
    <case is=0><overview-panel/></case>
    <case is=1><settings-panel/></case>
  </switch>
</tabs>

Router

const home    = { default: `<h1>Home</h1>` }
const about   = { default: `<h1>About</h1>` }
const article = {
  default: `
    <article>
      <h1>{input.params.slug}</h1>
      <p>Category: {input.params.category}</p>
      <if( input.query.ref )><small>via {input.query.ref}</small></if>
    </article>`
}

const app = {
  _static: {
    routes: [
      { path: '/', template: home, default: true },
      { path: '/about', template: about },
      { path: '/blog/:category/:slug', template: article }
    ]
  },

  handler: {
    afterNavigate({ toState }){ document.title = toState.path },
    notFound( path ){ console.warn('no route for', path ) },
    go( path ){ this.context.navigate( path ) }
  },

  default: `
    <div class="app">
      <nav>
        <a on-click( () => self.go('/') )>Home</a>
        <a on-click( () => self.go('/about') )>About</a>
      </nav>

      <router global routes=static.routes
              on-after(afterNavigate)
              on-not-found(notFound)/>
    </div>`
}

lips.root( app, '#app')

Recursive tree

Components may render themselves — the data decides where recursion stops.

const treeNode = {
  state: { open: true },

  handler: {
    toggle(){ this.state.open = !this.state.open }
  },

  default: `
    <li>
      <if( input.node.children )>
        <button on-click(toggle)>{state.open ? '▾' : '▸'}</button>
      </if>
      <span>{input.node.name}</span>

      <if( state.open && input.node.children )>
        <tree-list nodes=input.node.children/>
      </if>
    </li>`
}

const treeList = {
  default: `
    <ul>
      <for [key, node] in=input.nodes>
        <tree-node node=node key=key/>
      </for>
    </ul>`
}

lips.register('tree-node', treeNode )
lips.register('tree-list', treeList )
lips.root({
  state: {
    tree: new Map([
      [ 'a', { name: 'Documents', children: new Map([
        [ 'a1', { name: 'notes.txt' } ]
      ]) } ],
      [ 'b', { name: 'Pictures' } ]
    ])
  },
  default: `<tree-list nodes=state.tree/>`
}, '#app')

Because state.tree is a reactive Map, state.tree.set( … ) from anywhere updates the tree — including nested Maps at any depth.

Live template preview

A minimal editor loop using hot-swap.

import Lips, { compileTemplate } from '@lipsjs/lips'

const lips = new Lips()

const preview = lips.render('preview', {
  state: { count: 0 },
  handler: { inc(){ this.state.count++ } },
  default: `<button on-click(inc)>{state.count}</button>`
})
preview.appendTo('#preview')

document.querySelector('#source').addEventListener('input', e => {
  const { ir, diagnostics } = compileTemplate( e.target.value )
  const errors = diagnostics.filter( d => d.severity === 'error' )

  if( errors.length ){
    showErrors( errors )   // each has code, message, hint, line, col
    return
  }

  const { changes, salvaged } = preview.swap( ir )
  console.log(`${changes.length} regions patched, ${salvaged.length} instances kept`)
})

The counter keeps its value across every edit that compiles.

Form with validation

const signup = {
  state: { email: '', password: '', touched: {}, submitting: false },

  _static: {
    isEmail: v => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test( v )
  },

  handler: {
    field( name, e ){
      this.state[ name ] = e.target.value
      this.state.touched[ name ] = true
    },
    emailError(){
      if( !this.state.touched.email ) return null
      if( !this.state.email ) return 'Email is required'
      if( !this.static.isEmail( this.state.email ) ) return 'That does not look like an email'
      return null
    },
    passwordError(){
      if( !this.state.touched.password ) return null
      return this.state.password.length < 8 ? 'At least 8 characters' : null
    },
    valid(){
      return this.state.email && this.state.password.length >= 8
             && !this.emailError() && !this.passwordError()
    },
    async submit( e ){
      e.preventDefault()
      if( !this.valid() ) return

      this.state.submitting = true
      try { await createAccount( this.state.email, this.state.password ) }
      finally { this.state.submitting = false }
    }
  },

  default: `
    <form on-submit(submit) novalidate>
      <label>
        Email
        <input type="email" value=state.email on-input(field, 'email')/>
        <if( self.emailError() )><small class="err">{self.emailError()}</small></if>
      </label>

      <label>
        Password
        <input type="password" value=state.password on-input(field, 'password')/>
        <if( self.passwordError() )><small class="err">{self.passwordError()}</small></if>
      </label>

      <button type="submit" disabled=(!self.valid() || state.submitting)>
        {state.submitting ? 'Creating…' : 'Create account'}
      </button>
    </form>`,

  stylesheet: `
    form { display: grid; gap: 1rem; max-width: 360px }
    label { display: grid; gap: .3rem }
    input { padding: .5rem }
    .err { color: #c00 }`
}

Note on-input(field, 'email') — declared arguments come first, and the DOM event is appended last, so the handler signature is field( name, event ).