Skip to content

Repository files navigation

🦝 sly

e280's buildercore view library.

built on lit. powered by strata.

  • 🎭 #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 lit



🎭 views

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 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>`)

🌞 light views

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.

🌚 shadow views

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>
  • .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 components

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>



🪝 hooks

composable view state and utilities

👮 follow the hooks rules, or you go to hooks jail

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..

🌚 shadow-only hooks

  • 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
    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

🌞 universal hooks

  • 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 Wait state
      $wait()// {done: true, ok: true, value: 123}
    • await for when the value is ready
      await$wait.ready// 123
    • you can supply an optional cleanup fn
      const$wait=useWait(fnMakesExpensiveThing,thing=>thing.dispose(),)
      your cleanup fn will run when the current view is unmounted
  • 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")})

🧑‍🍳 happy hooks recipes

  • 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")}))



⏳️ spinners

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.

⏳️ ui jumpstart

  • 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"

⏳️ make your own spinners

  • 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



💅 spa

toolkit for clientside single-page-application hash-routing.

💅 typical spa setup.

  • 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.
    constnavigate=hashNav({home: ()=>``,about: ()=>`about`,project: (id: string)=>`project/${id}`,})
    then you can call those fns to navigate.
    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.
    const$content=derived(()=>route($hash()))
    then you can plop that onto your page somewhere.
    dom.render(dom(".content"),html`<div>${$content()}</div>`)

💅 advanced spa usage for giant brains.

  • 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"
  • norm to manually normalize url paths, chops off leading slashes and hash chars.
    norm(location.hash)// "#/settings" -> "settings"
    norm(location.pathname)// "/settings" -> "settings"



🪙 loot

drag-and-drop facilities

import{loot,view,dom}from"@e280/sly"import{ev}from"@e280/stz"

🪙 loot.Drops

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)
  • 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;
    }

🪙 loot.DragAndDrops

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 helpers

  • loot.hasFiles(event) — return true if DragEvent contains any files (useful in predicate)
  • loot.files(event) — returns an array of files in a drop's DragEvent (useful in acceptDrop)



🪄 dom

the "it's not jquery!" multitool

import{dom}from"@e280/sly"

🪄 dom queries

  • need an element
    dom(".demo")// HTMLElement (or throws)
    // aliasdom.need(".demo")// HTMLElement (or throws)
  • maybe get an element
    dom.maybe(".demo")// HTMLElement | undefined
  • all matching elements in an array
    dom.all(".demo ul li")// HTMLElement[]

🪄 dom.in scope

  • 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 utilities

  • dom.register web components
    dom.register({MyComponent, AnotherCoolComponent})// <my-component>// <another-cool-component>
    • dom.register automatically dashes the tag names (MyComponent becomes <my-component>)
  • dom.render content into an element
    dom.render(element,html`<p>hello world</p>`)
    dom.in(".demo").render(html`<p>hello world</p>`)
  • dom.el little element builder
    constdiv=dom.el("div",{"data-whatever": 123,"data-active": true})// <div data-whatever="123" data-active></div>
  • dom.elmer make an element with a fluent chain
    constdiv=dom.elmer("div").attr("data-whatever",123).attr("data-active").children("hello world").done()// HTMLElement
  • dom.mk make an element with a lit template (returns the first)
    constdiv=dom.mk(html`<divdata-whatever="123" data-active> hello world</div>`)// HTMLElement
  • dom.events to attach event listeners
    constdetach=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.attrs to setup a type-happy html attribute helper
    constattrs=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
    attrs.name=undefined// removes the attrattrs.count=undefined// removes the attr
    or if you wanna be more loosey-goosey, skip the spec
    const{attrs}=dom.in(".demo")attrs.strings.name="pimsley"attrs.numbers.count=125attrs.booleans.active=true



🧑‍💻 sly is by e280

reward us with github stars
build with us at https://e280.org/ but only if you're cool

About

🦝 mischievous shadow views

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages