Skip to content

e280/sly

Repository files navigation

🦝 sly

mischievous shadow views

@e280's new lit-based frontend webdev library. (sly replaces its predecessor, slate)

  • shiny — our wip component library https://shiny.e280.org/
  • 🍋 #views — shadow-dom'd, hooks-based, componentizable
  • 🪵 #base-element — for a more classical experience
  • 🪄 #dom — the "it's not jquery" multitool
  • 🫛 #ops — reactive tooling for async operations
  • #loaders — animated loading spinners for rendering ops
  • 💅 #spa — hash routing for your spa-day
  • 🪙 #loot — drag-and-drop facilities
  • 🧪 testing page — https://sly.e280.org/



🦝 sly and friends

@e280/sly

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

Note

  • 🔥 lit, for html rendering
  • ⛏️ @e280/strata, for state management (signals, state trees)
  • 🏂 @e280/stz, our ts standard library
  • 🐢 @e280/scute, our buildy-bundly-buddy

Tip

you can import everything in sly from @e280/sly,
or from specific subpackages like @e280/sly/view, @e280/sly/dom, etc...



🍋🦝 sly views

@e280/sly/view
the crown jewel of sly

view(use => () => html`<p>hello world</p>`)
  • 🪶 no compile step — just god's honest javascript, via lit-html tagged-template-literals
  • 🥷 shadow dom'd — each view gets its own cozy shadow bubble, and supports slots
  • 🪝 hooks-based — declarative rendering with the use family of ergonomic hooks
  • reactive — they auto-rerender whenever any strata-compatible state changes
  • 🧐 not components, per se — they're comfy typescript-native ui building blocks (technically, lit directives)
  • 🧩 componentizable — any view can be magically converted into a proper web component

🍋 view example

import {view, dom, BaseElement} from "@e280/sly"
import {html, css} from "lit"
  • declare view
    export const CounterView = view(use => (start: number) => {
      use.styles(css`p {color: green}`)
    
      const $count = use.signal(start)
      const increment = () => $count.value++
    
      return html`
        <button @click="${increment}">
          ${$count.value}
        </button>
      `
    })
  • inject view into dom
    dom.in(".app").render(html`
      <h1>cool counter demo</h1>
      ${CounterView(1)}
    `)
  • 🤯 register view as web component
    dom.register({
      MyCounter: CounterView
        .component()
        .props(() => [1]),
    })
    <my-counter></my-counter>

🍋 view settings

  • optional settings for views you should know about
    export const CoolView = view
      .settings({mode: "open", delegatesFocus: true})
      .render(use => (greeting: string) => html`😎 ${greeting} <slot></slot>`)
    • all attachShadow params (like mode and delegatesFocus) are valid settings
    • note the <slot></slot> we'll use in the next example lol

🍋 view chains

  • views have this sick chaining syntax for supplying more stuff at the template injection site
    dom.in(".app").render(html`
      <h2>cool example</h2>
      ${CoolView
        .props("hello")
        .attr("class", "hero")
        .children(html`<em>spongebob</em>`)
        .render()}
    `)
    • props — provide props and start a view chain
    • attr — set html attributes on the <sly-view> host element
    • children — add nested slottable content
    • render — end the view chain and render the lit directive

🍋 view/component universality

  • you can start with a view,
    export const GreeterView = view(use => (name: string) => {
      return html`<p>hello ${name}</p>`
    })
    • view usage
      GreeterView("pimsley")
    then you can convert it to a component.
    export class GreeterComponent extends (
      GreeterView
        .component()
        .props(component => [component.getAttribute("name") ?? "unknown"])
    ) {}
    • html usage
      <greeter-component name="pimsley"></greeter-component>
  • you can start with a component,
    export class GreeterComponent extends (
      view(use => (name: string) => {
        return html`<p>hello ${name}</p>`
      })
      .component()
      .props(component => [component.getAttribute("name") ?? "unknown"])
    ) {}
    • html usage
      <greeter-component name="pimsley"></greeter-component>
    and it already has .view ready for you.
    • view usage
      GreeterComponent.view("pimsley")
  • understanding .component(BaseElement) and .props(fn)
    • .props takes a fn that is called every render, which returns the props given to the view
      .props(() => ["pimsley"])
      the props fn receives the component instance, so you can query html attributes or instance properties
      .props(component => [component.getAttribute("name") ?? "unknown"])
    • .component accepts a subclass of BaseElement, so you can define your own properties and methods for your component class
      const GreeterComponent = GreeterView
      
        // declare your own custom class
        .component(class extends BaseElement {
          $name = signal("jim raynor")
          updateName(name: string) {
            this.$name.value = name
          }
        })
      
        // props gets the right types on 'component'
        .props(component => [component.$name.value])
    • .component provides the devs interacting with your component, with noice typings
      dom<GreeterComponent>("greeter-component").updateName("mortimer")
    • typescript class wizardry
      • ❌ smol-brain approach exports class value, but NOT the typings
        export const GreeterComponent = (...)
      • ✅ giga-brain approach exports class value AND the typings
        export class GreeterComponent extends (...) {}
  • register web components to the dom
    dom.register({GreeterComponent})
  • oh and don't miss out on the insta-component shorthand
    dom.register({
      QuickComponent: view.component(use => html`⚡ incredi`),
    })

🍋 "use" hooks reference

  • 👮 follow the hooks rules

    just like react hooks, the execution order of sly's use hooks actually matters..
    you must not call these hooks under if conditionals, or for loops, or in callbacks, or after a conditional return statement, or anything like that.. otherwise, heed my warning: weird bad stuff will happen..

  • use.name — set the "view" attr value, eg <sly-view view="squarepants">
    use.name("squarepants")
  • use.styles — attach stylesheets into the view's shadow dom
    use.styles(css1, css2, css3)
    (alias use.css)
  • use.signal — create a strata signal
    const $count = use.signal(1)
    
    // read the signal
    $count()
    
    // write the signal
    $count(2)
    • derived signals
      const $product = use.derived(() => $count() * $whatever())
    • lazy signals
      const $product = use.lazy(() => $count() * $whatever())
    • go read the strata readme about this stuff
  • use.once — run fn at initialization, and return a value
    const whatever = use.once(() => {
      console.log("happens only once")
      return 123
    })
    
    whatever // 123
  • use.mount — setup mount/unmount lifecycle
    use.mount(() => {
      console.log("view mounted")
    
      return () => {
        console.log("view unmounted")
      }
    })
  • use.wake — run fn each time mounted, and return value
    const whatever = use.wake(() => {
      console.log("view mounted")
      return 123
    })
    
    whatever // 123
  • use.life — mount/unmount lifecycle, but also return a value
    const v = use.life(() => {
      console.log("mounted")
      const value = 123
      return [value, () => console.log("unmounted")]
    })
    
    v // 123
  • use.events — attach event listeners to the element (auto-cleaned up)
    use.events({
      keydown: (e: KeyboardEvent) => console.log("keydown", e.code),
      keyup: (e: KeyboardEvent) => console.log("keyup", e.code),
    })
  • use.statesinternal states helper
    const states = use.states()
    states.assign("active", "cool")
    [view="my-view"]::state(active) { color: yellow; }
    [view="my-view"]::state(cool) { outline: 1px solid cyan; }
  • use.attrs — ergonomic typed html attribute access
    • use.attrs is similar to #dom.attrs
      const attrs = use.attrs({
        name: String,
        count: Number,
        active: Boolean,
      })
      attrs.name // "chase"
      attrs.count // 123
      attrs.active // true
    • use.attrs.{strings/numbers/booleans}
      use.attrs.strings.name // "chase"
      use.attrs.numbers.count // 123
      use.attrs.booleans.active // true
    • use.attrs.on
      use.attrs.on(() => console.log("an attribute changed"))
  • use.render — rerender the view (debounced)
    use.render()
  • use.renderNow — rerender the view instantly (not debounced)
    use.renderNow()
  • use.rendered — promise that resolves after the next render
    use.rendered.then(() => {
      const slot = use.shadow.querySelector("slot")
      console.log(slot)
    })
  • use.op — start with an op based on an async fn
    const op = use.op(async() => {
      await nap(5000)
      return 123
    })
  • use.op.promise — start with an op based on a promise
    const op = use.op.promise(doAsyncWork())

🍋 "use" recipes

  • make a ticker — mount, cycle, and nap
    import {cycle, nap} from "@e280/stz"
    const $seconds = use.signal(0)
    
    use.mount(() => cycle(async() => {
      await nap(1000)
      $seconds.value++
    }))
  • wake + rendered, to do something after each mount's first render
    use.wake(() => use.rendered.then(() => {
      console.log("after first render")
    }))



🪵🦝 sly base element

@e280/sly/base
the classic experience

import {BaseElement, Use, dom} from "@e280/sly"
import {html, css} from "lit"

BaseElement is more of an old-timey class-based "boomer" approach to making web components, but with a millennial twist — its render method gives you the same use hooks that views enjoy.

👮 a BaseElement is not a View, and cannot be converted into a View.

🪵 let's clarify some sly terminology

  • "Element"
    • an html element; any subclass of the browser's HTMLElement
    • all genuine "web components" are elements
  • "BaseElement"
    • sly's own subclass of the browser-native HTMLElement
    • is a true element and web component (can be registered to the dom)
  • "View"
    • sly's own magic concept that uses a lit-directive to render stuff
    • NOT an element or web component (can NOT be registered to the dom)
    • NOT related to BaseElement
    • can be converted into a Component via view.component().props(() => [])
  • "Component"
    • a sly view that has been converted into an element
    • is a true element and web component (can be registered to the dom)
    • actually a subclass of BaseElement
    • actually contains the view on Component.view

🪵 base element setup

  • declare your element class
    export class MyElement extends BaseElement {
      static styles = css`span{color:orange}`
    
      // custom property
      $start = signal(10)
    
      // custom attributes
      attrs = dom.attrs(this).spec({
        multiply: Number,
      })
    
      // custom methods
      hello() {
        return "world"
      }
    
      render(use: Use) {
        const $count = use.signal(1)
        const increment = () => $count.value++
    
        const {$start} = this
        const {multiply = 1} = this.attrs
        const result = $start() + (multiply * $count())
    
        return html`
          <span>${result}</span>
          <button @click="${increment}">+</button>
        `
      }
    }
  • register your element to the dom
    dom.register({MyElement})

🪵 base element usage

  • place the element in your html body
    <body>
      <my-element></my-element>
    </body>
  • now you can interact with it
    const myElement = dom<MyElement>("my-element")
    
    // js property
    myElement.$start(100)
    
    // html attributes
    myElement.attrs.multiply = 2
    
    // methods
    myElement.hello()
      // "world"



🪄🦝 sly dom

@e280/sly/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 a = dom.in(".demo").attrs
    a.strings.name = "pimsley"
    a.numbers.count = 125
    a.booleans.active = true



🫛🦝 sly ops

@e280/sly/ops
tools for async operations and loading spinners

import {nap} from "@e280/stz"
import {Pod, podium, Op, loaders} from "@e280/sly"

🫛 pods: loading/ready/error

  • a pod represents an async operation in terms of json-serializable data
  • there are three kinds of Pod<V>
    // loading pod
    ["loading"]
    
    // ready pod contains value 123
    ["ready", 123]
    
    // error pod contains an error
    ["error", new Error()]

🫛 podium: helps you work with pods

  • get pod status
    podium.status(["ready", 123])
      // "ready"
  • get pod ready value (or undefined)
    podium.value(["loading"])
      // undefined
    
    podium.value(["ready", 123])
      // 123
  • see more at podium.ts

