☰ VanJS by Example

VanJS: Learning by Example

Simplicity is the ultimate sophistication.

-- Steve Jobs

Despite being an ultra-lightweight UI framework, VanJS allows you to write incredibly elegant and expressive code for comprehensive application logic. This page is a curated list of cool things you can do with just a few lines of JavaScript code, including several handy utilities built with VanJS.

See also Community Examples.

Hello World!


This is the Hello World program shown in the Home page:

const Hello = () => div(
  p("πŸ‘‹Hello"),
  ul(
    li("πŸ—ΊοΈWorld"),
    li(a({href: "https://vanjs.org/"}, "🍦VanJS")),
  ),
)

Demo:

Try on jsfiddle

This is the funnier Hello program shown in Getting Started page:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))

const Run = ({sleepMs}) => {
  const headingSpaces = van.state(40), trailingUnderscores = van.state(0)

  const animate = async () => {
    while (headingSpaces.val > 0) {
      await sleep(sleepMs)
      --headingSpaces.val, ++trailingUnderscores.val
    }
  }
  animate()

  return pre(() =>
    `${" ".repeat(headingSpaces.val)}πŸšπŸ’¨Hello VanJS!${"_".repeat(trailingUnderscores.val)}`)
}

const Hello = () => {
  const dom = div()
  return div(
    dom,
    button({onclick: () => van.add(dom, Run({sleepMs: 2000}))}, "Hello 🐌"),
    button({onclick: () => van.add(dom, Run({sleepMs: 500}))}, "Hello 🐒"),
    button({onclick: () => van.add(dom, Run({sleepMs: 100}))}, "Hello πŸšΆβ€β™‚οΈ"),
    button({onclick: () => van.add(dom, Run({sleepMs: 10}))}, "Hello 🏎️"),
    button({onclick: () => van.add(dom, Run({sleepMs: 2}))}, "Hello πŸš€"),
  )
}

Demo:

Try on jsfiddle

DOM Composition and Manipulation


Even without state and state binding, you can build interactive web pages thanks to VanJS's flexible API for DOM composition and manipulation: tag functions and van.add. Check out the example below:

const StaticDom = () => {
  const dom = div(
    div(
      button("Dummy Button"),
      button(
        {onclick: () =>
          van.add(dom,
            div(button("New Button")),
            div(a({href: "https://www.example.com/"}, "This is a link")),
          )
        },
        "Button to Add More Elements"),
      button({onclick: () => alert("Hello from 🍦VanJS")}, "Hello"),
    ),
  )
  return dom
}

Demo:

Try on jsfiddle

Counter


The Counter App is a good illustration on how to leverage States to make your application reactive. This is the program shown in the Home page:

const Counter = () => {
  const counter = van.state(0)
  return span(
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, "πŸ‘"),
    button({onclick: () => --counter.val}, "πŸ‘Ž"),
  )
}

Demo:

Try on jsfiddle

This is a slightly advanced version of Counter App:

const buttonStyleList = [
  ["πŸ‘†", "πŸ‘‡"],
  ["πŸ‘", "πŸ‘Ž"],
  ["πŸ”Ό", "πŸ”½"],
  ["⬆️", "⬇️"],
  ["⏫", "⏬"],
  ["πŸ“ˆ", "πŸ“‰"],
]

const Counter = ({buttons}) => {
  const counter = van.state(0)
  const dom = div(
    "❀️ ", counter, " ",
    button({onclick: () => ++counter.val}, buttons[0]),
    button({onclick: () => --counter.val}, buttons[1]),
    button({onclick: () => dom.remove()}, "❌"),
  )
  return dom
}

const CounterSet = () => {
  const containerDom = div()
  return div(
    containerDom,
    button({onclick: () => van.add(containerDom,
      Counter({buttons: buttonStyleList[Math.floor(Math.random() * buttonStyleList.length)]}))},
      "βž•",
    ),
  )
}

Demo:

Try on jsfiddle

Stopwatch


This is a Stopwatch App, similar to the Timer App shown in the tutorial:

const Stopwatch = () => {
  const elapsed = van.state(0)
  let id
  const start = () => id = id || setInterval(() => elapsed.val += .01, 10)
  return span(
    pre({style: "display: inline;"}, () => elapsed.val.toFixed(2), "s "),
    button({onclick: start}, "Start"),
    button({onclick: () => (clearInterval(id), id = 0)}, "Stop"),
    button({onclick: () => (clearInterval(id), id = 0, elapsed.val = 0)}, "Reset"),
  )
}

Demo:

Try on jsfiddle

Blog


VanJS doesn't have an equivalent to React's <Fragment>. For most of the cases, returning an array of HTML elements from your custom component would serve the similar purpose. Here is the sample code equivalent to the Blog example in React's official website:

const Blog = () => [
  Post({title: "An update", body: "It's been a while since I posted..."}),
  Post({title: "My new blog", body: "I am starting a new blog!"}),
]

const Post = ({title, body}) => [
  PostTitle({title}),
  PostBody({body}),
]

const PostTitle = ({title}) => h1(title)
const PostBody = ({body}) => article(p(body))

Try on jsfiddle

The sample code in React is 29 lines. Thus VanJS's equivalent code is ~3 times shorter by eliminating unnecessary boilerplate.

Note that: The result of the binding function of a state-derived child node can't be an array of elements. You can wrap the result into a pass-through container (span for inline elements and div for block elements) if multiple elements need to be returned.

List


As an unopinionated framework, VanJS supports multiple programming paradigms. You can construct the DOM tree in an imperative way (modifying the DOM tree via van.add), or in a functional/declarative way.

Below is an example of building a list even numbers in 1..N, using an imperative way:

const EvenNumbers = ({N}) => {
  const listDom = ul()
  for (let i = 1; i <= N; ++i)
    if (i % 2 === 0)
      van.add(listDom, li(i))

  return div(
    p("List of even numbers in 1.." + N + ":"),
    listDom,
  )
}

Try on jsfiddle

Alternatively, you can build a list of even numbers in 1..N, using a functional/declarative way:

const EvenNumbers = ({N}) => div(
  p("List of even numbers in 1.." + N + ":"),
  ul(
    Array.from({length: N}, (_, i) => i + 1)
      .filter(i => i % 2 === 0)
      .map(i => li(i)),
  ),
)

Try on jsfiddle

TODO List


Similarly, to build reactive applications, you can build in a procedural way, which updates UI via the integration with native DOM API (it's easy to do with VanJS as it doesn't introduce an ad-hoc virtual-DOM layer), or in a functional/reactive way, which delegates UI changes to State Binding. You can also choose a hybrid approach between the 2 paradigms, depending on which approach fits well for a specific problem.

ι“ε―ι“οΌŒιžεΈΈι“
(A rule that can be told by words, is not the rule that should universally apply)

-- θ€ε­οΌŒι“εΎ·η»

Below is an example of building a TODO List in a completely procedural way:

const TodoItem = ({text}) => div(
  input({type: "checkbox", onchange: e =>
    e.target.closest("div").querySelector("span").style["text-decoration"] =
      e.target.checked ? "line-through" : ""
  }),
  span(text),
  a({onclick: e => e.target.closest("div").remove()}, "❌"),
)

const TodoList = () => {
  const inputDom = input({type: "text"})
  const dom = div(
    inputDom,
    button({onclick: () => van.add(dom, TodoItem({text: inputDom.value}))}, "Add"),
  )
  return dom
}

Demo:

Try on jsfiddle

Alternatively, you can use a functional/reactive way to build TODO Items:

const TodoItem = ({text}) => {
  const done = van.state(false), deleted = van.state(false)
  return () => deleted.val ? null : div(
    input({type: "checkbox", checked: done, onclick: e => done.val = e.target.checked}),
    () => (done.val ? strike : span)(text),
    a({onclick: () => deleted.val = true}, "❌"),
  )
}

const TodoList = () => {
  const inputDom = input({type: "text"})
  const dom = div(
    inputDom,
    button({onclick: () => van.add(dom, TodoItem({text: inputDom.value}))}, "Add"),
  )
  return dom
}

Demo:

Try on jsfiddle

A Fully Reactive TODO App


You can also go fully reactive for the TODO App. That is, the entire state of the app is captured by a global appState. With the full reactivity it's easier to persist the appState into localStorage so that the state is kept across page reloads.

