e280's frontend library for buildercore wizards. built on lit. powered by strata.
📦 npm i lit @e280/sly
🎭 #views, light-dom or shadow-dom.
🪝 #hooks, react-like composable hooks.
⏳ #spinners, animated loading spinners.
💅 #spa, tiny router for hashy single-page-apps.
🪙 #loot, drag-and-drop facilities.
🪄 #dom, the handy "it's not jquery" multitool.
🧪 https://sly.e280.org/ sly's silly testing page.
you didn't know you wanted these in your life. but you do.
sly views yoink the most premium ideas from react, preact, web components, lit, haunted, and more.
- 🌗 light or shadow, render html nakedly, or tucked into a cozy shadow bubble.
- 🪝 hooks-based, familiar react-style hooks.
- ⚡ auto-reactive, views magically rerender on strata-compatible state changes.
- 🪶 no special compiler, just god's honest javascript via lit-html tagged-templates.
- 🧩 view aren't web components, no dom registration needed, nice typings.
- 🔮 see codepen demo, plain html. (no build!)
each shadow view gets its own comfy shadow-dom blankie to protect its local css, and it also supports slotting.
- define your shadow view.
import {css, html} from "lit" import {shadowView, useName, useCss, useSignal, renderIn} from "@e280/sly" export const MyCounter = shadowView((start: number, step = 1) => { useName("counter") useCss(css`button { color: cyan }`) const $count = useSignal(start) const increment = () => $count($count() + step) return html` <button @click="${increment}">${$count()}</button> <slot></slot> ` })
- render it on your page.
renderIn(".demo", html` <h1>my cool counter demo</h1> ${MyCounter(234)} `) // <h1>my cool counter demo</h1> // <sly-shadow view="counter"></sly-shadow>
- get more control:
.withto nest children or set attrs.renderIn(".demo", html` <h1>my cool counter demo</h1> ${MyCounter.with({ props: [234], attrs: {"data-whatever": 555}, children: html` <p>woah, slotting support!</p> `, })} `)
- fancy custom shadow settings, if you need. (default shown)
import {SlyShadow} from "@e280/sly" const customShadowView = shadow.setup(() => { SlyShadow.register() // soft-registers <sly-shadow> const host = document.createElement("sly-shadow") const shadow = host.attachShadow({mode: "open"}) return {host, shadow} }) const MyShadowView = customShadowView(() => { return html`<p>shrouded in darkness</p>` })
- there are light views too.
// render naked on page with no shadow bubble, no css, no host element. export const MyLightCounter = lightView(() => { return html`<p>blinded by the light</p>` })
web components are a great way to give html authors some cool widgets to play with.
they're not as great as the primary building blocks for typescript app devs.
- they use hooks like views, but they don't take props.
import {html} from "lit" import {lightElement, shadowElement, register} from "@e280/sly" const MyLight = lightElement(() => html`hello`) const MyShadow = shadowElement(() => html`hello`) register({MyLight, MyShadow})
<my-light></my-light> <my-shadow></my-shadow>
- shadowDuplex, make a view and an element at the same time.
this is great where as a library author, we want to supply a widget as both a sly view, and also standard web component.import {html} from "lit" import {shadowDuplex, register} from "@e280/sly" const [SumView, makeSumElement] = shadowDuplex( (a: number, b: number) => html`<p>sum: ${a + b}</p>` )
// view usage in any html template SumView(1, 2)
// register the element to the dom register({SumElement: makeSumElement(1, 2)})
- the params are hard-baked into the component at registration time.
- the common pattern here, is to pass an app context object to these duplexes.
- hooks like
useAttrscan query the element's runtime attributes. - there also a matching
lightDuplex.
just like react hooks, the execution order of hooks seriously matters.
you must not call these hooks under if-conditionals, or for-loops, or inside callback functions, or after a conditional return statement, or anything like that.. otherwise, heed my warning: weird bad stuff will happen..
- useName, set the "view" attribute value.
useName("squarepants") // <sly-shadow view="squarepants">
- useCss, attach stylesheets (use lit's
css!) to the shadow root.useCss(css1, css2, css3)
- useHost, get the host element.
const host = useHost()
- useShadow, get the shadow root.
const shadow = useShadow()
- useAttrs, access host element attributes (and rerender on attr changes).
const attrs = useAttrs({ name: String, count: Number, active: Boolean, }) attrs.count = 123 // set the attr
- useState, react-like hook to create some reactive state (we prefer signals).
const [count, setCount] = useState(0) const increment = () => setCount(n => n + 1)
- useRef, react-like hook to make a non-reactive box for a value.
const ref = useRef(0) ref.current // 0 ref.current = 1 // does not trigger rerender
- useSignal, create a strata signal.
const $count = useSignal(1) // read the signal $count() // write the signal $count(2)
- useDerived, create a strata derived formula.
const $product = useDerived(() => $count() * $whatever())
- useEffect, run a fn whenever strata state changes.
useEffect(() => console.log($count()))
- useOnce, run fn at initialization, and return a value.
const whatever = useOnce(() => { console.log("happens one time") return 123 }) whatever // 123
- useMount, setup mount/unmount lifecycle.
useMount(() => { console.log("mounted") return () => console.log("unmounted") })
- useUnmount, setup unmount handler.
useUnmount(() => console.log("unmounted"))
- useWake, run fn each time mounted, and return value.
const whatever = useWake(() => { console.log("mounted") return 123 }) whatever // 123
- useMounted, mount/unmount lifecycle, but also return a value.
const whatever = useMounted(() => { console.log("mounted") const value = 123 return [value, () => console.log("unmounted")] }) whatever // 123
- useRender, returns a fn to rerender the view (debounced).
const render = useRender() render().then(() => console.log("render done"))
- useRendered, get a promise that resolves after the next render.
useRendered().then(() => console.log("rendered"))
- useCount, get count of how many times this view has been rendered (starts at 0).
console.log(useCount()) // 0
- useWait, start loading a strata#wait signal.
const $wait = useWait(async() => { await nap(2000) return 123 })
$wait() // {done: true, ok: true, value: 123}
await $wait.ready // 123
const $wait = useWait(fnMakesExpensiveThing, thing => { // disposed when current view is unmounted. thing.dispose() })
- useWaitFormal, start a strata#wait, but with a formal stz#ok ok/err result.
const $wait = useWaitFormal(async() => { await nap(2000) return (Math.random() > 0.5) ? ok(123) : err("uh oh") })
- make a ticker, mount, cycle, and nap.
import {cycle, nap} from "@e280/stz"
const $seconds = useSignal(0) useMount(() => cycle(async() => { await nap(1000) $seconds($seconds() + 1) }))
- wake + rendered, to do something after each mount's first render.
const rendered = useRendered() useWake(() => rendered.then(() => { console.log("after first render") }))
sly's spinners integrate with strata wait, which in turn integrates with stz ok, so you might want to read each of those docs.
- okay, so let's just do a loading spinner example.
import {html} from "lit" import {shadowView, useWait, spinner} from "@e280/sly" const MyView = shadowView(() => { // ⏳️ create a $wait signal const $wait = useWait(async() => { await nap(2000) // contrived async job return 123 // return a value }) // ⏳️ ui display for the changing $wait signal return spinner($wait(), value => html` <p>done, the value is ${value}</p> `) })
- while the async fn is running, an animated spinner will be shown.
- when the async fn resolves, our little
<p>tag will render. - if the async fn errors out, the error message will be displayed in red.
- stock spinners for your convenience (earth is my favorite).
import {spinner, dotsSpinner, waveSpinner, earthSpinner, moonSpinner} from "@e280/sly"
- it's easy
import {makeSpinner, makeAsciiAnim, ErrorDisplay} from "@e280/sly" export const pieSpinner = makeSpinner( makeAsciiAnim(10, ["◷", "◶", "◵", "◴"]), ErrorDisplay, )
- so makeSpinner accepts two views, one for the loading state, and one for the error state.
- feel free to make your own views.
- import stuff.
import {html} from "lit" import {derived} from "@e280/strata" import {watchHash, hashNav, router} from "@e280/sly"
- make a readonly hash signal that stays synced to the current normalized url hash.
const $hash = watchHash() // like "", or "about", or "project/123"
- hashNav to setup navigation fns.
then you can call those fns to navigate.
const navigate = hashNav({ home: () => ``, about: () => `about`, project: (id: string) => `project/${id}`, })
navigate.project("123")
- router produces a fn that returns the content that matches the given path.
const route = router({ "": () => html`<h1>home</h1>`, "about": () => html`<h1>about</h1>`, "project/{id}": ({id}) => html`<h1>project ${id}</h1>`, })
- render the hash's route onto your page.
const App = lightView(() => route($hash())) renderIn(".content", App())
- subrouting pattern for composable routers.
// here's a subrouter const user = (params: {id: string}) => router({ "profile": () => `user ${params.id} profile`, "invites": () => `user ${params.id} invites`, }) // here's the main router, where we can nest the subrouter const route = router({ // this {*} captures the rest of the string, we pass it to the subrouter "user/{id}/{*}": (params, subpath) => user(params)(subpath), })
route("user/123/profile") // "user 123 profile"
normto manually normalize url paths, chops off leading slashes and hash chars.norm(location.hash) // "#/settings" -> "settings"
norm(location.pathname) // "/settings" -> "settings"
import {loot, view, dom} from "@e280/sly"
import {ev} from "@e280/stz"accept the user dropping stuff like files onto the page.
- setup drops.
const drops = new loot.Drops({ predicate: loot.hasFiles, acceptDrop: event => { const files = loot.files(event) console.log("files dropped", files) }, })
- attach event listeners to your dropzone, one of these ways:
- view example.
light(() => html` <div ?data-indicator="${drops.$indicator()}" @dragover="${drops.dragover}" @dragleave="${drops.dragleave}" @drop="${drops.drop}"> my dropzone </div> `)
- vanilla-js whole-page example.
// attach listeners to the body ev(document.body, { dragover: drops.dragover, dragleave: drops.dragleave, drop: drops.drop, }) // sly attribute handler for the body const attrs = dom.attrs(document.body).spec({ "data-indicator": Boolean, }) // sync the data-indicator attribute drops.$indicator.on(bool => attrs["data-indicator"] = bool)
- view example.
- flashy css indicator for the dropzone, so the user knows your app is eager to accept the drop.
[data-indicator] { border: 0.5em dashed cyan; }
setup drag-and-drops between items within your page.
- declare types for your draggy and droppy things.
// money that can be picked up and dragged type Money = {value: number} // dnd will call this a "draggy" // bag that money can be dropped into type Bag = {id: number} // dnd will call this a "droppy"
- make your dnd.
const dnd = new loot.DragAndDrops<Money, Bag>({ acceptDrop: (event, money, bag) => { console.log("drop!", {money, bag}) }, })
- attach dragzone listeners (there can be many dragzones...).
light(() => { const money = useOnce((): Money => ({value: 280})) const dragzone = useOnce(() => dnd.dragzone(() => money)) return html` <div draggable="${dragzone.draggable}" @dragstart="${dragzone.dragstart}" @dragend="${dragzone.dragend}"> money ${money.value} </div> ` })
- attach dropzone listeners (there can be many dropzones...).
light(() => { const bag = useOnce((): Bag => ({id: 1})) const dropzone = useOnce(() => dnd.dropzone(() => bag)) const indicator = !!(dnd.dragging && dnd.hovering === bag) return html` <div ?data-indicator="${indicator}" @dragenter="${dropzone.dragenter}" @dragleave="${dropzone.dragleave}" @dragover="${dropzone.dragover}" @drop="${dropzone.drop}"> bag ${bag.id} </div> ` })
loot.hasFiles(event)— return true ifDragEventcontains any files (useful inpredicate).loot.files(event)— returns an array of files in a drop'sDragEvent(useful inacceptDrop).
import {dom, register, renderIn} from "@e280/sly"registerweb components, automatically dashifying the names (MyComponentbecomes<my-component>).register({MyComponent, AnotherCoolComponent}) // <my-component> // <another-cool-component>
renderIncontent into an element.renderIn(".demo", html`<p>hello world</p>`)
renderIn(element, html`<p>hello world</p>`)
needan element.dom(".demo") // HTMLElement (or throws)
// alias dom.need(".demo") // HTMLElement (or throws)
maybeget an element.dom.maybe(".demo") // HTMLElement | undefined
allmatching elements in an array.dom.all(".demo ul li") // HTMLElement[]
- make a scope.
dom.in(".demo") // selector // Dom instance
dom.in(demoElement) // element // Dom instance
- run queries in that scope.
dom.in(demoElement).need(".button")
dom.in(demoElement).maybe(".button")
dom.in(demoElement).all("ol li")
dom.ellittle element builder.const div = dom.el("div", {"data-whatever": 123, "data-active": true}) // <div data-whatever="123" data-active></div>
dom.elmermake an element with a fluent chain.const div = dom.elmer("div") .attr("data-whatever", 123) .attr("data-active") .children("hello world") .done() // HTMLElement
dom.mkmake an element with a lit template (returns the first).const div = dom.mk(html` <div data-whatever="123" data-active> hello world </div> `) // HTMLElement
dom.eventsto attach event listeners.const detach = dom.events(element, { keydown: (e: KeyboardEvent) => console.log("keydown", e.code), keyup: (e: KeyboardEvent) => console.log("keyup", e.code), })
const detach = dom.in(".demo").events({ keydown: (e: KeyboardEvent) => console.log("keydown", e.code), keyup: (e: KeyboardEvent) => console.log("keyup", e.code), })
// unattach those event listeners when you're done detach()
dom.attrsto setup a type-happy html attribute helper.const attrs = dom.attrs(element).spec({ name: String, count: Number, active: Boolean, })
const attrs = dom.in(".demo").attrs.spec({ name: String, count: Number, active: Boolean, })
attrs.name // "chase" attrs.count // 123 attrs.active // true
attrs.name = "zenky" attrs.count = 124 attrs.active = false // removes html attr
or if you wanna be more loosey-goosey, skip the spec:attrs.name = undefined // removes the attr attrs.count = undefined // removes the attr
const {attrs} = dom.in(".demo") attrs.strings.name = "pimsley" attrs.numbers.count = 125 attrs.booleans.active = true
i've been whittling away at sly and friends for many years in isolation.
this is my way of doing frontend. maybe it's the way of a madman in a shack. maybe it's a manifesto.
anyways i hope you think it's neat.
— chase.