🫛 ops: nice pod ergonomics

  • an Op<V> wraps a pod with a signal for reactivity
  • create an op
    const op = new Op<number>() // loading status by default
    const op = Op.loading<number>()
    const op = Op.ready<number>(123)
    const op = Op.error<number>(new Error())
  • 🔥 create an op that calls and tracks an async fn
    const op = Op.load(async() => {
      await nap(4000)
      return 123
    })
  • await for the next ready value (or thrown error)
    await op // 123
  • get pod info
    op.pod // ["loading"]
    op.status // "loading"
    op.value // undefined (or value if ready)
    op.isLoading // true
    op.isReady // false
    op.isError // false
  • select executes a fn based on the status
    const result = op.select({
      loading: () => "it's loading...",
      ready: value => `dude, it's ready! ${value}`,
      error: err => `dude, there's an error!`,
    })
    
    result
      // "dude, it's ready! 123"
  • morph returns a new pod, transforming the value if ready
    op.morph(n => n + 1)
      // ["ready", 124]
  • you can combine a number of ops into a single pod like this
    Op.all(Op.ready(123), Op.loading())
      // ["loading"]
    Op.all(Op.ready(1), Op.ready(2), Op.ready(3))
      // ["ready", [1, 2, 3]]
    • error if any ops are in error, otherwise
    • loading if any ops are in loading, otherwise
    • ready if all the ops are ready



⏳🦝 sly loaders

@e280/sly/loaders
animated loading spinners for ops

import {loaders} from "@e280/sly"

⏳ make a loader, choose an anim

  • create a loader fn
    const loader = loaders.make(loaders.anims.dots)
    • see all the anims available on the testing page https://sly.e280.org/
    • ngl, i made too many.. i was having fun, okay?

⏳ render an op with it

  • use your loader to render an op
    return html`
      <h2>cool stuff</h2>
    
      ${loader(op, value => html`
        <div>${value}</div>
      `)}
    `
    • when the op is loading, the loading spinner will animate
    • when the op is in error, the error will be displayed
    • when the op is ready, your fn is called and given the value



💅🦝 sly spa

@e280/sly/spa
hash router for single-page-apps

import {spa, html} from "@e280/sly"

💅 spa.Router basics

  • make a spa router
    const router = new spa.Router({
      routes: {
        home: spa.route("#/", async() => html`home`),
        settings: spa.route("#/settings", async() => html`settings`),
        user: spa.route("#/user/{userId}", async({userId}) => html`user ${userId}`),
      },
    })
    • all route strings must start with #/
    • use braces like {userId} to accept string params
    • home-equivalent hashes like "" and "#" are normalized to "#/"
    • the router has an effect on the appearance of the url in the browser address bar -- the home #/ is removed, aesthetically, eg, e280.org/#/ is rewritten to e280.org using history.replaceState
    • you can provide loader option if you want to specify the loading spinner (defaults to loaders.make())
    • you can provide notFound option, if you want to specify what is shown on invalid routes (defaults to () => null)
    • when auto is true (default), the router calls .refresh() and .listen() in the constructor.. set it to false if you want manual control
    • you can set auto option false if you want to omit the default initial refresh and listen calls
  • render your current page
    return html`
      <div class="my-page">
        ${router.render()}
      </div>
    `
    • returns lit content
    • shows a loading spinner when pages are loading
    • will display the notFound content for invalid routes (defaults to null)
  • perform navigations
    • go to settings page
      await router.nav.settings.go()
        // goes to "#/settings"
    • go to user page
      await router.nav.user.go("123")
        // goes to "#/user/123"

💅 spa.Router advanced

  • generate a route's hash string
    const hash = router.nav.user.hash("123")
      // "#/user/123"
    
    html`<a href="${hash}">user 123</a>`
  • check if a route is the currently-active one
    const hash = router.nav.user.active
      // true
  • force-refresh the router
    await router.refresh()
  • force-navigate the router by hash
    await router.refresh("#/user/123")
  • get the current hash string (normalized)
    router.hash
      // "#/user/123"
  • the route(...) helper fn enables the braces-params syntax
    • but, if you wanna do it differently, you can implement your own hash parser to do your own funky syntax
  • dispose the router when you're done with it
    router.dispose()
      // stop listening to hashchange events



🪙🦝 loot

@e280/sly/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
      view(() => () => 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...)
    view(use => () => {
      const money = use.once((): Money => ({value: 280}))
      const dragzone = use.once(() => 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...)
    view(use => () => {
      const bag = use.once((): Bag => ({id: 1}))
      const dropzone = use.once(() => 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)



🧑‍💻🦝 sly is by e280

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