Note that even if the app is fully reactive, we don't need to re-render the whole DOM tree for state updates, thanks to the optimization with stateful binding.

The code was implemented in TypeScript.

class TodoItemState {
  constructor(public text: string, public done: State<boolean>, public deleted: State<boolean>) {}
  serialize() { return {text: this.text, done: this.done.val} }
}

const TodoItem = ({text, done, deleted}: TodoItemState) => () => deleted.val ? null : div(
  input({type: "checkbox", checked: done, onclick: e => done.val = e.target.checked}),
  () => (done.val ? strike : span)(text),
  a({onclick: () => deleted.val = true}, "❌"),
)

class TodoListState {
  private constructor(public todos: TodoItemState[]) {}

  save() {
    localStorage.setItem("appState", JSON.stringify(
      (this.todos = this.todos.filter(t => !t.deleted.val)).map(t => t.serialize())))
  }

  static readonly load = () => new TodoListState(
    JSON.parse(localStorage.getItem("appState") ?? "[]")
      .map((t: any) => new TodoItemState(t.text, van.state(t.done), van.state(false)))
  )

  add(text: string) {
    this.todos.push(new TodoItemState(text, van.state(false), van.state(false)))
    return new TodoListState(this.todos)
  }
}

const TodoList = () => {
  const appState = van.state(TodoListState.load())
  van.derive(() => appState.val.save())
  const inputDom = input({type: "text"})
  return div(
    inputDom, button({onclick: () => appState.val = appState.val.add(inputDom.value)}, "Add"),
    (dom?: Element) => dom ?
      van.add(dom, TodoItem(appState.val.todos.at(-1)!)) :
      div(appState.val.todos.map(TodoItem)),
  )
}

Demo:

Try on CodeSandbox

Stargazers


The following code can show the number of stars for a Github repo, and a list of most recent stargazers:

const Stars = async repo => {
  const repoJson = await fetch(`https://api.github.com/repos/${repo}`).then(r => r.json())
  const pageNum = Math.floor((repoJson.stargazers_count - 1) / 100) + 1
  const starsJson = await fetch(
    `https://api.github.com/repos/${repo}/stargazers?per_page=100&page=${pageNum}`)
    .then(r => r.json())
  return div(
    p(repoJson.stargazers_count, " ⭐️:"),
    ul(
      starsJson.reverse().map(u => li(a({href: u.html_url}, u.login))),
    ),
  )
}

Try it out here

Try on jsfiddle

Epoch Timestamp Converter


Below is an application which converts a Unix epoch timestamp into a human-readable datetime string:

const tsToDate = ts =>
  ts < 1e10 ? new Date(ts * 1e3) :
  ts < 1e13 ? new Date(ts) :
  ts < 1e16 ? new Date(ts / 1e3) :
  new Date(ts / 1e6)

const Converter = () => {
  const nowTs = van.state(Math.floor(new Date().getTime() / 1e3)), date = van.state(null)
  setInterval(() => ++nowTs.val, 1000)
  const inputDom = input({type: "text", size: 25, value: nowTs.val})
  return div(
    div(b("Now: "), nowTs),
    inputDom, " ",
    button({onclick: () => date.val = tsToDate(Number(inputDom.value))}, "Convert"),
    p(i("Supports Unix timestamps in seconds, milliseconds, microseconds and nanoseconds.")),
    () => date.val ? p(
      div(date.val.toString()),
      div(b("GMT: "), date.val.toGMTString()),
    ) : p(),
  )
}

Demo:

Try on jsfiddle

Keyboard Event Inspector


Below is an application to inspect all relevant key codes in keyboard keydown events:

const Label = text => span({class: "label"}, text)
const Value = text => span({class: "value"}, text)

const Inspector = () => {
  const keyEvent = van.state(new KeyboardEvent("keydown"))

  const Result = prop => span(Label(prop + ": "), Value(() => keyEvent.val[prop]))

  return div(
    div(input({placeholder: "Focus here and press keys…", style: "width: 260px",
      onkeydown: e => (e.preventDefault(), keyEvent.val = e)})),
    div(Result("key"), Result("code"), Result("which"), Result("keyCode")),
    div(Result("ctrlKey"), Result("metaKey"), Result("altKey"), Result("shiftKey")),
  )
}

Demo:

Try on jsfiddle

Diff


Here is a Diff App with the integration of jsdiff. The app can compare 2 pieces of text (very handy tool to check how your text is revised by ChatGPT πŸ™‚):

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const DiffApp = () => {
  const oldTextDom = textarea({oninput: autoGrow, rows: 1})
  const newTextDom = textarea({oninput: autoGrow, rows: 1})
  const diff = van.state([])
  return div(
    div({class: "row"},
      div({class: "column"}, oldTextDom), div({class: "column"}, newTextDom),
    ),
    div({class: "row"},
      button({onclick: () => diff.val = Diff.diffWords(oldTextDom.value, newTextDom.value)},
        "Diff",
      ),
    ),
    div({class: "row"}, () => div({class: "column", style: "white-space: pre-wrap;"},
      diff.val.map(d => span({class: d.added ? "add" : (d.removed ? "remove" : "")}, d.value)),
    )),
  )
}

Demo:

Try on jsfiddle

Here is a more advanced Diff App that supports side-by-side and line-by-line comparison:

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const Line = ({diff, skipAdd, skipRemove}) => div(
  {class: "column", style: "white-space: pre-wrap;"},
  diff.filter(d => !(skipAdd && d.added || skipRemove && d.removed)).map(d =>
    span({class: d.added ? "add" : (d.removed ? "remove" : "")}, d.value)),
)

const DiffLine = (oldLine, newLine, showMerged) => {
  const diff = Diff.diffWords(oldLine, newLine)
  return div({class: "row" + (showMerged ? " merged" : "")},
    showMerged ?
      Line({diff}) : [Line({diff, skipAdd: true}), Line({diff, skipRemove: true})],
  )
}

const DiffApp = () => {
  const oldTextDom = textarea({oninput: autoGrow, rows: 1})
  const newTextDom = textarea({oninput: autoGrow, rows: 1})
  const diff = van.state([])
  const showMerged = van.state(true)
  return div(
    div({class: "row"},
      div({class: "column"}, oldTextDom), div({class: "column"}, newTextDom),
    ),
    div({class: "row"},
      button({onclick: () => diff.val = Diff.diffLines(oldTextDom.value, newTextDom.value)},
        "Diff",
      ),
      input({type: "checkbox", checked: showMerged,
        oninput: e => showMerged.val = e.target.checked}),
      "show merged result"
    ),
    () => {
      const diffVal = diff.val, showMergedVal = showMerged.val, resultDom = div()
      for (let i = 0; i < diffVal.length; ) {
        let line
        if (diffVal[i].added && diffVal[i + 1].removed) {
          line = DiffLine(diffVal[i + 1].value, diffVal[i].value, showMergedVal)
          i += 2
        } else if (diffVal[i].removed && diffVal[i + 1].added) {
          line = DiffLine(diffVal[i].value, diffVal[i + 1].value, showMergedVal)
          i += 2
        } else if (diffVal[i].added) {
          line = showMergedVal ? div({class: "merged add row"},
            div({class: "column", style: "white-space: pre-wrap;"}, diffVal[i].value),
          ) : div({class: "row"},
            div({class: "column"}),
            div({class: "add column", style: "white-space: pre-wrap;"}, diffVal[i].value),
          )
          ++i
        } else if (diffVal[i].removed) {
          line = showMergedVal ? div({class: "merged remove row"},
            div({class: "column", style: "white-space: pre-wrap;"}, diffVal[i].value),
          ) : div({class: "row"},
            div({class: "remove column", style: "white-space: pre-wrap;"}, diffVal[i].value),
          )
          ++i
        } else {
          line = div({class: "row", style: "white-space: pre-wrap;"},
            showMergedVal ? div({class: "merged column"}, diffVal[i].value) :
              [
                div({class: "column"}, diffVal[i].value),
                div({class: "column"}, diffVal[i].value),
              ],
          )
          ++i
        }
        van.add(resultDom, line)
      }
      return resultDom
    },
  )
}

Demo:

