VanJS: Learning by Example
Simplicity is the ultimate sophistication.
-- Apple Inc.
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:
This is the funnier Hello
program shown in Getting Started page:
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const Run = ({sleepMs}) => {
const steps = van.state(0)
;(async () => { for (; steps.val < 40; ++steps.val) await sleep(sleepMs) })()
return pre(() => `${" ".repeat(40 - steps.val)}๐๐จHello VanJS!${"_".repeat(steps.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:
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:
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:
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:
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:
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))
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,
)
}
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)),
),
)
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:
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 ? del : 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:
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 ? del : 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:
With the help of VanX, the code above can be simplified to just 10+ lines:
const TodoList = () => {
const items = vanX.reactive(JSON.parse(localStorage.getItem("appState") ?? "[]"))
van.derive(() => localStorage.setItem("appState", JSON.stringify(vanX.compact(items))))
const inputDom = input({type: "text"})
return div(
inputDom, button({onclick: () => items.push({text: inputDom.value, done: false})}, "Add"),
vanX.list(div, items, ({val: v}, deleter) => div(
input({type: "checkbox", checked: () => v.done, onclick: e => v.done = e.target.checked}),
() => (v.done ? del : span)(v.text),
a({onclick: deleter}, "โ"),
)),
)
}
Demo:
You can refer to vanX.list
for more details.
You can also check the example by @ArcaneEngineer, which is a slight variation of this TODO App
to allow TODO items to be editable.
Fun Game: Emojis Pops
We're able to implement a mini game engine with VanJS in just a few lines. Here is a fun game implemented under 60 lines with the help of VanJS and VanX:
const Game = () => {
const time = van.state(60), score = van.state(0), inGame = van.state(false), items = vanX.reactive([])
const fps = 60, height = 400, frameFns = Array(fps * time.val).fill().map(() => [])
let curFrame = 0
const Item = ({val: v}, deleter) => {
const x = Math.floor(Math.random() * (document.body.clientWidth - 42)), y = van.state(0)
let deleted = false
v.removing = false
van.derive(() => v.removing &&
nextFrames(Math.floor(0.3 * fps)).then(() => (deleted = true, deleter())))
;(async () => {
do { await nextFrames(1) } while (!deleted && (y.val += v.speed) <= height)
v.removing || deleter()
})()
return span({
class: "item",
style: () => `left: ${x}px; bottom: ${y.val}px; opacity: ${v.removing ? 0 : 1};`,
onclick: () => inGame.val && !v.removing &&
frameFns[curFrame].push(() => (v.removing = v.msg, v.action())),
}, v.icon, () => v.removing ? span({class: "msg " + (v.bad ? "bad": "good")}, v.removing) : "")
}
const itemTypes = [
{icon: "๐", speed: 5, n: 60, msg: "+1", action: () => ++score.val},
{icon: "๐", speed: 10, n: 12, msg: "+10", action: () => score.val += 10},
{icon: "๐", speed: 3, n: 60, msg: "-5", bad: true, action: () => score.val -= 5},
{icon: "๐", speed: 5, n: 6, msg: "Slowed", action: () => items.forEach(it => it.speed /= 2)},
{icon: "๐ฃ", speed: 3, n: 60, msg: "BOOM!", bad: true, action: () =>
items.forEach(it => it.removing = "BOOM!")},
]
const begin = () => {
setInterval(() => {
if (!inGame.val) return
for (const fn of frameFns[curFrame]) fn()
++curFrame % 60 === 0 && --time.val
curFrame === frameFns.length && end()
}, 1000 / fps)
inGame.val = true
for (const type of itemTypes)
for (let i = 0; i < type.n; ++i)
frameFns[Math.floor(Math.random() * frameFns.length)].push(() => items.push({...type}))
}
const end = () => (alert("Your score: " + score.val), location.reload())
const nextFrames = n => new Promise(r => frameFns[curFrame + n]?.push(r))
return div({class: "root"},
span({class: "time"}, "Time: ", time), span({class: "score"}, "Score: ", score),
vanX.list(() => div({class: "board"}), items, Item),
div({class: "panel"},
button({onclick: () => curFrame ? inGame.val = !inGame.val : begin()},
() => inGame.val ? "Pause" : "Start",
),
),
)
}
๐ฎ Let's play! (you can share your score here: #174)
SPA w/ Client-Side Routing: Code Browser
With VanJS, you can built a single-page application with client-side routing support, thanks to VanJS's powerful builtin state management and state derivation:
const Browser = () => {
const file = van.state(location.hash.slice(1))
window.addEventListener("hashchange", () => file.val = location.hash.slice(1))
const text = van.derive(() => file.val ? (
fetch("https://api.github.com/repos/vanjs-org/van/contents/src/" + file.val)
.then(r => r.json())
.then(json => text.val = {lang: file.val.split(".").at(-1), str: atob(json.content)})
.catch(e => text.val = {str: e.toString()}),
{str: "Loading"}
) : {str: "Select a file to browse"})
const files = van.state([])
fetch("https://api.github.com/repos/vanjs-org/van/contents/src")
.then(r => r.json())
.then(json => files.val = json.map(f => f.name).filter(n => /\.(ts|js)$/.test(n)))
.catch(e => text.val = {str: e.toString()})
const browseFile = e => {
e.preventDefault()
history.pushState({}, "", new URL(e.target.href).hash)
dispatchEvent(new Event("hashchange"))
}
return div({class: "row"},
div({class: "left"}, ul(li({class: "folder"}, "src", () => ul(
files.val.map(f => li({class: "file"},
a({href: "#" + f, class: () => f === file.val ? "selected" : "", onclick: browseFile}, f),
)),
)))),
(dom = div({class: "right"}, pre(code()))) => {
const codeDom = dom.querySelector("code")
codeDom.textContent = text.val.str
codeDom.className = text.val.lang ? "language-" + text.val.lang : ""
if (text.val.lang) setTimeout(() => Prism.highlightAll(), 5)
return dom
},
)
}
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))),
),
)
}
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:
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:
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:
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:
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:
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 App | React-based App | |
---|---|---|
# of files: | 2 | 16 |
# of lines: | 143 | 616 |
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:
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()
try {
const packages = Object.entries(JSON.parse(json.val).packages).filter(([k]) => k)
return div(
h4("All Dependencies (", packages.length, ")"),
table(
thead(tr(th("Package"), th("Version"))),
tbody(packages.map(([k, {version}]) => {
const name = k.slice("node_modules/".length)
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())
}
},
]
}
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:
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:
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:
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:
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 [email protected] to add yours):
Author | Project | Preview |
---|---|---|
Yahia Berashish | VanJS JavaScript and TypeScript Vite Template | link |
artydev | VanJS Series | |
barrymun | Division Game | link |
enpitsuLin | TODO App | link |
Kwame Opare Asiedu | TODO App with routing and authentication | link |
่ฃๅฏ | Local Share - A tool for transferring files over LAN, using the WebRTC tech | link |
Kane | VanJS Chart.js graph render | link |
Neven DREAN | Modal Component & Routing with VanJS | link |
b rad c | VanJS SPA Template | link |
Vlad Sirenko | VanJS with Leaflet | link |
kangaroolab | tippy: a local first note app | link |
FredericHeem | Multi-Page App Starter Kit under 5kB | |
FredericHeem | VanJS Playground with Vite |