e280's buildercore view library.
- 🎭 #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
npm install @e280/sly @e280/strata @e280/stz litreactive 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 good vibes and good typings
import{html}from"lit"import{light,shadow,dom}from"@e280/sly"exportconstMyLightView=light(()=>html`<p>blinded by the light</p>`)exportconstMyShadowView=shadow(()=>html`<p>shrouded in darkness</p>`)lit, signals, hooks — life is good
- define a light view
import{html}from"lit"import{light,useSignal}from"@e280/sly"exportconstMyCounter=light((start: number)=>{const$count=useSignal(start)constincrement=()=>$count($count()+1)returnhtml`<button@click="${increment}">${$count()}</button> `})
- render it into the dom
dom.in(".demo").render(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 to protect its local css, and it also supports slotting
- define a shadow view
import{css,html}from"lit"import{shadow,useName,useCss,useSignal}from"@e280/sly"exportconstMyShadowCounter=shadow((start: number)=>{useName("counter")useCss(css`button { color: cyan }`)const$count=useSignal(start)constincrement=()=>$count($count()+1)returnhtml`<button@click="${increment}">${$count()}</button><slot></slot> `})
- render it into the dom
dom.in(".demo").render(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-shadowview="counter"></sly-shadow>
- shadow views have a host element, rendered output looks like:
- .with to nest children or set attrs
dom.in(".demo").render(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"constcustomShadow=shadow.setup(()=>{SlyShadow.register()consthost=document.createElement("sly-shadow")constshadow=host.attachShadow({mode: "open"})return{host, shadow}})constMyShadowView=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"constMyLight=lightElement(()=>html`hello`)constMyShadow=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
consthost=useHost()
- useShadow, get the shadow root
constshadow=useShadow()
- useAttrs, access host element attributes (and rerender on attr changes)
constattrs=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)constincrement=()=>setCount(n=>n+1)
- useRef, react-like hook to make a non-reactive box for a value
constref=useRef(0)ref.current// 0ref.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
constwhatever=useOnce(()=>{console.log("happens one time")return123})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
constwhatever=useWake(()=>{console.log("mounted")return123})whatever// 123
- useMounted, mount/unmount lifecycle, but also return a value
constwhatever=useMounted(()=>{console.log("mounted")constvalue=123return[value,()=>console.log("unmounted")]})whatever// 123
- useRender, returns a fn to rerender the view (debounced)
constrender=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()=>{awaitnap(2000)return123})
- look at the current
Waitstate$wait()// {done: true, ok: true, value: 123}
- await for when the value is ready
await$wait.ready// 123
- you can supply an optional
cleanupfnyour cleanup fn will run when the current view is unmountedconst$wait=useWait(fnMakesExpensiveThing,thing=>thing.dispose(),)
- look at the current
- useWaitFormal, start a strata#wait, but with a formal stz#ok ok/err result
const$wait=useWaitFormal(async()=>{awaitnap(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()=>{awaitnap(1000)$seconds($seconds()+1)}))
- wake + rendered, to do something after each mount's first render
constrendered=useRendered()useWake(()=>rendered.then(()=>{console.log("after first render")}))
animated loading spinners
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{shadow,useWait,spinner}from"@e280/sly"constMyView=shadow(()=>{// ⏳️ create a $wait signalconst$wait=useWait(async()=>{awaitnap(2000)// contrived async jobreturn123// return a value})// ⏳️ ui display for the changing $wait signalreturnspinner($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"exportconstpieSpinner=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
toolkit for clientside single-page-application hash-routing.
- 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.
constnavigate=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.
constroute=router({"": ()=>html`<h1>home</h1>`,"about": ()=>html`<h1>about</h1>`,"project/{id}": ({id})=>html`<h1>project ${id}</h1>`,})
- make an auto-updating signal that updates content when hash changes.then you can plop that onto your page somewhere.
const$content=derived(()=>route($hash()))
dom.render(dom(".content"),html`<div>${$content()}</div>`)
- subrouting pattern for composable routers.
// here's a subrouterconstuser=(params: {id: string})=>router({"profile": ()=>`user ${params.id} profile`,"invites": ()=>`user ${params.id} invites`,})// here's the main router, where we can nest the subrouterconstroute=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"
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
constdrops=newloot.Drops({predicate: loot.hasFiles,acceptDrop: event=>{constfiles=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 bodyev(document.body,{dragover: drops.dragover,dragleave: drops.dragleave,drop: drops.drop,})// sly attribute handler for the bodyconstattrs=dom.attrs(document.body).spec({"data-indicator": Boolean,})// sync the data-indicator attributedrops.$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 draggedtypeMoney={value: number}// dnd will call this a "draggy"// bag that money can be dropped intotypeBag={id: number}// dnd will call this a "droppy"
- make your dnd
constdnd=newloot.DragAndDrops<Money,Bag>({acceptDrop: (event,money,bag)=>{console.log("drop!",{money, bag})},})
- attach dragzone listeners (there can be many dragzones...)
light(()=>{constmoney=useOnce((): Money=>({value: 280}))constdragzone=useOnce(()=>dnd.dragzone(()=>money))returnhtml`<divdraggable="${dragzone.draggable}"@dragstart="${dragzone.dragstart}"@dragend="${dragzone.dragend}"> money ${money.value}</div> `})
- attach dropzone listeners (there can be many dropzones...)
light(()=>{constbag=useOnce((): Bag=>({id: 1}))constdropzone=useOnce(()=>dnd.dropzone(()=>bag))constindicator=!!(dnd.dragging&&dnd.hovering===bag)returnhtml`<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"needan elementdom(".demo")// HTMLElement (or throws)
// aliasdom.need(".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).need(".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 builderconstdiv=dom.el("div",{"data-whatever": 123,"data-active": true})// <div data-whatever="123" data-active></div>
dom.elmermake an element with a fluent chainconstdiv=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)constdiv=dom.mk(html`<divdata-whatever="123" data-active> hello world</div>`)// HTMLElement
dom.eventsto attach event listenersconstdetach=dom.events(element,{keydown: (e: KeyboardEvent)=>console.log("keydown",e.code),keyup: (e: KeyboardEvent)=>console.log("keyup",e.code),})
constdetach=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 donedetach()
dom.attrsto setup a type-happy html attribute helperconstattrs=dom.attrs(element).spec({name: String,count: Number,active: Boolean,})
constattrs=dom.in(".demo").attrs.spec({name: String,count: Number,active: Boolean,})
attrs.name// "chase"attrs.count// 123attrs.active// true
attrs.name="zenky"attrs.count=124attrs.active=false// removes html attr
or if you wanna be more loosey-goosey, skip the specattrs.name=undefined// removes the attrattrs.count=undefined// removes the attr
const{attrs}=dom.in(".demo")attrs.strings.name="pimsley"attrs.numbers.count=125attrs.booleans.active=true
reward us with github stars
build with us at https://e280.org/ but only if you're cool