Try on jsfiddle

Calculator


The code below implements a Calculator App similar to the one that you are using on your smartphones:

const Calculator = () => {
  let lhs = van.state(null), op = null, rhs = van.state(0)

  const calc = (lhs, op, rhs) =>
    !op || lhs === null ? rhs :
    op === "+" ? lhs + rhs :
    op === "-" ? lhs - rhs :
    op === "x" ? lhs * rhs : lhs / rhs

  const onclick = e => {
    const str = e.target.innerText
    if (str >= "0" && str <= "9")
      typeof rhs.val === "string" ? rhs.val += str : rhs.val = rhs.val * 10 + Number(str)
    else if (str === "AC") lhs.val = op = null, rhs.val = 0
    else if (str === "+/-" && rhs.val) rhs.val = -rhs.val
    else if (str === "%" && rhs.val) rhs.val *= 0.01
    else if (str === "+" || str === "-" || str === "x" || str === "Γ·") {
      if (rhs.val !== null) lhs.val = calc(lhs.val, op, Number(rhs.val)), rhs.val = null
      op = str
    } else if (str === "=" && op && rhs.val !== null)
      lhs.val = calc(lhs.val, op, Number(rhs.val)), op = null, rhs.val = null
    else if (str === ".")
      rhs.val = rhs.val ? rhs.val + "." : "0."
  }

  const Button = str => div({class: "button"}, button(str))

  return div({id: "root"},
    div({id: "display"}, div(() => rhs.val ?? lhs.val)),
    div({id: "panel", onclick},
      div(Button("AC"), Button("+/-"), Button("%"), Button("Γ·")),
      div(Button("7"), Button("8"), Button("9"), Button("x")),
      div(Button("4"), Button("5"), Button("6"), Button("-")),
      div(Button("1"), Button("2"), Button("3"), Button("+")),
      div(div({class: "button wide"}, button("0")), Button("."), Button("=")),
    ),
  )
}

Demo:

Try on jsfiddle

Notably, this Calculator App is equivalent to the React-based implementation here: github.com/ahfarmer/calculator. Here is the size comparison of the total package between the 2 apps:

VanJS-based AppReact-based App
# of files:216
# of lines:143616

As you can see, not only VanJS is ~50 times smaller than React, apps built with VanJS also tends to be much slimmer.

Table-View Example: JSON/CSV Table Viewer


The following code implements a Table Viewer for JSON/CSV-based data by leveraging functional-style DOM tree building:

const TableViewer = ({inputText, inputType}) => {
  const jsonRadioDom = input({type: "radio", checked: inputType === "json",
    name: "inputType", value: "json"})
  const csvRadioDom = input({type: "radio", checked: inputType === "csv",
    name: "inputType", value: "csv"})
  const autoGrow = e => {
    e.style.height = "5px"
    e.style.height = (e.scrollHeight + 5) + "px"
  }
  const textareaDom = textarea({oninput: e => autoGrow(e.target)}, inputText)
  setTimeout(() => autoGrow(textareaDom), 10)

  const text = van.state("")

  const tableFromJson = text => {
    const json = JSON.parse(text), head = Object.keys(json[0])
    return {
      head,
      data: json.map(row => head.map(h => row[h]))
    }
  }

  const tableFromCsv = text => {
    const lines = text.split("\n").filter(l => l.length > 0)
    return {
      head: lines[0].split(","),
      data: lines.slice(1).map(l => l.split(",")),
    }
  }

  return div(
    div(jsonRadioDom, label("JSON"), csvRadioDom, label("CSV (Quoting not Supported)")),
    div(textareaDom),
    div(button({onclick: () => text.val = textareaDom.value}, "Show Table")),
    p(() => {
      if (!text.val) return div()
      try {
        const {head, data} = (jsonRadioDom.checked ? tableFromJson : tableFromCsv)(text.val)
        return table(
          thead(tr(head.map(h => th(h)))),
          tbody(data.map(row => tr(row.map(col => td(col))))),
        )
      } catch (e) {
        return pre({class: "err"}, e.toString())
      }
    }),
  )
}

Demo:

Try on jsfiddle

package-lock.json Inspector


Below is an example which can extract and display all dependency packages and their versions from package-lock.json file:

const PackageLockInspector = () => {
  const json = van.state("")
  return [
    div("Paste the content of package-lock.json file here:"),
    textarea({rows: 10, cols: 80, oninput: e => json.val = e.target.value}),
    () => {
      if (!json.val) return div()
      const packagesEntries = Object.entries(JSON.parse(json.val).packages)
      try {
        return div(
          h4("All Dependencies (", packagesEntries.length, ")"),
          table(
            thead(tr(th("Package"), th("Version"))),
            tbody(packagesEntries.map(([k, v]) => {
              if (!k) return null
              const name = k.slice("node_modules/".length), version = v.version
              return tr(
                td(a({href: "https://www.npmjs.com/package/" + name}, name)),
                td(a({href: `https://www.npmjs.com/package/${name}/v/${version}`}, version)),
              )
            })),
          ),
        )
      } catch (e) {
        return pre({style: "color: red;"}, "Parsing error: ", e.toString())
      }
    },
  ]
}

Try it out here

Try on jsfiddle

Tree-View Example: JSON Inspector


This is another example of leveraging functional-style DOM tree building - to build a tree view for inspecting JSON data:

const ListItem = ({key, value, indent = 0}) => {
  const hide = van.state(key !== "")
  const valueDom = typeof value !== "object" ? value : div(
    {style: () => hide.val ? "display: none;" : ""},
    Object.entries(value).map(([k, v]) =>
      ListItem({key: k, value: v, indent: indent + 2 * (key !== "")})),
  )
  return (key ? div : pre)(
    " ".repeat(indent),
    key ? (
      typeof valueDom !== "object" ? ["🟰 ", b(`${key}: `)] :
        a({onclick: () => hide.val = !hide.val, style: "cursor: pointer"},
          () => hide.val ? "βž• " : "βž– ", b(`${key}: `), () => hide.val ? "…" : "",
        )
    ) : [],
    valueDom,
  )
}

const JsonInspector = ({initInput}) => {
  const autoGrow = e => {
    e.style.height = "5px"
    e.style.height = (e.scrollHeight + 5) + "px"
  }
  const textareaDom = textarea({oninput: e => autoGrow(e.target)}, initInput)
  setTimeout(() => autoGrow(textareaDom), 10)
  const errmsg = van.state(""), json = van.state(null)

  const inspect = () => {
    try {
      json.val = JSON.parse(textareaDom.value)
      errmsg.val = ""
    } catch (e) {
      errmsg.val = e.message
    }
  }

  return div(
    div(textareaDom),
    div(button({onclick: inspect}, "Inspect")),
    pre({style: "color: red"}, errmsg),
    () => json.val ? ListItem({key: "", value: json.val}) : "",
  )
}

Demo:

Try on jsfiddle

Textarea with Autocomplete


The code below implements a textarea with autocomplete support. This implementation leverages Stateful DOM binding to optimize the performance of DOM tree rendering:

The code was implemented in TypeScript to validate VanJS's TypeScript support.

interface SuggestionListProps {
  readonly candidates: readonly string[]
  readonly selectedIndex: number
}
const SuggestionList = ({candidates, selectedIndex}: SuggestionListProps) =>
  div({class: "suggestion"}, candidates.map((s, i) => pre({
    "data-index": i,
    class: i === selectedIndex ? "text-row selected" : "text-row",
  }, s)))

const lastWord = (text: string) => text.match(/\w+$/)?.[0] ?? ""

