Skip to content

e280/sly

Repository files navigation

🦝 sly

npm install lit @e280/sly @e280/strata @e280/stz

@e280's lit-based web library for reactive light or shadow views

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



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

🌞 light views

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.

🌚 shadow views

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>
  • .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 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"
    
    const MyLight = lightElement(() => html`hello`)
    const MyShadow = 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
    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

🌞 universal hooks

  • 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 Wait state
      $wait()
        // {done: true, ok: true, value: 123}
    • await for when the value is ready
      await $wait.ready
        // 123
  • 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")
    })

🧑‍🍳 happy hooks recipes

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



⏳️ spinners

animated loading spinners

⏳️ stuff you'll need

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

⏳️ ui jumpstart

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

⏳️ make your own spinners

  • 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



💅 spa

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
    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}`,
    })
    you get a fn that resolves the path you give it
    route("")
      // "home"
    
    route("settings")
      // "settings"
    
    route("user/123/profile")
      // "user 123 profile"
    
    route("unknown/whatever")
      // undefined
  • norm fn chops off leading slashes and/or hash chars
    route(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.hash
    const $hash = hashSignal()
    $hash.value
      // "user/123/profile"
    • the signal value auto-updates whenever the hash changes
    • the value is run through the norm fn to chop off the leading #/
    • whenever the hash changes, it runs cleanHash fn which aesthetically converts e280.org/#/ to just e280.org/ in the address bar
  • you should setup a derived signal that routes whenever that hash signal changes
    const $content = derived(() => route($hash()))
      // "user 123 profile"
    then you can plop that content into your lit html
    html`
      <div>
        ${$content()}
      </div>
    `



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

  • require an element
    dom(".demo")
      // HTMLElement (or throws)
    // alias
    dom.require(".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).require(".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
    const div = 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
    const div = 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)
    const div = dom.mk(html`
      <div data-whatever="123" data-active>
        hello world
      </div>
    `) // HTMLElement
  • dom.events to 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.attrs to 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
    attrs.name = undefined // removes the attr
    attrs.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 = 125
    attrs.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