npm install lit @e280/sly @e280/strata @e280/stz- 🎭 #views, reactive lit views, light-dom or shadow-dom
- 🪝 #hooks, react-like composable hooks
- ⏳ #spinners, display async operations with animations
- 💅 #spa, tiny router for hashy little single-page-apps
- 🪙 #loot, drag-and-drop facilities
- 🪄 #dom, the "it's not jquery" multitool
- 🧪 https://sly.e280.org/ sly's testing page
reactive lit-html views
- 🔮 see codepen demo, plain html (no build!)
- 🌗 light or shadow, render nakedly on the page, or within a cozy shadow bubble
- 🪝 hooks-based, familiar react-style hooks
- ⚡ auto-reactive, views magically rerender on strata-compatible state changes
- 🪶 no compile step, just god's honest javascript via lit-html tagged-templates
- 🧩 not web components, no dom registration needed, just vibes and good typings
import {html} from "lit"
import {light, shadow, dom} from "@e280/sly"
export const MyLightView = light(() => html`<p>blinded by the light</p>`)
export const MyShadowView = shadow(() => html`<p>shrouded in darkness</p>`)lit, signals, hooks — life is joyous again
- define a light view
import {html} from "lit" import {light, useSignal} from "@e280/sly" export const MyCounter = light((start: number) => { const $count = useSignal(start) const increment = () => $count.value++ return html` <button @click="${increment}">${$count.value}</button> ` })
- render it into the dom
dom.render(dom(".demo"), html` <h1>my cool counter demo</h1> ${MyCounter(123)} `)
- remember, light views are naked.
so they don't have a containing host element,
and they can't have their own styles.
each shadow view gets its own cozy shadow-dom bubble, which scopes local css, and also supports slotting
- define a shadow view
import {css, html} from "lit" import {shadow, useName, useCss, useSignal} from "@e280/sly" export const MyShadowCounter = shadow((start: number) => { useName("counter") useCss(css`button { color: cyan }`) const $count = useSignal(start) const increment = () => $count.value++ return html` <button @click="${increment}">${$count.value}</button> <slot></slot> ` })
- render it into the dom
dom.render(dom(".demo"), html` <h1>my cool counter demo</h1> ${MyShadowCounter(234)} `)
- shadow views have a host element, rendered output looks like:
<h1>my cool counter demo</h1> <sly-shadow view="counter"></sly-shadow>
- shadow views have a host element, rendered output looks like:
- .with to nest children or set attrs
dom.render(dom(".demo"), html` <h1>my cool counter demo</h1> ${MyShadowCounter.with({ props: [234], attrs: {"data-whatever": 555}, children: html` <p>woah, slotting support!</p> `, })} `)
- you can do custom shadow setup if needed (default shown)
import {SlyShadow} from "@e280/sly" const customShadow = shadow.setup(() => { SlyShadow.register() const host = document.createElement("sly-shadow") const shadow = host.attachShadow({mode: "open"}) return {host, shadow} }) const MyShadowView = customShadow(() => html`<p>shrouded in darkness</p>`)
web-native custom elements
- they use hooks like the views, but they don't take props
import {html} from "lit" import {lightElement, shadowElement} from "@e280/sly" const MyLight = lightElement(() => html`hello`) const MyShadow = shadowElement(() => html`hello`) dom.register({MyLight, MyShadow})
<my-light></my-light> <my-shadow></my-shadow>
composable view state and utilities
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 rootuseCss(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 signal
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") })
- useWake, run fn each time mounted, and return value
const whatever = useWake(() => { console.log("mounted") return 123 }) whatever // 123
- useLifecycle, mount/unmount lifecycle, but also return a value
const whatever = useLifecycle(() => { 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"))
- useWait, start loading a strata#wait signal
const $wait = useWait(async() => { await nap(2000) return 123 })
- look at the current
Waitstate$wait() // {done: true, ok: true, value: 123}
- await for when the value is ready
await $wait.ready // 123
- look at the current
- useWaitResult, start a strata#wait, but with a formal stz#ok ok/err result
const $wait = useWaitResult(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.value++ }))
- wake + rendered, to do something after each mount's first render
const rendered = useRendered() useWake(() => rendered.then(() => { console.log("after first render") }))
animated loading spinners
- from stz#ok — ok/err, formal error handling
import {ok, err, nap} from "@e280/stz"
- from strata#wait — wait, async operation state
import {wait} from "@e280/strata"
- okay, so let's just do a loading spinner example
import {html} from "lit" import {shadow, useWait, spinner} from "@e280/sly" const MyView = shadow(() => { // ⏳️ 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
tiny router for cozy single page apps
import {derived} from "@e280/strata"
import {router, norm, hashNav, hashSignal} from "@e280/sly"the spa router is agnostic about whether you're routing location.hash or location.pathname or otherwise.
- router
you get a fn that resolves the path you give it
const route = router({ "": () => "home", // 💁 routes can return anything "settings": () => "settings", // 🧩 params use braces "user/{id}": params => `user ${params.id}`, // 🔀 subpath with {*} "user/{id}/{*}": (params, subpath) => `user ${params.id} ${subpath}`, })
route("") // "home" route("settings") // "settings" route("user/123/profile") // "user 123 profile" route("unknown/whatever") // undefined
normfn chops off leading slashes and/or hash charsroute(norm(location.hash)) // "#/settings" -> "settings"
route(norm(location.pathname)) // "/settings" -> "settings"
- subrouting pattern
// 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"
now, if you want to setup location.hash routing, you might want these primitives.
- hashNav fn to trigger navigations
const go = hashNav({ home: () => ``, settings: () => `settings`, user: (id: string) => `user/${id}`, userProfile: (id: string) => `user/${id}/profile`, userInvites: (id: string) => `user/${id}/invites`, }) go.settings() // navigates to "#/settings" go.user("123") // navigates to "#/user/123"
- hashSignal create a strata signal for the current normalized
location.hashconst $hash = hashSignal()
$hash.value // "user/123/profile"
- the signal value auto-updates whenever the hash changes
- the value is run through the
normfn to chop off the leading#/ - whenever the hash changes, it runs
cleanHashfn which aesthetically convertse280.org/#/to juste280.org/in the address bar
- you should setup a derived signal that routes whenever that hash signal changes
then you can plop that content into your lit html
const $content = derived(() => route($hash())) // "user 123 profile"
html` <div> ${$content()} </div> `
drag-and-drop facilities
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)
the "it's not jquery!" multitool
import {dom} from "@e280/sly"requirean elementdom(".demo") // HTMLElement (or throws)
// alias dom.require(".demo") // HTMLElement (or throws)
maybeget an elementdom.maybe(".demo") // HTMLElement | undefined
allmatching elements in an arraydom.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).require(".button")
dom.in(demoElement).maybe(".button")
dom.in(demoElement).all("ol li")
dom.registerweb componentsdom.register({MyComponent, AnotherCoolComponent}) // <my-component> // <another-cool-component>
dom.registerautomatically dashes the tag names (MyComponentbecomes<my-component>)
dom.rendercontent into an elementdom.render(element, html`<p>hello world</p>`)
dom.in(".demo").render(html`<p>hello world</p>`)
dom.ellittle element builderconst div = dom.el("div", {"data-whatever": 123, "data-active": true}) // <div data-whatever="123" data-active></div>
dom.elmermake an element with a fluent chainconst 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 listenersconst 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 helperconst 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 specattrs.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
reward us with github stars
build with us at https://e280.org/ but only if you're cool