const AutoComplete = ({words}: {readonly words: readonly string[]}) => {
  const getCandidates = (prefix: string) => {
    const maxTotal = 10, result: string[] = []
    for (let word of words) {
      if (word.startsWith(prefix.toLowerCase())) result.push(word)
      if (result.length >= maxTotal) break
    }
    return result
  }

  const prefix = van.state("")
  const candidates = van.derive(() => getCandidates(prefix.val))
  // Resetting selectedIndex to 0 whenever candidates change
  const selectedIndex = van.derive(() => (candidates.val, 0))

  const onkeydown = (e: KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      selectedIndex.val = selectedIndex.val + 1 < candidates.val.length ? selectedIndex.val + 1 : 0
      e.preventDefault()
    } else if (e.key === "ArrowUp") {
      selectedIndex.val = selectedIndex.val > 0 ? selectedIndex.val - 1 : candidates.val.length - 1
      e.preventDefault()
    } else if (e.key === "Enter") {
      const candidate = candidates.val[selectedIndex.val] ?? prefix.val
      const target = <HTMLTextAreaElement>e.target
      target.value += candidate.substring(prefix.val.length)
      target.setSelectionRange(target.value.length, target.value.length)
      prefix.val = lastWord(target.value)
      e.preventDefault()
    }
  }

  const oninput = (e: Event) => prefix.val = lastWord((<HTMLTextAreaElement>e.target).value)

  return div({class: "root"}, textarea({onkeydown, oninput}), (dom?: Element) => {
    if (dom && candidates.val === candidates.oldVal) {
      // If the candidate list doesn't change, we don't need to re-render the
      // suggestion list. Just need to change the selected candidate.
      dom.querySelector(`[data-index="${selectedIndex.oldVal}"]`)
        ?.classList?.remove("selected")
      dom.querySelector(`[data-index="${selectedIndex.val}"]`)
        ?.classList?.add("selected")
      return dom
    }
    return SuggestionList({candidates: candidates.val, selectedIndex: selectedIndex.val})
  })
}

Demo:

Try on jsfiddle

Alternatively, we can implement the same app with State-derived properties:

The code was implemented in TypeScript to validate VanJS's TypeScript support.

const lastWord = (text: string) => text.match(/\w+$/)?.[0] ?? ""

const AutoComplete = ({words}: {readonly words: readonly string[]}) => {
  const maxTotalCandidates = 10

  const getCandidates = (prefix: string) => {
    const result: string[] = []
    for (let word of words) {
      if (word.startsWith(prefix.toLowerCase())) result.push(word)
      if (result.length >= maxTotalCandidates) break
    }
    return result
  }

  const prefix = van.state("")
  const candidates = van.derive(() => getCandidates(prefix.val))
  // Resetting selectedIndex to 0 whenever candidates change
  const selectedIndex = van.derive(() => (candidates.val, 0))

  const SuggestionListItem = ({index}: {index: number}) => pre(
    {class: () => index === selectedIndex.val ? "text-row selected" : "text-row"},
    () => candidates.val[index] ?? "",
  )

  const suggestionList = div({class: "suggestion"},
    Array.from({length: 10}).map((_, index) => SuggestionListItem({index})))

  const onkeydown = (e: KeyboardEvent) => {
    if (e.key === "ArrowDown") {
      selectedIndex.val = selectedIndex.val + 1 < candidates.val.length ? selectedIndex.val + 1 : 0
      e.preventDefault()
    } else if (e.key === "ArrowUp") {
      selectedIndex.val = selectedIndex.val > 0 ? selectedIndex.val - 1 : candidates.val.length - 1
      e.preventDefault()
    } else if (e.key === "Enter") {
      const candidate = candidates.val[selectedIndex.val] ?? prefix.val
      const target = <HTMLTextAreaElement>e.target
      target.value += candidate.substring(prefix.val.length)
      target.setSelectionRange(target.value.length, target.value.length)
      prefix.val = lastWord(target.value)
      e.preventDefault()
    }
  }

  const oninput = (e: Event) => prefix.val = lastWord((<HTMLTextAreaElement>e.target).value)

  return div({class: "root"}, textarea({onkeydown, oninput}), suggestionList)
}

Demo:

Try on jsfiddle

HTML/MD to VanJS Code Converter


The online UI for the HTML/MD snippet to VanJS code converter, is also implemented with VanJS.

Source code: convert.ts

Jupyter-like JavaScript Console


Next up, we're going to demonstrate a simplified Jupyter-like JavaScript console implemented in ~100 lines of code with VanJS. The JavaScript console supports drawing tables (with the technique similar to Table Viewer), inspecting objects in a tree view (with the technique similar to Json Inspector) and plotting (with the integration of Google Charts).

Here is the implementation:

const toDataArray = data => {
  const hasPrimitive = !data.every(r => typeof r === "object")
  const keys = [...new Set(
    data.flatMap(r => typeof r === "object" ? Object.keys(r) : []))]
  return [
    (hasPrimitive ? ["Value"] : []).concat(keys),
    ...data.map(r =>
      (typeof r === "object" ? (hasPrimitive ? [""] : []) : [r]).concat(
        keys.map(k => r[k] ?? "")
      )),
  ]
}

const table = data => {
  const dataArray = toDataArray(data)
  return van.tags.table(
    thead(tr(th("(index)"), dataArray[0].map(k => th(k)))),
    tbody(dataArray.slice(1).map((r, i) => tr(td(i), r.map(c => td(c))))),
  )
}

const plot = (data, chartType, options) => {
  if (data[0].constructor === Object) data = toDataArray(data)
  else if (typeof data[0] === "number")
    data = [["", "Value"], ...data.map((d, i) => [i + 1, d])]
  const dom = div({class: "chart"})
  setTimeout(() => new google.visualization[chartType](dom).draw(
    google.visualization.arrayToDataTable(data), options))
  return dom
}

const Tree = ({obj, indent = ""}) =>
  (indent ? div : pre)(Object.entries(obj).map(([k, v]) => {
    if (v?.constructor !== Object && !Array.isArray(v))
      return div(indent + "🟰 ", van.tags.b(k + ": "), v)
    const expanded = van.state(false)
    let treeDom
    const onclick = van.derive(() => expanded.val ?
      () => (treeDom.remove(), expanded.val = !expanded.val) :
      () => (treeDom = result.appendChild(Tree({obj: v, indent: indent + "  "}),
        expanded.val = !expanded.val)))
    const result = div(
      indent,
      van.tags.a({onclick},
        () => expanded.val ? "βž– " : "βž• ",
        van.tags.b(k + ":"),
        () => expanded.val ? "" : " {…}",
      ),
    )
    return result
  }))

const ValueView = expr => {
  try {
    const value = eval(`(${expr})`)
    if (value instanceof Element) return value
    if (value?.constructor === Object || Array.isArray(value)) return Tree({obj: value})
    return pre(String(value))
  } catch (e) {
    return pre({class: "err"}, e.message + "\n" + e.stack)
  }
}

const Output = ({id, expr}) => div({class: "row"},
  pre({class: "left"}, `Out[${id}]:`),
  div({class: "break"}),
  div({class: "right"}, ValueView(expr)),
)

const autoGrow = e => {
  e.target.style.height = "5px"
  e.target.style.height = (e.target.scrollHeight + 5) + "px"
}

const Input = ({id}) => {
  const run = () => {
    textareaDom.setAttribute("readonly", true)
    runDom.disabled = true
    const newTextDom = van.add(textareaDom.closest(".console"), Output({id, expr: textareaDom.value}))
      .appendChild(Input({id: id + 1}))
      .querySelector("textarea")
    newTextDom.focus()
    setTimeout(() => newTextDom.scrollIntoView(), 10)
  }
  const runDom = button({class: "run", onclick: run}, "Run")
  const onkeydown = async e => {
    if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
      e.preventDefault()
      run()
    }
  }
  const textareaDom = textarea({id, type: "text", onkeydown, oninput: autoGrow,
    rows: 1, placeholder: 'Enter JS expression here:'})
  return div({class: "row"},
    pre({class: "left"}, `In[${id}]:`), runDom, div({class: "break"}),
    div({class: "right"}, textareaDom),
  )
}

const Console = () => div({class: "console"}, Input({id: 1}))

Demo:

Try on jsfiddle

You can also try out the JavaScript console in this standalone page.

An Improved Unix Terminal


Next up is a web-based Unix terminal that connects to your local computer, with notable improvements, all under 300 lines of code. This is to demonstrate that, with VanJS, we can easily provide great extension to commandline utilities with fancy GUI by leveraging all available HTML elements. The program is heavily tested in macOS, and should in theory works in Linux, or in any environment that has /bin/sh.

See github.com/vanjs-org/van/tree/main/demo/terminal for the app (preview).

Community Examples


Besides the official VanJS examples, there are also sample apps from the great VanJS community. Below is a curated list (contact tao@vanjs.org to add yours):